diff --git a/.dockerignore b/.dockerignore index 8644d676..66cd5781 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,6 +25,8 @@ **/.env **/.env.* !**/.env.example +config.yml +apps/web/config.yml code-zero.deployment.yml Dockerfile .dockerignore diff --git a/.gitignore b/.gitignore index 2122c2de..e65ba40c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,7 @@ test-report.junit.xml # Local editor tooling; never something a checkout should carry. /code /vscode_cli.tar.gz +# The instance file (config.yml) where a deployment keeps it locally, and its old name anywhere. +/config.yml +/apps/web/config.yml code-zero.deployment.yml -!code-zero.deployment.example.yml diff --git a/README.md b/README.md index eadc8e9d..1ef5fb2e 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The CLI parses arguments with [`@bomb.sh/args`](https://github.com/bomb-sh/args) | `/api/v1/**` | The same router over OpenAPI/REST; interactive docs at `/api/v1/docs`, spec at `/api/v1/openapi.json` | | `/api/auth/**` | The Better Auth handler (mounted by `@onmax/nuxt-better-auth` from `server/auth.config.ts`) | -`/rpc/**` and `/api/v1/**` are the same `rpcRouter` from [`packages/api`](./packages/api) served over two wire protocols, so authorization behaves identically either way. Reads are open for the dashboard; mutations (`tasks.create`, `approvals.decide`) fail closed. `CODE_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` bearer credentials — the one half of this policy that is a secret, and so the one half still in the environment. Which execution modes each principal may request is `control_plane.modes` in `code-zero.deployment.yml`; without a grant a principal may only request the non-writable `observe` and `suggest` modes, so `fix` and `autonomous` require an explicit operator grant. Which checkouts `tasks.create` may target is neither a secret nor fixed for the life of the process, so it is the `repository` table an operator edits from the dashboard; with none configured every task creation is refused. The approval actor is the authenticated principal's name, never a wire-supplied value. This bearer-token scheme authorizes the control-plane API and is independent of the Better Auth session that protects the dashboard UI. +`/rpc/**` and `/api/v1/**` are the same `rpcRouter` from [`packages/api`](./packages/api) served over two wire protocols, so authorization behaves identically either way. Reads are open for the dashboard; mutations (`tasks.create`, `approvals.decide`) fail closed. `CODE_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` bearer credentials — the one half of this policy that is a secret, and so the one half still in the environment. Which execution modes each principal may request is `control_plane.modes` in the instance file, `config.yml`; without a grant a principal may only request the non-writable `observe` and `suggest` modes, so `fix` and `autonomous` require an explicit operator grant. Which checkouts `tasks.create` may target is neither a secret nor fixed for the life of the process, so it is the `repository` table an operator edits from the dashboard; with none configured every task creation is refused. The approval actor is the authenticated principal's name, never a wire-supplied value. This bearer-token scheme authorizes the control-plane API and is independent of the Better Auth session that protects the dashboard UI. Typed clients infer their shape from the router rather than redeclaring request and response types: @@ -206,18 +206,17 @@ off until you turn them on, so a fresh deployment cannot be signed up for by a s | `NUXT_BETTER_AUTH_SECRET` | yes | – | Session signing secret. Required under this name in production; `BETTER_AUTH_SECRET` is a development-only fallback | | `DATABASE_URL` | yes | – | Postgres connection string | | `AUTH_ENABLE_SIGNUP` | no | `false` | Set to `true` to allow self-registration | -| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | no | – | Enables the GitHub button when both are set | +| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | no | – | Enables the GitHub button when both are set; GitLab (`GITLAB_*`) and Google (`GOOGLE_*`) follow the same rule | | `NUXT_PUBLIC_SITE_URL` | no | – | Base URL override for a custom domain or deterministic OAuth callbacks; auto-detected from the request otherwise | `AUTH_DATABASE_URL` is still read when `DATABASE_URL` is unset, so a deployment configured before the store moved into `packages/database` keeps starting; prefer `DATABASE_URL` for new ones. -Sign-in lives at `/login` and self-registration at `/signup`; the methods each page offers are -derived at build time from the same policy variables `server/auth.config.ts` reads at runtime -(`AUTH_ENABLE_SIGNUP`, `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`), and no runtime override can -change them. The flip side of that build-time capture is a deployment contract: whenever you change -those policy variables, rebuild the app, or the auth pages will keep advertising the old -capabilities (the server still enforces its own policy either way). +Sign-in lives at `/login` and self-registration at `/signup`. The methods each page offers are +read at runtime from `/api/capabilities`, which the server derives from the same policy variables +`server/auth.config.ts` builds Better Auth from, so changing them needs a restart rather than a +rebuild. Every variable is listed, with its default, in `apps/web/.env.example`; the full policy is +described in the [authentication guide](apps/docs/content/1.guide/11.authentication/1.overview.md). The interface ships English and Italian through `@nuxtjs/i18n`, with dictionaries split by scope in `packages/i18n/locales//` and shared with the marketing site; each app loads only the @@ -422,7 +421,7 @@ also tagged with its full commit SHA, and `vX.Y.Z` tags matching the dashboard v docker build -t code-zero . docker run --rm -p 3000:3000 --env-file apps/web/.env \ -v code-zero-data:/app/.data \ - -v "$PWD/code-zero.deployment.yml:/app/code-zero.deployment.yml:ro" \ + -v "$PWD/config.yml:/app/config.yml:ro" \ code-zero ``` @@ -437,8 +436,8 @@ environment sets. Apply migrations against `DATABASE_URL` with root needs `RAILWAY_RUN_UID=0`, since the image runs as the unprivileged `node` user). The image carries only `.output/`, so the deployment policy has to be supplied: mount -`code-zero.deployment.yml` at `/app/code-zero.deployment.yml` as above, or mount it elsewhere and -point `CODE_ZERO_CONFIG` at it. Without it the process falls back to the closed defaults (no CORS +`config.yml` at `/app/config.yml` as above (see [`config.example.yml`](./config.example.yml)), or +mount it elsewhere and point `CODE_ZERO_CONFIG` at it. Without it the process falls back to the closed defaults (no CORS origins, no `fix` or `autonomous` grants). The image ships `git` for the host runner but no container engine, so `runner.isolation: container` is not supported by it: tasks that require container isolation fail closed instead of running on the host. Deploy the `.output/` bundle on a diff --git a/apps/docs/content/1.guide/10.api/4.protect-endpoints.md b/apps/docs/content/1.guide/10.api/4.protect-endpoints.md index 1d46b374..c40cf627 100644 --- a/apps/docs/content/1.guide/10.api/4.protect-endpoints.md +++ b/apps/docs/content/1.guide/10.api/4.protect-endpoints.md @@ -20,7 +20,7 @@ The authenticated principal's **name** (not the token) is what the system record ## Execution-mode grants -Which execution modes a principal may request is deployment policy, so it lives in `code-zero.deployment.yml` (or the path `CODE_ZERO_CONFIG` names) rather than in the environment beside the token itself: +Which execution modes a principal may request is deployment policy, so it lives in the instance file, `config.yml` (or the path `CODE_ZERO_CONFIG` names), rather than in the environment beside the token itself: ```yaml control_plane: diff --git a/apps/docs/content/1.guide/11.authentication/1.overview.md b/apps/docs/content/1.guide/11.authentication/1.overview.md index a17c355b..3096dd72 100644 --- a/apps/docs/content/1.guide/11.authentication/1.overview.md +++ b/apps/docs/content/1.guide/11.authentication/1.overview.md @@ -8,7 +8,7 @@ The dashboard UI is protected by [Better Auth](https://better-auth.com), mounted Authentication follows the adapter rule at the package level: -- **`packages/auth`** holds the policy: `authBetterAuthOptions` builds the database, policy, and provider options Better Auth needs. It has no HTTP server and no runtime imports, and its `./config` subpath stays free of database dependencies so the login page can read feature flags without bundling one. +- **`packages/auth`** holds the policy: `authBetterAuthOptions` builds the database, policy, and provider options Better Auth needs, and `authCapabilities` derives what the sign-in surface may advertise. It has no HTTP server and no runtime imports, and its `./config` and `./access` subpaths stay free of database dependencies. - **`packages/database`** owns the store: the Drizzle schema, the connection factory, and the checked-in migrations. It knows nothing about sign-in and must never import `packages/auth` — see [Database](/guide/database). - **`apps/web/server/auth.config.ts`** composes that policy into the running instance. It is the only route in the app that resolves the connection string (through `packages/database`) and the signing secret, and therefore the only process that opens a connection to Postgres. @@ -24,16 +24,39 @@ Authentication follows the adapter rule at the package level: ## Closed by default -Registration and GitHub OAuth are off until you turn them on, so a fresh deployment cannot be signed up for by a stranger: +Registration and social sign-in are off until you turn them on, so a fresh deployment cannot be signed up for by a stranger: -- `AUTH_ENABLE_SIGNUP=true` enables self-registration at `/signup` (default `false`); with it off, that page shows a closed state and `/login` hides the link to it; -- GitHub OAuth requires both `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` — see [GitHub OAuth](/guide/authentication/oauth). +- `AUTH_ENABLE_SIGNUP=true` enables self-registration at `/signup` (default `false`); with it off, that page shows a closed state, `/login` hides the link to it, and every other method — social providers, magic links, SSO — signs in existing accounts only; +- each social provider requires both halves of its credential — see [OAuth providers](/guide/authentication/oauth). -::warning -**Rebuild after policy changes** +## Read at runtime -The methods the auth pages offer are derived **at build time** from the same policy variables the server reads at runtime, and no runtime override can change them. Whenever you change those variables, rebuild the app — the server still enforces its own policy either way, but `/login` and `/signup` will keep advertising the old capabilities until rebuilt. -:: +The policy is read when the server starts, not when the app is built. The server publishes what it serves at `GET /api/capabilities` — plain booleans, no credential or origin — and `/login`, `/signup`, and the navigation render from it. Changing a variable needs a restart, not a rebuild, and a page can never advertise a method the server does not serve. + +## Methods and account security + +| Variable | Default | Effect | +| ---------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AUTH_ENABLE_EMAIL_VERIFICATION` | `false` | Sends a verification link on sign-up; needs a mail transport | +| `AUTH_REQUIRE_EMAIL_VERIFICATION` | `false` | Refuses password sign-in for an unverified address; implies sending the link | +| `AUTH_ENABLE_PASSWORD_RESET` | `false` | Offers a reset link by mail; resetting revokes every other session | +| `AUTH_ENABLE_MAGIC_LINK` | `false` | Passwordless sign-in by mail (single-use, hashed, ten minutes) | +| `AUTH_ENABLE_TWO_FACTOR` | `true` | TOTP with backup codes and 30-day trusted devices, issuer "Code Zero" | +| `AUTH_ENABLE_TWO_FACTOR_EMAIL_OTP` | `false` | Also delivers the second factor as a mailed code | +| `AUTH_ENABLE_PASSKEY` | `true` | WebAuthn passkeys, bound to `AUTH_PASSKEY_ORIGIN` or the site URL; off when neither names an origin | +| `AUTH_ENABLE_API_KEYS` | `false` | Organization API keys for CI: `cz_` prefix, stored hashed, 90-day default lifetime, rate limited; needs organizations. Sent as `Authorization: Bearer cz_…`, a key may submit `observe` and `suggest` tasks | + +A capability that needs something it does not have — a mail transport, a passkey origin — stops the server at startup with a message that names what is missing and never a configured value. Passwords are bounded at 8–128 characters; Better Auth 1.7 rejects a longer one with `PASSWORD_TOO_LONG`. + +Two-factor and passkeys are on by default because they add no way in: enrolling either needs an existing session and the account's password, and nothing changes for an account that never opts in. + +## Administration + +The app-wide `user.role` belongs to Better Auth's admin plugin, which every deployment registers. An account with the `admin` role may ban and unban accounts, impersonate one (sessions are attributed with `impersonated_by` and last an hour), and grant roles; the control plane asks the same plugin whether a signed-in account is an administrator rather than reading the role itself. + +## Enterprise single sign-on and provisioning + +SAML/OIDC single sign-on (`@better-auth/sso`, with domain verification) and SCIM provisioning (`@better-auth/scim`, managed per organization) are registered only when the deployment's edition grants the `sso` or `scim` entitlement. Without it their routes do not exist. SCIM also needs `SCIM_CREDENTIAL_HASH_SECRET` (at least 32 characters), the key its bearer credentials are stored as HMACs under. ## SSR and sessions diff --git a/apps/docs/content/1.guide/11.authentication/2.oauth.md b/apps/docs/content/1.guide/11.authentication/2.oauth.md index 448ddf3b..c675f6c2 100644 --- a/apps/docs/content/1.guide/11.authentication/2.oauth.md +++ b/apps/docs/content/1.guide/11.authentication/2.oauth.md @@ -1,7 +1,11 @@ --- -title: GitHub OAuth +title: OAuth providers --- +GitHub, GitLab, and Google are each offered only when both halves of their credential are set. Each one closes sign-up exactly when `AUTH_ENABLE_SIGNUP` does: with registration off, a provider signs in an account that already exists and never creates one. + +## GitHub + The login page offers a GitHub button only when both halves of the credential are set: ```bash @@ -24,8 +28,18 @@ The auth module auto-detects the base URL from the incoming request. Set `NUXT_P NUXT_PUBLIC_SITE_URL=https://code-zero.example.com ``` -::warning -**Rebuild after changing credentials** +## GitLab and Google + +```bash +GITLAB_CLIENT_ID=… +GITLAB_CLIENT_SECRET=… +# A self-managed instance; leave empty for gitlab.com. +GITLAB_ISSUER=https://gitlab.example.com + +GOOGLE_CLIENT_ID=… +GOOGLE_CLIENT_SECRET=… +``` + +Register the callback URLs `/api/auth/callback/gitlab` and `/api/auth/callback/google` with the provider. -The login page derives its sign-in methods at build time. After setting or removing the GitHub credentials, rebuild the app or the button state will be stale (the server still enforces the real policy). See [Authentication overview](/guide/authentication/overview). -:: +Credentials are read when the server starts, and the sign-in page learns which providers exist from `/api/capabilities`, so adding or removing one needs a restart, not a rebuild. diff --git a/apps/docs/content/1.guide/11.authentication/3.permissions.md b/apps/docs/content/1.guide/11.authentication/3.permissions.md index 9553a1d4..03525244 100644 --- a/apps/docs/content/1.guide/11.authentication/3.permissions.md +++ b/apps/docs/content/1.guide/11.authentication/3.permissions.md @@ -27,3 +27,20 @@ What a run may do to a checkout is decided by the target repository's own `.code ## Organization roles When [organizations](/guide/organizations) are enabled, membership and roles govern what a signed-in user sees and manages inside the dashboard. Organization authority never extends to the control plane or to repository policy. + +| Permission | Owner | Admin | Member | +| ------------------------------------------ | :---: | :---: | :----: | +| `repository: read` | yes | yes | yes | +| `repository: write, configure` | yes | yes | – | +| `task: create` | yes | yes | yes | +| `task: approve, cancel` | yes | yes | – | +| `policy: write` | yes | yes | – | +| `member: invite, remove` | yes | yes | – | +| `apiKey: create, revoke` | yes | yes | – | +| `billing: manage`, delete the organization | yes | – | – | + +The roles also carry Better Auth's own organization permissions, which its endpoints, Better Enrollment, and the API-key plugin check; they are granted in step with the table above. Members are invited with enrollment invitations (`AUTH_ENABLE_INVITATIONS`); the organization plugin's own invitation endpoints are not served. + +## Administrators + +The app-wide `admin` role is decided by Better Auth's admin plugin. Administrators may ban accounts, impersonate one, and grant app-wide roles, and the control plane grants a signed-in administrator its writable modes after asking the same plugin. diff --git a/apps/docs/content/1.guide/12.organizations.md b/apps/docs/content/1.guide/12.organizations.md index 4162d3c1..ac784acd 100644 --- a/apps/docs/content/1.guide/12.organizations.md +++ b/apps/docs/content/1.guide/12.organizations.md @@ -25,7 +25,9 @@ Enabling organizations requires a working mail transport, because invitations ar - Organization data (membership, invitations, roles) lives in the Better Auth session store — the same Postgres database, declared in `packages/database`'s Drizzle schema. - Policy constants such as the membership limit and invitation expiry are defined in `packages/auth` and composed into the Better Auth options. -- The dashboard's organization UI lives in `apps/web/modules/organizations/` — pages for creating an organization, managing members, and accepting invitations. -- Like every auth capability, the organization feature flags are captured at build time; rebuild after changing them. See [Authentication overview](/guide/authentication/overview). +- Roles are `owner`, `admin`, and `member`, defined once with `createAccessControl` in `packages/auth` — see [Permissions](/guide/authentication/permissions). +- Members are invited with Better Enrollment `org-join` invitations (`AUTH_ENABLE_INVITATIONS`), redeemed at `/invite` like every other invitation. The organization plugin's own invitation endpoints are not served, so there is one invitation model. +- The dashboard's organization UI lives in `apps/web/modules/organizations/` — pages for creating an organization and managing members. +- Like every auth capability, the organization flags are read at runtime from `/api/capabilities`; a change needs a restart, not a rebuild. See [Authentication overview](/guide/authentication/overview). Organization authority governs the dashboard UI only — it never extends to the control plane or repository policy. See [Permissions](/guide/authentication/permissions). diff --git a/apps/docs/content/1.guide/13.frontend.md b/apps/docs/content/1.guide/13.frontend.md index 7844b997..7bd9f426 100644 --- a/apps/docs/content/1.guide/13.frontend.md +++ b/apps/docs/content/1.guide/13.frontend.md @@ -49,7 +49,7 @@ The interface ships English and Italian through `@nuxtjs/i18n` (`strategy: 'no_p ## Auth capability flags -The login page's capabilities (signup enabled, GitHub button) are published via `appConfig` — captured at **build time**, deliberately not runtime-overridable. Rebuild after changing auth policy variables. See [Authentication overview](/guide/authentication/overview). +Pages read the sign-in capabilities (signup enabled, which social providers, organizations) at runtime through `useAuthCapabilities()`, which fetches `/api/capabilities` during SSR so the first paint already reflects the policy. Only the hosted-infrastructure flag stays in `appConfig`, because it decides which client code ships. See [Authentication overview](/guide/authentication/overview). ## Testing diff --git a/apps/docs/content/1.guide/16.deployment.md b/apps/docs/content/1.guide/16.deployment.md index 6ac2bc68..444160c0 100644 --- a/apps/docs/content/1.guide/16.deployment.md +++ b/apps/docs/content/1.guide/16.deployment.md @@ -17,11 +17,12 @@ node apps/web/.output/server/index.mjs - [ ] **Session secret** — set `NUXT_BETTER_AUTH_SECRET` (this exact name; the development fallback is ignored in production). Generate with `openssl rand -base64 32`. - [ ] **Postgres** — point `DATABASE_URL` at a managed database and apply the schema once with `aube run db:migrate`. See [Database](/guide/database). -- [ ] **Auth policy** — decide `AUTH_ENABLE_SIGNUP`, GitHub OAuth credentials, and organization flags, then **build with them set**: the auth pages capture capabilities at build time. See [Authentication](/guide/authentication/overview). +- [ ] **Auth policy** — decide `AUTH_ENABLE_SIGNUP`, the OAuth credentials, organization flags, and the mail-backed flows; they are read at server start, so a change needs a restart. Set `NUXT_PUBLIC_SITE_URL`, which passkeys are bound to. See [Authentication](/guide/authentication/overview). - [ ] **Mail** — configure Resend (`RESEND_API_KEY`; `MAIL_PROVIDER=resend` is optional) or set `MAIL_PROVIDER=smtp` with its SMTP variables. With neither, `console` only logs. Required if invitations or organizations are enabled. See [Mails](/guide/mails). -- [ ] **Control plane** — issue bearer tokens via `CODE_ZERO_CONTROL_PLANE_TOKENS`, add the repositories `tasks.create` may target from the dashboard (they are rows in the store), and grant modes via `control_plane.modes` in `code-zero.deployment.yml`. Without a token, mutations are rejected. See [Protect endpoints](/guide/api/protect-endpoints). +- [ ] **Control plane** — issue bearer tokens via `CODE_ZERO_CONTROL_PLANE_TOKENS`, add the repositories `tasks.create` may target from the dashboard (they are rows in the store), and grant modes via `control_plane.modes` in `config.yml`. Without a token, mutations are rejected. See [Protect endpoints](/guide/api/protect-endpoints). - [ ] **Webhooks** — set `GITHUB_WEBHOOK_SECRET` and `CODE_ZERO_CHECKOUT_PATH`; the webhook route fails closed (503) until both are set. Configure the webhook on the source-control side per [Source-control providers](/reference/source-control-providers). - [ ] **Isolated execution** — production runs that write require `runner.isolation: container` in repository policy, with an image and resource limits. The included `LocalRunner` is for trusted local development only. See the [Safety model](/guide/safety). +- [ ] **Deployment settings** — set `CODE_ZERO_EDITION` (`self-hosted` unless this is the managed service) and choose `CODE_ZERO_SETTINGS_SOURCE`: `file` reads the limits from `config.yml`, `dashboard` lets an admin edit them at `/settings`. Invalid settings refuse every run. See [Deployment settings](/guide/settings). - [ ] **Model provider** — select the provider in repository policy and set its credential environment variable. See [Model providers](/reference/model-providers). ## Task history storage @@ -36,7 +37,7 @@ For hosted execution, the provider-neutral `RunnerPool` supports vendor adapters Rebuild the app whenever you change: -- `AUTH_ENABLE_SIGNUP`, `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`, or organization flags (login-page capabilities are captured at build time); +- the `BETTER_AUTH_*` hosted-infrastructure variables (they decide which client code ships; every other auth variable needs only a restart); - anything in `packages/*` (the server bundle inlines them). ## Vercel diff --git a/apps/docs/content/1.guide/17.settings.md b/apps/docs/content/1.guide/17.settings.md new file mode 100644 index 00000000..83fd4cd9 --- /dev/null +++ b/apps/docs/content/1.guide/17.settings.md @@ -0,0 +1,125 @@ +--- +title: Deployment settings +--- + +Deployment settings are the limits every run on a deployment is held to: how many tasks run at once, how long one command may take, which execution modes may start, how much network a run gets, and, optionally, one model and one container sandbox for every repository. They apply on top of each repository's own [`.code-zero.yml`](/guide/configuration), and the stricter of the two always wins. + +The dashboard shows them at `/settings`. What that page lets you do depends on the edition and on where the settings come from. + +## Editions + +The edition is stated in the environment, never guessed from the host: + +| `CODE_ZERO_EDITION` | What it is | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `self-hosted` (default) | You run Code Zero. An administrator sees and, with the dashboard source, edits every setting. | +| `cloud` | The managed service. The service fixes the AI provider, sandbox, database, and mail; organizations may only lower their limits. | + +A self-hosted install on Cloudflare, Vercel, or any other host is still `self-hosted`. A value other than these two is treated as `cloud`, the locked edition, and every run is refused until it is corrected. + +## Choose where the settings come from + +A self-hosted deployment has exactly one source, chosen with `CODE_ZERO_SETTINGS_SOURCE` when the server starts: + +```bash +# The default: config.yml is the only authority, and the page is read-only. +CODE_ZERO_SETTINGS_SOURCE=file + +# Postgres is the only authority, and an administrator edits the settings on the page. +CODE_ZERO_SETTINGS_SOURCE=dashboard +``` + +| Source | Where the settings live | The settings page | +| ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------- | +| `file` (default) | The `execution`, `model`, and `runner` sections of `config.yml` | Read-only. Shows which file to edit. Edit it and restart the server. | +| `dashboard` | The `deployment_setting` table in Postgres | An administrator edits and saves them. They can be exported as YAML. | + +The two never merge. With the dashboard as the source, settings sections still present in `config.yml` are ignored, and the page warns about them. Moving between sources is a restart with a different `CODE_ZERO_SETTINGS_SOURCE`, never something a request can do. To move from the dashboard to the file, export the settings first and paste them into `config.yml`. + +Only an account with the admin role can open the page on a self-hosted deployment; anyone else is told so. Reading and saving need a signed-in session: API keys and operator tokens cannot read or change the settings. + +## The instance file: config.yml + +Code Zero reads `config.yml` from the server's working directory, or the file `CODE_ZERO_CONFIG` names: + +```bash +CODE_ZERO_CONFIG=/etc/code-zero/config.yml +``` + +The old name `code-zero.deployment.yml` is still read when it is the only file present, with a warning to rename it. When both names exist and `CODE_ZERO_CONFIG` names neither, nothing is read and the settings are refused until you keep one. + +`config.yml` also holds the control-plane and polling policy (`control_plane`, `poll`); those are read whichever source the settings use. The annotated template is [`config.example.yml`](https://github.com/wolfstar-project/code-zero/blob/main/config.example.yml). A complete settings example: + +```yaml +execution: + # Tasks this deployment runs at once; the rest wait in the queue. 1 to 64. + max_concurrent_tasks: 4 + # Minutes before one repository command is stopped. 1 to 1440. + command_timeout_minutes: 30 + # Modes a run may start in. Must include observe. + allowed_modes: [observe, suggest, fix] + # The widest network any run may have: none, restricted, or full. + max_network: restricted + +# Optional. Replaces each repository's model choice. The credential stays in the environment. +model: + provider: anthropic + name: claude-sonnet-4-5 + +# Optional. Forces every run this process executes into a container. +runner: + isolation: container + engine: docker + image: ghcr.io/acme/code-zero-runner:1.0 +``` + +| Key | Default | Effect on a run | +| ----------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------ | +| `execution.max_concurrent_tasks` | `4` | The scheduler's ceiling for the whole deployment. | +| `execution.command_timeout_minutes` | `30` | Caps the repository's own command timeout. See [below](#the-timeout-is-per-command). | +| `execution.allowed_modes` | `[observe, suggest, fix, autonomous]` | A run in any other mode is refused before it is recorded. | +| `execution.max_network` | `full` | A ceiling: a repository may narrow it, never widen it. | +| `model` | none: each repository chooses | `provider` and `name` replace the repository's model. | +| `runner` | none: each repository chooses | Forces container isolation with this `engine` (`docker` or `podman`) and `image`. | + +The settings are strict. An unknown key, an out-of-range number, a value that looks like a credential, or YAML that does not parse makes the whole document invalid, with no field quietly falling back to a default. Invalid settings fail closed: no task starts, from the dashboard, a webhook, or the poller, until they are fixed, and the settings page lists every issue with its `$.path`. `observe` cannot be removed from `allowed_modes`, because it is the mode every unrequested run starts in and the one that never writes. `runner` accepts only container isolation: a setting that moved a repository onto the host would be the one place a run ended up less isolated than its own policy asked for. + +## Edit the settings from the dashboard + +With `CODE_ZERO_SETTINGS_SOURCE=dashboard`, an administrator edits the execution limits, the model, and the runner on the settings page. The inputs show the accepted ranges as hints, but the server checks every value with the same parser `config.yml` goes through; a refused value is reported beside its field. A saved change applies to the next task that starts, and the page shows who saved it and when. + +**Export** downloads the settings as `config.yml`, or copies them as YAML. The export never contains a secret: credentials stay in the environment, the settings refuse anything shaped like one, and the export is redacted against the environment's secrets on the way out. + +## Managed cloud + +On `cloud` the service's own `config.yml` is the only source, and `CODE_ZERO_SETTINGS_SOURCE=dashboard` is refused. The AI provider, the runner and sandbox, the database and storage, and the mail transport are fixed by the service and are not editable. Customers see only whether each one is configured, never a credential, host, or provider name. + +The plan's limits are shown read-only and are the ceiling. An organization's owners and admins may set stricter limits for their own organization's tasks: + +- max concurrent tasks; +- timeout per command; +- allowed modes, chosen from the plan's. + +An empty field uses the plan's value. The server refuses any value above the plan, and takes the lower of the two again on every run, so a plan lowered later still wins. Members see their organization's limits and the effective result, with no control to change them. The organization is the one selected in the sidebar; its membership and role are checked against the database on every request. + +## The timeout is per command + +`command_timeout_minutes` is how long one repository command may run before it is stopped, and the longest a hosted sandbox lease lasts. It is not a limit on a whole run: a run that executes several commands, or waits on the model, can take longer in total. The page and the API name it accordingly. + +## Not available yet + +These are not settings today, and the page shows no control for them, because the server has nothing that would enforce one: + +- cost budgets, for a deployment, a plan, or an organization; +- a list of commands runs are allowed to execute; +- requiring an approval before a run writes (narrow `allowed_modes` instead); +- an organization-wide two-factor requirement; +- per-organization repository connections and triggers; +- personal notification preferences. + +## How it is enforced + + + + diff --git a/apps/docs/content/1.guide/4.environment-variables.md b/apps/docs/content/1.guide/4.environment-variables.md index acf707c7..1364f323 100644 --- a/apps/docs/content/1.guide/4.environment-variables.md +++ b/apps/docs/content/1.guide/4.environment-variables.md @@ -51,15 +51,26 @@ Each provider reads only its documented variable. See [Model providers](/referen The control-plane API (`/rpc/**` and `/api/v1/**`) fails closed: without `CODE_ZERO_CONTROL_PLANE_TOKENS` every mutation is rejected while reads stay open. -| Variable | Purpose | -| -------------------------------- | --------------------------------------------------------------------------------------------------- | -| `CODE_ZERO_CONTROL_PLANE_TOKENS` | Comma-separated `name:token` bearer credentials | -| `CODE_ZERO_CONFIG` | Path to the deployment policy file; defaults to `code-zero.deployment.yml` in the working directory | +| Variable | Purpose | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `CODE_ZERO_CONTROL_PLANE_TOKENS` | Comma-separated `name:token` bearer credentials | +| `CODE_ZERO_CONFIG` | Path to the instance file; defaults to `config.yml` in the working directory (`code-zero.deployment.yml` when only it exists) | The rest of the control plane's policy is not environment configuration: allowed CORS origins (`control_plane.origins`) and execution-mode grants (`control_plane.modes`) are read from that deployment file, and the repositories `tasks.create` may target are rows in the store rather than a variable. See [Protect endpoints](/guide/api/protect-endpoints) for how these are enforced. +## Deployment settings + +Two bootstrap values decide what the [settings page](/guide/settings) is and where the settings it shows come from. Both are read once at start; no request can change them. + +| Variable | Values | Purpose | +| --------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `CODE_ZERO_EDITION` | `self-hosted` (default), `cloud` | Which product this deployment is. Never inferred from the host; an unrecognised value is treated as `cloud` and refuses every run. | +| `CODE_ZERO_SETTINGS_SOURCE` | `file` (default), `dashboard` | Self-hosted only: read the settings from `config.yml` (read-only on the page) or from Postgres (edited on the page). `cloud` needs `file`. | + +The settings themselves (concurrency, per-command timeout, allowed modes, network ceiling, model, runner) are not environment variables: they live in `config.yml` or in the store. See [Deployment settings](/guide/settings). + ## Webhooks `POST /webhooks/github` fails closed (503, nothing ingested) until both variables are set. @@ -114,9 +125,9 @@ In other words: enabling `dash()` delegates account creation in your database to :: ::warning -**Build-time capture** +**Build-time capture of the hosted infrastructure** -The sign-in methods the login page offers are derived at build time from the same policy variables the server reads at runtime. Whenever you change `AUTH_ENABLE_SIGNUP`, the GitHub OAuth credentials, or the `BETTER_AUTH_*` hosted-infrastructure variables, rebuild the app, or the login page will keep advertising the old capabilities (the server still enforces its own policy either way). +Sign-in methods are read at runtime from `/api/capabilities`, so `AUTH_ENABLE_SIGNUP` and the OAuth credentials need only a restart. The `BETTER_AUTH_*` hosted-infrastructure variables are the exception: they decide whether the browser-side sentinel client ships, so rebuild the app after changing them. :: ## Mail diff --git a/apps/web/.env.example b/apps/web/.env.example index 720be74e..9e609675 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -27,14 +27,29 @@ GITHUB_TOKEN= # secret, which is why it is the only half still spelled here. # # What each principal may run, and which origins may read /api/v1/** cross-origin, are policy -# rather than secrets and live in `code-zero.deployment.yml` (see code-zero.deployment.example.yml). +# rather than secrets and live in `config.yml` (see config.example.yml at the repository root). # Which checkouts a task may target is neither: it changes while the process runs, so it is a table # an operator edits from the dashboard. CODE_ZERO_CONTROL_PLANE_TOKENS= -# Path to the deployment configuration file. Defaults to `code-zero.deployment.yml` in the working -# directory; a deployment that has changed nothing ships no file and gets the defaults. +# Path to the instance configuration file. Defaults to `config.yml` in the working directory (the +# old name `code-zero.deployment.yml` is still read when it is the only one present); a deployment +# that has changed nothing ships no file and gets the defaults. CODE_ZERO_CONFIG= +# Deployment settings (/settings in the dashboard): the limits every run is held to — concurrency, +# the per-command timeout, allowed modes, network ceiling, and optionally one model and one +# container sandbox. Both values are read once at start and no request can change them. +# +# Which product this is: `self-hosted` (the default) or `cloud`, the managed service, where the AI +# provider, sandbox, database and mail are fixed and organizations may only lower their limits. +# Stated, never inferred from the host; an unrecognised value is treated as `cloud` and every run +# is refused until it is corrected. +CODE_ZERO_EDITION=self-hosted +# Where a self-hosted deployment's settings come from, exactly one of: `file` (the default; the +# execution/model/runner sections of config.yml, shown read-only) or `dashboard` (stored in +# Postgres, edited by an admin on the settings page, exportable as YAML). `cloud` requires `file`. +CODE_ZERO_SETTINGS_SOURCE=file + GITHUB_WEBHOOK_SECRET= # Checkout the webhook route binds incoming events to. POST /webhooks/github fails closed # (503, nothing ingested) until both this and GITHUB_WEBHOOK_SECRET are set. @@ -51,7 +66,7 @@ CODE_ZERO_CHECKOUT_PATH= # Which repositories it watches, and the mode each one runs in, are rows in the `repository` table # rather than variables here: an operator turns polling on for a repository from the dashboard and # the next pass picks it up, with no restart. The interval between passes is in -# `code-zero.deployment.yml`. Each row pairs a repository on the provider with the checkout on this +# `config.yml`. Each row pairs a repository on the provider with the checkout on this # host a run may execute against; the path is never derived from the slug, so a run can only ever # target a checkout an operator named. That checkout has to be kept current (a periodic # `git fetch`) — a review reads the diff between the pull request's base and head commits, so a @@ -78,11 +93,39 @@ AUTH_ENABLE_SIGNUP=false # Both halves are required before the GitHub button is offered. GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -# Organizations are off unless this is exactly "true". Enabling them requires a working mail -# transport below, because invitations are delivered by email. +# GitLab and Google follow the same rule. GITLAB_ISSUER points GitLab at a self-managed instance; +# leave it empty for gitlab.com. +GITLAB_CLIENT_ID= +GITLAB_CLIENT_SECRET= +GITLAB_ISSUER= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# Mail-backed flows, each off unless exactly "true". Each one needs the mail transport below and +# fails startup without it rather than promising a message nobody sends. Requiring verification +# also sends it. Magic links and mail-delivered second-factor codes have no mail template yet, so +# leave those two off for now. +AUTH_ENABLE_EMAIL_VERIFICATION=false +AUTH_REQUIRE_EMAIL_VERIFICATION=false +AUTH_ENABLE_PASSWORD_RESET=false +AUTH_ENABLE_MAGIC_LINK=false +AUTH_ENABLE_TWO_FACTOR_EMAIL_OTP=false +# Two-factor (TOTP, backup codes, trusted devices) and passkeys are on unless exactly "false": they +# add no way in, only a stronger one for an account that opts in. Passkeys are bound to the origin +# in AUTH_PASSKEY_ORIGIN, or to NUXT_PUBLIC_SITE_URL when that is empty, and stay off when neither +# names one. The relying-party id defaults to that origin's hostname. +AUTH_ENABLE_TWO_FACTOR=true +AUTH_ENABLE_PASSKEY=true +AUTH_PASSKEY_ORIGIN= +AUTH_PASSKEY_RP_ID= +AUTH_PASSKEY_RP_NAME= +# Organizations are off unless this is exactly "true". Members are invited into one through +# enrollment invitations, so inviting also needs AUTH_ENABLE_INVITATIONS and the mail transport. AUTH_ENABLE_ORGANIZATIONS=false # Whether any signed-in user may create an organization. Ignored while organizations are off. AUTH_ALLOW_ORGANIZATION_CREATION=false +# Organization API keys for CI and the GitHub Action (cz_ prefix, stored hashed, expiring, rate +# limited). Off unless exactly "true", and ignored while organizations are off. +AUTH_ENABLE_API_KEYS=false # Invitations are off unless this is exactly "true". Enabling them requires a working mail # transport below: a private invitation's link is never shown to whoever created it, so email is # the only path it travels. Independent of AUTH_ENABLE_SIGNUP — with registration closed they are @@ -146,10 +189,10 @@ SMTP_SECURE=false SMTP_USER= SMTP_PASSWORD= -# Sign-in capabilities are derived from AUTH_ENABLE_SIGNUP and the GitHub OAuth credentials above -# at build time, so rebuild whenever those policy values change. NUXT_PUBLIC_SITE_URL is optional: -# the auth module auto-detects the base URL from the incoming request, and this only needs to be -# set for a custom domain or deterministic OAuth callbacks. +# Sign-in capabilities are read from the variables above when the server starts and published at +# /api/capabilities, so a change needs a restart, not a rebuild. NUXT_PUBLIC_SITE_URL is optional +# for sign-in — the auth module auto-detects the base URL from the incoming request — but it is the +# origin passkeys and invitation links are bound to, so set it for any real deployment. NUXT_PUBLIC_SITE_URL= PORT=3000 diff --git a/apps/web/app/auth.config.ts b/apps/web/app/auth.config.ts index 5446f9dc..a5c97859 100644 --- a/apps/web/app/auth.config.ts +++ b/apps/web/app/auth.config.ts @@ -1,11 +1,16 @@ +import { apiKeyClient } from '@better-auth/api-key/client'; import { dashClient, sentinelClient } from '@better-auth/infra/client'; +import { passkeyClient } from '@better-auth/passkey/client'; import { betterEnrollmentClient } from '@octopi-ai/better-enrollment/client'; import { defineClientAuth } from '@onmax/nuxt-better-auth/config'; import { + adminClient, deviceAuthorizationClient, lastLoginMethodClient, + magicLinkClient, multiSessionClient, organizationClient, + twoFactorClient, } from 'better-auth/client/plugins'; // Better Auth is mounted in this app's own server (`server/auth.config.ts`), so every request is @@ -18,8 +23,9 @@ import { // and the rest stop existing on the client's type. // // Declared as a factory rather than a plain object so `useAppConfig()` is callable: the module -// builds the client lazily, inside the Nuxt app, so the policy published at build time is readable -// by the time this runs. +// builds the client lazily, inside the Nuxt app. Only the hosted-infrastructure flag is read from +// there, because it decides which client code loads; every sign-in capability is read at runtime +// from `/api/capabilities` instead. export default defineClientAuth(() => { const { auth } = useAppConfig(); @@ -34,11 +40,20 @@ export default defineClientAuth(() => { // between the accounts this browser holds sessions for, `deviceAuthorizationClient` is what the // `/device` page calls to approve or deny a CLI's request, and `dashClient` reads the hosted // audit log. + // + // `adminClient`, `twoFactorClient`, `passkeyClient`, `magicLinkClient`, and `apiKeyClient` are + // the client halves of plugins the server registers from runtime policy; the pages that drive + // them read `/api/capabilities` to decide whether to offer them. plugins: [ organizationClient(), betterEnrollmentClient(), lastLoginMethodClient(), multiSessionClient(), + adminClient(), + twoFactorClient(), + passkeyClient(), + magicLinkClient(), + apiKeyClient(), deviceAuthorizationClient(), dashClient(), // The one entry that is gated, and the only one whose absence changes nothing about the diff --git a/apps/web/app/pages/(auth)/login.vue b/apps/web/app/pages/(auth)/login.vue index 3f013b39..4f2e66c5 100644 --- a/apps/web/app/pages/(auth)/login.vue +++ b/apps/web/app/pages/(auth)/login.vue @@ -59,14 +59,14 @@ */ definePageMeta({ layout: 'auth' }); -const appConfig = useAppConfig(); +const capabilities = await useAuthCapabilities(); const { localizeAuthError } = useAuthErrorMessage(); const signInEmail = useSignIn('email'); const signInSocial = useSignIn('social'); -const canSignUp = appConfig.auth.enableSignup; -const canUseGithub = appConfig.auth.enableGithubOauth; +const canSignUp = computed(() => capabilities.value.signup); +const canUseGithub = computed(() => capabilities.value.social.github); const email = ref(''); const password = ref(''); diff --git a/apps/web/app/pages/(auth)/signup.vue b/apps/web/app/pages/(auth)/signup.vue index c28bc098..d4f99ed2 100644 --- a/apps/web/app/pages/(auth)/signup.vue +++ b/apps/web/app/pages/(auth)/signup.vue @@ -68,20 +68,20 @@ * Registration lives on its own route rather than behind a toggle on the sign-in page, so the two * flows have distinct URLs to link to, bookmark, and gate. * - * `enableSignup` only decides what this page offers. The server enforces the same policy on its own - * from the same environment, independent of this page, so the closed state below is presentation, - * not the actual gate. + * The `signup` capability, read from `/api/capabilities` at runtime, only decides what this page + * offers. The server enforces the same policy on its own from the same environment, independent + * of this page, so the closed state below is presentation, not the actual gate. */ definePageMeta({ layout: 'auth' }); -const appConfig = useAppConfig(); +const capabilities = await useAuthCapabilities(); const { localizeAuthError } = useAuthErrorMessage(); const signUpEmail = useSignUp('email'); const signInSocial = useSignIn('social'); -const canSignUp = appConfig.auth.enableSignup; -const canUseGithub = appConfig.auth.enableGithubOauth; +const canSignUp = computed(() => capabilities.value.signup); +const canUseGithub = computed(() => capabilities.value.social.github); const email = ref(''); const password = ref(''); diff --git a/apps/web/app/pages/(dashboard)/settings.vue b/apps/web/app/pages/(dashboard)/settings.vue new file mode 100644 index 00000000..dddc27a1 --- /dev/null +++ b/apps/web/app/pages/(dashboard)/settings.vue @@ -0,0 +1,193 @@ + + + diff --git a/apps/web/app/pages/(organizations)/organizations/accept-invitation/[id].vue b/apps/web/app/pages/(organizations)/organizations/accept-invitation/[id].vue deleted file mode 100644 index 68a85823..00000000 --- a/apps/web/app/pages/(organizations)/organizations/accept-invitation/[id].vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/apps/web/app/pages/(organizations)/organizations/index.vue b/apps/web/app/pages/(organizations)/organizations/index.vue index 96e62888..582888c2 100644 --- a/apps/web/app/pages/(organizations)/organizations/index.vue +++ b/apps/web/app/pages/(organizations)/organizations/index.vue @@ -13,7 +13,9 @@
-
+ +
@@ -26,6 +28,7 @@ diff --git a/apps/web/modules/settings/components/Export.vue b/apps/web/modules/settings/components/Export.vue new file mode 100644 index 00000000..5cf9053d --- /dev/null +++ b/apps/web/modules/settings/components/Export.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/web/modules/settings/components/Field.vue b/apps/web/modules/settings/components/Field.vue new file mode 100644 index 00000000..5243c737 --- /dev/null +++ b/apps/web/modules/settings/components/Field.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/web/modules/settings/components/Form.vue b/apps/web/modules/settings/components/Form.vue new file mode 100644 index 00000000..edb59a53 --- /dev/null +++ b/apps/web/modules/settings/components/Form.vue @@ -0,0 +1,305 @@ + + + diff --git a/apps/web/modules/settings/components/IssueList.vue b/apps/web/modules/settings/components/IssueList.vue new file mode 100644 index 00000000..ae0444a4 --- /dev/null +++ b/apps/web/modules/settings/components/IssueList.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/web/modules/settings/components/OrganizationLimits.vue b/apps/web/modules/settings/components/OrganizationLimits.vue new file mode 100644 index 00000000..40a810b2 --- /dev/null +++ b/apps/web/modules/settings/components/OrganizationLimits.vue @@ -0,0 +1,216 @@ + + + diff --git a/apps/web/modules/settings/components/Problems.vue b/apps/web/modules/settings/components/Problems.vue new file mode 100644 index 00000000..bc5f165e --- /dev/null +++ b/apps/web/modules/settings/components/Problems.vue @@ -0,0 +1,47 @@ + + + diff --git a/apps/web/modules/settings/components/SaveBar.vue b/apps/web/modules/settings/components/SaveBar.vue new file mode 100644 index 00000000..c7de2aed --- /dev/null +++ b/apps/web/modules/settings/components/SaveBar.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/web/modules/settings/components/Services.vue b/apps/web/modules/settings/components/Services.vue new file mode 100644 index 00000000..53dde915 --- /dev/null +++ b/apps/web/modules/settings/components/Services.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/web/modules/settings/components/Source.vue b/apps/web/modules/settings/components/Source.vue new file mode 100644 index 00000000..ee45cdf3 --- /dev/null +++ b/apps/web/modules/settings/components/Source.vue @@ -0,0 +1,91 @@ + + + diff --git a/apps/web/modules/settings/components/State.vue b/apps/web/modules/settings/components/State.vue new file mode 100644 index 00000000..11225cf5 --- /dev/null +++ b/apps/web/modules/settings/components/State.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/web/modules/settings/components/Summary.vue b/apps/web/modules/settings/components/Summary.vue new file mode 100644 index 00000000..9f95b49a --- /dev/null +++ b/apps/web/modules/settings/components/Summary.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/web/modules/settings/composables/useDeploymentSettingsForm.ts b/apps/web/modules/settings/composables/useDeploymentSettingsForm.ts new file mode 100644 index 00000000..f5510727 --- /dev/null +++ b/apps/web/modules/settings/composables/useDeploymentSettingsForm.ts @@ -0,0 +1,35 @@ +import type { SettingsView } from '@code-zero/api'; +import { ref } from 'vue'; + +import { + formFromView, + settingsFieldIssues, + settingsInputFromForm, + type SelfHostedSettingsView, + type SettingsInput, +} from '../utils/form'; +import { useSettingsForm } from './useSettingsForm'; + +/** `$orpc.settings.update` from the page that owns the client. */ +export type SettingsSaver = (input: SettingsInput) => Promise; + +/** + * The deployment settings form, for a self-hosted deployment whose source is the dashboard. + * + * Offered only where the view says the settings are `editable`; the server refuses the save + * anywhere else regardless, so a page that rendered this by mistake could still change nothing. + */ +export function useDeploymentSettingsForm( + view: () => SelfHostedSettingsView, + save: SettingsSaver, + onSaved: (next: SettingsView) => void, +) { + return useSettingsForm({ + form: ref(formFromView(view())), + initial: () => formFromView(view()), + build: settingsInputFromForm, + fieldIssues: settingsFieldIssues, + save, + onSaved, + }); +} diff --git a/apps/web/modules/settings/composables/useOrganizationLimitsForm.ts b/apps/web/modules/settings/composables/useOrganizationLimitsForm.ts new file mode 100644 index 00000000..3baf2ce0 --- /dev/null +++ b/apps/web/modules/settings/composables/useOrganizationLimitsForm.ts @@ -0,0 +1,39 @@ +import type { OrganizationSettingsView } from '@code-zero/api'; +import type { ExecutionSettings } from '@code-zero/config'; +import { ref } from 'vue'; + +import { + organizationFieldIssues, + organizationFormFromView, + organizationInputFromForm, + type OrganizationLimitsInput, +} from '../utils/form'; +import { useSettingsForm } from './useSettingsForm'; + +/** `$orpc.settings.organization.update` from the page that owns the client. */ +export type OrganizationLimitsSaver = (request: { + organizationId: string; + limits: OrganizationLimitsInput; +}) => Promise; + +/** + * One organization's own limits on the managed cloud, for an owner or admin. + * + * The plan is the ceiling: the server refuses any value above it, and the issue it reports is + * shown beside the field. Emptying a field hands that limit back to the plan. + */ +export function useOrganizationLimitsForm( + view: () => OrganizationSettingsView, + plan: () => ExecutionSettings | null, + save: OrganizationLimitsSaver, + onSaved: (next: OrganizationSettingsView) => void, +) { + return useSettingsForm({ + form: ref(organizationFormFromView(view(), plan())), + initial: () => organizationFormFromView(view(), plan()), + build: organizationInputFromForm, + fieldIssues: organizationFieldIssues, + save: (limits: OrganizationLimitsInput) => save({ organizationId: view().id, limits }), + onSaved, + }); +} diff --git a/apps/web/modules/settings/composables/useSettings.test.ts b/apps/web/modules/settings/composables/useSettings.test.ts new file mode 100644 index 00000000..a34415cf --- /dev/null +++ b/apps/web/modules/settings/composables/useSettings.test.ts @@ -0,0 +1,66 @@ +import type { SettingsView } from '@code-zero/api'; +import { describe, expect, it } from 'vitest'; + +import { useSettings } from './useSettings'; + +function cloudView(organizationId: string): SettingsView { + return { + edition: 'cloud', + services: [], + plan: null, + organization: { + id: organizationId, + role: 'owner', + editable: true, + limits: { maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }, + invalid: false, + effective: null, + }, + bounds: { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, + }, + }; +} + +/** A read the test answers by hand, so two reads can finish in the opposite order. */ +function deferredReads() { + const pending: ((view: SettingsView) => void)[] = []; + const read = () => + new Promise((resolve) => { + pending.push(resolve); + }); + return { read, pending }; +} + +describe('useSettings', () => { + it('keeps the latest read when an earlier one answers last', async () => { + const { read, pending } = deferredReads(); + const settings = useSettings(read); + + const first = settings.refresh(); + const second = settings.refresh(); + pending[1]?.(cloudView('org_b')); + await second; + pending[0]?.(cloudView('org_a')); + await first; + + expect(settings.view.value?.edition === 'cloud' && settings.view.value.organization?.id).toBe( + 'org_b', + ); + expect(settings.pending.value).toBe(false); + }); + + it('stays pending until the latest read answers', async () => { + const { read, pending } = deferredReads(); + const settings = useSettings(read); + + const first = settings.refresh(); + void settings.refresh(); + pending[0]?.(cloudView('org_a')); + await first; + + expect(settings.pending.value).toBe(true); + expect(settings.view.value).toBeNull(); + }); +}); diff --git a/apps/web/modules/settings/composables/useSettings.ts b/apps/web/modules/settings/composables/useSettings.ts new file mode 100644 index 00000000..992bdc39 --- /dev/null +++ b/apps/web/modules/settings/composables/useSettings.ts @@ -0,0 +1,51 @@ +// Imported explicitly rather than relying on Nuxt auto-imports, so the dependency stays visible at +// the call site; `nuxt typecheck` resolves either form. +import type { SettingsView } from '@code-zero/api'; +import { shallowRef } from 'vue'; + +import { classifySettingsError, type SettingsError } from '../utils/form'; + +/** Reads the settings page's read model; `$orpc.settings.get` from the page that owns the client. */ +export type SettingsReader = () => Promise; + +/** + * The deployment's settings as `settings.get` describes them for the signed-in reader. + * + * Client-side only, like the audit log: the procedure authenticates the browser's session, and + * what it returns depends on who asks. A failed read clears the view rather than leaving a stale + * one on screen, because a page that still shows settings after a refusal reads as permission. + */ +export function useSettings(read: SettingsReader) { + const view = shallowRef(null); + const pending = shallowRef(false); + const error = shallowRef(null); + // Reads can overlap — the organization switcher asks again while an earlier read is in flight — + // and only the most recent request describes what the page should show. Each read takes a + // number, and an answer whose number is no longer the latest is dropped, however late it lands. + let latest = 0; + + async function refresh(): Promise { + const request = ++latest; + pending.value = true; + error.value = null; + try { + const next = await read(); + if (request !== latest) return; + view.value = next; + } catch (caught) { + if (request !== latest) return; + view.value = null; + error.value = classifySettingsError(caught); + } finally { + if (request === latest) pending.value = false; + } + } + + /** Takes the view a successful save answered with, so the page shows what the server stored. */ + function replace(next: SettingsView): void { + view.value = next; + error.value = null; + } + + return { view, pending, error, refresh, replace }; +} diff --git a/apps/web/modules/settings/composables/useSettingsExport.ts b/apps/web/modules/settings/composables/useSettingsExport.ts new file mode 100644 index 00000000..f344a935 --- /dev/null +++ b/apps/web/modules/settings/composables/useSettingsExport.ts @@ -0,0 +1,36 @@ +import { shallowRef } from 'vue'; + +import { classifySettingsError, type SettingsError } from '../utils/form'; + +/** `$orpc.settings.export` from the page that owns the client. */ +export type SettingsExporter = () => Promise<{ yaml: string }>; + +/** + * The settings as the YAML `config.yml` would hold, fetched only when asked for. + * + * The server renders it with every environment secret redacted, and the settings themselves refuse + * anything shaped like a credential, so what arrives here is safe to download or copy whole. + */ +export function useSettingsExport(read: SettingsExporter) { + const yaml = shallowRef(null); + const pending = shallowRef(false); + const error = shallowRef(null); + + async function load(): Promise { + pending.value = true; + error.value = null; + try { + const result = await read(); + yaml.value = result.yaml; + return result.yaml; + } catch (caught) { + yaml.value = null; + error.value = classifySettingsError(caught); + return null; + } finally { + pending.value = false; + } + } + + return { yaml, pending, error, load }; +} diff --git a/apps/web/modules/settings/composables/useSettingsForm.test.ts b/apps/web/modules/settings/composables/useSettingsForm.test.ts new file mode 100644 index 00000000..03b28aa2 --- /dev/null +++ b/apps/web/modules/settings/composables/useSettingsForm.test.ts @@ -0,0 +1,219 @@ +import type { OrganizationSettingsView, SettingsView } from '@code-zero/api'; +import type { DeploymentSettings } from '@code-zero/config'; +import { ORPCError } from '@orpc/client'; +import { describe, expect, it, vi } from 'vitest'; +import { nextTick, shallowRef } from 'vue'; + +import type { SelfHostedSettingsView, SettingsInput } from '../utils/form.js'; +import { useDeploymentSettingsForm } from './useDeploymentSettingsForm.js'; +import { + useOrganizationLimitsForm, + type OrganizationLimitsSaver, +} from './useOrganizationLimitsForm.js'; +import { useSettings } from './useSettings.js'; + +/** + * The composables take the procedures they call as arguments, so these specs hand them stubs and + * stay in the plain-Node `unit` project, with no Nuxt app to boot. + */ +const SETTINGS: DeploymentSettings = { + execution: { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: ['observe', 'suggest', 'fix', 'autonomous'], + maxNetwork: 'full', + }, + model: null, + runner: null, +}; + +function view(settings: DeploymentSettings = SETTINGS): SelfHostedSettingsView { + return { + edition: 'self-hosted', + source: 'dashboard', + editable: true, + filePath: null, + state: { ok: true, settings, saved: null, warnings: [] }, + services: [], + bounds: { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, + }, + }; +} + +describe('useDeploymentSettingsForm', () => { + it('saves the mapped input and reports the stored view as saved', async () => { + const stored = view({ + ...SETTINGS, + execution: { ...SETTINGS.execution, maxConcurrentTasks: 2 }, + }); + const save = vi.fn<(input: SettingsInput) => Promise>().mockResolvedValue(stored); + const onSaved = vi.fn<(next: SettingsView) => void>(); + const editor = useDeploymentSettingsForm(() => view(), save, onSaved); + + editor.form.value.maxConcurrentTasks = '2'; + await editor.submit(); + + expect(save).toHaveBeenCalledWith({ + execution: { + maxConcurrentTasks: 2, + commandTimeoutMinutes: 30, + allowedModes: ['observe', 'suggest', 'fix', 'autonomous'], + maxNetwork: 'full', + }, + model: null, + runner: null, + }); + expect(onSaved).toHaveBeenCalledWith(stored); + expect(editor.status.value).toBe('saved'); + }); + + it('stops saying saved once the form no longer matches what was saved', async () => { + const editor = useDeploymentSettingsForm( + () => view(), + () => Promise.resolve(view()), + () => {}, + ); + + await editor.submit(); + expect(editor.saved.value).toBe(true); + + editor.form.value.commandTimeoutMinutes = '10'; + expect(editor.saved.value).toBe(false); + expect(editor.dirty.value).toBe(true); + }); + + it("puts each of the server's issues beside its field", async () => { + const refusal = new ORPCError('BAD_REQUEST', { + data: { + issues: [ + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + { path: '$.runner.image', message: 'Expected a container image reference.' }, + ], + }, + }); + const onSaved = vi.fn<(next: SettingsView) => void>(); + const editor = useDeploymentSettingsForm( + () => view(), + () => Promise.reject(refusal), + onSaved, + ); + + await editor.submit(); + + expect(editor.status.value).toBe('error'); + expect(editor.error.value).toBe('invalid'); + expect(editor.issues.value).toEqual({ + commandTimeoutMinutes: [{ text: 'Expected an integer from 1 to 1440.' }], + runnerImage: [{ text: 'Expected a container image reference.' }], + }); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it('reports a refusal that is not about the values with no field issues', async () => { + const editor = useDeploymentSettingsForm( + () => view(), + () => Promise.reject(new ORPCError('FORBIDDEN', { message: 'requires the admin role' })), + () => {}, + ); + + await editor.submit(); + + expect(editor.error.value).toBe('forbidden'); + expect(editor.issues.value).toEqual({}); + }); + + it('never calls the server for a form it cannot express', async () => { + const save = vi.fn<(input: SettingsInput) => Promise>(); + const editor = useDeploymentSettingsForm( + () => view(), + save, + () => {}, + ); + + editor.form.value.maxConcurrentTasks = ''; + await editor.submit(); + + expect(save).not.toHaveBeenCalled(); + expect(editor.issues.value.maxConcurrentTasks).toEqual([ + { key: 'dashboard.settings.form.numberRequired' }, + ]); + }); + + it('starts the draft over when the settings it describes change', async () => { + const current = shallowRef(view()); + const editor = useDeploymentSettingsForm( + () => current.value, + vi.fn(), + () => {}, + ); + editor.form.value.maxConcurrentTasks = '9'; + + current.value = view({ + ...SETTINGS, + execution: { ...SETTINGS.execution, maxConcurrentTasks: 3 }, + }); + await nextTick(); + + expect(editor.form.value.maxConcurrentTasks).toBe('3'); + expect(editor.dirty.value).toBe(false); + }); +}); + +describe('useOrganizationLimitsForm', () => { + it('saves for the organization the view names, and reports a value above the plan', async () => { + const organization: OrganizationSettingsView = { + id: 'org_acme', + role: 'admin', + editable: true, + limits: { maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }, + invalid: false, + effective: null, + }; + const save = vi.fn().mockRejectedValue( + new ORPCError('BAD_REQUEST', { + data: { + issues: [{ path: '$.max_concurrent_tasks', message: 'The plan allows at most 4.' }], + }, + }), + ); + const editor = useOrganizationLimitsForm( + () => organization, + () => null, + save, + () => {}, + ); + + editor.form.value.maxConcurrentTasks = '8'; + await editor.submit(); + + expect(save).toHaveBeenCalledWith({ + organizationId: 'org_acme', + limits: { maxConcurrentTasks: 8, commandTimeoutMinutes: null, allowedModes: null }, + }); + expect(editor.issues.value).toEqual({ + maxConcurrentTasks: [{ text: 'The plan allows at most 4.' }], + }); + }); +}); + +describe('useSettings', () => { + it('clears the view when a later read is refused, so nothing stale reads as permission', async () => { + const read = vi + .fn<() => Promise>() + .mockResolvedValueOnce(view()) + .mockRejectedValueOnce(new ORPCError('FORBIDDEN')); + const settings = useSettings(read); + + await settings.refresh(); + expect(settings.view.value?.edition).toBe('self-hosted'); + + await settings.refresh(); + expect(settings.view.value).toBeNull(); + expect(settings.error.value).toBe('forbidden'); + }); +}); diff --git a/apps/web/modules/settings/composables/useSettingsForm.ts b/apps/web/modules/settings/composables/useSettingsForm.ts new file mode 100644 index 00000000..e1e37895 --- /dev/null +++ b/apps/web/modules/settings/composables/useSettingsForm.ts @@ -0,0 +1,97 @@ +import { computed, shallowRef, watch, type Ref } from 'vue'; + +import { + classifySettingsError, + issuesFromError, + type FieldIssues, + type FormResult, + type ServerIssue, + type SettingsError, +} from '../utils/form'; + +/** Where a save stands. `saved` is only reported while the form still matches what was saved. */ +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +export interface SettingsFormOptions { + /** + * The draft, created by the caller with `ref`, so its type is the caller's concrete form rather + * than this function's generic one, and every control bound to it is deeply reactive. + */ + form: Ref
; + /** The form the current server state fills in; read again whenever that state changes. */ + initial: () => Form; + /** The input the form describes, or the fields that cannot be sent at all. */ + build: (form: Form) => FormResult; + /** Server issues by the control each one belongs to. */ + fieldIssues: (issues: readonly ServerIssue[]) => FieldIssues; + save: (input: Input) => Promise; + /** Receives what the server stored, so the page shows its answer rather than the draft. */ + onSaved: (result: Result) => void; +} + +/** + * A settings form: a draft, the server's verdict on it, and nothing decided in between. + * + * The draft starts from the server's state and starts over whenever that state changes, so a + * refresh or a save never leaves a form describing settings that are no longer there. Issues come + * back per field from the same parser the deployment file goes through; the form never decides a + * value is acceptable on its own, it only declines to send one it cannot express. + */ +export function useSettingsForm( + options: SettingsFormOptions, +) { + const { form } = options; + const baseline = shallowRef(snapshot(form.value)); + const issues = shallowRef>({}); + const status = shallowRef('idle'); + const error = shallowRef(null); + + watch(options.initial, (next) => { + form.value = next; + baseline.value = snapshot(next); + issues.value = {}; + }); + + const dirty = computed(() => snapshot(form.value) !== baseline.value); + const saving = computed(() => status.value === 'saving'); + const saved = computed(() => status.value === 'saved' && !dirty.value); + + async function submit(): Promise { + if (saving.value) return; + const built = options.build(form.value); + if (!built.ok) { + issues.value = built.issues; + status.value = 'error'; + error.value = 'invalid'; + return; + } + status.value = 'saving'; + issues.value = {}; + error.value = null; + try { + const result = await options.save(built.input); + status.value = 'saved'; + options.onSaved(result); + } catch (caught) { + const server = issuesFromError(caught); + issues.value = server ? options.fieldIssues(server) : {}; + error.value = classifySettingsError(caught); + status.value = 'error'; + } + } + + /** Discards the draft and its issues, back to what the server holds. */ + function reset(): void { + form.value = options.initial(); + baseline.value = snapshot(form.value); + issues.value = {}; + error.value = null; + status.value = 'idle'; + } + + return { form, issues, status, error, dirty, saving, saved, submit, reset }; +} + +function snapshot(value: unknown): string { + return JSON.stringify(value); +} diff --git a/apps/web/modules/settings/index.ts b/apps/web/modules/settings/index.ts new file mode 100644 index 00000000..c2a9b81b --- /dev/null +++ b/apps/web/modules/settings/index.ts @@ -0,0 +1,20 @@ +import { addComponentsDir, addImportsDir, createResolver, defineNuxtModule } from 'nuxt/kit'; + +/** + * Registers `settings/components` and `settings/composables`. Prefixed like the audit module: + * names like Form and Services are generic enough to collide with the next feature module. + * + * `utils/` is deliberately not auto-imported: it is the form's mapping to and from the API, and + * every caller imports it by name so the mapping stays one visible dependency. + */ +export default defineNuxtModule({ + meta: { + name: 'dashboard-settings', + }, + setup() { + const resolver = createResolver(import.meta.url); + + addComponentsDir({ path: resolver.resolve('./components'), prefix: 'Settings' }); + addImportsDir(resolver.resolve('./composables')); + }, +}); diff --git a/apps/web/modules/settings/utils/form.test.ts b/apps/web/modules/settings/utils/form.test.ts new file mode 100644 index 00000000..90bb20ed --- /dev/null +++ b/apps/web/modules/settings/utils/form.test.ts @@ -0,0 +1,316 @@ +import type { OrganizationSettingsView } from '@code-zero/api'; +import type { DeploymentSettings } from '@code-zero/config'; +import { ORPCError } from '@orpc/client'; +import { describe, expect, it } from 'vitest'; + +import { + classifySettingsError, + DEFAULT_SETTINGS, + formFromSettings, + formFromView, + issuesFromError, + organizationFieldIssues, + organizationFormFromView, + organizationInputFromForm, + selectableModes, + settingsFieldIssues, + settingsInputFromForm, + type SelfHostedSettingsView, +} from './form.js'; + +const SETTINGS: DeploymentSettings = { + execution: { + maxConcurrentTasks: 8, + commandTimeoutMinutes: 20, + allowedModes: ['observe', 'suggest'], + maxNetwork: 'restricted', + }, + model: { provider: 'anthropic', name: 'claude-sonnet' }, + runner: { isolation: 'container', engine: 'podman', image: 'ghcr.io/acme/runner:1' }, +}; + +const BOUNDS = { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, +} as const; + +function selfHosted(state: SelfHostedSettingsView['state']): SelfHostedSettingsView { + return { + edition: 'self-hosted', + source: 'dashboard', + editable: true, + filePath: null, + state, + services: [], + bounds: BOUNDS, + }; +} + +function organization(limits: OrganizationSettingsView['limits']): OrganizationSettingsView { + return { id: 'org_1', role: 'owner', editable: true, limits, invalid: false, effective: null }; +} + +describe('the deployment settings form', () => { + it('sends back exactly the settings it was filled from', () => { + const result = settingsInputFromForm(formFromSettings(SETTINGS)); + + expect(result).toEqual({ + ok: true, + input: { + execution: { + maxConcurrentTasks: 8, + commandTimeoutMinutes: 20, + allowedModes: ['observe', 'suggest'], + maxNetwork: 'restricted', + }, + model: { provider: 'anthropic', name: 'claude-sonnet' }, + runner: { isolation: 'container', engine: 'podman', image: 'ghcr.io/acme/runner:1' }, + }, + }); + }); + + it('sends no model and no runner when each repository chooses its own', () => { + const form = formFromSettings(SETTINGS); + form.modelSource = 'repository'; + form.runnerSource = 'repository'; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.model).toBeNull(); + expect(result.ok && result.input.runner).toBeNull(); + }); + + it('sends modes in the canonical order, whatever order they were ticked in', () => { + const form = formFromSettings(SETTINGS); + form.allowedModes = ['fix', 'observe', 'suggest']; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.execution.allowedModes).toEqual(['observe', 'suggest', 'fix']); + }); + + it('trims the model name and the image before sending them', () => { + const form = formFromSettings(SETTINGS); + form.modelName = ' gpt-5 '; + form.runnerImage = ' ghcr.io/acme/runner:2\n'; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.model?.name).toBe('gpt-5'); + expect(result.ok && result.input.runner?.image).toBe('ghcr.io/acme/runner:2'); + }); + + it('accepts the numbers Vue casts a number input to, as well as text', () => { + const form = formFromSettings(SETTINGS); + form.maxConcurrentTasks = 12; + form.commandTimeoutMinutes = '45'; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.execution.maxConcurrentTasks).toBe(12); + expect(result.ok && result.input.execution.commandTimeoutMinutes).toBe(45); + }); + + it('refuses to send an emptied number, naming the field', () => { + const form = formFromSettings(SETTINGS); + form.commandTimeoutMinutes = ''; + + expect(settingsInputFromForm(form)).toEqual({ + ok: false, + issues: { commandTimeoutMinutes: [{ key: 'dashboard.settings.form.numberRequired' }] }, + }); + }); + + it('leaves the bounds to the server: an out-of-range value is sent as typed', () => { + const form = formFromSettings(SETTINGS); + form.maxConcurrentTasks = '9000'; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.execution.maxConcurrentTasks).toBe(9000); + }); + + it("starts from the parser's defaults when the stored settings are invalid", () => { + const form = formFromView( + selfHosted({ ok: false, issues: [{ path: '$', message: 'Broken.' }] }), + ); + + expect(form).toEqual(formFromSettings(DEFAULT_SETTINGS)); + }); +}); + +describe('server issues', () => { + it('lands each issue on the control its path names', () => { + const issues = settingsFieldIssues([ + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + { path: '$.execution.allowed_modes', message: 'Expected a list that includes observe.' }, + { path: '$.model.name', message: 'Looks like a credential.' }, + { path: '$.model', message: 'A cloud deployment must configure the model.' }, + { path: '$.runner.image', message: 'Expected a container image reference.' }, + ]); + + expect(issues).toEqual({ + commandTimeoutMinutes: [{ text: 'Expected an integer from 1 to 1440.' }], + allowedModes: [{ text: 'Expected a list that includes observe.' }], + modelName: [{ text: 'Looks like a credential.' }], + modelSource: [{ text: 'A cloud deployment must configure the model.' }], + runnerImage: [{ text: 'Expected a container image reference.' }], + }); + }); + + it('keeps an issue no control owns at the form level rather than dropping it', () => { + const issues = settingsFieldIssues([ + { path: '$.execution.max_concurrent_tasks_typo', message: 'Unknown setting.' }, + { path: '$', message: 'Expected an object.' }, + ]); + + expect(issues).toEqual({ + form: [{ text: 'Unknown setting.' }, { text: 'Expected an object.' }], + }); + }); + + it("reads the organization limits' paths from the limits document's own root", () => { + expect( + organizationFieldIssues([ + { path: '$.command_timeout_minutes', message: 'The plan allows at most 60.' }, + { path: '$.allowed_modes', message: 'The plan does not allow fix.' }, + ]), + ).toEqual({ + commandTimeoutMinutes: [{ text: 'The plan allows at most 60.' }], + allowedModes: [{ text: 'The plan does not allow fix.' }], + }); + }); + + it("reads issues out of a BAD_REQUEST's data, skipping entries of any other shape", () => { + const refusal = new ORPCError('BAD_REQUEST', { + data: { + issues: [ + { path: '$.model.name', message: 'Expected a model name.' }, + { message: 'Input validation failed', path: ['execution', 0] }, + 'not an issue', + ], + }, + }); + + expect(issuesFromError(refusal)).toEqual([ + { path: '$.model.name', message: 'Expected a model name.' }, + { path: '$', message: 'Input validation failed' }, + ]); + }); + + it('finds no value issues in a refusal that was not about the values', () => { + expect(issuesFromError(new ORPCError('FORBIDDEN'))).toBeNull(); + expect(issuesFromError(new Error('network'))).toBeNull(); + }); + + it('classifies a failure by its code, never by its message', () => { + expect(classifySettingsError(new ORPCError('FORBIDDEN', { message: 'anything' }))).toBe( + 'forbidden', + ); + expect(classifySettingsError(new ORPCError('UNAUTHORIZED'))).toBe('unauthorized'); + expect(classifySettingsError(new ORPCError('NOT_IMPLEMENTED'))).toBe('unavailable'); + expect(classifySettingsError(new ORPCError('BAD_REQUEST'))).toBe('invalid'); + expect(classifySettingsError(new ORPCError('PRECONDITION_FAILED'))).toBe('precondition'); + expect(classifySettingsError(new ORPCError('INTERNAL_SERVER_ERROR'))).toBe('generic'); + expect(classifySettingsError(new TypeError('fetch failed'))).toBe('generic'); + }); +}); + +describe('the organization limits form', () => { + it('sends null for every limit left to the plan', () => { + const form = organizationFormFromView( + organization({ maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }), + null, + ); + + expect(organizationInputFromForm(form)).toEqual({ + ok: true, + input: { maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }, + }); + }); + + it('sends the limits an organization chose, modes in the canonical order', () => { + const form = organizationFormFromView( + organization({ maxConcurrentTasks: 2, commandTimeoutMinutes: 10, allowedModes: ['observe'] }), + null, + ); + form.allowedModes = ['suggest', 'observe']; + + expect(organizationInputFromForm(form)).toEqual({ + ok: true, + input: { + maxConcurrentTasks: 2, + commandTimeoutMinutes: 10, + allowedModes: ['observe', 'suggest'], + }, + }); + }); + + it('hands the modes back to the plan when they are no longer restricted', () => { + const form = organizationFormFromView( + organization({ + maxConcurrentTasks: null, + commandTimeoutMinutes: null, + allowedModes: ['observe'], + }), + null, + ); + form.restrictModes = false; + + const result = organizationInputFromForm(form); + + expect(result.ok && result.input.allowedModes).toBeNull(); + }); + + it('never sends a mode the plan does not allow, since the form shows no box for one', () => { + const plan = { + maxConcurrentTasks: 6, + commandTimeoutMinutes: 20, + allowedModes: ['observe', 'suggest', 'fix'], + maxNetwork: 'restricted', + } as const; + const form = organizationFormFromView( + organization({ maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }), + plan, + ); + + // Restrict, then untick fix: what is left is what the reader sees ticked, and nothing else. + form.restrictModes = true; + form.allowedModes = form.allowedModes.filter((mode) => mode !== 'fix'); + + const result = organizationInputFromForm(form); + expect(result.ok && result.input.allowedModes).toEqual(['observe', 'suggest']); + }); + + it('drops a stored mode the plan has since stopped allowing from the draft', () => { + const form = organizationFormFromView( + organization({ + maxConcurrentTasks: null, + commandTimeoutMinutes: null, + allowedModes: ['observe', 'autonomous'], + }), + { + maxConcurrentTasks: 6, + commandTimeoutMinutes: 20, + allowedModes: ['observe'], + maxNetwork: 'none', + }, + ); + + expect(form.allowedModes).toEqual(['observe']); + }); + + it("offers only the plan's modes to narrow", () => { + expect( + selectableModes({ + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: ['observe', 'fix'], + maxNetwork: 'none', + }), + ).toEqual(['observe', 'fix']); + }); +}); diff --git a/apps/web/modules/settings/utils/form.ts b/apps/web/modules/settings/utils/form.ts new file mode 100644 index 00000000..0629ce59 --- /dev/null +++ b/apps/web/modules/settings/utils/form.ts @@ -0,0 +1,378 @@ +// Types only: `@code-zero/config` reads files and parses YAML, so none of its code belongs in the +// browser bundle. The lists below restate its enumerations, and the server re-checks every value. +import type { OrganizationSettingsView, SettingsView } from '@code-zero/api'; +import type { DeploymentSettings, ExecutionSettings, OrganizationLimits } from '@code-zero/config'; +import type { ModelProviderKind, NetworkPolicy, RunMode } from '@code-zero/shared'; +import { ORPCError } from '@orpc/client'; + +export type SelfHostedSettingsView = Extract; +export type CloudSettingsView = Extract; +export type ContainerEngine = NonNullable['engine']; + +// Records rather than arrays, so adding a mode, a policy, an engine or a provider to the shared +// types fails the type check here until the form offers it too. +const MODE_SET: Record = { + observe: true, + suggest: true, + fix: true, + autonomous: true, +}; +const NETWORK_SET: Record = { none: true, restricted: true, full: true }; +const ENGINE_SET: Record = { docker: true, podman: true }; +const PROVIDER_SET: Record = { + 'ai-gateway': true, + anthropic: true, + 'claude-code': true, + 'codex-cli': true, + google: true, + openai: true, + 'openai-compatible': true, +}; + +/** In the canonical order the parser stores them in: least to most write access. */ +export const RUN_MODES = keysOf(MODE_SET); +/** Narrowest first. */ +export const NETWORK_POLICIES = keysOf(NETWORK_SET); +export const CONTAINER_ENGINES = keysOf(ENGINE_SET); +export const MODEL_PROVIDERS = keysOf(PROVIDER_SET); + +/** + * What the form starts from when the stored settings cannot be read: the parser's own defaults + * (`defaultDeploymentSettings` in `@code-zero/config`), so saving an untouched form changes nothing + * a deployment without settings did not already do. + */ +export const DEFAULT_SETTINGS: DeploymentSettings = { + execution: { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: RUN_MODES, + maxNetwork: 'full', + }, + model: null, + runner: null, +}; + +/** + * What a number input holds. Vue's `v-model` casts a `type="number"` input's value to a number and + * leaves it as text when it cannot (an emptied field is `''`), so both arrive here, and an emptied + * field is reported as empty rather than turned into a `0` the server would refuse for a reason + * the reader did not cause. + */ +export type NumberText = string | number; + +/** The editable settings, flat so every control binds one field and every issue names one. */ +export interface SettingsFormState { + maxConcurrentTasks: NumberText; + commandTimeoutMinutes: NumberText; + allowedModes: RunMode[]; + maxNetwork: NetworkPolicy; + /** `repository` leaves the model to each repository's `.code-zero.yml`. */ + modelSource: 'repository' | 'deployment'; + modelProvider: ModelProviderKind; + modelName: string; + /** `repository` leaves isolation to each repository's `.code-zero.yml`. */ + runnerSource: 'repository' | 'container'; + runnerEngine: ContainerEngine; + runnerImage: string; +} + +export type SettingsField = keyof SettingsFormState; + +/** An organization's own limits. An empty number, or unrestricted modes, means the plan's value. */ +export interface OrganizationFormState { + maxConcurrentTasks: NumberText; + commandTimeoutMinutes: NumberText; + restrictModes: boolean; + allowedModes: RunMode[]; +} + +export type OrganizationField = keyof OrganizationFormState; + +/** `settings.update`'s input, as `deploymentSettingsInput` in `@code-zero/api` declares it. */ +export interface SettingsInput { + execution: { + maxConcurrentTasks: number; + commandTimeoutMinutes: number; + allowedModes: string[]; + maxNetwork: string; + }; + model: { provider: string; name: string } | null; + runner: { isolation: string; engine: string; image: string } | null; +} + +/** The `limits` half of `settings.organization.update`'s input. `null` keeps the plan's value. */ +export interface OrganizationLimitsInput { + maxConcurrentTasks: number | null; + commandTimeoutMinutes: number | null; + allowedModes: string[] | null; +} + +/** + * One problem with one field: a key this app translates, or the server's own sentence. + * + * The server's sentences come from the settings parser and name the rule a value broke ("Expected + * an integer from 1 to 64."); they are rendered as text, never as markup. + */ +export type IssueText = { readonly key: string } | { readonly text: string }; + +/** Problems by field. `form` holds those that belong to no single control. */ +export type FieldIssues = Partial>; + +export type FormResult = + | { ok: true; input: Input } + | { ok: false; issues: FieldIssues }; + +/** An issue as the settings procedures report it in a `BAD_REQUEST`'s `data.issues`. */ +export interface ServerIssue { + path: string; + message: string; +} + +/** Why a settings call failed, in the terms the page has copy for. */ +export type SettingsError = + | 'forbidden' + | 'unauthorized' + | 'unavailable' + | 'invalid' + | 'precondition' + | 'generic'; + +/** Paths the settings parser reports, most specific first, and the control each belongs to. */ +const SETTINGS_PATHS: readonly (readonly [string, SettingsField])[] = [ + ['$.execution.max_concurrent_tasks', 'maxConcurrentTasks'], + ['$.execution.command_timeout_minutes', 'commandTimeoutMinutes'], + ['$.execution.allowed_modes', 'allowedModes'], + ['$.execution.max_network', 'maxNetwork'], + ['$.model.provider', 'modelProvider'], + ['$.model.name', 'modelName'], + ['$.model', 'modelSource'], + ['$.runner.engine', 'runnerEngine'], + ['$.runner.image', 'runnerImage'], + ['$.runner', 'runnerSource'], +]; + +/** The organization limits parser reports paths from the limits document's own root. */ +const ORGANIZATION_PATHS: readonly (readonly [string, OrganizationField])[] = [ + ['$.max_concurrent_tasks', 'maxConcurrentTasks'], + ['$.command_timeout_minutes', 'commandTimeoutMinutes'], + ['$.allowed_modes', 'allowedModes'], +]; + +const NUMBER_REQUIRED: IssueText = { key: 'dashboard.settings.form.numberRequired' }; + +/** The form a set of settings fills in. */ +export function formFromSettings(settings: DeploymentSettings): SettingsFormState { + return { + maxConcurrentTasks: String(settings.execution.maxConcurrentTasks), + commandTimeoutMinutes: String(settings.execution.commandTimeoutMinutes), + allowedModes: [...settings.execution.allowedModes], + maxNetwork: settings.execution.maxNetwork, + modelSource: settings.model ? 'deployment' : 'repository', + modelProvider: settings.model?.provider ?? 'openai', + modelName: settings.model?.name ?? '', + runnerSource: settings.runner ? 'container' : 'repository', + runnerEngine: settings.runner?.engine ?? 'docker', + runnerImage: settings.runner?.image ?? '', + }; +} + +/** The form for a self-hosted view: its settings, or the defaults while they are invalid. */ +export function formFromView(view: SelfHostedSettingsView): SettingsFormState { + return formFromSettings(view.state.ok ? view.state.settings : DEFAULT_SETTINGS); +} + +/** + * The `settings.update` input a form describes. + * + * Only what cannot be sent at all is caught here — an emptied number. Bounds, names and images + * are the server's to judge, with the same parser the deployment file goes through, so the form + * and the file cannot come to disagree about what is allowed. + */ +export function settingsInputFromForm( + form: SettingsFormState, +): FormResult { + const issues: FieldIssues = {}; + const maxConcurrentTasks = requiredNumber(form.maxConcurrentTasks); + const commandTimeoutMinutes = requiredNumber(form.commandTimeoutMinutes); + if (maxConcurrentTasks === null) issues.maxConcurrentTasks = [NUMBER_REQUIRED]; + if (commandTimeoutMinutes === null) issues.commandTimeoutMinutes = [NUMBER_REQUIRED]; + if (maxConcurrentTasks === null || commandTimeoutMinutes === null) return { ok: false, issues }; + return { + ok: true, + input: { + execution: { + maxConcurrentTasks, + commandTimeoutMinutes, + // Sent in the canonical order whatever order the boxes were ticked in. + allowedModes: RUN_MODES.filter((mode) => form.allowedModes.includes(mode)), + maxNetwork: form.maxNetwork, + }, + model: + form.modelSource === 'deployment' + ? { provider: form.modelProvider, name: form.modelName.trim() } + : null, + runner: + form.runnerSource === 'container' + ? { isolation: 'container', engine: form.runnerEngine, image: form.runnerImage.trim() } + : null, + }, + }; +} + +/** The form an organization's stored limits fill in. */ +export function organizationFormFromView( + view: OrganizationSettingsView, + plan: ExecutionSettings | null, +): OrganizationFormState { + return organizationFormFromLimits(view.limits, plan); +} + +/** + * The draft's modes are only ever the plan's: the form shows a box for each of those and no + * other, so a mode the plan does not allow — the full list while the plan's own modes apply, or a + * stored mode the plan has since dropped — would otherwise be sent without the reader seeing it. + */ +export function organizationFormFromLimits( + limits: OrganizationLimits, + plan: ExecutionSettings | null, +): OrganizationFormState { + const offered = selectableModes(plan); + return { + maxConcurrentTasks: limits.maxConcurrentTasks === null ? '' : String(limits.maxConcurrentTasks), + commandTimeoutMinutes: + limits.commandTimeoutMinutes === null ? '' : String(limits.commandTimeoutMinutes), + restrictModes: limits.allowedModes !== null, + allowedModes: limits.allowedModes + ? offered.filter((mode) => limits.allowedModes?.includes(mode)) + : offered, + }; +} + +/** + * The organization limits a form describes. An empty number keeps the plan's value, so the only + * local mistake left is text that is not a number at all. + */ +export function organizationInputFromForm( + form: OrganizationFormState, +): FormResult { + const issues: FieldIssues = {}; + const maxConcurrentTasks = optionalNumber(form.maxConcurrentTasks); + const commandTimeoutMinutes = optionalNumber(form.commandTimeoutMinutes); + if (maxConcurrentTasks === undefined) issues.maxConcurrentTasks = [NUMBER_REQUIRED]; + if (commandTimeoutMinutes === undefined) issues.commandTimeoutMinutes = [NUMBER_REQUIRED]; + if (maxConcurrentTasks === undefined || commandTimeoutMinutes === undefined) + return { ok: false, issues }; + return { + ok: true, + input: { + maxConcurrentTasks, + commandTimeoutMinutes, + allowedModes: form.restrictModes + ? RUN_MODES.filter((mode) => form.allowedModes.includes(mode)) + : null, + }, + }; +} + +/** Server issues for the deployment settings form, by the control each one belongs to. */ +export function settingsFieldIssues(issues: readonly ServerIssue[]): FieldIssues { + return byField(issues, SETTINGS_PATHS); +} + +/** Server issues for the organization limits form, by the control each one belongs to. */ +export function organizationFieldIssues( + issues: readonly ServerIssue[], +): FieldIssues { + return byField(issues, ORGANIZATION_PATHS); +} + +/** + * The issues a refused save carried, or null when the refusal was not about the values. + * + * Read defensively: the shape is the settings procedures' own, but anything that does not match it + * (the transport's input validation, say) still reaches the reader as a form-level issue rather + * than vanishing. + */ +export function issuesFromError(caught: unknown): ServerIssue[] | null { + if (!(caught instanceof ORPCError) || caught.code !== 'BAD_REQUEST') return null; + const data: unknown = caught.data; + const list = isRecord(data) && Array.isArray(data.issues) ? (data.issues as unknown[]) : []; + const issues = list.flatMap((issue): ServerIssue[] => + isRecord(issue) && typeof issue.message === 'string' + ? [{ path: typeof issue.path === 'string' ? issue.path : '$', message: issue.message }] + : [], + ); + return issues; +} + +/** + * The error code decides the copy, never the server's message: a refusal's wording is the + * server's, and the page has its own sentence for each kind of refusal. + */ +export function classifySettingsError(caught: unknown): SettingsError { + if (!(caught instanceof ORPCError)) return 'generic'; + switch (caught.code) { + case 'FORBIDDEN': + return 'forbidden'; + case 'UNAUTHORIZED': + return 'unauthorized'; + case 'NOT_IMPLEMENTED': + return 'unavailable'; + case 'BAD_REQUEST': + return 'invalid'; + case 'PRECONDITION_FAILED': + return 'precondition'; + default: + return 'generic'; + } +} + +/** Whether a mode is the one every deployment and organization must keep. */ +export function isRequiredMode(mode: RunMode): boolean { + return mode === 'observe'; +} + +/** The plan's modes an organization may choose from: it can narrow them, never add to them. */ +export function selectableModes(plan: ExecutionSettings | null): RunMode[] { + return plan ? RUN_MODES.filter((mode) => plan.allowedModes.includes(mode)) : [...RUN_MODES]; +} + +/** A set's keys, in the order they were written. */ +function keysOf(set: Readonly>): Key[] { + return Object.keys(set).filter((key): key is Key => key in set); +} + +function byField( + issues: readonly ServerIssue[], + paths: readonly (readonly [string, Field])[], +): FieldIssues { + const grouped: FieldIssues = {}; + for (const issue of issues) { + const field = paths.find(([prefix]) => covers(prefix, issue.path))?.[1] ?? 'form'; + (grouped[field] ??= []).push({ text: issue.message }); + } + return grouped; +} + +function covers(prefix: string, path: string): boolean { + return path === prefix || path.startsWith(`${prefix}.`) || path.startsWith(`${prefix}[`); +} + +function requiredNumber(text: NumberText): number | null { + const trimmed = String(text).trim(); + if (trimmed === '') return null; + const value = Number(trimmed); + return Number.isFinite(value) ? value : null; +} + +/** `null` for an empty field, `undefined` for text that is not a number. */ +function optionalNumber(text: NumberText): number | null | undefined { + const trimmed = String(text).trim(); + if (trimmed === '') return null; + const value = Number(trimmed); + return Number.isFinite(value) ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/web/modules/settings/utils/labels.ts b/apps/web/modules/settings/utils/labels.ts new file mode 100644 index 00000000..3b0464f6 --- /dev/null +++ b/apps/web/modules/settings/utils/labels.ts @@ -0,0 +1,44 @@ +import type { ServiceName } from '@code-zero/api'; +import type { NetworkPolicy, RunMode } from '@code-zero/shared'; + +import type { SettingsError } from './form'; + +// Spelled out rather than interpolated, so `i18n:report` can verify every key exists. + +export const MODE_LABEL_KEYS: Readonly> = { + observe: 'dashboard.settings.modes.observe', + suggest: 'dashboard.settings.modes.suggest', + fix: 'dashboard.settings.modes.fix', + autonomous: 'dashboard.settings.modes.autonomous', +}; + +export const NETWORK_LABEL_KEYS: Readonly> = { + none: 'dashboard.settings.network.none', + restricted: 'dashboard.settings.network.restricted', + full: 'dashboard.settings.network.full', +}; + +export const SERVICE_LABEL_KEYS: Readonly> = { + model: 'dashboard.settings.services.model', + sandbox: 'dashboard.settings.services.sandbox', + database: 'dashboard.settings.services.database', + mail: 'dashboard.settings.services.mail', +}; + +/** Why a save was refused. */ +export const SAVE_ERROR_KEYS: Readonly> = { + forbidden: 'dashboard.settings.form.forbidden', + unauthorized: 'dashboard.settings.form.unauthorized', + unavailable: 'dashboard.settings.error.unavailable', + invalid: 'dashboard.settings.form.invalid', + precondition: 'dashboard.settings.form.precondition', + generic: 'dashboard.settings.form.generic', +}; + +/** An ISO timestamp as the dashboard prints times: UTC, to the second. */ +export function formatTimestamp(iso: string): string { + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? iso + : `${date.toISOString().slice(0, 19).replace('T', ' ')}Z`; +} diff --git a/apps/web/modules/shared/components/app/Sidebar.vue b/apps/web/modules/shared/components/app/Sidebar.vue index d66bc883..2905120b 100644 --- a/apps/web/modules/shared/components/app/Sidebar.vue +++ b/apps/web/modules/shared/components/app/Sidebar.vue @@ -45,8 +45,8 @@ - + @@ -104,8 +104,8 @@ import { NuxtLink } from '#components'; const collapsed = useSidebarCollapsed(); const route = useRoute(); -const appConfig = useAppConfig(); -const enableOrganizations = appConfig.auth.enableOrganizations; +const capabilities = await useAuthCapabilities(); +const enableOrganizations = computed(() => capabilities.value.organizations); interface NavItem { key: string; @@ -125,6 +125,9 @@ interface NavItem { const navItems: readonly NavItem[] = [ { key: 'control', labelKey: 'dashboard.nav.control', icon: 'lucide:layout-dashboard', to: '/' }, { key: 'audit', labelKey: 'dashboard.nav.audit', icon: 'lucide:scroll-text', to: '/audit' }, + // Listed for every signed-in account: what the page shows depends on the edition and the role, + // and it explains a refusal itself rather than the nav guessing who may open it. + { key: 'settings', labelKey: 'dashboard.nav.settings', icon: 'lucide:settings', to: '/settings' }, ]; function isActive(item: NavItem): boolean { diff --git a/apps/web/modules/shared/composables/useAppShortcuts.ts b/apps/web/modules/shared/composables/useAppShortcuts.ts index b8379404..f7466ccd 100644 --- a/apps/web/modules/shared/composables/useAppShortcuts.ts +++ b/apps/web/modules/shared/composables/useAppShortcuts.ts @@ -31,6 +31,7 @@ export interface AppShortcut { export const APP_SHORTCUTS: readonly AppShortcut[] = [ { chords: ['G', 'C'], labelKey: 'dashboard.shortcuts.control' }, { chords: ['G', 'A'], labelKey: 'dashboard.shortcuts.audit' }, + { chords: ['G', 'S'], labelKey: 'dashboard.shortcuts.settings' }, { chords: ['B'], labelKey: 'dashboard.shortcuts.sidebar' }, { chords: ['Shift+/'], labelKey: 'dashboard.shortcuts.help' }, { chords: ['R'], labelKey: 'dashboard.shortcuts.refresh' }, @@ -69,6 +70,7 @@ export function useAppShortcuts(): void { [ { sequence: ['G', 'C'], callback: () => void router.push('/') }, { sequence: ['G', 'A'], callback: () => void router.push('/audit') }, + { sequence: ['G', 'S'], callback: () => void router.push('/settings') }, ], notWhileDialogOpen, ); diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index a282e99a..41e38829 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -2,28 +2,28 @@ import { symlink, unlink } from 'node:fs/promises'; import { basename } from 'node:path'; import process from 'node:process'; -// Read from `@code-zero/auth/config` rather than restated here, so the build-time label below and -// the runtime enforcement in `server/auth.config.ts` cannot drift. That subpath carries policy -// only, with none of the database dependencies `authBetterAuthOptions` needs. -import { authConfigFromEnvironment, infraFromEnvironment } from '@code-zero/auth/config'; +// Read from `@code-zero/auth/config` rather than restated here, so the build-time flag below and +// the server plugins in `server/auth.config.ts` cannot drift. That subpath carries policy only, +// with none of the database dependencies `authBetterAuthOptions` needs. +import { infraFromEnvironment } from '@code-zero/auth/config'; import { defaultLocale, i18nLocalesFor, localeCookieName } from '@code-zero/i18n'; import { defineNuxtConfig } from 'nuxt/config'; import { viteHubPresetFromEnvironment, viteHubVercelEntryAlias } from './config/env.js'; -// Resolved once at config evaluation so the dashboard's auth pages publish the same sign-in -// policy `server/auth.config.ts` enforces at runtime (AUTH_ENABLE_SIGNUP, GitHub OAuth -// credentials). Build-time capture is deliberate: the deployment contract (documented in README -// and .env.example) is that the app is rebuilt whenever those policy variables change. The server -// config enforces its own policy regardless, so a stale build can only mislabel a page, never open -// a sign-in method the server rejects. -const authPolicy = authConfigFromEnvironment(); +// The sign-in policy is deliberately not read here. Pages ask `/api/capabilities` at runtime, which +// derives it from the same environment `server/auth.config.ts` builds Better Auth from, so changing +// AUTH_ENABLE_SIGNUP or a provider's credentials needs a restart, not a rebuild. +// +// The hosted-infrastructure settings are the one exception, because they decide which client code +// ships: `app/auth.config.ts` loads `sentinelClient()` only where they are configured. +const infra = infraFromEnvironment(); // Only the KV origin is read out of the hosted-infrastructure settings, and only so the browser // can be told where to identify. The API key sitting next to it in `infraFromEnvironment()` is // never touched here: appConfig is published to the client, so anything read into it leaves the // server. -const infraKvUrl = infraFromEnvironment()?.kvUrl ?? ''; +const infraKvUrl = infra?.kvUrl ?? ''; // ViteHub's deployment preset is a build-time decision that also pins Nitro's own preset, so the // deployment target has to be resolved here rather than detected later: `node` emits the @@ -158,21 +158,17 @@ export default defineNuxtConfig({ }, // Published through appConfig rather than runtimeConfig.public: Nuxt maps NUXT_PUBLIC_* env vars - // onto public runtime keys, which would let a deployment advertise a sign-in capability that - // diverges from the policy `server/auth.config.ts` enforces from the same environment. appConfig - // has no env override channel, so AUTH_ENABLE_SIGNUP and the GitHub OAuth credentials stay the - // single authoritative source for both the build-time label and the runtime enforcement. + // onto public runtime keys, which would let a deployment load the sentinel client while its + // server registers no `sentinel()` plugin. appConfig has no env override channel, so the + // `BETTER_AUTH_*` credentials stay the single source for both halves. Sign-in capabilities are + // not here: pages read them from `/api/capabilities` at runtime. appConfig: { auth: { - enableSignup: authPolicy.enableSignup, - enableGithubOauth: authPolicy.enableGithubOauth, - enableOrganizations: authPolicy.enableOrganizations, - // Read by `app/auth.config.ts` to decide whether to load `sentinelClient()`. Unlike the - // three above, this one is not a label: the sentinel client identifies every visitor - // against Better Auth's KV service from their browser, so publishing it wrongly would send - // a self-hosted deployment's visitors to a third party. appConfig having no env override - // channel is what makes `BETTER_AUTH_*` the single source for both halves. - enableInfra: authPolicy.enableInfra, + // Read by `app/auth.config.ts` to decide whether to load `sentinelClient()`. Not a label: + // the sentinel client identifies every visitor against Better Auth's KV service from their + // browser, so publishing it wrongly would send a self-hosted deployment's visitors to a + // third party. + enableInfra: infra !== undefined, // The project's own ingestion endpoint. Without it `sentinelClient()` falls back to Better // Auth's shared global one, which its own startup warning calls out as not recommended. // Not a secret — the visitor's browser posts to it directly — unlike the API key it is @@ -231,6 +227,9 @@ export default defineNuxtConfig({ // A session is enough to reach the page; the admin role is enforced by the endpoint it reads // (the router's `audit.list`), so a non-admin sees the refusal rather than a redirect. '/audit': { appLayout: 'default', auth: { only: 'user' } }, + // Same reasoning: who may read or change the settings is the router's `settings.*` decision + // (admin self-hosted, membership and role on the managed cloud), shown on the page itself. + '/settings': { appLayout: 'default', auth: { only: 'user' } }, '/login': { auth: { only: 'guest' } }, '/signin': { auth: { only: 'guest' } }, '/organizations': { appLayout: 'default', auth: { only: 'user' } }, @@ -239,9 +238,6 @@ export default defineNuxtConfig({ // than being shown the form. The auth layout, not the shell, because the visitor arriving here // typed a code off a terminal and has no business in the navigation. '/device': { auth: { only: 'user' } }, - // Reached from an invitation email, so the visitor is frequently signed out at that moment: - // requiring a session sends them through /login and back, rather than rejecting the link. - '/organizations/accept-invitation/**': { appLayout: 'default', auth: { only: 'user' } }, }, vite: { diff --git a/apps/web/package.json b/apps/web/package.json index 53c7cdfc..fabc3fe0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,9 @@ "typecheck": "nuxt typecheck" }, "dependencies": { - "@better-auth/infra": "^0.4.0", + "@better-auth/api-key": "^1.7.10", + "@better-auth/infra": "^0.4.20", + "@better-auth/passkey": "^1.7.10", "@code-zero/api": "workspace:*", "@code-zero/auth": "workspace:*", "@code-zero/build-env": "workspace:*", @@ -32,7 +34,7 @@ "@code-zero/mail": "workspace:*", "@code-zero/shared": "workspace:*", "@code-zero/source-control": "workspace:*", - "@octopi-ai/better-enrollment": "^0.4.0", + "@octopi-ai/better-enrollment": "^0.5.2", "@onmax/nuxt-better-auth": "^0.1.2", "@orpc/client": "2.0.0-beta.26", "@orpc/evlog": "2.0.0-beta.26", @@ -43,7 +45,7 @@ "@tanstack/vue-hotkeys": "^0.10.0", "@tanstack/vue-query": "^5.101.4", "@tanstack/vue-table": "^9.1.2", - "better-auth": "^1.6.26", + "better-auth": "^1.7.10", "evlog": "^2.26.0", "h3": "^1.15.11", "nuxt": "^4.5.2", diff --git a/apps/web/server/api/capabilities.get.ts b/apps/web/server/api/capabilities.get.ts new file mode 100644 index 00000000..d1cfb500 --- /dev/null +++ b/apps/web/server/api/capabilities.get.ts @@ -0,0 +1,14 @@ +/** + * Which sign-in and account capabilities this deployment serves, for pages to render from. + * + * Read per request from the same environment `server/auth.config.ts` builds Better Auth from, so + * a policy change shows on the sign-in page without a rebuild. Public on purpose — the sign-in page + * needs it before anyone is signed in — which is safe because `authCapabilities` publishes plain + * booleans only: no credential, origin, or limit. + */ +export default defineEventHandler((event) => { + // Short and private: a proxy must not pin one deployment's policy for another, and the page + // should see a change within a minute. + setResponseHeader(event, 'cache-control', 'private, max-age=60'); + return deploymentAuthCapabilities(); +}); diff --git a/apps/web/server/api/v1/[...].ts b/apps/web/server/api/v1/[...].ts index a69dd480..6eac55b8 100644 --- a/apps/web/server/api/v1/[...].ts +++ b/apps/web/server/api/v1/[...].ts @@ -47,8 +47,11 @@ export default defineEventHandler(async (event) => { const { matched, response } = await handler.handle(request, { prefix: '/api/v1', context: { - ...buildRpcContext(request, await controlPlaneAccess(), taskStore, repositoryStore), + ...buildRpcContext(request, await controlPlaneAccess(), taskStore, repositoryStore, { + apiKeys: apiKeyVerifier(serverAuth(event)), + }), auditLog: auditLogStore, + settings: await settingsAuthority(), }, }); if (matched) return response; diff --git a/apps/web/server/auth.config.ts b/apps/web/server/auth.config.ts index 7ba70492..fa83d098 100644 --- a/apps/web/server/auth.config.ts +++ b/apps/web/server/auth.config.ts @@ -31,11 +31,22 @@ const deliversMail = */ const dashboardUrl = dashboardUrlFromEnvironment(process.env); +/** + * Policy is read here, when the server starts, from the same environment `/api/capabilities` + * publishes from (see `server/utils/auth-policy.ts`), not from anything captured at build time. + * No entitlements are passed, so SSO and SCIM stay unregistered until an edition supplies them. + */ const options = authBetterAuthOptions({ - ...authDatabaseOptionsFromEnvironment(), + ...authDatabaseOptionsFromEnvironment(authEnvironment()), ...(dashboardUrl ? { dashboardUrl } : {}), ...(deliversMail ? { + // Each of these is wired only because a template exists for it; enabling the matching + // policy flag without a transport is a startup error in `packages/auth`, not a no-op. + sendVerificationEmail: ({ to, name, verifyUrl }) => + sendMail({ to, templateId: 'emailVerification', context: { name, verifyUrl } }), + sendPasswordResetEmail: ({ to, name, resetUrl }) => + sendMail({ to, templateId: 'passwordReset', context: { name, resetUrl } }), // The enrollment plugin deliberately never returns a private invitation's link to whoever // created it, so this callback is the only path the token travels. The template renders // the optional halves away rather than printing "null": an invitation with no invitee name @@ -108,6 +119,8 @@ export default defineServerAuth( invite: [], inviteUse: [], deviceCode: [], + twoFactor: [], + passkey: [], }), // Test-only, and deliberately added here rather than in `packages/auth`: `testUtils` // registers no HTTP route, but it hangs privileged helpers off the auth context that can diff --git a/apps/web/server/plugins/poller.ts b/apps/web/server/plugins/poller.ts index f32eb9bf..63d55528 100644 --- a/apps/web/server/plugins/poller.ts +++ b/apps/web/server/plugins/poller.ts @@ -1,4 +1,5 @@ import { githubTokenFromEnvironment, runTask } from '@code-zero/api'; +import { isModeAllowed } from '@code-zero/config'; import { GitHubPullRequests } from '@code-zero/source-control'; /** @@ -39,6 +40,13 @@ export default defineNitroPlugin((nitroApp) => { let stopped = false; async function pass(): Promise { + // Read on every pass, like the watch list: a settings change applies to the next pass, and a + // pass under invalid settings starts nothing rather than running on defaults nobody chose. + const policy = await deploymentRunPolicy(); + if (!policy.ok) { + console.error('[poll] the deployment settings are invalid; not starting reviews'); + return; + } const watched = await repositoryStore.watched(); // Only a GitHub source exists in this process; a repository configured for another provider // would otherwise be queried through it under owner/repo coordinates that provider never @@ -52,6 +60,12 @@ export default defineNitroPlugin((nitroApp) => { ); return []; } + if (!isModeAllowed(policy.value.settings.execution, repository.mode)) { + console.warn( + `[poll] ${repository.owner}/${repository.name} runs in ${repository.mode}, which the deployment settings do not allow; skipping`, + ); + return []; + } return [{ ...repository, provider: 'github' as const }]; }); if (repositories.length === 0) return; @@ -68,7 +82,7 @@ export default defineNitroPlugin((nitroApp) => { source: request.source, pullRequest: request.pullRequest, }, - { store: taskStore }, + { store: taskStore, policy: policy.value }, ), onError: (repository, error) => { console.error(`[poll] ${repository} failed`, error); diff --git a/apps/web/server/routes/rpc/[...].ts b/apps/web/server/routes/rpc/[...].ts index e010383c..0b7fcb24 100644 --- a/apps/web/server/routes/rpc/[...].ts +++ b/apps/web/server/routes/rpc/[...].ts @@ -37,18 +37,17 @@ const handler = new RPCHandler(rpcRouter, { */ export default defineEventHandler(async (event) => { const request = toWebRequest(event); + const auth = serverAuth(event); try { const { matched, response } = await handler.handle(request, { prefix: '/rpc', context: { - ...buildRpcContext( - request, - await controlPlaneAccess(), - taskStore, - repositoryStore, - serverAuth(event), - ), + ...buildRpcContext(request, await controlPlaneAccess(), taskStore, repositoryStore, { + session: auth, + apiKeys: apiKeyVerifier(auth), + }), auditLog: auditLogStore, + settings: await settingsAuthority(), }, }); if (matched) return response; diff --git a/apps/web/server/routes/webhooks/github.post.ts b/apps/web/server/routes/webhooks/github.post.ts index 03a899f7..8db7d241 100644 --- a/apps/web/server/routes/webhooks/github.post.ts +++ b/apps/web/server/routes/webhooks/github.post.ts @@ -17,6 +17,11 @@ export default defineEventHandler(async (event) => { const checkoutPath = checkoutPathFromEnvironment(process.env); if (!checkoutPath) throw errors.misconfigured('CODE_ZERO_CHECKOUT_PATH'); + // Refused while the settings are invalid, like every other run: a delivery must not start work on + // defaults nobody chose. + const policy = await deploymentRunPolicy(); + if (!policy.ok) throw errors.unavailable('The deployment settings are invalid'); + const request = toWebRequest(event); let outcome; try { @@ -31,6 +36,7 @@ export default defineEventHandler(async (event) => { store: taskStore, deliveryClaims: deliveryClaimStore, github: { token: githubTokenFromEnvironment() }, + policy: policy.value, }, ); } catch (error) { diff --git a/apps/web/server/utils/access.ts b/apps/web/server/utils/access.ts index 95041da6..269b4f8b 100644 --- a/apps/web/server/utils/access.ts +++ b/apps/web/server/utils/access.ts @@ -28,7 +28,7 @@ export function createControlPlaneAccessCache( * Who may call the control plane as a machine, composed from the two places its halves belong. * * The tokens are secrets and stay in `CODE_ZERO_CONTROL_PLANE_TOKENS`, beside the database - * password. What each of them may run is policy and comes from `code-zero.deployment.yml`, where + * password. What each of them may run is policy and comes from the instance file, `config.yml`, where * it is a readable map instead of the `name:mode|mode` string it used to be squeezed into. * * Resolved once: both halves are fixed for the life of the process, and re-reading them per diff --git a/apps/web/server/utils/auth-policy.ts b/apps/web/server/utils/auth-policy.ts new file mode 100644 index 00000000..aab91aeb --- /dev/null +++ b/apps/web/server/utils/auth-policy.ts @@ -0,0 +1,37 @@ +import { + authCapabilities, + authConfigFromEnvironment, + type AuthCapabilities, +} from '@code-zero/auth/config'; + +import { dashboardUrlFromEnvironment } from './environment.js'; + +/** + * The environment authentication policy is read from, with this app's own public origin filled in. + * + * The dashboard and the auth server are one origin here, so the origin a passkey is bound to is the + * one `dashboardUrlFromEnvironment` resolves. Supplying it as `AUTH_DASHBOARD_ORIGIN` when the + * deployment did not set that variable itself is what lets `@code-zero/auth` resolve the relying + * party without a second copy of the precedence rule. Shared by `server/auth.config.ts`, which + * builds the Better Auth instance, and `/api/capabilities`, which publishes what it serves, so the + * two read one policy. + */ +export function authEnvironment( + environment: Readonly> = process.env, +): Readonly> { + if (environment.AUTH_DASHBOARD_ORIGIN?.trim()) return environment; + const dashboardUrl = dashboardUrlFromEnvironment(environment); + return dashboardUrl ? { ...environment, AUTH_DASHBOARD_ORIGIN: dashboardUrl } : environment; +} + +/** + * The capabilities this deployment's sign-in surface exposes, read when the request arrives. + * + * No entitlements are passed: SSO and SCIM stay off until an edition supplies them, which keeps + * this in step with `server/auth.config.ts`, which registers neither plugin either. + */ +export function deploymentAuthCapabilities( + environment: Readonly> = process.env, +): AuthCapabilities { + return authCapabilities(authConfigFromEnvironment(authEnvironment(environment))); +} diff --git a/apps/web/server/utils/context.ts b/apps/web/server/utils/context.ts index baa0ddef..b4a19a73 100644 --- a/apps/web/server/utils/context.ts +++ b/apps/web/server/utils/context.ts @@ -2,26 +2,32 @@ import { resolve } from 'node:path'; import { authenticate, + type ApiKeyVerifier, type BetterAuthSessionApi, type ControlPlaneAccess, type RepositoryAdmin, type RpcContext, type TaskStore, } from '@code-zero/api'; +import { API_KEY_PREFIX } from '@code-zero/auth/config'; + +const BEARER_PREFIX = 'Bearer '; /** * Builds the oRPC context shared by the `/rpc/**` and `/api/v1/**` transports. * * Both transports authorize and delegate through the same {@link rpcRouter} procedures, so they - * resolve context identically apart from `auth`: only `/rpc/**` accepts a dashboard session, and - * only because it is same-origin and carries CSRF protection. `/api/v1/**` is the cross-origin - * REST surface, where honouring a cookie would turn every allowed origin into a confused deputy, - * so it is left token-only. + * resolve context identically apart from `credentials.session`: only `/rpc/**` accepts a dashboard + * session, and only because it is same-origin and carries CSRF protection. `/api/v1/**` is the + * cross-origin REST surface, where honouring a cookie would turn every allowed origin into a + * confused deputy, so it accepts bearer credentials only: operator tokens and organization API + * keys, which both transports verify. * * `principal` is resolved eagerly because it is a constant-time comparison against the configured - * operator tokens. A session is not: `packages/api`'s `authMiddleware` performs that lookup, and - * only for procedures that require an identity, so an anonymous read never queries the - * authentication store. + * operator tokens. A session or an API key is not: `packages/api`'s `authMiddleware` performs that + * lookup, and only for procedures that require an identity, so an anonymous read never queries the + * authentication store. A bearer value is treated as an API key only when it matched no operator + * token and carries the key prefix, so an operator token never reaches the key store. * * Which checkout a run may target is asked of `repositories` per request rather than fixed at * boot: the allow-list is a table an operator edits from the dashboard, so a repository added a @@ -41,14 +47,59 @@ export function buildRpcContext( access: ControlPlaneAccess | undefined, store: TaskStore, repositories: RepositoryAdmin & { allows: (checkoutPath: string) => Promise }, - auth?: BetterAuthSessionApi, + credentials: { session?: BetterAuthSessionApi; apiKeys?: ApiKeyVerifier } = {}, ): RpcContext { - const principal = authenticate(request.headers.get('authorization') ?? undefined, access); + const authorization = request.headers.get('authorization') ?? undefined; + const principal = authenticate(authorization, access); + const presentedApiKey = + !principal && authorization?.startsWith(`${BEARER_PREFIX}${API_KEY_PREFIX}`) + ? authorization.slice(BEARER_PREFIX.length) + : undefined; + const { session, apiKeys } = credentials; return { store, ...(principal ? { principal } : {}), - ...(auth ? { auth } : {}), + ...(session ? { auth: session } : {}), + ...(presentedApiKey === undefined ? {} : { presentedApiKey }), + ...(apiKeys ? { apiKeys } : {}), mayTargetRepository: (repository) => repositories.allows(resolve(repository)), repositories, }; } + +/** + * The API-key verifier of a Better Auth instance, or `undefined` when it serves none. + * + * The instance's static type does not list the plugin's endpoints (the composition root widens + * its plugin list), so the endpoint is looked up at runtime. A deployment that left API keys off + * has no `verifyApiKey`, and a presented key is then refused rather than looked up. + */ +export function apiKeyVerifier(auth: { api: object }): ApiKeyVerifier | undefined { + const { api } = auth; + if (!('verifyApiKey' in api) || typeof api.verifyApiKey !== 'function') return undefined; + const verify = api.verifyApiKey; + return { + verifyApiKey: async (options) => { + const result: unknown = await verify.call(api, options); + return isVerification(result) ? result : { valid: false, key: null }; + }, + }; +} + +type Verification = Awaited>; + +/** Whether a `verifyApiKey` answer has the fields the middleware reads. */ +function isVerification(value: unknown): value is Verification { + if (typeof value !== 'object' || value === null || !('valid' in value) || !('key' in value)) + return false; + const { valid, key } = value; + if (typeof valid !== 'boolean') return false; + if (key === null) return true; + return ( + typeof key === 'object' && + 'id' in key && + typeof key.id === 'string' && + 'referenceId' in key && + typeof key.referenceId === 'string' + ); +} diff --git a/apps/web/server/utils/deployment.ts b/apps/web/server/utils/deployment.ts index c828d11c..3d69df2b 100644 --- a/apps/web/server/utils/deployment.ts +++ b/apps/web/server/utils/deployment.ts @@ -1,50 +1,114 @@ -import { resolve } from 'node:path'; - import { defaultDeploymentConfig, DEPLOYMENT_CONFIG_FILE, describeDeploymentConfigIssues, - loadDeploymentConfig, + editionFromEnvironment, + LEGACY_DEPLOYMENT_CONFIG_FILE, + loadDeploymentFile, + locateDeploymentConfig, + settingsSourceFromEnvironment, type DeploymentConfig, + type DeploymentConfigIssue, + type DeploymentFile, + type Edition, + type SettingsSource, } from '@code-zero/config'; /** - * How this deployment behaves, read once from `code-zero.deployment.yml`. + * Which product this deployment is, and where its settings come from, as its environment says. * - * One file replaces the handful of comma-separated environment variables this policy used to be - * spelled in — a list of origins, a `name:mode|mode` grant string, a poll interval — each of which - * had its own ad-hoc format to get wrong. What stays in the environment is what a deployment - * already keeps there: secrets, and the two bootstrap values needed to find everything else - * (`DATABASE_URL`, and the path to this file). + * Both are bootstrap values the process reads before anything else, so they live in the + * environment beside `DATABASE_URL`, where only whoever runs the deployment can change them. No + * request reaches them: the settings page can show them, never move them. + */ +export interface DeploymentIdentity { + edition: Edition; + source: SettingsSource; + /** Why the environment could not be read. While any is present, every run is refused. */ + issues: readonly DeploymentConfigIssue[]; +} + +/** + * Read the edition and the settings source from the environment. * - * `CODE_ZERO_CONFIG` names the file; without it the process reads `code-zero.deployment.yml` from - * its working directory, and a deployment that has never needed to change anything ships no file - * at all and gets the defaults. + * An edition this process does not recognise is treated as the managed cloud, the locked one: + * nothing becomes editable because a variable was mistyped, and the issue it carries keeps every run + * refused until it is corrected. + */ +export function deploymentIdentity( + environment: Readonly> = process.env, +): DeploymentIdentity { + const edition = editionFromEnvironment(environment); + const resolved: Edition = edition.ok ? edition.value : 'cloud'; + const source = settingsSourceFromEnvironment(environment, resolved); + return { + edition: resolved, + source: source.ok ? source.value : 'file', + issues: [...(edition.ok ? [] : edition.issues), ...(source.ok ? [] : source.issues)], + }; +} + +/** The instance configuration file as this process read it, and where it was found. */ +export interface LoadedDeploymentFile extends DeploymentFile { + path: string; + /** True when it was found under its old name, `code-zero.deployment.yml`. */ + legacy: boolean; +} + +/** + * Read the instance configuration file — `config.yml`, or the path `CODE_ZERO_CONFIG` names. + * + * One file, decided once. When both `config.yml` and the old `code-zero.deployment.yml` exist and + * neither is named, nothing is read: the control-plane policy keeps its closed defaults and the + * settings are refused, rather than one file silently winning over the other. + */ +export async function readDeploymentFile( + environment: Readonly>, + directory: string, + edition: Edition, +): Promise { + const located = await locateDeploymentConfig(environment, directory); + if (!located.ok) + return { + config: structuredClone(defaultDeploymentConfig), + issues: located.issues, + settings: { ok: false, issues: located.issues }, + declaresSettings: false, + path: `${directory}/${DEPLOYMENT_CONFIG_FILE}`, + legacy: false, + }; + return { ...(await loadDeploymentFile(located.value.path, edition)), ...located.value }; +} + +/** + * The instance configuration, read once per process and awaited by every reader. * - * Resolved once and awaited by every reader, rather than re-read per request: this is deployment - * policy, which changes when the process restarts. Repository configuration is the opposite — it - * changes while the process runs — which is exactly why it lives in the store instead. + * Deployment policy changes when the process restarts. What changes while it runs — repositories, + * and settings kept in the dashboard — lives in the store instead. */ -let pending: Promise | undefined; +let pending: Promise | undefined; -export function deploymentConfig(): Promise { - pending ??= read(); +export function deploymentFile(): Promise { + pending ??= readDeploymentFile(process.env, process.cwd(), deploymentIdentity().edition).then( + (file) => { + if (file.issues.length > 0) + // Reported rather than thrown: a refused control-plane field falls back to its own closed + // default, and the process starting with a loud complaint beats a dashboard that will not + // load at all. Every issue names its path, so the fix is one edit. + console.error( + `[config] ${file.path} has problems; the defaults stand where a value was refused:\n${describeDeploymentConfigIssues(file.issues)}`, + ); + if (file.legacy) + console.warn( + `[config] ${file.path} uses the old name ${LEGACY_DEPLOYMENT_CONFIG_FILE}; rename it to ${DEPLOYMENT_CONFIG_FILE}.`, + ); + return file; + }, + ); return pending; } -async function read(): Promise { - const path = resolve(process.env.CODE_ZERO_CONFIG?.trim() || DEPLOYMENT_CONFIG_FILE); - const { config, issues } = await loadDeploymentConfig(path); - if (issues.length > 0) { - // Reported rather than thrown: a malformed field falls back to its own default, and the - // process starting with a loud complaint beats a dashboard that will not load at all because - // one line of YAML is wrong. Every issue names its path, so the fix is one edit. - console.error( - `[config] ${path} has problems; the defaults stand where a value was refused:\n${describeDeploymentConfigIssues(issues)}`, - ); - } - return config; +/** The control-plane and polling half of the instance configuration. */ +export async function deploymentConfig(): Promise { + return (await deploymentFile()).config; } - -/** The defaults, for a caller that must answer before the file has been read. */ -export const deploymentDefaults: DeploymentConfig = defaultDeploymentConfig; diff --git a/apps/web/server/utils/errors.ts b/apps/web/server/utils/errors.ts index 50dd271a..ef29d8e0 100644 --- a/apps/web/server/utils/errors.ts +++ b/apps/web/server/utils/errors.ts @@ -40,6 +40,12 @@ export const errors = { misconfigured: (variable: string) => fail({ status: 503, message: `${variable} is not configured` }), + /** + * The deployment cannot take work until its operator fixes something, such as invalid settings. + * 503 rather than 500: nothing failed unexpectedly, and the same request succeeds once it is fixed. + */ + unavailable: (reason: string) => fail({ status: 503, message: reason }), + /** * The caller is signed in, but the session does not carry the role the route requires. Distinct * from the 401 `requireUserSession` raises for an absent session: signing in again would not diff --git a/apps/web/server/utils/settings.ts b/apps/web/server/utils/settings.ts new file mode 100644 index 00000000..f15a4034 --- /dev/null +++ b/apps/web/server/utils/settings.ts @@ -0,0 +1,206 @@ +import { + deploymentServices, + type DeploymentConnections, + type DeploymentSettingsAuthority, + type OrganizationSettingsStore, + type RunPolicy, + type SettingsState, +} from '@code-zero/api'; +import { organizationRoles } from '@code-zero/auth/access'; +import { + defaultDeploymentSettings, + deploymentSettingsDocument, + LEGACY_DEPLOYMENT_CONFIG_FILE, + parseDeploymentSettings, + type DeploymentConfigIssue, + type Parsed, +} from '@code-zero/config'; +import { + optionalDatabaseUrlFromEnvironment, + organizationMemberRole, + readDeploymentSettings, + readOrganizationSettings, + saveDeploymentSettings, + saveOrganizationSettings, + type StoredSettings, +} from '@code-zero/database'; +import { mailProviderNameFromEnvironment } from '@code-zero/mail'; + +import { database } from './database.js'; +import { + deploymentFile, + deploymentIdentity, + type DeploymentIdentity, + type LoadedDeploymentFile, +} from './deployment.js'; + +/** The stored deployment settings, behind the two calls the dashboard source needs. */ +export interface StoredSettingsSource { + read(): Promise; + save(document: Record, actor: string): Promise; +} + +/** Everything the settings authority reads from, injected so a test can hold it all in memory. */ +export interface SettingsAuthorityDependencies { + identity: DeploymentIdentity; + file: LoadedDeploymentFile; + stored: StoredSettingsSource; + organizations: OrganizationSettingsStore; + connections: DeploymentConnections; + environment: Readonly>; +} + +/** + * The deployment's settings, from exactly one source. + * + * With the file as the source, the file's settings sections are the settings and nothing writes + * them. With the dashboard as the source, the store is, and the file's settings sections — if it + * still has any — are reported as ignored rather than merged: a value has one home, and the + * operator is told which one it is not. The file's control-plane and polling sections are read + * either way; they are bootstrap policy, not settings. + * + * Every read goes back to the source, so a saved change is what the next run is held to. + */ +export function createSettingsAuthority( + dependencies: SettingsAuthorityDependencies, +): DeploymentSettingsAuthority { + const { identity, file, stored, organizations, connections, environment } = dependencies; + + async function read(): Promise { + if (identity.issues.length > 0) return { ok: false, issues: identity.issues }; + const notes = file.legacy ? [renameNote(file)] : []; + if (identity.source === 'file') { + if (!file.settings.ok) return { ok: false, issues: file.settings.issues }; + return { ok: true, settings: file.settings.value, saved: null, warnings: notes }; + } + const record = await stored.read(); + const parsed: Parsed = record + ? parseDeploymentSettings(record.document) + : { ok: true, value: defaultDeploymentSettings }; + if (!parsed.ok) return { ok: false, issues: parsed.issues }; + return { + ok: true, + settings: parsed.value, + saved: record ? { by: record.updatedBy, at: record.updatedAt.toISOString() } : null, + warnings: file.declaresSettings ? [...notes, ignoredNote(file)] : notes, + }; + } + + async function services() { + const state = await read(); + return deploymentServices(state.ok ? state.settings : null, connections, environment); + } + + const shared = { + edition: identity.edition, + read, + services, + ...(identity.edition === 'cloud' ? { organizations } : {}), + }; + if (identity.source === 'dashboard') + return { + ...shared, + source: 'dashboard', + save: (settings, actor) => stored.save(deploymentSettingsDocument(settings), actor), + }; + return { ...shared, source: 'file', filePath: file.path }; +} + +function renameNote(file: LoadedDeploymentFile): DeploymentConfigIssue { + return { + path: '$', + message: `${file.path} uses the old name ${LEGACY_DEPLOYMENT_CONFIG_FILE}; rename it to config.yml.`, + }; +} + +function ignoredNote(file: LoadedDeploymentFile): DeploymentConfigIssue { + return { + path: '$', + message: `${file.path} states settings, which are ignored because the dashboard is their source. Remove them from the file.`, + }; +} + +/** + * Whether an organization role may change the organization's policy. + * + * Asked of `@code-zero/auth`'s own role definitions rather than a list of role names, so the + * organization plugin and this check cannot disagree about who manages policy. A membership can + * hold several comma-separated roles, the way Better Auth stores them; any one that grants + * `policy: write` is enough. + */ +export function managesOrganizationPolicy(role: string): boolean { + return role + .split(',') + .map((name) => name.trim()) + .some((name) => { + const definition = Object.entries(organizationRoles).find(([key]) => key === name)?.[1]; + const statements: Readonly> = + definition?.statements ?? {}; + return statements.policy?.includes('write') ?? false; + }); +} + +/** Organization limits and memberships, read from Postgres on every call. */ +function postgresOrganizationSettings(): OrganizationSettingsStore { + return { + async membership(organizationId, userId) { + const role = await organizationMemberRole(database(), organizationId, userId); + return role === null ? null : { role, managesPolicy: managesOrganizationPolicy(role) }; + }, + read: async (organizationId) => + (await readOrganizationSettings(database(), organizationId))?.document ?? null, + save: async (organizationId, document, actor) => { + await saveOrganizationSettings(database(), organizationId, document, actor); + }, + }; +} + +function postgresStoredSettings(): StoredSettingsSource { + return { + read: () => readDeploymentSettings(database()), + save: async (document, actor) => { + await saveDeploymentSettings(database(), document, actor); + }, + }; +} + +/** + * The authority this process uses, built once the instance file has been read. + * + * No hosted sandbox provider is wired into this app's task execution yet, so the sandbox counts as + * set up only when the settings name a container sandbox. + */ +let pending: Promise | undefined; + +export function settingsAuthority(): Promise { + pending ??= deploymentFile().then((file) => + createSettingsAuthority({ + identity: deploymentIdentity(), + file, + stored: postgresStoredSettings(), + organizations: postgresOrganizationSettings(), + connections: { + database: optionalDatabaseUrlFromEnvironment(process.env) !== undefined, + mail: mailProviderNameFromEnvironment(process.env) !== 'console', + hostedSandbox: false, + }, + environment: process.env, + }), + ); + return pending; +} + +/** + * The policy work this deployment starts on its own — a webhook delivery, a polling pass — runs + * under: the deployment's settings, with no organization, since that work belongs to no customer. + * Refused while the settings are invalid, like every other run. + */ +export async function deploymentRunPolicy(): Promise> { + const authority = await settingsAuthority(); + const state = await authority.read(); + if (!state.ok) return { ok: false, issues: state.issues }; + return { + ok: true, + value: { settings: state.settings, isolationRequired: authority.edition === 'cloud' }, + }; +} diff --git a/apps/web/test/nuxt/components/SettingsForm.spec.ts b/apps/web/test/nuxt/components/SettingsForm.spec.ts new file mode 100644 index 00000000..d7541e33 --- /dev/null +++ b/apps/web/test/nuxt/components/SettingsForm.spec.ts @@ -0,0 +1,100 @@ +import type { SettingsView } from '@code-zero/api'; +import { mountSuspended } from '@nuxt/test-utils/runtime'; +import { ORPCError } from '@orpc/client'; +import { flushPromises } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import SettingsForm from '~~/modules/settings/components/Form.vue'; +import type { SelfHostedSettingsView, SettingsInput } from '~~/modules/settings/utils/form'; + +/** + * The dashboard-source form: it edits, sends, and shows the server's verdict beside each field. + * The save is a prop, so these specs hand it a stub and the router is never reached. + */ +const VIEW: SelfHostedSettingsView = { + edition: 'self-hosted', + source: 'dashboard', + editable: true, + filePath: null, + state: { + ok: true, + settings: { + execution: { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: ['observe', 'suggest', 'fix', 'autonomous'], + maxNetwork: 'full', + }, + model: null, + runner: { isolation: 'container', engine: 'docker', image: 'ghcr.io/acme/runner:1' }, + }, + saved: { by: 'admin@example.com', at: '2026-10-18T10:00:00.000Z' }, + warnings: [], + }, + services: [], + bounds: { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, + }, +}; + +describe('SettingsForm', () => { + it('shows the server issues beside the fields they name, and keeps the draft', async () => { + const save = vi.fn<(input: SettingsInput) => Promise>().mockRejectedValue( + new ORPCError('BAD_REQUEST', { + data: { + issues: [ + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + ], + }, + }), + ); + const wrapper = await mountSuspended(SettingsForm, { props: { view: VIEW, save } }); + + await wrapper.find('input[name="commandTimeoutMinutes"]').setValue('5000'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(save).toHaveBeenCalledOnce(); + expect(save.mock.calls[0]?.[0].execution.commandTimeoutMinutes).toBe(5000); + const timeout = wrapper.find('input[name="commandTimeoutMinutes"]'); + expect(timeout.attributes('aria-invalid')).toBe('true'); + expect(wrapper.text()).toContain('Expected an integer from 1 to 1440.'); + expect(wrapper.text()).toContain('Some values were refused.'); + expect((timeout.element as HTMLInputElement).value).toBe('5000'); + expect(wrapper.emitted('saved')).toBeUndefined(); + }); + + it('emits the stored view once the server accepts the save', async () => { + const save = vi.fn<(input: SettingsInput) => Promise>().mockResolvedValue(VIEW); + const wrapper = await mountSuspended(SettingsForm, { props: { view: VIEW, save } }); + + await wrapper.find('input[name="maxConcurrentTasks"]').setValue('2'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(save.mock.calls[0]?.[0]).toMatchObject({ + execution: { maxConcurrentTasks: 2 }, + runner: { isolation: 'container', engine: 'docker', image: 'ghcr.io/acme/runner:1' }, + }); + expect(wrapper.emitted('saved')?.[0]).toEqual([VIEW]); + }); + + it('keeps observe ticked and out of reach, since no deployment can drop it', async () => { + const wrapper = await mountSuspended(SettingsForm, { props: { view: VIEW, save: vi.fn() } }); + + const observe = wrapper.find('input[name="allowedModes"][value="observe"]'); + expect((observe.element as HTMLInputElement).checked).toBe(true); + expect(observe.attributes('disabled')).toBeDefined(); + }); + + it('offers the model fields only when one model is imposed on every run', async () => { + const wrapper = await mountSuspended(SettingsForm, { props: { view: VIEW, save: vi.fn() } }); + + expect(wrapper.find('input[name="modelName"]').exists()).toBe(false); + await wrapper.find('input[name="modelSource"][value="deployment"]').setValue(true); + expect(wrapper.find('input[name="modelName"]').exists()).toBe(true); + }); +}); diff --git a/apps/web/test/nuxt/pages/login.spec.ts b/apps/web/test/nuxt/pages/login.spec.ts index 441d0c76..c575c035 100644 --- a/apps/web/test/nuxt/pages/login.spec.ts +++ b/apps/web/test/nuxt/pages/login.spec.ts @@ -1,19 +1,45 @@ +import type { AuthCapabilities } from '@code-zero/auth/config'; import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'; import type { VueWrapper } from '@vue/test-utils'; import { beforeEach, describe, expect, it } from 'vitest'; +import { computed } from 'vue'; import LoginPage from '~/pages/(auth)/login.vue'; /** - * The login page renders whatever sign-in capabilities `appConfig.auth` publishes, which - * `nuxt.config.ts` derives from the shared environment through `authConfigFromEnvironment()` - * (`AUTH_ENABLE_SIGNUP`, GitHub OAuth credentials). That derivation is covered in - * `packages/auth/src/config.test.ts`; the mock stands in for it here so each spec can render the - * page under a specific deployment policy. + * The login page renders whatever sign-in capabilities `/api/capabilities` publishes, which the + * server derives at runtime through `authCapabilities()` from the same environment it builds Better + * Auth from. That derivation is covered in `packages/auth/src/config.test.ts`; the mock stands in + * for the request here so each spec can render the page under a specific deployment policy. */ const authCapabilities = { enableSignup: false, enableGithubOauth: false }; -mockNuxtImport('useAppConfig', () => () => ({ auth: authCapabilities })); +mockNuxtImport( + 'useAuthCapabilities', + () => () => + Promise.resolve( + computed(() => ({ + signup: authCapabilities.enableSignup, + password: true, + passwordReset: false, + emailVerification: false, + magicLink: false, + passkey: false, + twoFactor: true, + twoFactorEmailOtp: false, + social: { github: authCapabilities.enableGithubOauth, gitlab: false, google: false }, + organizations: false, + organizationCreation: false, + organizationInvitations: false, + invitations: false, + apiKeys: false, + deviceAuthorization: false, + sso: false, + scim: false, + infra: false, + })), + ), +); function getButtonByText(wrapper: VueWrapper, text: string) { const button = wrapper.findAll('button').find((candidate) => candidate.text() === text); diff --git a/apps/web/test/nuxt/pages/settings.spec.ts b/apps/web/test/nuxt/pages/settings.spec.ts new file mode 100644 index 00000000..528807af --- /dev/null +++ b/apps/web/test/nuxt/pages/settings.spec.ts @@ -0,0 +1,218 @@ +import type { OrganizationSettingsView, SettingsView } from '@code-zero/api'; +import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'; +// Imported explicitly rather than relying on Nuxt auto-imports: the plain `tsc` pass over test/** +// does not see the generated auto-import declarations that `vue-tsc` does. +import { useState } from 'nuxt/app'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick, shallowRef } from 'vue'; + +import SettingsPage from '~/pages/(dashboard)/settings.vue'; + +/** + * The page renders whatever `settings.get` described, and nothing it decided for itself: which + * edition, which source, and who may edit are the server's answers. These specs replace the read + * with a fixed view and check each state renders as the controls it promises — and no others. + */ +const view = shallowRef(null); +const error = shallowRef(null); +const pending = shallowRef(false); +const refresh = vi.fn<() => Promise>(() => Promise.resolve()); + +mockNuxtImport('useSettings', () => () => ({ + view, + error, + pending, + refresh, + replace: (next: SettingsView) => { + view.value = next; + }, +})); + +const BOUNDS = { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, +} as const; + +const SERVICES = [ + { service: 'sandbox', configured: true }, + { service: 'database', configured: true }, + { service: 'mail', configured: false }, +] as const; + +function fileView(state: Extract['state']): SettingsView { + return { + edition: 'self-hosted', + source: 'file', + editable: false, + filePath: '/srv/code-zero/config.yml', + state, + services: SERVICES, + bounds: BOUNDS, + }; +} + +const PLAN = { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 60, + allowedModes: ['observe', 'suggest', 'fix'], + maxNetwork: 'restricted', +} as const; + +function cloudView(member: OrganizationSettingsView | null): SettingsView { + return { + edition: 'cloud', + services: [{ service: 'model', configured: true }, ...SERVICES], + plan: PLAN, + organization: member, + bounds: BOUNDS, + }; +} + +function organization(editable: boolean): OrganizationSettingsView { + return { + id: 'org_acme', + role: editable ? 'owner' : 'member', + editable, + limits: { maxConcurrentTasks: 2, commandTimeoutMinutes: null, allowedModes: null }, + invalid: false, + effective: { ...PLAN, maxConcurrentTasks: 2 }, + }; +} + +describe('settings page', () => { + beforeEach(() => { + view.value = null; + error.value = null; + pending.value = false; + refresh.mockClear(); + useState<{ id: string } | null>('organizations:active').value = null; + }); + + it('shows file settings read-only, with the file to edit', async () => { + view.value = fileView({ + ok: true, + settings: { + execution: { ...PLAN, allowedModes: ['observe', 'suggest'] }, + model: { provider: 'anthropic', name: 'claude-sonnet' }, + runner: null, + }, + saved: null, + warnings: [], + }); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('/srv/code-zero/config.yml'); + expect(wrapper.text()).toContain('claude-sonnet'); + expect(wrapper.text()).toContain('Timeout per command'); + expect(wrapper.text()).toContain('60 min'); + // Read-only means no control that could even look like it saves something. + expect(wrapper.find('form').exists()).toBe(false); + expect(wrapper.findAll('input')).toHaveLength(0); + expect(wrapper.text()).toContain('Download config.yml'); + }); + + it('lists every issue when the file is invalid, and offers no export of it', async () => { + view.value = fileView({ + ok: false, + issues: [{ path: '$.execution.max_network', message: 'Expected none, restricted, or full.' }], + }); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('The settings are invalid'); + expect(wrapper.text()).toContain('$.execution.max_network'); + expect(wrapper.text()).toContain('Expected none, restricted, or full.'); + const download = wrapper.findAll('button').find((button) => button.text().includes('Download')); + expect(download?.attributes('disabled')).toBeDefined(); + }); + + it('explains a refusal instead of rendering an empty page', async () => { + error.value = 'forbidden'; + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('requires the admin role'); + expect(wrapper.find('form').exists()).toBe(false); + }); + + it('lets an organization owner lower the limits, below the plan', async () => { + view.value = cloudView(organization(true)); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('Plan limits'); + expect(wrapper.find('form').exists()).toBe(true); + expect(wrapper.find('input[name="maxConcurrentTasks"]').attributes('placeholder')).toBe( + 'Plan: 4', + ); + // The service's own wiring is never a control on the cloud. + expect(wrapper.find('select[name="modelProvider"]').exists()).toBe(false); + expect(wrapper.find('input[name="runnerImage"]').exists()).toBe(false); + }); + + it('shows a member the limits without any control to change them', async () => { + view.value = cloudView(organization(false)); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.find('form').exists()).toBe(false); + expect(wrapper.findAll('input')).toHaveLength(0); + expect(wrapper.text()).toContain( + 'Only an organization owner or admin can change these limits.', + ); + expect(wrapper.text()).toContain('Effective limits'); + }); + + it('asks for an organization when none is selected on the cloud', async () => { + view.value = cloudView(null); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('Select an organization to see its limits.'); + }); + + it('drops a save answer for an organization the page no longer shows', async () => { + view.value = cloudView({ ...organization(true), id: 'org_a' }); + const wrapper = await mountSuspended(SettingsPage); + const form = wrapper.findComponent({ name: 'SettingsOrganizationLimits' }); + + // The reader switched to another organization while the first one's save was in flight. + view.value = cloudView({ ...organization(true), id: 'org_b' }); + await nextTick(); + form.vm.$emit('saved', { ...organization(true), id: 'org_a' }); + await nextTick(); + + expect(view.value?.edition === 'cloud' && view.value.organization?.id).toBe('org_b'); + }); + + it('ignores a save answer that names another organization, even from the shown form', async () => { + view.value = cloudView({ ...organization(true), id: 'org_b' }); + const wrapper = await mountSuspended(SettingsPage); + + wrapper + .findComponent({ name: 'SettingsOrganizationLimits' }) + .vm.$emit('saved', { ...organization(true), id: 'org_a' }); + await nextTick(); + + expect(view.value?.edition === 'cloud' && view.value.organization?.id).toBe('org_b'); + }); + + it('reads the settings again when the sidebar switches organization', async () => { + view.value = cloudView(null); + await mountSuspended(SettingsPage); + refresh.mockClear(); + + // The switcher selects an organization after mount, for a session that had none. + useState<{ id: string } | null>('organizations:active').value = { id: 'org_acme' }; + await nextTick(); + + // Every page still mounted by an earlier spec answers too, so the count is not asserted. + expect(refresh).toHaveBeenCalled(); + + refresh.mockClear(); + useState<{ id: string } | null>('organizations:active').value = { id: 'org_acme' }; + await nextTick(); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/nuxt/pages/signup.spec.ts b/apps/web/test/nuxt/pages/signup.spec.ts index 9c21fbf6..d43c91d8 100644 --- a/apps/web/test/nuxt/pages/signup.spec.ts +++ b/apps/web/test/nuxt/pages/signup.spec.ts @@ -1,21 +1,48 @@ +import type { AuthCapabilities } from '@code-zero/auth/config'; import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'; import type { VueWrapper } from '@vue/test-utils'; import { beforeEach, describe, expect, it } from 'vitest'; +import { computed } from 'vue'; import SignupPage from '~/pages/(auth)/signup.vue'; /** - * Registration renders whatever `appConfig.auth` publishes, which `nuxt.config.ts` derives from the - * shared environment through `authConfigFromEnvironment()` (`AUTH_ENABLE_SIGNUP`, GitHub OAuth - * credentials). That derivation is covered in `packages/auth/src/config.test.ts`; the mock stands in - * for it here so each spec can render the page under a specific deployment policy. + * Registration renders whatever `/api/capabilities` publishes, which the server derives at runtime + * through `authCapabilities()` from the same environment it builds Better Auth from. That derivation + * is covered in `packages/auth/src/config.test.ts`; the mock stands in for the request here so each + * spec can render the page under a specific deployment policy. * * The closed state is presentation only — the server rejects sign-up regardless of what this page * offers. */ const authCapabilities = { enableSignup: false, enableGithubOauth: false }; -mockNuxtImport('useAppConfig', () => () => ({ auth: authCapabilities })); +mockNuxtImport( + 'useAuthCapabilities', + () => () => + Promise.resolve( + computed(() => ({ + signup: authCapabilities.enableSignup, + password: true, + passwordReset: false, + emailVerification: false, + magicLink: false, + passkey: false, + twoFactor: true, + twoFactorEmailOtp: false, + social: { github: authCapabilities.enableGithubOauth, gitlab: false, google: false }, + organizations: false, + organizationCreation: false, + organizationInvitations: false, + invitations: false, + apiKeys: false, + deviceAuthorization: false, + sso: false, + scim: false, + infra: false, + })), + ), +); function getButtonByText(wrapper: VueWrapper, text: string) { const button = wrapper.findAll('button').find((candidate) => candidate.text() === text); diff --git a/apps/web/test/unit/auth-policy.test.ts b/apps/web/test/unit/auth-policy.test.ts new file mode 100644 index 00000000..9c19ad5b --- /dev/null +++ b/apps/web/test/unit/auth-policy.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { authEnvironment, deploymentAuthCapabilities } from '../../server/utils/auth-policy.js'; + +describe('authEnvironment', () => { + it('fills the dashboard origin from the public site URL, so passkeys bind to this app', () => { + expect( + authEnvironment({ NUXT_PUBLIC_SITE_URL: 'https://app.example.test' }).AUTH_DASHBOARD_ORIGIN, + ).toBe('https://app.example.test'); + }); + + it('leaves an explicitly configured dashboard origin alone', () => { + const environment = { + AUTH_DASHBOARD_ORIGIN: 'https://console.example.test', + NUXT_PUBLIC_SITE_URL: 'https://app.example.test', + }; + + expect(authEnvironment(environment)).toBe(environment); + }); +}); + +describe('deploymentAuthCapabilities', () => { + it('reads the policy when it is called, so a change needs no rebuild', () => { + expect(deploymentAuthCapabilities({}).signup).toBe(false); + expect(deploymentAuthCapabilities({ AUTH_ENABLE_SIGNUP: 'true' }).signup).toBe(true); + }); + + it('offers passkeys once the app knows its own origin', () => { + expect(deploymentAuthCapabilities({}).passkey).toBe(false); + expect(deploymentAuthCapabilities({ NODE_ENV: 'development' }).passkey).toBe(true); + }); + + it('serves no enterprise capability without an edition to grant it', () => { + expect(deploymentAuthCapabilities({ AUTH_ENABLE_ORGANIZATIONS: 'true' })).toMatchObject({ + sso: false, + scim: false, + }); + }); +}); diff --git a/apps/web/test/unit/context.test.ts b/apps/web/test/unit/context.test.ts new file mode 100644 index 00000000..9711a45c --- /dev/null +++ b/apps/web/test/unit/context.test.ts @@ -0,0 +1,99 @@ +import { MemoryTaskStore, accessFromEnvironment, type ApiKeyVerifier } from '@code-zero/api'; +import { describe, expect, it } from 'vitest'; + +import { apiKeyVerifier, buildRpcContext } from '../../server/utils/context.js'; + +const repositories = { + list: () => Promise.resolve([]), + save: () => Promise.reject(new Error('unused')), + remove: () => Promise.resolve(false), + allows: () => Promise.resolve(false), +}; + +const verifier: ApiKeyVerifier = { + verifyApiKey: () => Promise.resolve({ valid: false, key: null }), +}; + +function requestWith(authorization?: string): Request { + return new Request('https://code-zero.example.test/api/v1/tasks', { + headers: authorization === undefined ? {} : { authorization }, + }); +} + +describe('buildRpcContext', () => { + const access = accessFromEnvironment('ci:cz_operator-token'); + + it('hands an unmatched prefixed bearer to the API-key verifier', () => { + const context = buildRpcContext( + requestWith('Bearer cz_organization-key'), + access, + new MemoryTaskStore(), + repositories, + { apiKeys: verifier }, + ); + + expect(context.principal).toBeUndefined(); + expect(context.presentedApiKey).toBe('cz_organization-key'); + expect(context.apiKeys).toBe(verifier); + }); + + it('never sends an operator token to the key store, even one with the key prefix', () => { + const context = buildRpcContext( + requestWith('Bearer cz_operator-token'), + access, + new MemoryTaskStore(), + repositories, + { apiKeys: verifier }, + ); + + expect(context.principal?.kind).toBe('token'); + expect(context.presentedApiKey).toBeUndefined(); + }); + + it('ignores a bearer value without the key prefix', () => { + const context = buildRpcContext( + requestWith('Bearer something-else'), + access, + new MemoryTaskStore(), + repositories, + { apiKeys: verifier }, + ); + + expect(context.presentedApiKey).toBeUndefined(); + }); + + it('carries no session unless the transport passes one', () => { + const context = buildRpcContext(requestWith(), access, new MemoryTaskStore(), repositories, { + apiKeys: verifier, + }); + + expect(context.auth).toBeUndefined(); + }); +}); + +describe('apiKeyVerifier', () => { + it('is absent for an instance that serves no API keys', () => { + expect(apiKeyVerifier({ api: {} })).toBeUndefined(); + }); + + it('passes a well-formed answer through', async () => { + const answer = { + valid: true, + key: { id: 'key_1', referenceId: 'org_1', permissions: { task: ['create'] } }, + }; + const verify = apiKeyVerifier({ api: { verifyApiKey: () => Promise.resolve(answer) } }); + + await expect(verify?.verifyApiKey({ body: { key: 'cz_x' } })).resolves.toEqual(answer); + }); + + it('refuses an answer it cannot read', async () => { + const verify = apiKeyVerifier({ + api: { verifyApiKey: () => Promise.resolve({ valid: true, key: { id: 'key_1' } }) }, + }); + + await expect(verify?.verifyApiKey({ body: { key: 'cz_x' } })).resolves.toEqual({ + valid: false, + key: null, + }); + }); +}); diff --git a/apps/web/test/unit/settings.test.ts b/apps/web/test/unit/settings.test.ts new file mode 100644 index 00000000..0dbe2714 --- /dev/null +++ b/apps/web/test/unit/settings.test.ts @@ -0,0 +1,193 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { OrganizationSettingsStore } from '@code-zero/api'; +import { defaultDeploymentSettings, parseDeploymentFile } from '@code-zero/config'; +import type { StoredSettings } from '@code-zero/database'; +import { describe, expect, it } from 'vitest'; + +import { + deploymentIdentity, + readDeploymentFile, + type LoadedDeploymentFile, +} from '../../server/utils/deployment.js'; +import { + createSettingsAuthority, + managesOrganizationPolicy, + type StoredSettingsSource, +} from '../../server/utils/settings.js'; + +const FILE_WITH_SETTINGS = 'execution:\n max_concurrent_tasks: 2\n'; + +function file(text: string, overrides: Partial = {}): LoadedDeploymentFile { + return { + ...parseDeploymentFile(text), + path: '/srv/code-zero/config.yml', + legacy: false, + ...overrides, + }; +} + +function memoryStore(initial: StoredSettings | null = null) { + let current = initial; + const writes: Record[] = []; + const store: StoredSettingsSource = { + read: () => Promise.resolve(current), + save: (document, actor) => { + writes.push(document); + current = { document, updatedBy: actor, updatedAt: new Date('2026-10-18T10:00:00Z') }; + return Promise.resolve(); + }, + }; + return { store, writes }; +} + +const noOrganizations: OrganizationSettingsStore = { + membership: () => Promise.resolve(null), + read: () => Promise.resolve(null), + save: () => Promise.resolve(), +}; + +function authority( + environment: Record, + loaded: LoadedDeploymentFile, + stored = memoryStore().store, +) { + return createSettingsAuthority({ + identity: deploymentIdentity(environment), + file: loaded, + stored, + organizations: noOrganizations, + connections: { database: true, mail: false, hostedSandbox: false }, + environment, + }); +} + +describe('createSettingsAuthority', () => { + it('reads the settings from the file while the file is the source', async () => { + const settings = authority({}, file(FILE_WITH_SETTINGS)); + + expect(settings.source).toBe('file'); + await expect(settings.read()).resolves.toMatchObject({ + ok: true, + settings: { execution: { maxConcurrentTasks: 2 } }, + }); + }); + + it('reads only the store while the dashboard is the source, and says the file is ignored', async () => { + const settings = authority( + { CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, + file(FILE_WITH_SETTINGS), + ); + + const state = await settings.read(); + + expect(state).toMatchObject({ ok: true, settings: defaultDeploymentSettings }); + expect(state.ok && state.warnings).toHaveLength(1); + }); + + it('saves the document the file source would read back', async () => { + const { store, writes } = memoryStore(); + const settings = authority({ CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, file(''), store); + if (settings.source !== 'dashboard') throw new Error('expected the dashboard source'); + + await settings.save( + { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxConcurrentTasks: 3 }, + }, + 'admin@example.test', + ); + + expect(writes).toEqual([ + { + execution: { + max_concurrent_tasks: 3, + command_timeout_minutes: 30, + allowed_modes: ['observe', 'suggest', 'fix', 'autonomous'], + max_network: 'full', + }, + }, + ]); + await expect(settings.read()).resolves.toMatchObject({ + ok: true, + settings: { execution: { maxConcurrentTasks: 3 } }, + saved: { by: 'admin@example.test' }, + }); + }); + + it('refuses a stored document that no longer parses instead of running on the defaults', async () => { + const settings = authority( + { CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, + file(''), + memoryStore({ + document: { execution: { max_concurrent_tasks: 'all' } }, + updatedBy: 'someone', + updatedAt: new Date(), + }).store, + ); + + await expect(settings.read()).resolves.toMatchObject({ ok: false }); + }); + + it('locks a deployment whose edition variable it cannot read, and refuses its settings', async () => { + const settings = authority({ CODE_ZERO_EDITION: 'clod' }, file('')); + + expect(settings.edition).toBe('cloud'); + await expect(settings.read()).resolves.toMatchObject({ ok: false }); + }); + + it('asks the operator to rename a file still using the old name', async () => { + const settings = authority({}, file('', { legacy: true })); + + const state = await settings.read(); + + expect(state.ok && state.warnings).toHaveLength(1); + }); +}); + +describe('readDeploymentFile', () => { + it('reads neither file when config.yml and the old name both exist', async () => { + const directory = await mkdtemp(join(tmpdir(), 'code-zero-instance-')); + await writeFile(join(directory, 'config.yml'), FILE_WITH_SETTINGS, 'utf8'); + await writeFile( + join(directory, 'code-zero.deployment.yml'), + 'control_plane:\n origins: [https://ops.example.com]\n', + 'utf8', + ); + + const loaded = await readDeploymentFile({}, directory, 'self-hosted'); + + expect(loaded.settings.ok).toBe(false); + expect(loaded.config.controlPlane.origins).toEqual([]); + }); + + it('reads the file CODE_ZERO_CONFIG names', async () => { + const directory = await mkdtemp(join(tmpdir(), 'code-zero-instance-')); + await writeFile(join(directory, 'instance.yml'), FILE_WITH_SETTINGS, 'utf8'); + + const loaded = await readDeploymentFile( + { CODE_ZERO_CONFIG: 'instance.yml' }, + directory, + 'self-hosted', + ); + + expect(loaded.settings).toMatchObject({ + ok: true, + value: { execution: { maxConcurrentTasks: 2 } }, + }); + }); +}); + +describe('managesOrganizationPolicy', () => { + it.each([ + ['owner', true], + ['admin', true], + ['member', false], + ['member, admin', true], + ['billing', false], + ])('answers %j with %j from the organization role definitions', (role, expected) => { + expect(managesOrganizationPolicy(role)).toBe(expected); + }); +}); diff --git a/apps/web/uno.config.ts b/apps/web/uno.config.ts index 75a1b56c..5ba7b912 100644 --- a/apps/web/uno.config.ts +++ b/apps/web/uno.config.ts @@ -14,5 +14,10 @@ export default defineCodeZeroUno({ 'focus-ring h-9 flex items-center justify-center border text-xs text-ink font-650 transition disabled:cursor-wait disabled:opacity-60', ], ['btn-link', 'focus-ring text-xs text-link'], + // A small, square tag for a value that is one of a few: a mode, a source, an edition. + [ + 'chip', + 'h-6 inline-flex items-center gap-1.5 border border-line bg-raised px-2 text-3xs text-ink font-650', + ], ], }); diff --git a/code-zero.deployment.example.yml b/code-zero.deployment.example.yml deleted file mode 100644 index 1a4d566d..00000000 --- a/code-zero.deployment.example.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Deployment policy for the dashboard, copied to `code-zero.deployment.yml` (or to any path named -# by CODE_ZERO_CONFIG). Every section is optional; a deployment that has changed nothing ships no -# file at all and gets these defaults. -# -# What belongs here, and what does not: -# -# - Here: policy. Values that are fixed for the life of the process, are not secret, and are read -# more easily as a structured document than as a comma-separated variable. -# - The environment: secrets, and the two bootstrap values needed to find everything else -# (DATABASE_URL, CODE_ZERO_CONFIG). -# - The `repository` table: anything that changes while the process runs. Which checkouts a task -# may target, and which repositories the poller watches, are rows an operator edits from the -# dashboard — a repository added a moment ago is answerable without a restart. -# -# Every field is validated. A value that is refused is reported on stderr with its own `$.path` and -# falls back to the default below, so one wrong line does not stop the dashboard from loading. - -control_plane: - # Origins allowed to read /api/v1/** cross-origin via CORS. Empty by default: `tasks.list`, - # `tasks.get` and `health` are unauthenticated by design, so letting a browser read their - # responses from another origin is an explicit opt-in, not the default. - origins: [] - # - https://ops.example.com - - # Execution modes each operator token may request from `tasks.create`, keyed by the principal - # name in CODE_ZERO_CONTROL_PLANE_TOKENS. Without a grant a principal may only request the - # non-writable `observe` and `suggest`. An unknown mode, or an unknown principal name, is - # refused rather than quietly narrowed. - modes: {} - # ci: [observe, suggest] - # release: [observe, suggest, fix, autonomous] - -poll: - # Seconds between polling passes. Clamped to 15..3600. - interval_seconds: 60 diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 00000000..5c229dcf --- /dev/null +++ b/config.example.yml @@ -0,0 +1,74 @@ +# Instance configuration for the dashboard, copied to `config.yml` in the server's working +# directory (or to any path named by CODE_ZERO_CONFIG). Every section is optional; a deployment that +# has changed nothing ships no file at all and gets these defaults. The old name, +# `code-zero.deployment.yml`, is still read when it is the only file present. +# +# This is not `.code-zero.yml`: that file lives in each repository and is that repository's own, +# untrusted policy. This one belongs to whoever runs the deployment, and where both speak, the +# stricter wins. +# +# What belongs here, and what does not: +# +# - Here: policy. Values that are fixed for the life of the process, are not secret, and are read +# more easily as a structured document than as a comma-separated variable. +# - The environment: secrets, and the bootstrap values needed to find everything else +# (DATABASE_URL, CODE_ZERO_CONFIG, CODE_ZERO_EDITION, CODE_ZERO_SETTINGS_SOURCE). +# - The `repository` table: anything that changes while the process runs. Which checkouts a task +# may target, and which repositories the poller watches, are rows an operator edits from the +# dashboard — a repository added a moment ago is answerable without a restart. +# +# The control_plane and poll sections are validated field by field: a value that is refused is +# reported on stderr with its own `$.path` and falls back to the default below, so one wrong line +# does not stop the dashboard from loading. The settings sections at the end are stricter. + +control_plane: + # Origins allowed to read /api/v1/** cross-origin via CORS. Empty by default: `tasks.list`, + # `tasks.get` and `health` are unauthenticated by design, so letting a browser read their + # responses from another origin is an explicit opt-in, not the default. + origins: [] + # - https://ops.example.com + + # Execution modes each operator token may request from `tasks.create`, keyed by the principal + # name in CODE_ZERO_CONTROL_PLANE_TOKENS. Without a grant a principal may only request the + # non-writable `observe` and `suggest`. An unknown mode, or an unknown principal name, is + # refused rather than quietly narrowed. + modes: {} + # ci: [observe, suggest] + # release: [observe, suggest, fix, autonomous] + +poll: + # Seconds between polling passes. Clamped to 15..3600. + interval_seconds: 60 + +# Deployment settings: the limits every run is held to, shown at /settings in the dashboard. +# +# These three sections are read only while CODE_ZERO_SETTINGS_SOURCE is `file` (the default). With +# `dashboard` the settings live in Postgres instead, and settings left here are ignored with a +# warning rather than merged. They are strict: an unknown key, an out-of-range value, or anything +# shaped like a credential makes the whole settings document invalid, and no task starts until it is +# fixed. Uncomment to set them; the values shown are the defaults, except where noted. +# +# execution: +# # Tasks this deployment runs at once; the rest wait in the queue. 1..64. +# max_concurrent_tasks: 4 +# # Minutes before ONE repository command is stopped (and the longest a hosted sandbox lease +# # lasts). Not a limit on a whole run, which can execute several commands. 1..1440. +# command_timeout_minutes: 30 +# # Modes a run may start in. Must include observe, the mode that never writes. +# allowed_modes: [observe, suggest, fix, autonomous] +# # The widest network any run may have: none, restricted, or full. A repository may only narrow it. +# max_network: full +# +# # Not set by default: each repository's .code-zero.yml chooses. When set, replaces that choice for +# # every run. The provider's credential stays in the environment (ANTHROPIC_API_KEY, ...). +# model: +# provider: anthropic +# name: claude-sonnet-4-5 +# +# # Not set by default: each repository chooses. When set, forces every run this process executes +# # into a container with this engine (docker or podman) and image. Only container isolation can be +# # imposed from here. +# runner: +# isolation: container +# engine: docker +# image: ghcr.io/acme/code-zero-runner:1.0 diff --git a/docs/architecture.md b/docs/architecture.md index ccefc898..0692c101 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,6 +119,31 @@ Transport concerns stop at the route handlers: headers, status mapping, and requ + + +## Settings authority boundary + +Deployment settings — how much work a deployment takes on, in which modes, with which model and which sandbox — have exactly one authority at a time, and the environment decides which one before the process serves a request. No request can move it. + +`CODE_ZERO_EDITION` says which product a deployment is: `self-hosted` (the default) or `cloud`, the managed service. It is stated, never inferred: a self-hosted install that happens to run on Cloudflare is still self-hosted. A value the process does not recognise is treated as the locked cloud edition and keeps every run refused until it is corrected, so a typo can never make anything editable. + +A self-hosted deployment reads its settings from one source, chosen with `CODE_ZERO_SETTINGS_SOURCE`: + +- `file` (the default) — the `execution`, `model`, and `runner` sections of the instance file, `config.yml` in the working directory or the path `CODE_ZERO_CONFIG` names (the old name `code-zero.deployment.yml` is still read when it is the only file present). The file is read once at start, the settings page shows it read-only, and `settings.update` refuses every write on the server whatever the page rendered. +- `dashboard` — the `deployment_setting` row in Postgres, edited by an administrator from the settings page through `settings.update` and exportable as the YAML the file would hold through `settings.export`. The export passes through the environment's secret redaction, and the parser refuses any value shaped like a credential, so it cannot carry a secret. Settings sections still present in the file are ignored and reported as a warning, never merged: a value has one home. + +The settings are strict. `@code-zero/config` parses both sources with one parser that refuses an unknown key, an out-of-range value, a flag-shaped image, or unparseable YAML, and refuses the whole document on any issue rather than defaulting field by field. Invalid settings fail closed: `tasks.create`, the webhook route, and the poller start no run, and the page lists every issue with its `$.path`. + +Every run is held to them on top of the repository's own `.code-zero.yml`, stricter winning: `execution.max_concurrent_tasks` is the scheduler's ceiling, `execution.allowed_modes` refuses a run before it is recorded, `execution.command_timeout_minutes` caps how long one repository command may run (and a hosted sandbox lease lasts) — not the whole run, which can execute several commands — `execution.max_network` is a ceiling a repository can only narrow, `model` replaces the repository's model choice, and `runner` forces container isolation with the deployment's engine and image. `observe` is always allowed, since it is the default every unrequested run starts in and the one mode that never writes. + +On the managed cloud the service file is the only source and cannot be switched to the dashboard. The AI provider, the sandbox, the database, and the mail transport are the service's own: customers see only whether each one is configured — no credential, host, or provider name — and the plan's limits. An organization's owners and admins may set stricter limits for their own tasks (concurrency, per-command timeout, allowed modes) in `organization_setting`; the server refuses any value above the plan and takes the minimum again on every run, so a plan lowered later still wins. Membership and role are looked up in Postgres on every call rather than trusted from the session. A customer's run always belongs to one verified organization — an organization API key's own, or the session's selected organization once the member table confirms it — and work the deployment starts itself (webhook deliveries, polling) belongs to none and runs under the plan. + +The `settings.*` procedures are session-only on both editions: an API key or an operator token can neither read the settings nor change them. + +The boundary follows the package rule. `packages/core/config` parses and applies the settings and knows nothing about where they are stored; `packages/database` owns the two tables; `packages/api` authorizes the `settings.*` procedures and turns the settings into the `RunPolicy` a task runs under; `apps/web/server/utils/settings.ts` composes the authority from the environment, the instance file, and the store. + + + ## Issue-to-PR workflow @@ -137,11 +162,15 @@ Publication has a single home: `prepareIssuePullRequest` in `packages/source-con ## Authentication boundary -Authentication follows the same adapter rule at the package level, but not at the process level: Better Auth is mounted in-process by `apps/web`'s `/api/auth/**` route (`server/auth.config.ts`), the only route in the app that resolves `packages/auth`'s environment options — including the connection string, through `packages/database` — and the signing secret (`NUXT_BETTER_AUTH_SECRET`, required in production; `BETTER_AUTH_SECRET` only works as a development fallback) and therefore the only part of the app that opens a connection to Postgres, the only database in the repository. Every other route reaches storage exclusively through the `KeyValueStorage` contract. `packages/auth` holds the policy: `authBetterAuthOptions` builds the database, policy, and provider options Better Auth needs, deliberately omitting `secret`, `baseURL`, and `trustedOrigins` so the `@onmax/nuxt-better-auth` module — which resolves those itself and constructs the actual instance — cannot diverge from it. `createAuth`, which does build a full standalone instance, remains for callers that own their own secret and origin, such as the Better Auth CLI's schema-generation entry point; nothing in `apps/web`'s request path uses it. `packages/auth`'s `./config` subpath stays free of database dependencies so the login page can read feature flags without bundling one. +Authentication follows the same adapter rule at the package level, but not at the process level: Better Auth is mounted in-process by `apps/web`'s `/api/auth/**` route (`server/auth.config.ts`), the only route in the app that resolves `packages/auth`'s environment options — including the connection string, through `packages/database` — and the signing secret (`NUXT_BETTER_AUTH_SECRET`, required in production; `BETTER_AUTH_SECRET` only works as a development fallback) and therefore the only part of the app that opens a connection to Postgres, the only database in the repository. Every other route reaches storage exclusively through the `KeyValueStorage` contract. `packages/auth` holds the policy: `authBetterAuthOptions` builds the database, policy, and provider options Better Auth needs, deliberately omitting `secret`, `baseURL`, and `trustedOrigins` so the `@onmax/nuxt-better-auth` module — which resolves those itself and constructs the actual instance — cannot diverge from it. `createAuth`, which does build a full standalone instance, remains for callers that own their own secret and origin, such as the Better Auth CLI's schema-generation entry point; nothing in `apps/web`'s request path uses it. `packages/auth`'s `./config` and `./access` subpaths stay free of database dependencies, so policy and the organization roles can be read without bundling one. + +Every plugin is decided by `AuthConfig` when the instance is built, read from the environment at server start rather than captured at build time. `authCapabilities(config)` derives a serializable view of the same policy — plain booleans, no credential or origin — which `apps/web` serves at `/api/capabilities` and its pages read to decide what to offer, so a policy change needs a restart, not a rebuild. The one build-time input left is whether the hosted-infrastructure client ships, because that decides which code reaches the browser. Each capability that needs something to work refuses to construct without it, naming the missing piece and never a configured value: email verification and password reset need their structural delivery callbacks (bound to `packages/mail` templates in `apps/web`), magic links and mail-delivered second-factor codes need theirs, passkeys need a relying party from configuration (never from a request's `Origin`), and the SCIM entitlement needs its own HMAC secret. Two-factor (issuer "Code Zero", backup codes, trusted devices) and passkeys are on by default because they add no way in, only a stronger one for an account that opts in. Every social provider, magic links, and SSO close sign-up exactly when the password form does. SSO and SCIM are registered only when the composition root passes `entitlements: { sso, scim }` as true; otherwise their routes do not exist. Organization API keys (`cz_` prefix, stored hashed, expiring, rate limited, never a session) require organizations. A key authenticates the control plane on both `/rpc/**` and `/api/v1/**` as `Authorization: Bearer cz_…`: a bearer value that matches no operator token and carries the prefix is verified through the plugin, which enforces expiry, uses and the rate limit, and becomes an `apiKey` principal named after the key and its organization. New keys carry `task: [create]`, and a key holding it gets only the non-writable `observe` and `suggest` modes; the writable modes wait for an organization-scoped run policy. A key never decides an approval, since an approval records a person's review and unblocks writable runs the key could not start. A key that fails verification is refused outright rather than falling back to a session cookie. + +Organization roles (`owner`, `admin`, `member`) come from one `createAccessControl` in `packages/auth/src/access.ts`: Better Auth's organization statements, which its own endpoints and Better Enrollment and the API-key plugin check, extended with Code Zero's `repository`, `task`, `policy`, `billing`, `member:invite/remove`, and `apiKey:create/revoke`, granted in step so either vocabulary gives the same answer. -Invitations (`AUTH_ENABLE_INVITATIONS`) are the Better Enrollment plugin, composed by the same factory and under the same rule: `packages/auth` decides policy and declares delivery structurally, and `apps/web`'s `server/auth.config.ts` binds `packages/mail` to it. The plugin's mode is auto-detected from the sign-up policy rather than configured a second time — with `AUTH_ENABLE_SIGNUP` off every sign-up route is closed and invitations are the only way in, and with it on they degrade to role and organization grants — so the two cannot drift apart. Enabling Better Enrollment fails startup without both a mail transport and a dashboard origin: a private invitation's link is deliberately never returned to whoever created it, so email is the only path the token travels, and an undeliverable invitation reaches nobody at all rather than failing loudly. Better Auth's separate organization plugin has no mail-delivery callback in this composition and does not depend on the dashboard origin. A public invitation still returns its shareable link once from create or resend, while Better Enrollment's `sendPublicInvitation` callback also sends the inviter a durable copy when it has an attributable email address; headless system invitations without one keep relying on the returned link. Every Better Enrollment link points at one path, `/invite?token=`, because what the redemption page must render is decided by the auth server when it reads the token; encoding the invitation's kind in the URL would both duplicate that decision and disclose it to anyone the link is forwarded to. That page renders whichever form the server's `nextAction` names and submits only the fields its `requiredFields` asks for, so one page covers private and public invitations, app and organization ones, and both modes. It is the one authenticated-surface route with no `auth` route rule: requiring a session would turn away the signed-out invitee it exists for, and requiring a guest would turn away the signed-in one accepting an organization invitation. Redemption deliberately does not start a session, so a newly created account is handed to the sign-in page with the credentials it just set — for a private invitation the page never learns the full address anyway, since `invite.get` returns it masked. +Invitations (`AUTH_ENABLE_INVITATIONS`) are the Better Enrollment plugin, composed by the same factory and under the same rule: `packages/auth` decides policy and declares delivery structurally, and `apps/web`'s `server/auth.config.ts` binds `packages/mail` to it. The plugin's mode is auto-detected from the sign-up policy rather than configured a second time — with `AUTH_ENABLE_SIGNUP` off every sign-up route is closed and invitations are the only way in, and with it on they degrade to role and organization grants — so the two cannot drift apart. Enabling Better Enrollment fails startup without both a mail transport and a dashboard origin: a private invitation's link is deliberately never returned to whoever created it, so email is the only path the token travels, and an undeliverable invitation reaches nobody at all rather than failing loudly. The organization plugin keeps organizations, members, and roles, but its own invitation flow is not served: its invitation endpoints are removed from the plugin before it is registered, so they are absent from `auth.api` and answer 404, and an organization invites into itself with a Better Enrollment `org-join` invite checked against the same roles. One model, one link format, one set of rules. A public invitation still returns its shareable link once from create or resend, while Better Enrollment's `sendPublicInvitation` callback also sends the inviter a durable copy when it has an attributable email address; headless system invitations without one keep relying on the returned link. Every Better Enrollment link points at one path, `/invite?token=`, because what the redemption page must render is decided by the auth server when it reads the token; encoding the invitation's kind in the URL would both duplicate that decision and disclose it to anyone the link is forwarded to. That page renders whichever form the server's `nextAction` names and submits only the fields its `requiredFields` asks for, so one page covers private and public invitations, app and organization ones, and both modes. It is the one authenticated-surface route with no `auth` route rule: requiring a session would turn away the signed-out invitee it exists for, and requiring a guest would turn away the signed-in one accepting an organization invitation. Redemption deliberately does not start a session, so a newly created account is handed to the sign-in page with the credentials it just set — for a private invitation the page never learns the full address anyway, since `invite.get` returns it masked. -The app-wide `user.role` column belongs to this boundary too, and is distinct from `member.role`, which scopes a role to a single organization. Better Auth keeps multiple app-wide roles in one comma-separated string, and only invitation redemption merges into it; any other write replaces the whole set, so code that changes a role has to send the full set or it silently revokes the rest. +The app-wide `user.role` column belongs to this boundary too, and is distinct from `member.role`, which scopes a role to a single organization. Better Auth's admin plugin, registered on every deployment, is its authority: it decides which roles administer the deployment (`adminRoles`, shared with Better Enrollment's invitation management) and owns banning and impersonation. `packages/api` does not parse the role itself; it asks the admin plugin's `userHasPermission` whether the account may set roles, and grants the writable modes only on a yes, failing closed when the plugin is absent. Better Auth keeps multiple app-wide roles in one comma-separated string, and only invitation redemption merges into it; any other write replaces the whole set, so code that changes a role has to send the full set or it silently revokes the rest. The dashboard renders with SSR. The session cookie is scoped to the app's own origin, so the server resolves it directly from the incoming request before the first paint, rather than rendering a signed-out shell that a client-side check then corrects. @@ -159,7 +188,7 @@ Declaring the schema here rather than letting Better Auth's own migration CLI ge `DATABASE_URL` is the connection string a deployment sets. `AUTH_DATABASE_URL` is still accepted, because the store used to live inside `packages/auth` and a deployment configured before the split must not fail to start on upgrade. -The same rule covers the tables a Better Auth plugin brings with it. `invite` and `invite_use` (Better Enrollment) are declared here alongside `organization` and `member`, so a deployment that never enables the feature still migrates them and simply never writes to them — the alternative is a schema that depends on which flags were set when the migration ran. Two of their columns are deliberate exceptions to the repository's usual habits: an accepted invitation is a permanent audit record, so it carries no foreign key to the organization, team, or account it names and denormalizes `inviter_name`/`inviter_email` in order to survive their deletion; and `invite_use` carries only `used_at` rather than the shared timestamp pair, because a use is a point-in-time fact that must not look rewritable. +The same rule covers the tables a Better Auth plugin brings with it. `invite` and `invite_use` (Better Enrollment), `two_factor`, `passkey`, `api_key` (the plugin's `apikey` model), `sso_provider`, the SCIM catalog and directory tables, the admin columns on `user` and `session`, and the organization tables are declared here, so a deployment that never enables a feature still migrates its tables and simply never writes to them — the alternative is a schema that depends on which flags were set when the migration ran. Column names are checked against each plugin's own `schema` and against Better Auth's Drizzle generator. `api_key.reference_id` carries no foreign key because the plugin stores a user or an organization id there depending on the key's configuration. Two of their columns are deliberate exceptions to the repository's usual habits: an accepted invitation is a permanent audit record, so it carries no foreign key to the organization, team, or account it names and denormalizes `inviter_name`/`inviter_email` in order to survive their deletion; and `invite_use` carries only `used_at` rather than the shared timestamp pair, because a use is a point-in-time fact that must not look rewritable. diff --git a/packages/api/package.json b/packages/api/package.json index c17957b5..d98f5077 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -34,7 +34,7 @@ "@orpc/evlog": "2.0.0-beta.26", "@orpc/openapi": "2.0.0-beta.26", "@orpc/server": "2.0.0-beta.26", - "better-auth": "^1.6.26", + "better-auth": "^1.7.10", "evlog": "^2.26.0", "zod": "^4.4.3" }, diff --git a/packages/api/src/access.ts b/packages/api/src/access.ts index 9420e105..02831dc0 100644 --- a/packages/api/src/access.ts +++ b/packages/api/src/access.ts @@ -6,10 +6,11 @@ import type { RunMode } from '@code-zero/shared'; * How a principal proved its identity. * * Recorded on every authenticated request so an audit log can tell a machine caller presenting a - * static operator token from a human signed into the dashboard: the two are granted different - * execution modes and revoked through entirely different channels. + * static operator token, a machine caller presenting an organization API key, and a human signed + * into the dashboard apart: each is granted different execution modes and revoked through a + * different channel (the deployment's environment, the organization's key list, the account). */ -export type PrincipalKind = 'token' | 'session'; +export type PrincipalKind = 'token' | 'apiKey' | 'session'; /** An authenticated control-plane caller. Mutations record this identity, never a wire-supplied one. */ export interface Principal { @@ -29,6 +30,14 @@ export interface Principal { * read it back would let one audit itself. */ admin: boolean; + /** + * The account behind a `session` principal: its id, and the organization the session last chose + * to act in. That organization is the session's claim, not a grant — membership is read from the + * store before anything is decided by it. Absent on every other kind. + */ + account?: { userId: string; activeOrganizationId: string | null }; + /** The organization that owns an `apiKey` principal, as Better Auth verified it. */ + organizationId?: string; } /** @@ -121,6 +130,44 @@ export function authenticate( * {@link DEFAULT_MODES} an ungranted token gets. Repository targeting is a separate grant either * way, answered by the composition root against the deployment's store. */ -export function sessionPrincipal(name: string, isAdmin: boolean): Principal { - return { name, kind: 'session', modes: isAdmin ? ADMIN_MODES : DEFAULT_MODES, admin: isAdmin }; +export function sessionPrincipal( + name: string, + isAdmin: boolean, + account?: { userId: string; activeOrganizationId: string | null }, +): Principal { + return { + name, + kind: 'session', + modes: isAdmin ? ADMIN_MODES : DEFAULT_MODES, + admin: isAdmin, + ...(account ? { account } : {}), + }; +} + +/** The API-key permission that lets a key submit tasks, as `@code-zero/auth` declares it. */ +const TASK_CREATE_PERMISSION = { resource: 'task', action: 'create' } as const; + +/** + * Build the principal for a caller who presented a verified organization API key. + * + * `permissions` is what the key carries, as Better Auth's API-key plugin stored it. A key that + * may create tasks is held to the non-writable {@link DEFAULT_MODES}, whatever else it carries: + * the writable modes run a model against a checkout with write access, and granting that to a + * credential pasted into a CI secret needs an organization-scoped policy the control plane does + * not have yet. A key without the permission can authenticate but request no mode at all. Never + * an administrator, for the same reason as an operator token. + */ +export function apiKeyPrincipal( + name: string, + permissions: Readonly> | null | undefined, + organizationId?: string, +): Principal { + const actions = permissions?.[TASK_CREATE_PERMISSION.resource] ?? []; + return { + name, + kind: 'apiKey', + modes: actions.includes(TASK_CREATE_PERMISSION.action) ? DEFAULT_MODES : [], + admin: false, + ...(organizationId === undefined ? {} : { organizationId }), + }; } diff --git a/packages/api/src/control-plane.test.ts b/packages/api/src/control-plane.test.ts index e689f1df..0c1a9991 100644 --- a/packages/api/src/control-plane.test.ts +++ b/packages/api/src/control-plane.test.ts @@ -272,4 +272,61 @@ describe('TaskScheduler', () => { await expect(scheduler.schedule('acme/site', async () => 'ran')).resolves.toBe('ran'); expect(scheduler.snapshot()).toMatchObject({ queued: 1 }); }); + + it('holds a job to the concurrency the deployment settings gave it', () => { + const scheduler = new TaskScheduler({ + maxConcurrent: 4, + maxQueued: 10, + maxConcurrentPerRepository: 1, + }); + void scheduler.schedule('one', () => new Promise(() => undefined), { maxConcurrent: 1 }); + void scheduler.schedule('two', () => new Promise(() => undefined), { maxConcurrent: 1 }); + expect(scheduler.snapshot()).toMatchObject({ active: 1, queued: 1 }); + }); + + it('runs above its own default when the deployment settings allow more', () => { + const scheduler = new TaskScheduler({ + maxConcurrent: 1, + maxQueued: 10, + maxConcurrentPerRepository: 1, + }); + for (const repository of ['one', 'two', 'three']) + void scheduler.schedule(repository, () => new Promise(() => undefined), { + maxConcurrent: 3, + }); + expect(scheduler.snapshot()).toMatchObject({ active: 3, queued: 0 }); + }); + + it('keeps an organization to its own lower limit while others keep running', () => { + const scheduler = new TaskScheduler({ + maxConcurrent: 4, + maxQueued: 10, + maxConcurrentPerRepository: 1, + }); + const organization = { key: 'org-a', maxConcurrent: 1 }; + void scheduler.schedule('one', () => new Promise(() => undefined), { group: organization }); + void scheduler.schedule('two', () => new Promise(() => undefined), { group: organization }); + void scheduler.schedule('three', () => new Promise(() => undefined)); + expect(scheduler.snapshot()).toMatchObject({ active: 2, queued: 1 }); + }); + + it('starts the queued organization job once its running one finishes', async () => { + const scheduler = new TaskScheduler({ + maxConcurrent: 4, + maxQueued: 10, + maxConcurrentPerRepository: 1, + }); + const organization = { key: 'org-a', maxConcurrent: 1 }; + const releases: Array<() => void> = []; + const first = scheduler.schedule( + 'one', + () => new Promise((resolve) => releases.push(() => resolve('first'))), + { group: organization }, + ); + const second = scheduler.schedule('two', async () => 'second', { group: organization }); + expect(scheduler.snapshot()).toMatchObject({ active: 1, queued: 1 }); + releases.shift()?.(); + await expect(first).resolves.toBe('first'); + await expect(second).resolves.toBe('second'); + }); }); diff --git a/packages/api/src/control-plane.ts b/packages/api/src/control-plane.ts index 53e91d4b..3ad830ce 100644 --- a/packages/api/src/control-plane.ts +++ b/packages/api/src/control-plane.ts @@ -32,6 +32,14 @@ export interface StoredTask extends DashboardTask { * mutation after verification cannot ride along under the run's evidence. */ changedFileSnapshot?: ChangedFileSnapshot[]; + /** + * The organization the run was started for on the managed cloud, fixed when it was created. + * + * Anything later done about this task — deciding it, retrying it — answers to this organization, + * not to whichever one the session that comes back has selected by then. Absent on a self-hosted + * deployment and for work the deployment started itself. + */ + organizationId?: string; } export interface TaskStore { @@ -220,6 +228,20 @@ export interface SchedulerOptions { maxConcurrentPerRepository: number; } +/** + * The limits one job was submitted under. + * + * Carried per job rather than fixed on the scheduler, because they come from settings that change + * while the process runs: a job queued under the old limit keeps it, and the next one is held to the + * new one, with no restart and no scheduler to rebuild. + */ +export interface ScheduleLimits { + /** How many jobs may run at once when this one starts. Replaces the scheduler's own ceiling. */ + maxConcurrent?: number; + /** At most `maxConcurrent` jobs sharing `key` run at once: one organization's own, lower ceiling. */ + group?: { key: string; maxConcurrent: number }; +} + export interface SchedulerSnapshot { active: number; queued: number; @@ -235,13 +257,15 @@ export class TaskQueueQuotaError extends Error { interface QueuedJob { repository: string; + limits: ScheduleLimits; start: () => Promise; } -/** Bounded FIFO scheduler with a separate per-repository fairness quota. */ +/** Bounded FIFO scheduler with separate per-repository and per-group fairness quotas. */ export class TaskScheduler { private active = 0; private readonly activeByRepository = new Map(); + private readonly activeByGroup = new Map(); private readonly queue: QueuedJob[] = []; constructor(private readonly options: SchedulerOptions) { @@ -250,12 +274,16 @@ export class TaskScheduler { assertPositiveInteger(options.maxConcurrentPerRepository, 'maxConcurrentPerRepository'); } - schedule(repository: string, run: () => Promise): Promise { - if (this.queue.length >= this.options.maxQueued && !this.hasImmediateCapacity(repository)) + schedule(repository: string, run: () => Promise, limits: ScheduleLimits = {}): Promise { + if (limits.maxConcurrent !== undefined) + assertPositiveInteger(limits.maxConcurrent, 'maxConcurrent'); + if (limits.group) assertPositiveInteger(limits.group.maxConcurrent, 'group.maxConcurrent'); + if (this.queue.length >= this.options.maxQueued && !this.canStart({ repository, limits })) throw new TaskQueueQuotaError('Task queue capacity is exhausted'); return new Promise((resolve, reject) => { this.queue.push({ repository, + limits, start: async () => { try { resolve(await run()); @@ -277,45 +305,51 @@ export class TaskScheduler { } /** - * Whether a new job for this repository would start synchronously in {@link drain}. + * Whether a job with these limits would start synchronously in {@link drain}. * * Only jobs without immediate capacity occupy the queue, so `maxQueued` must bound exactly those: - * a job blocked by its repository quota counts against the queue even while global capacity is - * free, otherwise per-repository-blocked submissions could grow the queue without limit. + * a job blocked by its repository or group quota counts against the queue even while global + * capacity is free, otherwise blocked submissions could grow the queue without limit. */ - private hasImmediateCapacity(repository: string): boolean { + private canStart(job: Pick): boolean { + const { group } = job.limits; return ( - this.active < this.options.maxConcurrent && - (this.activeByRepository.get(repository) ?? 0) < this.options.maxConcurrentPerRepository + this.active < (job.limits.maxConcurrent ?? this.options.maxConcurrent) && + (this.activeByRepository.get(job.repository) ?? 0) < + this.options.maxConcurrentPerRepository && + (!group || (this.activeByGroup.get(group.key) ?? 0) < group.maxConcurrent) ); } private drain(): void { - while (this.active < this.options.maxConcurrent) { - const index = this.queue.findIndex( - (job) => - (this.activeByRepository.get(job.repository) ?? 0) < - this.options.maxConcurrentPerRepository, - ); + for (;;) { + const index = this.queue.findIndex((job) => this.canStart(job)); if (index < 0) return; const [job] = this.queue.splice(index, 1); if (!job) return; this.active += 1; - this.activeByRepository.set( - job.repository, - (this.activeByRepository.get(job.repository) ?? 0) + 1, - ); + increment(this.activeByRepository, job.repository); + if (job.limits.group) increment(this.activeByGroup, job.limits.group.key); void job.start().finally(() => { this.active -= 1; - const repositoryActive = (this.activeByRepository.get(job.repository) ?? 1) - 1; - if (repositoryActive === 0) this.activeByRepository.delete(job.repository); - else this.activeByRepository.set(job.repository, repositoryActive); + decrement(this.activeByRepository, job.repository); + if (job.limits.group) decrement(this.activeByGroup, job.limits.group.key); this.drain(); }); } } } +function increment(counts: Map, key: string): void { + counts.set(key, (counts.get(key) ?? 0) + 1); +} + +function decrement(counts: Map, key: string): void { + const remaining = (counts.get(key) ?? 1) - 1; + if (remaining === 0) counts.delete(key); + else counts.set(key, remaining); +} + function sanitizeTask(task: StoredTask, secrets: readonly string[]): StoredTask { const value = redactDeep(task, secrets); if (!isStoredTask(value)) throw new Error('Refusing to persist an invalid task record'); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d104f1be..39374bfd 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -29,6 +29,7 @@ export { } from './operations.js'; export { accessFromEnvironment, + apiKeyPrincipal, authenticate, sessionPrincipal, type ControlPlaneAccess, @@ -47,6 +48,7 @@ export { type DeliveryClaim, type DeliveryClaimStore, type KeyValueStorage, + type ScheduleLimits, type SchedulerOptions, type SchedulerSnapshot, type StoredTask, @@ -70,6 +72,7 @@ export { dashboardOverview, type DashboardOverview } from './dashboard.js'; export { authMiddleware, betterAuthPrincipal, + type ApiKeyVerifier, type BetterAuthContext, type BetterAuthSessionApi, } from './orpc/auth.js'; @@ -81,4 +84,18 @@ export type { RepositoryMode, RepositoryRecord, } from './repositories.js'; +export { deploymentServices } from './settings.js'; +export type { + DashboardSettingsAuthority, + DeploymentConnections, + DeploymentSettingsAuthority, + FileSettingsAuthority, + OrganizationMembership, + OrganizationSettingsStore, + RunPolicy, + ServiceName, + ServiceStatus, + SettingsState, +} from './settings.js'; +export type { OrganizationSettingsView, SettingsView } from './orpc/settings.js'; export { FileKeyValueStorage } from './storage.js'; diff --git a/packages/api/src/operations.test.ts b/packages/api/src/operations.test.ts index 46ea569e..e39a2c42 100644 --- a/packages/api/src/operations.test.ts +++ b/packages/api/src/operations.test.ts @@ -5,7 +5,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { RunnerPool, type Runner, type SandboxProvider } from '@code-zero/runner'; +import { defaultDeploymentSettings, type DeploymentSettings } from '@code-zero/config'; +import { + RunnerPool, + type Runner, + type SandboxProvider, + type SandboxRequest, +} from '@code-zero/runner'; import type { EvidenceBundle } from '@code-zero/shared'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -35,6 +41,8 @@ import { const secret = 'webhook-secret-value'; const MODE_ERROR = /mode/i; +const ISOLATION_ERROR = /isolated sandbox/; +const MODE_REFUSED_ERROR = /not allowed by the deployment settings/; function sign(body: string): string { return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; @@ -232,6 +240,146 @@ describe('runTask', () => { }); }); +describe('runTask under deployment settings', () => { + const observeOnly: DeploymentSettings = { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, allowedModes: ['observe'] }, + }; + + it('refuses a mode the settings do not allow before recording anything', async () => { + await expect( + runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'suggest' }, + { policy: { settings: observeOnly } }, + ), + ).rejects.toThrow(MODE_REFUSED_ERROR); + await expect(listTasks()).resolves.toEqual({ tasks: [] }); + }); + + it('refuses a mode an organization narrowed away even though the plan allows it', async () => { + await expect( + runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'suggest' }, + { + policy: { + settings: defaultDeploymentSettings, + organization: { + id: 'org-a', + limits: { + maxConcurrentTasks: null, + commandTimeoutMinutes: null, + allowedModes: ['observe'], + }, + }, + }, + }, + ), + ).rejects.toThrow(MODE_REFUSED_ERROR); + }); + + it('runs inside the deployment sandbox with the narrower network, whatever the repository says', async () => { + await writeFile( + join(checkout, '.code-zero.yml'), + 'version: 1\npermissions:\n network: full\nrunner:\n isolation: local\n', + 'utf8', + ); + const result = await runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'observe' }, + { + policy: { + settings: { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxNetwork: 'none' }, + runner: { isolation: 'container', engine: 'docker', image: 'ghcr.io/acme/runner:1' }, + }, + }, + }, + ); + + expect(result.runner).toMatchObject({ kind: 'container', isolated: true, network: 'none' }); + }); + + it('sends a hosted sandbox the network ceiling and the command timeout as its lease', async () => { + await writeFile( + join(checkout, '.code-zero.yml'), + 'version: 1\npermissions:\n network: full\n', + 'utf8', + ); + const requests: SandboxRequest[] = []; + const provider = sandboxProvider(); + const pool = new RunnerPool( + { + ...provider, + provision: (request) => { + requests.push(request); + return provider.provision(request); + }, + }, + { maxActive: 1, maxActivePerRepository: 1, maxLeaseMs: 3_600_000 }, + ); + + await runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'observe' }, + { + runnerPool: pool, + policy: { + settings: { + ...defaultDeploymentSettings, + execution: { + ...defaultDeploymentSettings.execution, + commandTimeoutMinutes: 5, + maxNetwork: 'restricted', + }, + }, + }, + }, + ); + + expect(requests).toMatchObject([{ network: 'restricted', leaseMs: 300_000 }]); + }); + + it('refuses a run that must be isolated when the hosted sandbox hands back an unisolated runner', async () => { + const provider = sandboxProvider(); + const stopped: string[] = []; + const pool = new RunnerPool( + { + ...provider, + provision: async (request) => { + const sandbox = await provider.provision(request); + const unisolated = () => ({ ...sandbox.runner.describe(), isolated: false }); + return { ...sandbox, runner: { ...sandbox.runner, describe: unisolated } }; + }, + stop: (externalId) => { + stopped.push(externalId); + return Promise.resolve(); + }, + }, + { maxActive: 1, maxActivePerRepository: 1, maxLeaseMs: 3_600_000 }, + ); + + await expect( + runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'observe' }, + { + runnerPool: pool, + policy: { settings: defaultDeploymentSettings, isolationRequired: true }, + }, + ), + ).rejects.toThrow(ISOLATION_ERROR); + expect(stopped).toHaveLength(1); + }); + + it('refuses a run that must be isolated when no sandbox exists, before recording it', async () => { + await expect( + runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'observe' }, + { policy: { settings: defaultDeploymentSettings, isolationRequired: true } }, + ), + ).rejects.toThrow(ISOLATION_ERROR); + await expect(listTasks()).resolves.toEqual({ tasks: [] }); + }); +}); + describe('ingestWebhook', () => { const options = () => ({ providers: [{ kind: 'github' as const, secret }], @@ -364,6 +512,38 @@ describe('ingestWebhook', () => { await expect(getTaskEvidence(outcome.result.id)).resolves.toContain('proactive finding'); }); + it('ignores a proactive review in a mode the deployment settings do not allow', async () => { + await writeFile( + join(checkout, '.code-zero.yml'), + 'version: 1\nproactive:\n enabled: true\nmode: suggest\n', + 'utf8', + ); + const body = JSON.stringify({ + action: 'opened', + repository: { name: 'app', owner: { login: 'acme' } }, + pull_request: { + number: 7, + base: { sha: 'b'.repeat(40) }, + head: { sha: 'a'.repeat(40) }, + }, + }); + const outcome = await ingestWebhook(githubDelivery('pull_request', body), { + ...options(), + policy: { + settings: { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, allowedModes: ['observe'] }, + }, + }, + }); + + expect(outcome).toEqual({ + status: 'ignored', + reason: 'The deployment settings do not allow suggest runs', + }); + expect(tasks.size).toBe(0); + }); + it('replays the recorded outcome for a redelivered proactive review rather than running it twice', async () => { await writeFile( join(checkout, '.code-zero.yml'), diff --git a/packages/api/src/operations.ts b/packages/api/src/operations.ts index 58939743..8621def3 100644 --- a/packages/api/src/operations.ts +++ b/packages/api/src/operations.ts @@ -1,7 +1,12 @@ import { createHash } from 'node:crypto'; import { CodeZero } from '@code-zero/agent'; -import { loadConfig, mayModifyRepository } from '@code-zero/config'; +import { + applyDeploymentSettings, + isModeAllowed, + loadConfig, + mayModifyRepository, +} from '@code-zero/config'; import { isSubscriptionProviderEnabled, modelFromEnvironment } from '@code-zero/models'; import { createRunner, @@ -51,6 +56,7 @@ import { type StoredTask, type TaskStore, } from './control-plane.js'; +import { runExecution, scheduleLimits, type RunPolicy } from './settings.js'; import { claudeCodeProcessSpawner, claudeCodeRefusalReason } from './subscription-isolation.js'; const TRAILING_SLASH = /\/$/u; @@ -121,6 +127,7 @@ export async function createTask( input: z.infer, store: TaskStore = defaultStore, scheduler: TaskScheduler = defaultScheduler, + policy?: RunPolicy, ): Promise { return runTask( { @@ -131,7 +138,7 @@ export async function createTask( ...(input.source ? { source: input.source } : {}), ...(input.files ? { files: input.files } : {}), }, - { store, scheduler }, + { store, scheduler, ...(policy ? { policy } : {}) }, ); } @@ -140,6 +147,29 @@ export interface RunTaskOptions { scheduler?: TaskScheduler; /** Optional hosted execution pool; its leases still expose only the Runner boundary. */ runnerPool?: RunnerPool; + /** + * The deployment's settings this run is held to. Without one the repository's own policy + * applies alone, which is how an embedded caller with no settings has always run. + */ + policy?: RunPolicy; +} + +/** Why a run never started: its mode is one the deployment's settings do not allow. */ +function modeRefusal(mode: ReviewInput['mode']): string { + return `Execution mode '${mode}' is not allowed by the deployment settings`; +} + +/** Why a run on the managed cloud never started, or stopped before executing anything. */ +const ISOLATION_UNAVAILABLE = + 'This deployment runs repository work only inside an isolated sandbox, and none is configured for this run'; + +/** + * Whether a run that must be isolated has any sandbox to reach: the deployment's container sandbox, + * or a hosted sandbox provider's pool. Decided before anything is recorded, because without either + * the run could only execute on the service's own host. + */ +function lacksRequiredSandbox(policy: RunPolicy | undefined, runnerPool: RunnerPool | undefined) { + return policy?.isolationRequired === true && policy.settings.runner === null && !runnerPool; } /** @@ -154,6 +184,12 @@ export async function runTask( ): Promise { const store = options.store ?? defaultStore; const scheduler = options.scheduler ?? defaultScheduler; + const { policy } = options; + const execution = policy ? runExecution(policy) : undefined; + // Refused before anything is recorded: a run the settings never allowed did not happen, so it + // leaves no task behind for the board to show as failed. + if (execution && !isModeAllowed(execution, input.mode)) throw new Error(modeRefusal(input.mode)); + if (lacksRequiredSandbox(policy, options.runnerPool)) throw new Error(ISOLATION_UNAVAILABLE); const identifier = taskId(); const timestamp = now(); const record: StoredTask = { @@ -163,90 +199,107 @@ export async function runTask( createdAt: timestamp, updatedAt: timestamp, events: [], + ...(policy?.organization ? { organizationId: policy.organization.id } : {}), }; await store.save(record); try { - return await scheduler.schedule(input.repository, async () => { - record.status = 'running'; - record.updatedAt = now(); - await store.save(record); - - const config = await loadConfig(input.repository); - let eventWrites = Promise.resolve(); - const writable = mayModifyRepository(config, input.mode); - const lease = options.runnerPool - ? await options.runnerPool.acquire({ - taskId: identifier, - repository: input.repository, - mode: input.mode, - writable, - network: config.permissions.network, - leaseMs: config.agent.timeoutMs, - }) - : undefined; - const runner = - lease?.runner ?? createRunner(input.repository, runnerOptionsFromPolicy(config, writable)); - // Refuses claude-code under container isolation when no CLI container image is configured, - // and outright under a RunnerPool lease regardless of runner.isolation: SandboxProvider - // (vitehub/cloudflare/vercel/custom) returns only a bounded-command Runner, never a live - // process handle, so there is no way to route the CLI's duplex spawn through the same hosted - // boundary the lease already gives repository commands — spawning it on the control-plane - // host instead would bypass exactly the isolation, lifecycle, quota, and audit controls an - // operator configured RunnerPool for. Reported to modelFromEnvironment as a refusal reason, - // not by disabling the enable flag: the flag would also skip fallback selection, turning a - // configured CODE_ZERO_MODEL_FALLBACK_PROVIDER into a run that fails outright instead of - // degrading to it. - const refusalReason = - config.model.provider !== 'claude-code' - ? undefined - : lease - ? 'A RunnerPool lease is active for this task; the claude-code CLI process cannot be ' + - "routed through an arbitrary SandboxProvider's boundary, so it cannot honor the " + - 'isolation the lease provides for repository commands.' - : claudeCodeRefusalReason(config, process.env); - const spawnClaudeCodeProcess = - config.model.provider === 'claude-code' && - refusalReason === undefined && - isSubscriptionProviderEnabled('claude-code', process.env) - ? claudeCodeProcessSpawner(config, process.env) + return await scheduler.schedule( + input.repository, + async () => { + record.status = 'running'; + record.updatedAt = now(); + await store.save(record); + + // The repository's `.code-zero.yml` is untrusted input from the checkout; the deployment's + // settings are applied over it, stricter winning, before anything reads the result. + const repositoryConfig = await loadConfig(input.repository); + const config = policy + ? applyDeploymentSettings(repositoryConfig, policy.settings, execution) + : repositoryConfig; + let eventWrites = Promise.resolve(); + const writable = mayModifyRepository(config, input.mode); + const lease = options.runnerPool + ? await options.runnerPool.acquire({ + taskId: identifier, + repository: input.repository, + mode: input.mode, + writable, + network: config.permissions.network, + leaseMs: config.agent.timeoutMs, + }) : undefined; - const agent = new CodeZero({ - model: modelFromEnvironment( - config.model, - process.env, - spawnClaudeCodeProcess, - refusalReason, - ), - runner, - config, - taskIdentifier: identifier, - onEvent: (event) => { - record.events.push(event); - record.updatedAt = event.timestamp; - eventWrites = eventWrites.then(() => store.save(record)); - }, - }); - let result: TaskResult; - let snapshot: ChangedFileSnapshot[] | undefined; - try { - result = await agent.run(input); - // Captured through the run's own boundary before the lease is released, so publication - // later reads exactly what the run verified, never whatever the checkout holds by then. - if (result.changedFiles.length > 0) - snapshot = await snapshotChangedFiles(runner, result.changedFiles); - } finally { - if (lease) await options.runnerPool?.release(lease.id); - } - await eventWrites; - record.status = result.state; - record.updatedAt = now(); - record.result = result; - record.evidence = evidenceFromResult(result, input); - if (snapshot) record.changedFileSnapshot = snapshot; - await store.save(record); - return result; - }); + const runner = + lease?.runner ?? + createRunner(input.repository, runnerOptionsFromPolicy(config, writable)); + // A provider can hand back a runner that is not isolated; checked on what was actually built, + // and before the agent starts, so nothing executes outside the sandbox the policy requires. + if (policy?.isolationRequired && !runner.describe().isolated) { + if (lease) await options.runnerPool?.release(lease.id); + throw new Error(ISOLATION_UNAVAILABLE); + } + // Refuses claude-code under container isolation when no CLI container image is configured, + // and outright under a RunnerPool lease regardless of runner.isolation: SandboxProvider + // (vitehub/cloudflare/vercel/custom) returns only a bounded-command Runner, never a live + // process handle, so there is no way to route the CLI's duplex spawn through the same hosted + // boundary the lease already gives repository commands — spawning it on the control-plane + // host instead would bypass exactly the isolation, lifecycle, quota, and audit controls an + // operator configured RunnerPool for. Reported to modelFromEnvironment as a refusal reason, + // not by disabling the enable flag: the flag would also skip fallback selection, turning a + // configured CODE_ZERO_MODEL_FALLBACK_PROVIDER into a run that fails outright instead of + // degrading to it. + const refusalReason = + config.model.provider !== 'claude-code' + ? undefined + : lease + ? 'A RunnerPool lease is active for this task; the claude-code CLI process cannot be ' + + "routed through an arbitrary SandboxProvider's boundary, so it cannot honor the " + + 'isolation the lease provides for repository commands.' + : claudeCodeRefusalReason(config, process.env); + const spawnClaudeCodeProcess = + config.model.provider === 'claude-code' && + refusalReason === undefined && + isSubscriptionProviderEnabled('claude-code', process.env) + ? claudeCodeProcessSpawner(config, process.env) + : undefined; + const agent = new CodeZero({ + model: modelFromEnvironment( + config.model, + process.env, + spawnClaudeCodeProcess, + refusalReason, + ), + runner, + config, + taskIdentifier: identifier, + onEvent: (event) => { + record.events.push(event); + record.updatedAt = event.timestamp; + eventWrites = eventWrites.then(() => store.save(record)); + }, + }); + let result: TaskResult; + let snapshot: ChangedFileSnapshot[] | undefined; + try { + result = await agent.run(input); + // Captured through the run's own boundary before the lease is released, so publication + // later reads exactly what the run verified, never whatever the checkout holds by then. + if (result.changedFiles.length > 0) + snapshot = await snapshotChangedFiles(runner, result.changedFiles); + } finally { + if (lease) await options.runnerPool?.release(lease.id); + } + await eventWrites; + record.status = result.state; + record.updatedAt = now(); + record.result = result; + record.evidence = evidenceFromResult(result, input); + if (snapshot) record.changedFileSnapshot = snapshot; + await store.save(record); + return result; + }, + policy ? scheduleLimits(policy) : {}, + ); } catch (error) { record.status = 'failed'; record.updatedAt = now(); @@ -311,6 +364,8 @@ export interface WebhookOptions { * deduplication only spans this process's lifetime and its bounded in-memory registry. */ deliveryClaims?: DeliveryClaimStore; + /** The deployment's settings every run this delivery starts is held to. */ + policy?: RunPolicy; } export type WebhookOutcome = @@ -380,6 +435,8 @@ export async function ingestWebhook( return { status: 'ignored', reason: 'Proactive review is disabled by repository policy' }; mode = config.mode; } + const refusal = settingsRefusal(options, mode); + if (refusal) return refusal; // A proactive trigger is unrequested work started on the delivery's own say-so — the same // description that fits a poller's pass over the same commit. Both claim this key before @@ -413,6 +470,24 @@ export async function ingestWebhook( return runReviewEvent(event, mode, options); } +/** + * An outcome for a delivery whose run the deployment's settings would refuse, or null. + * + * Answered before any claim is taken, so a refused delivery leaves nothing standing for a later + * redelivery, after the settings change, to trip over. + */ +function settingsRefusal( + options: WebhookOptions, + mode: ReviewInput['mode'], +): WebhookOutcome | null { + if (!options.policy) return null; + if (!isModeAllowed(runExecution(options.policy), mode)) + return { status: 'ignored', reason: `The deployment settings do not allow ${mode} runs` }; + if (lacksRequiredSandbox(options.policy, undefined)) + return { status: 'ignored', reason: ISOLATION_UNAVAILABLE }; + return null; +} + /** * The idempotency key for one proactive review of one change-request commit. * @@ -434,6 +509,7 @@ async function runReviewEvent( const runOptions: RunTaskOptions = { ...(options.store ? { store: options.store } : {}), ...(options.scheduler ? { scheduler: options.scheduler } : {}), + ...(options.policy ? { policy: options.policy } : {}), }; const result = await runTask( reviewInputFromEvent(event, { checkoutPath: options.checkoutPath, mode }), @@ -478,6 +554,8 @@ async function ingestIssueEvent( ...(options.ignoreAuthors ? { ignoreAuthors: options.ignoreAuthors } : {}), }); if (!task) return { status: 'ignored', reason: 'No actionable issue task in this event' }; + const refusal = settingsRefusal(options, config.mode); + if (refusal) return refusal; const identity = await new LocalRunner(options.checkoutPath).originRepository(); const mismatch = checkoutRepositoryMismatch(identity, task.issue); @@ -549,6 +627,7 @@ async function runIssueTask( const runOptions: RunTaskOptions = { ...(options.store ? { store: options.store } : {}), ...(options.scheduler ? { scheduler: options.scheduler } : {}), + ...(options.policy ? { policy: options.policy } : {}), }; const result = await runTask( issueInputFromTask(task, { checkoutPath: options.checkoutPath, mode: policy.mode }), diff --git a/packages/api/src/orpc/actor.ts b/packages/api/src/orpc/actor.ts new file mode 100644 index 00000000..cb9a72f5 --- /dev/null +++ b/packages/api/src/orpc/actor.ts @@ -0,0 +1,14 @@ +import type { Principal } from '../access.js'; +import type { AuditActor } from '../audit.js'; + +/** + * The audited identity of an authenticated caller; never the name the request asked to use. + * + * The two authentication sources stay distinguishable in the trail: a dashboard session is a + * person, an operator token is a machine, and they are revoked through different channels. A + * record that called both `principal` would leave a reader unable to tell which one to go turn + * off — the same reason `AuditActorKind` carries `user` at all. + */ +export function principalActor(principal: Principal): AuditActor { + return { type: principal.kind === 'session' ? 'user' : 'api', id: principal.name }; +} diff --git a/packages/api/src/orpc/auth.ts b/packages/api/src/orpc/auth.ts index 71be180c..3f238672 100644 --- a/packages/api/src/orpc/auth.ts +++ b/packages/api/src/orpc/auth.ts @@ -2,10 +2,10 @@ import { ORPCError, os } from '@orpc/server'; import type { RequestHeadersHandlerPluginContext } from '@orpc/server/plugins'; import type { Session, User } from 'better-auth'; -import { sessionPrincipal, type Principal } from '../access.js'; +import { apiKeyPrincipal, sessionPrincipal, type Principal } from '../access.js'; /** - * The one Better Auth endpoint this integration calls, declared structurally. + * The Better Auth endpoints this integration calls, declared structurally. * * Typed against `better-auth`'s own `Session`/`User` rather than a hand-rolled shape, but never * against a concrete instance: building one needs a database adapter and the deployment's @@ -18,6 +18,20 @@ import { sessionPrincipal, type Principal } from '../access.js'; export interface BetterAuthSessionApi { api: { getSession: (options: { headers: Headers }) => Promise; + /** + * The admin plugin's permission check, which decides who administers the deployment. + * + * Optional in the type because the composition root widens its plugin list, so a real + * instance does not carry the plugin's endpoints in its static type even though it serves + * them. At runtime an instance without it grants no one the administrator modes. + */ + userHasPermission?: (options: { + body: { + userId: string; + role?: string; + permissions: Readonly>; + }; + }) => Promise<{ success: boolean }>; }; } @@ -29,11 +43,32 @@ export interface BetterAuthSessionApi { * below never looks at. */ interface BetterAuthSessionPayload { - session: Pick; - /** `role` is an additional field the deployment adds, so it is absent from Better Auth's `User`. */ + /** `activeOrganizationId` is the organization plugin's field, absent while it is off. */ + session: Pick & { readonly activeOrganizationId?: string | null }; + /** `role` is the admin plugin's field, so it is absent from Better Auth's base `User`. */ user: Pick & { readonly role?: unknown }; } +/** + * Better Auth's API-key verification, declared structurally for the same reason as + * {@link BetterAuthSessionApi}. + * + * The plugin's `verifyApiKey` is server-only and enforces everything a key carries besides its + * permissions: that it exists, is enabled, has not expired, has uses left, and is within its rate + * limit. Kept apart from {@link BetterAuthSessionApi} because the two are offered to different + * transports: `/api/v1/**` verifies keys but must never honour a session cookie. + */ +export interface ApiKeyVerifier { + verifyApiKey: (options: { body: { key: string } }) => Promise<{ + valid: boolean; + key: { + id: string; + referenceId: string; + permissions?: Readonly> | null; + } | null; + }>; +} + /** * Context the authentication middleware reads. * @@ -54,28 +89,51 @@ export interface BetterAuthContext extends RequestHeadersHandlerPluginContext { * the authentication store, so a machine caller never pays for one. */ principal?: Principal; - /** App-wide role granted the writable execution modes. Defaults to {@link DEFAULT_ADMIN_ROLE}. */ - adminRole?: string; + /** + * An organization API key the transport found in the `Authorization` header and did not match + * against an operator token. Verified lazily, like a session, so only a procedure that needs an + * identity pays for the lookup. + */ + presentedApiKey?: string; + /** Verifies {@link BetterAuthContext.presentedApiKey}. Absent where keys are not enabled. */ + apiKeys?: ApiKeyVerifier; } -/** App-wide role that grants a signed-in user the writable execution modes. */ -const DEFAULT_ADMIN_ROLE = 'admin'; +/** + * The permission that makes an account an administrator of the deployment. + * + * Granting roles is the one permission that implies every other: an account that may set roles + * can make itself anything. Asking for it, rather than for a role name, leaves the admin plugin + * the only place that decides what administration is — its `adminRoles`, its access control, and + * its `adminUserIds` — instead of this package parsing `user.role` against a second list. + */ +const ADMINISTRATION_PERMISSION = { user: ['set-role'] } as const; /** - * Better Auth's base `User` carries no `role`; the deployment adds it as an additional field (see - * `@code-zero/auth`'s `authBetterAuthOptions`). Read defensively rather than asserted, so a - * deployment that drops the field grants the non-writable modes instead of throwing. + * Ask Better Auth's admin plugin whether `user` administers the deployment. * - * Better Auth stores multiple roles as one comma-separated string, so membership is checked - * rather than equality: an account provisioned with, say, `"support,admin"` holds the admin role - * exactly as much as one provisioned with `"admin"` alone. + * Passes the role the session already carried, so the check costs no further lookup; the plugin + * still resolves what that role means. Fails closed: an instance without the plugin, or a check + * that throws, grants the non-writable modes rather than the writable ones. */ -function isAdministrator(user: BetterAuthSessionPayload['user'], adminRole: string): boolean { - if (typeof user.role !== 'string') return false; - return user.role - .split(',') - .map((role) => role.trim()) - .includes(adminRole); +async function isAdministrator( + auth: BetterAuthSessionApi, + user: BetterAuthSessionPayload['user'], +): Promise { + const check = auth.api.userHasPermission; + if (!check) return false; + try { + const { success } = await check({ + body: { + userId: user.id, + ...(typeof user.role === 'string' ? { role: user.role } : {}), + permissions: ADMINISTRATION_PERMISSION, + }, + }); + return success; + } catch { + return false; + } } /** @@ -91,22 +149,56 @@ export async function betterAuthPrincipal( if (!auth || !reqHeaders) return undefined; const sessionData = await auth.api.getSession({ headers: reqHeaders }); if (!sessionData?.session || !sessionData.user) return undefined; - const { user } = sessionData; + const { session, user } = sessionData; // The email is the identity an operator recognises in an audit trail; the immutable user id is // the fallback so a deployment that leaves it blank still records something resolvable. const name = user.email.trim() === '' ? user.id : user.email; - return sessionPrincipal(name, isAdministrator(user, context.adminRole ?? DEFAULT_ADMIN_ROLE)); + return sessionPrincipal(name, await isAdministrator(auth, user), { + userId: user.id, + activeOrganizationId: session.activeOrganizationId ?? null, + }); +} + +/** + * Resolve a presented organization API key into a {@link Principal}. + * + * Returns `undefined` when none was presented, and `null` when one was presented and refused: + * the caller chose that credential, so a failure must not fall back to whatever session cookie + * happens to ride along. A verifier that throws refuses the key, as does a deployment without one. + */ +async function apiKeyPrincipalFrom( + context: BetterAuthContext, +): Promise { + const { presentedApiKey, apiKeys } = context; + if (presentedApiKey === undefined) return undefined; + if (!apiKeys) return null; + try { + const { valid, key } = await apiKeys.verifyApiKey({ body: { key: presentedApiKey } }); + if (!valid || !key) return null; + // The key id is what an organization sees in its key list and revokes by, and the reference + // is the organization that owns it, so the trail names something an operator can act on. + return apiKeyPrincipal( + `api-key:${key.id}@${key.referenceId}`, + key.permissions, + key.referenceId, + ); + } catch { + return null; + } } /** - * Requires an authenticated caller: a static operator token, or a Better Auth dashboard session. + * Requires an authenticated caller: a static operator token, an organization API key, or a Better + * Auth dashboard session. * * @see {@link https://orpc.dev/docs/integrations/better-auth | oRPC — Better Auth integration} */ export const authMiddleware = os .$context() .middleware(async ({ context, next }) => { - const principal = context.principal ?? (await betterAuthPrincipal(context)); + const fromKey = context.principal ? undefined : await apiKeyPrincipalFrom(context); + const principal = + context.principal ?? (fromKey === undefined ? await betterAuthPrincipal(context) : fromKey); if (!principal) throw new ORPCError('UNAUTHORIZED', { message: 'Authentication required' }); return next({ context: { principal } }); }); diff --git a/packages/api/src/orpc/router.test.ts b/packages/api/src/orpc/router.test.ts index c9b0e654..e88ea61b 100644 --- a/packages/api/src/orpc/router.test.ts +++ b/packages/api/src/orpc/router.test.ts @@ -6,7 +6,7 @@ import type { Principal } from '../access.js'; import { MemoryAuditLogStore, type AuditEvent } from '../audit.js'; import { MemoryTaskStore, type StoredTask } from '../control-plane.js'; import type { RepositoryAdmin, RepositoryInput, RepositoryRecord } from '../repositories.js'; -import type { BetterAuthSessionApi } from './auth.js'; +import type { ApiKeyVerifier, BetterAuthSessionApi } from './auth.js'; import { requestLoggerStorage } from './logging.js'; import { rpcRouter } from './router.js'; @@ -57,6 +57,7 @@ const FORBIDDEN_ERROR = /not allow-listed/i; const MODE_ERROR = /not granted/i; const STORAGE_ERROR = /storage unavailable/i; const ADMIN_ERROR = /admin role/i; +const API_KEY_APPROVAL_ERROR = /may not decide approvals/i; const NO_AUDIT_LOG_ERROR = /keeps no audit log/i; const NO_REPOSITORY_STORE_ERROR = /keeps no repository store/i; @@ -72,6 +73,8 @@ let audited: MockAudit; interface ClientOptions { principal?: Principal; auth?: BetterAuthSessionApi; + presentedApiKey?: string; + apiKeys?: ApiKeyVerifier; reqHeaders?: Headers; allowRepository?: boolean; } @@ -84,6 +87,10 @@ function client(options: ClientOptions = {}) { ...(options.principal ? { principal: options.principal } : {}), ...(options.auth ? { auth: options.auth } : {}), ...(options.reqHeaders ? { reqHeaders: options.reqHeaders } : {}), + ...(options.presentedApiKey === undefined + ? {} + : { presentedApiKey: options.presentedApiKey }), + ...(options.apiKeys ? { apiKeys: options.apiKeys } : {}), mayTargetRepository: () => options.allowRepository ?? false, repositories, auditLog, @@ -107,6 +114,10 @@ function failingStoreClient(options: ClientOptions = {}) { save: () => Promise.reject(new Error('storage unavailable')), }, ...(options.principal ? { principal: options.principal } : {}), + ...(options.presentedApiKey === undefined + ? {} + : { presentedApiKey: options.presentedApiKey }), + ...(options.apiKeys ? { apiKeys: options.apiKeys } : {}), mayTargetRepository: () => options.allowRepository ?? false, auditLog, }, @@ -161,18 +172,50 @@ function operator() { }); } -/** A Better Auth instance stubbed down to the one endpoint the integration calls. */ -function betterAuth(user: { email: string; role: string } | null): BetterAuthSessionApi { +/** + * A Better Auth instance stubbed down to the endpoints the integration calls. + * + * `userHasPermission` answers the way the admin plugin's default access control does: an account + * administers the deployment when any of its comma-separated roles is `admin`. `administers` + * overrides that answer, to show the decision is the plugin's and not the role string's. + */ +function betterAuth( + user: { email: string; role: string } | null, + administers?: boolean, +): BetterAuthSessionApi { return { api: { getSession: () => Promise.resolve( user === null ? null : { session: { id: 'sess_1' }, user: { id: 'usr_1', ...user } }, ), + userHasPermission: ({ body }) => + Promise.resolve({ + success: + administers ?? + (body.role ?? '') + .split(',') + .map((role) => role.trim()) + .includes('admin'), + }), }, }; } +/** An API-key verifier that knows one key, owned by `org_1`, carrying `permissions`. */ +function apiKeys( + permissions: Record | null = { task: ['create'] }, +): ApiKeyVerifier { + return { + verifyApiKey: ({ body }) => + Promise.resolve( + body.key === 'cz_valid' + ? { valid: true, key: { id: 'key_1', referenceId: 'org_1', permissions } } + : { valid: false, key: null }, + ), + }; +} + function auditRecord(id: string, occurredAt: string): AuditEvent { return { id, @@ -349,6 +392,103 @@ describe('rpc router', () => { await expect(store.list()).resolves.toEqual([]); }); + it('lets an organization API key submit a non-writable task', async () => { + // The store refuses to persist, so the call stops right after the authentication and mode + // gates instead of starting a run; reaching the store is what proves both let the key in. + const keyed = failingStoreClient({ + presentedApiKey: 'cz_valid', + apiKeys: apiKeys(), + allowRepository: true, + }); + + await expect( + instrumented(() => keyed.tasks.create({ repository: '.', feedback: 'x', mode: 'observe' })), + ).rejects.toThrow(STORAGE_ERROR); + // Recorded as a machine caller, named by the key and the organization that can revoke it. + expect(audited.events).toMatchObject([ + { actor: { type: 'api', id: 'api-key:key_1@org_1' }, action: 'task.create' }, + ]); + }); + + it('holds an organization API key to the non-writable execution modes', async () => { + const keyed = client({ + presentedApiKey: 'cz_valid', + apiKeys: apiKeys(), + allowRepository: true, + }); + + await expect( + instrumented(() => keyed.tasks.create({ repository: '.', feedback: 'x', mode: 'fix' })), + ).rejects.toThrow(MODE_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it('grants no mode to a key that may not create tasks', async () => { + const keyed = client({ + presentedApiKey: 'cz_valid', + apiKeys: apiKeys(null), + allowRepository: true, + }); + + await expect( + instrumented(() => keyed.tasks.create({ repository: '.', feedback: 'x', mode: 'observe' })), + ).rejects.toThrow(MODE_ERROR); + }); + + it.each(['approved', 'rejected'] as const)( + 'refuses to let an organization API key record an approval (%s)', + async (decision) => { + await store.save(awaiting('cz_1')); + const keyed = client({ presentedApiKey: 'cz_valid', apiKeys: apiKeys() }); + + await expect( + instrumented(() => keyed.approvals.decide({ taskId: 'cz_1', decision })), + ).rejects.toThrow(API_KEY_APPROVAL_ERROR); + await expect(store.get('cz_1')).resolves.toMatchObject({ status: 'needs-human' }); + expect(audited.events).toMatchObject([ + { + actor: { type: 'api', id: 'api-key:key_1@org_1' }, + action: 'approval.decide', + outcome: 'denied', + }, + ]); + }, + ); + + it('refuses a presented key that does not verify, without falling back to the session', async () => { + const keyed = client({ + presentedApiKey: 'cz_revoked', + apiKeys: apiKeys(), + auth: betterAuth({ email: 'ops@example.test', role: 'admin' }), + reqHeaders: new Headers(), + allowRepository: true, + }); + + await expect( + instrumented(() => keyed.tasks.create({ repository: '.', feedback: 'x', mode: 'observe' })), + ).rejects.toThrow(UNAUTHORIZED_ERROR); + }); + + it('refuses a presented key where the deployment verifies none', async () => { + const keyed = client({ presentedApiKey: 'cz_valid', allowRepository: true }); + + await expect( + instrumented(() => keyed.tasks.create({ repository: '.', feedback: 'x', mode: 'observe' })), + ).rejects.toThrow(UNAUTHORIZED_ERROR); + }); + + it('refuses a key whose verification throws', async () => { + const keyed = client({ + presentedApiKey: 'cz_valid', + apiKeys: { verifyApiKey: () => Promise.reject(new Error('store unavailable')) }, + allowRepository: true, + }); + + await expect( + instrumented(() => keyed.tasks.create({ repository: '.', feedback: 'x', mode: 'observe' })), + ).rejects.toThrow(UNAUTHORIZED_ERROR); + }); + it('rejects a request whose session lookup finds nobody', async () => { const anonymous = client({ auth: betterAuth(null), reqHeaders: new Headers() }); await expect( @@ -515,6 +655,38 @@ describe('rpc audit trail', () => { ).resolves.toMatchObject({ events: [{ id: 'audit_1' }] }); }); + it('takes the administrator decision from the admin plugin, not from the role string', async () => { + await auditLog.append(auditRecord('audit_1', '2026-08-09T10:00:00.000Z')); + + await expect( + instrumented(() => + client({ + auth: betterAuth({ email: 'ops@example.test', role: 'admin' }, false), + reqHeaders: new Headers(), + }).audit.list({}), + ), + ).rejects.toThrow(ADMIN_ERROR); + await expect( + instrumented(() => + client({ + auth: betterAuth({ email: 'ops@example.test', role: 'operator' }, true), + reqHeaders: new Headers(), + }).audit.list({}), + ), + ).resolves.toMatchObject({ events: [{ id: 'audit_1' }] }); + }); + + it('grants no administrator modes when the admin plugin is absent', async () => { + const { userHasPermission: _absent, ...api } = betterAuth({ + email: 'ops@example.test', + role: 'admin', + }).api; + + await expect( + instrumented(() => client({ auth: { api }, reqHeaders: new Headers() }).audit.list({})), + ).rejects.toThrow(ADMIN_ERROR); + }); + it('refuses a signed-in reader who is not an administrator', async () => { await expect( instrumented(() => @@ -636,6 +808,38 @@ describe('rpc repositories', () => { ]); }); + it('takes the administrator decision from the admin plugin, not from the role string', async () => { + await auditLog.append(auditRecord('audit_1', '2026-08-09T10:00:00.000Z')); + + await expect( + instrumented(() => + client({ + auth: betterAuth({ email: 'ops@example.test', role: 'admin' }, false), + reqHeaders: new Headers(), + }).audit.list({}), + ), + ).rejects.toThrow(ADMIN_ERROR); + await expect( + instrumented(() => + client({ + auth: betterAuth({ email: 'ops@example.test', role: 'operator' }, true), + reqHeaders: new Headers(), + }).audit.list({}), + ), + ).resolves.toMatchObject({ events: [{ id: 'audit_1' }] }); + }); + + it('grants no administrator modes when the admin plugin is absent', async () => { + const { userHasPermission: _absent, ...api } = betterAuth({ + email: 'ops@example.test', + role: 'admin', + }).api; + + await expect( + instrumented(() => client({ auth: { api }, reqHeaders: new Headers() }).audit.list({})), + ).rejects.toThrow(ADMIN_ERROR); + }); + it('refuses a signed-in reader who is not an administrator', async () => { await expect( instrumented(() => diff --git a/packages/api/src/orpc/router.ts b/packages/api/src/orpc/router.ts index 56e912cb..321353ac 100644 --- a/packages/api/src/orpc/router.ts +++ b/packages/api/src/orpc/router.ts @@ -11,8 +11,7 @@ import { openapi } from '@orpc/openapi'; import { ORPCError, os } from '@orpc/server'; import { z } from 'zod'; -import type { Principal } from '../access.js'; -import type { AuditActor, AuditLogStore } from '../audit.js'; +import type { AuditLogStore } from '../audit.js'; import type { TaskStore } from '../control-plane.js'; import { dashboardOverview } from '../dashboard.js'; import { @@ -25,8 +24,21 @@ import { taskInput, } from '../operations.js'; import type { RepositoryAdmin } from '../repositories.js'; +import type { DeploymentSettingsAuthority } from '../settings.js'; +import { principalActor } from './actor.js'; import { authMiddleware, type BetterAuthContext } from './auth.js'; import { useLogger } from './logging.js'; +import { + deploymentSettingsInput, + exportSettings, + organizationInput, + organizationLimitsInput, + readOrganizationSettings, + readSettings, + taskPolicy, + updateOrganizationSettings, + updateSettings, +} from './settings.js'; export interface RpcContext extends BetterAuthContext { store: TaskStore; @@ -58,6 +70,13 @@ export interface RpcContext extends BetterAuthContext { * "nothing has happened". */ auditLog?: AuditLogStore; + /** + * The deployment's settings: what they are, where they come from, and — when the dashboard is + * their source — where they are saved. Every task `tasks.create` starts is held to them. + * Optional like the capabilities above: an embedded caller without settings runs every task + * under the repository's own policy, as before settings existed. + */ + settings?: DeploymentSettingsAuthority; } /** @@ -90,6 +109,8 @@ function requireRepositoryAdmin(context: RpcContext): RepositoryAdmin { const procedure = os.$context(); +const API_KEY_APPROVAL_REFUSAL = 'An organization API key may not decide approvals'; + /** * Mutations require an authenticated principal; reads stay open for the dashboard. * @@ -181,9 +202,15 @@ export const rpcRouter = { // anything, so returning here without a record would leave a durable task nobody // authorised in the trail. The failure is audited under the attempted action, since no // task id is in hand to point at once the call rejected. + // Read after the identity checks above, so a caller with no grant learns nothing about the + // settings; refusals inside are audited under the same action. + const policy = await taskPolicy(context, { + repository: input.repository, + mode: input.mode, + }); let task: Awaited>; try { - task = await createTask(input, context.store); + task = await createTask(input, context.store, undefined, policy); } catch (error) { useLogger().audit({ actor, @@ -323,6 +350,69 @@ export const rpcRouter = { }); }), }, + settings: { + /** + * The settings page's read model: the whole document for a self-hosted administrator, service + * status and limits for a managed-cloud customer. + */ + get: authenticated + .meta( + openapi({ + method: 'GET', + path: '/settings', + tags: ['Settings'], + summary: 'Read the deployment settings and the status of each service', + }), + ) + .handler(({ context }) => readSettings(context)), + /** Replace the settings. Only where the dashboard is their source, and only for an admin. */ + update: authenticated + .meta( + openapi({ + method: 'PUT', + path: '/settings', + tags: ['Settings'], + summary: 'Replace the deployment settings when the dashboard is their source', + }), + ) + .input(deploymentSettingsInput) + .handler(({ input, context }) => updateSettings(context, input)), + /** The settings as the YAML the deployment file would hold, with no credential in it. */ + export: authenticated + .meta( + openapi({ + method: 'GET', + path: '/settings/export', + tags: ['Settings'], + summary: 'Export the deployment settings as YAML', + }), + ) + .handler(({ context }) => exportSettings(context)), + organization: { + get: authenticated + .meta( + openapi({ + method: 'GET', + path: '/organizations/{organizationId}/settings', + tags: ['Settings'], + summary: "Read an organization's limits on the managed cloud", + }), + ) + .input(organizationInput) + .handler(({ input, context }) => readOrganizationSettings(context, input)), + update: authenticated + .meta( + openapi({ + method: 'PUT', + path: '/organizations/{organizationId}/settings', + tags: ['Settings'], + summary: "Lower an organization's limits below its plan", + }), + ) + .input(organizationLimitsInput) + .handler(({ input, context }) => updateOrganizationSettings(context, input)), + }, + }, approvals: { decide: authenticated .meta( @@ -335,9 +425,20 @@ export const rpcRouter = { ) .input(approvalInput) .handler(async ({ input, context }) => { - // No denial branch to audit here: `decideApproval` refuses an unknown task or one that is - // not awaiting review by throwing before it touches the record, so nothing happened that - // a trail would have to explain. + // An approval records that a person reviewed the change, and it unblocks writable runs a + // key could never create. An organization API key is a credential pasted into a CI secret, + // so it may submit work but not sign off on it, whatever permissions it carries. + if (context.principal.kind === 'apiKey') { + useLogger().audit.deny(API_KEY_APPROVAL_REFUSAL, { + actor: principalActor(context.principal), + action: 'approval.decide', + target: { type: 'task', id: input.taskId, decision: input.decision }, + }); + throw new ORPCError('FORBIDDEN', { message: API_KEY_APPROVAL_REFUSAL }); + } + // No other denial branch to audit here: `decideApproval` refuses an unknown task or one + // that is not awaiting review by throwing before it touches the record, so nothing + // happened that a trail would have to explain. const task = await decideApproval( input.taskId, input.decision, @@ -361,16 +462,4 @@ export const rpcRouter = { }, }; -/** - * The audited identity of an authenticated caller; never the name the request asked to use. - * - * The two authentication sources stay distinguishable in the trail: a dashboard session is a - * person, an operator token is a machine, and they are revoked through different channels. A - * record that called both `principal` would leave a reader unable to tell which one to go turn - * off — the same reason `AuditActorKind` carries `user` at all. - */ -function principalActor(principal: Principal): AuditActor { - return { type: principal.kind === 'session' ? 'user' : 'api', id: principal.name }; -} - export type RpcRouter = typeof rpcRouter; diff --git a/packages/api/src/orpc/settings.test.ts b/packages/api/src/orpc/settings.test.ts new file mode 100644 index 00000000..5f35decc --- /dev/null +++ b/packages/api/src/orpc/settings.test.ts @@ -0,0 +1,612 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { defaultDeploymentSettings, type DeploymentSettings } from '@code-zero/config'; +import { createRouterClient } from '@orpc/server'; +import { createRequestLogger, initLogger, mockAudit, type MockAudit } from 'evlog'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { Principal } from '../access.js'; +import { MemoryTaskStore } from '../control-plane.js'; +import type { + DashboardSettingsAuthority, + DeploymentSettingsAuthority, + FileSettingsAuthority, + OrganizationMembership, + SettingsState, +} from '../settings.js'; +import { requestLoggerStorage } from './logging.js'; +import { rpcRouter } from './router.js'; + +initLogger({ silent: true }); + +const NO_ORGANIZATION_ERROR = /Select the organization/; +const ADMIN_ERROR = /admin role/; +const FILE_SOURCE_ERROR = /deployment file/; +const ISOLATION_ERROR = /isolated sandbox/; +const CLOUD_ONLY_ERROR = /managed cloud/; +const CLOUD_LOCKED_ERROR = /managed service/; +const NOT_A_MEMBER_ERROR = /not a member/; +const MODE_REFUSED_ERROR = /not allowed by the deployment settings/; +const POLICY_ROLE_ERROR = /owner or admin/; +const SETTINGS_INVALID_ERROR = /settings are invalid/; +const SESSION_ONLY_ERROR = /token or an API key/; + +const FORBIDDEN = /FORBIDDEN|not allowed|requires|not a member|Select the organization|session/i; +const INVALID = /invalid/i; +const OWNER: OrganizationMembership = { role: 'owner', managesPolicy: true }; +const MEMBER: OrganizationMembership = { role: 'member', managesPolicy: false }; + +const SERVICE_SETTINGS: DeploymentSettings = { + execution: { + maxConcurrentTasks: 10, + commandTimeoutMinutes: 60, + allowedModes: ['observe', 'suggest'], + maxNetwork: 'restricted', + }, + model: { provider: 'anthropic', name: 'service-model-name' }, + runner: { isolation: 'container', engine: 'docker', image: 'ghcr.io/service/runner:7' }, +}; + +let store: MemoryTaskStore; +let audited: MockAudit; +let checkout: string; + +beforeEach(async () => { + store = new MemoryTaskStore(); + audited = mockAudit(); + checkout = await mkdtemp(join(tmpdir(), 'code-zero-settings-')); + await writeFile(join(checkout, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8'); +}); + +afterEach(() => { + audited.restore(); +}); + +/** Opens the request logger the router audits through, as a transport would. */ +function instrumented(run: () => Promise): Promise { + if (!requestLoggerStorage) return run(); + const logger = createRequestLogger(); + return requestLoggerStorage.run(logger, async () => { + try { + return await run(); + } finally { + logger.emit(); + } + }); +} + +function session( + options: { admin?: boolean; userId?: string; activeOrganizationId?: string | null } = {}, +): Principal { + return { + name: `${options.userId ?? 'usr_1'}@example.test`, + kind: 'session', + modes: options.admin ? ['observe', 'suggest', 'fix', 'autonomous'] : ['observe', 'suggest'], + admin: options.admin ?? false, + account: { + userId: options.userId ?? 'usr_1', + activeOrganizationId: options.activeOrganizationId ?? null, + }, + }; +} + +const operatorToken: Principal = { + name: 'service-ops', + kind: 'token', + modes: ['observe', 'suggest'], + admin: false, +}; + +function apiKey(organizationId: string): Principal { + return { + name: `api-key:key_1@${organizationId}`, + kind: 'apiKey', + modes: ['observe', 'suggest'], + admin: false, + organizationId, + }; +} + +/** Counts reads, so a test can show a refusal came before the source was touched. */ +interface Counted { + reads: number; +} + +function fileAuthority( + state: SettingsState = ready(defaultDeploymentSettings), +): FileSettingsAuthority & Counted { + const authority: FileSettingsAuthority & Counted = { + edition: 'self-hosted', + source: 'file', + filePath: '/srv/code-zero/config.yml', + reads: 0, + read: () => { + authority.reads += 1; + return Promise.resolve(state); + }, + services: () => + Promise.resolve([ + { service: 'model', configured: false }, + { service: 'database', configured: true }, + ]), + }; + return authority; +} + +function dashboardAuthority(): DashboardSettingsAuthority & { saved: DeploymentSettings[] } { + let current = defaultDeploymentSettings; + const authority: DashboardSettingsAuthority & { saved: DeploymentSettings[] } = { + edition: 'self-hosted', + source: 'dashboard', + saved: [], + read: () => Promise.resolve(ready(current)), + save: (settings) => { + current = settings; + authority.saved.push(settings); + return Promise.resolve(); + }, + services: () => Promise.resolve([]), + }; + return authority; +} + +/** + * A managed-cloud deployment whose memberships and stored organization limits live in memory. + * + * `members` maps an organization to the accounts in it; changing it between calls is how a test + * removes someone from an organization while their session still names it. + */ +function cloudAuthority( + members: Map>, + limits = new Map>(), + settings: DeploymentSettings = SERVICE_SETTINGS, +): FileSettingsAuthority & { savedLimits: Map> } { + return { + edition: 'cloud', + source: 'file', + filePath: '/srv/service/config.yml', + savedLimits: limits, + read: () => Promise.resolve(ready(settings)), + services: () => + Promise.resolve([ + { service: 'model', configured: true }, + { service: 'sandbox', configured: true }, + { service: 'database', configured: true }, + { service: 'mail', configured: true }, + ]), + organizations: { + membership: (organizationId, userId) => + Promise.resolve(members.get(organizationId)?.get(userId) ?? null), + read: (organizationId) => Promise.resolve(limits.get(organizationId) ?? null), + save: (organizationId, document) => { + limits.set(organizationId, document); + return Promise.resolve(); + }, + }, + }; +} + +function ready(settings: DeploymentSettings): SettingsState { + return { ok: true, settings, saved: null, warnings: [] }; +} + +function client(principal: Principal, settings: DeploymentSettingsAuthority) { + return createRouterClient(rpcRouter, { + context: { store, principal, settings, mayTargetRepository: () => true }, + }); +} + +describe('settings.get', () => { + it('refuses a self-hosted member who is not an administrator before reading the source', async () => { + const authority = fileAuthority(); + + await expect(instrumented(() => client(session(), authority).settings.get())).rejects.toThrow( + ADMIN_ERROR, + ); + expect(authority.reads).toBe(0); + }); + + it('refuses an operator token and an organization API key', async () => { + const authority = fileAuthority(); + + await expect( + instrumented(() => client(operatorToken, authority).settings.get()), + ).rejects.toThrow(FORBIDDEN); + await expect( + instrumented(() => client(apiKey('org-a'), authority).settings.get()), + ).rejects.toThrow(FORBIDDEN); + }); + + it('shows a self-hosted administrator the file as a read-only source', async () => { + const view = await instrumented(() => + client(session({ admin: true }), fileAuthority()).settings.get(), + ); + + expect(view).toMatchObject({ + edition: 'self-hosted', + source: 'file', + editable: false, + filePath: '/srv/code-zero/config.yml', + }); + }); + + it('shows a cloud customer whether services work, never how the service configured them', async () => { + const members = new Map([['org-a', new Map([['usr_1', MEMBER]])]]); + const view = await instrumented(() => + client(session({ activeOrganizationId: 'org-a' }), cloudAuthority(members)).settings.get(), + ); + const published = JSON.stringify(view); + + expect(view).toMatchObject({ + edition: 'cloud', + organization: { id: 'org-a', editable: false }, + }); + expect(published).not.toContain('service-model-name'); + expect(published).not.toContain('ghcr.io/service/runner'); + expect(published).not.toContain('/srv/service/config.yml'); + }); +}); + +describe('settings.update', () => { + const input = { + execution: { + maxConcurrentTasks: 2, + commandTimeoutMinutes: 15, + allowedModes: ['observe'], + maxNetwork: 'none', + }, + model: null, + runner: null, + }; + + it('saves the settings when the dashboard is their source', async () => { + const authority = dashboardAuthority(); + + await instrumented(() => client(session({ admin: true }), authority).settings.update(input)); + + expect(authority.saved).toEqual([ + { + execution: { + maxConcurrentTasks: 2, + commandTimeoutMinutes: 15, + allowedModes: ['observe'], + maxNetwork: 'none', + }, + model: null, + runner: null, + }, + ]); + expect(audited.events).toMatchObject([{ action: 'settings.updated', outcome: 'success' }]); + }); + + it('refuses an invalid document whole and saves nothing', async () => { + const authority = dashboardAuthority(); + + await expect( + instrumented(() => + client(session({ admin: true }), authority).settings.update({ + ...input, + execution: { ...input.execution, maxConcurrentTasks: 0 }, + }), + ), + ).rejects.toThrow(INVALID); + expect(authority.saved).toEqual([]); + }); + + it('refuses edits while the file is the source, even from an administrator', async () => { + await expect( + instrumented(() => client(session({ admin: true }), fileAuthority()).settings.update(input)), + ).rejects.toThrow(FILE_SOURCE_ERROR); + expect(audited.events).toMatchObject([{ action: 'settings.update', outcome: 'denied' }]); + }); + + it('refuses every edit on the managed cloud, even from an administrator', async () => { + const members = new Map([['org-a', new Map([['usr_1', OWNER]])]]); + + await expect( + instrumented(() => + client( + session({ admin: true, activeOrganizationId: 'org-a' }), + cloudAuthority(members), + ).settings.update(input), + ), + ).rejects.toThrow(CLOUD_LOCKED_ERROR); + expect(audited.events).toMatchObject([{ action: 'settings.update', outcome: 'denied' }]); + }); + + it('refuses an organization API key, whatever it is allowed to run', async () => { + const authority = dashboardAuthority(); + + await expect( + instrumented(() => client(apiKey('org-a'), authority).settings.update(input)), + ).rejects.toThrow(SESSION_ONLY_ERROR); + expect(authority.saved).toEqual([]); + expect(audited.events).toMatchObject([ + { actor: { type: 'api' }, action: 'settings.update', outcome: 'denied' }, + ]); + }); + + it('refuses a signed-in member who is not an administrator', async () => { + const authority = dashboardAuthority(); + + await expect( + instrumented(() => client(session(), authority).settings.update(input)), + ).rejects.toThrow(ADMIN_ERROR); + expect(authority.saved).toEqual([]); + }); +}); + +describe('settings.export', () => { + it('exports YAML that carries no credential from the environment', async () => { + const secret = 'gateway-secret-0123456789'; + const original = process.env.AI_GATEWAY_API_KEY; + process.env.AI_GATEWAY_API_KEY = secret; + try { + const { yaml } = await instrumented(() => + client( + session({ admin: true }), + fileAuthority( + ready({ + ...defaultDeploymentSettings, + model: { provider: 'ai-gateway', name: `${secret}-model` }, + }), + ), + ).settings.export(), + ); + + expect(yaml).toContain('provider: ai-gateway'); + expect(yaml).not.toContain(secret); + } finally { + if (original === undefined) delete process.env.AI_GATEWAY_API_KEY; + else process.env.AI_GATEWAY_API_KEY = original; + } + }); + + it('refuses to export the managed service settings to a customer', async () => { + await expect( + instrumented(() => + client(session({ admin: true }), cloudAuthority(new Map())).settings.export(), + ), + ).rejects.toThrow(CLOUD_LOCKED_ERROR); + }); +}); + +describe('settings.organization', () => { + const lower = { + organizationId: 'org-a', + limits: { maxConcurrentTasks: 2, commandTimeoutMinutes: null, allowedModes: ['observe'] }, + }; + + it('lets an owner lower the limits below the plan', async () => { + const members = new Map([['org-a', new Map([['usr_1', OWNER]])]]); + + const view = await instrumented(() => + client( + session({ activeOrganizationId: 'org-a' }), + cloudAuthority(members), + ).settings.organization.update(lower), + ); + + expect(view.effective).toMatchObject({ + maxConcurrentTasks: 2, + commandTimeoutMinutes: 60, + allowedModes: ['observe'], + }); + expect(audited.events).toMatchObject([ + { action: 'organization.settings.updated', target: { type: 'organization', id: 'org-a' } }, + ]); + }); + + it('refuses limits above the plan, naming each one', async () => { + const members = new Map([['org-a', new Map([['usr_1', OWNER]])]]); + const authority = cloudAuthority(members); + + await expect( + instrumented(() => + client(session(), authority).settings.organization.update({ + organizationId: 'org-a', + limits: { maxConcurrentTasks: 11, commandTimeoutMinutes: 61, allowedModes: null }, + }), + ), + ).rejects.toThrow(INVALID); + expect(authority.savedLimits.size).toBe(0); + }); + + it('refuses a member whose role does not manage the policy', async () => { + const members = new Map([['org-a', new Map([['usr_1', MEMBER]])]]); + const authority = cloudAuthority(members); + + await expect( + instrumented(() => client(session(), authority).settings.organization.update(lower)), + ).rejects.toThrow(POLICY_ROLE_ERROR); + expect(authority.savedLimits.size).toBe(0); + expect(audited.events).toMatchObject([ + { action: 'organization.settings.update', outcome: 'denied' }, + ]); + }); + + it("refuses to read or change another organization's limits", async () => { + const members = new Map([ + ['org-a', new Map([['usr_1', OWNER]])], + ['org-b', new Map([['usr_2', OWNER]])], + ]); + const authority = cloudAuthority(members); + const intruder = client(session({ activeOrganizationId: 'org-a' }), authority); + + await expect( + instrumented(() => intruder.settings.organization.get({ organizationId: 'org-b' })), + ).rejects.toThrow(NOT_A_MEMBER_ERROR); + await expect( + instrumented(() => + intruder.settings.organization.update({ ...lower, organizationId: 'org-b' }), + ), + ).rejects.toThrow(NOT_A_MEMBER_ERROR); + expect(authority.savedLimits.size).toBe(0); + }); + + it('does not exist on a self-hosted deployment', async () => { + await expect( + instrumented(() => + client(session({ admin: true }), dashboardAuthority()).settings.organization.update(lower), + ), + ).rejects.toThrow(CLOUD_ONLY_ERROR); + }); +}); + +describe('tasks.create under the deployment settings', () => { + it('refuses every task while the settings are invalid', async () => { + const authority = fileAuthority({ + ok: false, + issues: [{ path: '$.execution.max_concurrent_tasks', message: 'Expected an integer.' }], + }); + + await expect( + instrumented(() => + client(session({ admin: true }), authority).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ), + ).rejects.toThrow(SETTINGS_INVALID_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it('refuses a mode the settings no longer allow, even to an administrator', async () => { + const authority = fileAuthority( + ready({ + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, allowedModes: ['observe'] }, + }), + ); + + await expect( + instrumented(() => + client(session({ admin: true }), authority).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'fix', + }), + ), + ).rejects.toThrow(MODE_REFUSED_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + describe('on the managed cloud', () => { + const members = () => + new Map([ + ['org-a', new Map([['usr_1', MEMBER]])], + ['org-b', new Map([['usr_2', MEMBER]])], + ]); + + it('refuses a session that selected no organization', async () => { + await expect( + instrumented(() => + client(session(), cloudAuthority(members())).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ), + ).rejects.toThrow(NO_ORGANIZATION_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it('refuses a session whose membership in its selected organization was removed', async () => { + const current = members(); + current.get('org-a')?.delete('usr_1'); + + await expect( + instrumented(() => + client(session({ activeOrganizationId: 'org-a' }), cloudAuthority(current)).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ), + ).rejects.toThrow(NOT_A_MEMBER_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it("refuses a session that selected another customer's organization", async () => { + await expect( + instrumented(() => + client( + session({ activeOrganizationId: 'org-b' }), + cloudAuthority(members()), + ).tasks.create({ repository: checkout, feedback: 'x', mode: 'observe' }), + ), + ).rejects.toThrow(NOT_A_MEMBER_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it("holds an organization API key to its own organization's narrower modes", async () => { + const limits = new Map([['org-a', { allowed_modes: ['observe'] }]]); + + await expect( + instrumented(() => + client(apiKey('org-a'), cloudAuthority(members(), limits)).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'suggest', + }), + ), + ).rejects.toThrow(MODE_REFUSED_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it('records the organization a task was created for, whatever the session selects later', async () => { + const current = members(); + current.get('org-b')?.set('usr_1', MEMBER); + const authority = cloudAuthority(current); + + const first = await instrumented(() => + client(session({ activeOrganizationId: 'org-a' }), authority).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ); + const second = await instrumented(() => + client(session({ activeOrganizationId: 'org-b' }), authority).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ); + + await expect(store.get(first.id)).resolves.toMatchObject({ organizationId: 'org-a' }); + await expect(store.get(second.id)).resolves.toMatchObject({ organizationId: 'org-b' }); + }); + + it('runs an operator token for the service under the plan, bound to no organization', async () => { + const task = await instrumented(() => + client(operatorToken, cloudAuthority(members())).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ); + + const record = await store.get(task.id); + expect(record).toBeDefined(); + expect(record?.organizationId).toBeUndefined(); + }); + + it('refuses a customer task when the service configured no sandbox to run it in', async () => { + const onlyOrgA = new Map([['org-a', new Map([['usr_1', MEMBER]])]]); + + await expect( + instrumented(() => + client( + session({ activeOrganizationId: 'org-a' }), + cloudAuthority(onlyOrgA, new Map(), { ...SERVICE_SETTINGS, runner: null }), + ).tasks.create({ repository: checkout, feedback: 'x', mode: 'observe' }), + ), + ).rejects.toThrow(ISOLATION_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + }); +}); diff --git a/packages/api/src/orpc/settings.ts b/packages/api/src/orpc/settings.ts new file mode 100644 index 00000000..bfbd35b0 --- /dev/null +++ b/packages/api/src/orpc/settings.ts @@ -0,0 +1,421 @@ +import { + EXECUTION_LIMITS, + effectiveExecution, + isModeAllowed, + noOrganizationLimits, + organizationLimitsAbovePlan, + organizationLimitsDocument, + parseDeploymentSettings, + parseOrganizationLimits, + renderDeploymentSettings, + type DeploymentConfigIssue, + type ExecutionSettings, + type OrganizationLimits, +} from '@code-zero/config'; +import { ORPCError } from '@orpc/server'; +import { z } from 'zod'; + +import type { Principal } from '../access.js'; +import { + runExecution, + type DeploymentSettingsAuthority, + type OrganizationSettingsStore, + type RunPolicy, + type ServiceStatus, + type SettingsState, +} from '../settings.js'; +import { principalActor } from './actor.js'; +import { useLogger } from './logging.js'; + +/** What every settings handler reads from the procedure context. */ +interface SettingsContext { + principal: Principal; + settings?: DeploymentSettingsAuthority | undefined; +} + +/** + * `settings.update`'s input: the shape of the settings, and nothing about their values. + * + * Values are checked once, by `@code-zero/config`'s parser, against the same rules the deployment + * file is held to — a second copy of the bounds here is how the form and the file would come to + * disagree about what is allowed. + */ +export const deploymentSettingsInput = z.object({ + execution: z.object({ + maxConcurrentTasks: z.number(), + commandTimeoutMinutes: z.number(), + allowedModes: z.array(z.string()), + maxNetwork: z.string(), + }), + model: z.object({ provider: z.string(), name: z.string() }).nullable(), + runner: z.object({ isolation: z.string(), engine: z.string(), image: z.string() }).nullable(), +}); + +/** `settings.organization.get`'s input. The organization is named, then membership is checked. */ +export const organizationInput = z.object({ organizationId: z.string().min(1) }); + +/** `settings.organization.update`'s input: `null` keeps the plan's value. */ +export const organizationLimitsInput = organizationInput.extend({ + limits: z.object({ + maxConcurrentTasks: z.number().nullable(), + commandTimeoutMinutes: z.number().nullable(), + allowedModes: z.array(z.string()).nullable(), + }), +}); + +/** One organization's limits, as a member sees them. */ +export interface OrganizationSettingsView { + id: string; + role: string; + /** Whether this member may change the limits. The update procedure checks again regardless. */ + editable: boolean; + limits: OrganizationLimits; + /** True when the stored limits could not be read; runs for the organization are refused. */ + invalid: boolean; + /** The limits its runs are held to, or null while the service settings are unavailable. */ + effective: ExecutionSettings | null; +} + +/** + * The settings page's read model. + * + * Self-hosted, an administrator sees the whole document and where it comes from. On the managed + * cloud a customer sees whether each service works, the plan's limits, and their organization's + * own — never the service's model, sandbox, or connections, and nothing that could carry a secret. + */ +export type SettingsView = + | { + edition: 'self-hosted'; + source: 'file' | 'dashboard'; + /** Whether settings can be saved from the dashboard at all. */ + editable: boolean; + filePath: string | null; + state: SettingsState; + services: readonly ServiceStatus[]; + bounds: typeof EXECUTION_LIMITS; + } + | { + edition: 'cloud'; + services: readonly ServiceStatus[]; + /** The plan's limits, or null while the service settings are unavailable. */ + plan: ExecutionSettings | null; + organization: OrganizationSettingsView | null; + bounds: typeof EXECUTION_LIMITS; + }; + +const SESSION_ONLY = + 'Settings are managed from a signed-in dashboard session, not with a token or an API key'; +const ADMIN_ONLY = 'Managing the deployment settings requires the admin role'; +const CLOUD_LOCKED = 'The managed service configures these settings; they cannot be changed here'; +const FILE_SOURCE = + 'These settings come from the deployment file; edit the file, or make the dashboard their source'; +const NOT_A_MEMBER = 'You are not a member of this organization'; +const NOT_A_POLICY_MANAGER = "Changing this organization's limits requires the owner or admin role"; +const SETTINGS_INVALID = + 'The deployment settings are invalid, so no task can start until they are fixed'; +const ORGANIZATION_LIMITS_INVALID = + "This organization's stored limits are invalid, so its tasks cannot start until they are saved again"; +const CLOUD_ONLY = 'Organization limits exist on the managed cloud only'; +const NO_ORGANIZATION_SELECTED = 'Select the organization this task is for before starting it'; +const KEY_WITHOUT_ORGANIZATION = 'This API key does not belong to an organization'; +const ORGANIZATION_UNVERIFIABLE = + 'Organization membership cannot be verified on this deployment, so no customer task can start'; + +/** `settings.get`. Authorizes before it reads anything from the source or the services. */ +export async function readSettings(context: SettingsContext): Promise { + const authority = requireAuthority(context.settings); + const { principal } = context; + if (principal.kind !== 'session') throw new ORPCError('FORBIDDEN', { message: SESSION_ONLY }); + if (authority.edition === 'self-hosted') { + if (!principal.admin) throw new ORPCError('FORBIDDEN', { message: ADMIN_ONLY }); + const [state, services] = await Promise.all([authority.read(), authority.services()]); + return { + edition: 'self-hosted', + source: authority.source, + editable: authority.source === 'dashboard', + filePath: authority.source === 'file' ? authority.filePath : null, + state, + services, + bounds: EXECUTION_LIMITS, + }; + } + const [state, services] = await Promise.all([authority.read(), authority.services()]); + // A customer is told whether the plan resolved, never why not: the reasons name the service's + // own file and values. + const plan = state.ok ? state.settings.execution : null; + const organizationId = principal.account?.activeOrganizationId ?? null; + const organization = + organizationId && authority.organizations + ? await organizationView(authority.organizations, principal, organizationId, plan) + : null; + return { edition: 'cloud', services, plan, organization, bounds: EXECUTION_LIMITS }; +} + +/** `settings.update`: replace the deployment settings, when the dashboard is their source. */ +export async function updateSettings( + context: SettingsContext, + input: z.infer, +): Promise { + const authority = requireAuthority(context.settings); + const { principal } = context; + const target = { type: 'settings', id: 'deployment' }; + // Every refusal below is decided here, on the server, whatever the page rendered: a disabled + // input is a courtesy, not a control. + if (principal.kind !== 'session') refuse(principal, 'settings.update', target, SESSION_ONLY); + if (authority.edition === 'cloud') refuse(principal, 'settings.update', target, CLOUD_LOCKED); + if (!principal.admin) refuse(principal, 'settings.update', target, ADMIN_ONLY); + if (authority.source !== 'dashboard') refuse(principal, 'settings.update', target, FILE_SOURCE); + + const parsed = parseDeploymentSettings(documentFromInput(input)); + if (!parsed.ok) throw invalid(parsed.issues); + await authority.save(parsed.value, principal.name); + useLogger().audit({ + actor: principalActor(principal), + action: 'settings.updated', + outcome: 'success', + target, + }); + return readSettings(context); +} + +/** `settings.export`: the settings as the YAML the deployment file would hold. */ +export async function exportSettings(context: SettingsContext): Promise<{ yaml: string }> { + const authority = requireAuthority(context.settings); + if (context.principal.kind !== 'session') + throw new ORPCError('FORBIDDEN', { message: SESSION_ONLY }); + if (authority.edition === 'cloud') throw new ORPCError('FORBIDDEN', { message: CLOUD_LOCKED }); + if (!context.principal.admin) throw new ORPCError('FORBIDDEN', { message: ADMIN_ONLY }); + const state = await authority.read(); + if (!state.ok) throw new ORPCError('PRECONDITION_FAILED', { message: SETTINGS_INVALID }); + return { yaml: renderDeploymentSettings(state.settings) }; +} + +/** `settings.organization.get`: one organization's limits, for one of its members. */ +export async function readOrganizationSettings( + context: SettingsContext, + input: z.infer, +): Promise { + const { authority, organizations } = requireOrganizations(context.settings); + if (context.principal.kind !== 'session') + throw new ORPCError('FORBIDDEN', { message: SESSION_ONLY }); + const state = await authority.read(); + const view = await organizationView( + organizations, + context.principal, + input.organizationId, + state.ok ? state.settings.execution : null, + ); + if (!view) throw new ORPCError('FORBIDDEN', { message: NOT_A_MEMBER }); + return view; +} + +/** `settings.organization.update`: lower an organization's limits below the plan. */ +export async function updateOrganizationSettings( + context: SettingsContext, + input: z.infer, +): Promise { + const { authority, organizations } = requireOrganizations(context.settings); + const { principal } = context; + const target = { type: 'organization', id: input.organizationId }; + if (principal.kind !== 'session' || !principal.account) + refuse(principal, 'organization.settings.update', target, SESSION_ONLY); + const membership = await organizations.membership(input.organizationId, principal.account.userId); + // One answer for "no such organization" and "not yours", so the refusal does not reveal which + // organizations exist. + if (!membership) refuse(principal, 'organization.settings.update', target, NOT_A_MEMBER); + if (!membership.managesPolicy) + refuse(principal, 'organization.settings.update', target, NOT_A_POLICY_MANAGER); + + const state = await authority.read(); + if (!state.ok) throw new ORPCError('PRECONDITION_FAILED', { message: SETTINGS_INVALID }); + const parsed = parseOrganizationLimits({ + max_concurrent_tasks: input.limits.maxConcurrentTasks, + command_timeout_minutes: input.limits.commandTimeoutMinutes, + allowed_modes: input.limits.allowedModes, + }); + if (!parsed.ok) throw invalid(parsed.issues); + const abovePlan = organizationLimitsAbovePlan(parsed.value, state.settings.execution); + if (abovePlan.length > 0) throw invalid(abovePlan); + + await organizations.save( + input.organizationId, + organizationLimitsDocument(parsed.value), + principal.name, + ); + useLogger().audit({ + actor: principalActor(principal), + action: 'organization.settings.updated', + outcome: 'success', + target, + }); + const view = await organizationView( + organizations, + principal, + input.organizationId, + state.settings.execution, + ); + if (!view) throw new ORPCError('FORBIDDEN', { message: NOT_A_MEMBER }); + return view; +} + +/** + * The policy a task this principal creates runs under, or a refusal. + * + * `undefined` when the deployment keeps no settings at all, which is an embedded caller running + * under repository policy alone, as it always has. Invalid settings refuse the task rather than + * running it on defaults nobody chose, and so do an organization's unreadable limits. + */ +export async function taskPolicy( + context: SettingsContext, + request: { repository: string; mode: Principal['modes'][number] }, +): Promise { + const authority = context.settings; + if (!authority) return undefined; + const { principal } = context; + const target = { type: 'repository', id: request.repository, mode: request.mode }; + const state = await authority.read(); + if (!state.ok) refuse(principal, 'task.create', target, SETTINGS_INVALID, 'PRECONDITION_FAILED'); + + const owner = await runOwner(authority, principal); + if (owner.kind === 'refused') refuse(principal, 'task.create', target, owner.reason); + let organization: RunPolicy['organization']; + if (owner.kind === 'organization') { + const limits = parseOrganizationLimits(await owner.store.read(owner.id)); + if (!limits.ok) + refuse(principal, 'task.create', target, ORGANIZATION_LIMITS_INVALID, 'PRECONDITION_FAILED'); + organization = { id: owner.id, limits: limits.value }; + } + const policy: RunPolicy = { + settings: state.settings, + ...(organization ? { organization } : {}), + isolationRequired: authority.edition === 'cloud', + }; + if (!isModeAllowed(runExecution(policy), request.mode)) + refuse( + principal, + 'task.create', + target, + `Execution mode '${request.mode}' is not allowed by the deployment settings`, + ); + return policy; +} + +/** Who a run is started for, decided once, when it is created. */ +type RunOwner = + | { kind: 'deployment' } + | { kind: 'organization'; id: string; store: OrganizationSettingsStore } + | { kind: 'refused'; reason: string }; + +/** + * Who a run is started for. + * + * Self-hosted, every run is the deployment's own. On the managed cloud a customer's run always + * belongs to one verified organization: an organization API key to the organization Better Auth + * bound it to, and a session to the organization it selected, once the member table confirms the + * account still belongs there. A session with no organization selected, or one that selected an + * organization it is not (or no longer) a member of, is refused rather than run under the plan + * alone — falling back would let a customer shed their organization's stricter limits by clearing + * or switching the selection. + * + * Operator tokens are the one deliberate exception: they are the service's own credentials, set in + * its environment by its operators, so they act for the deployment under the plan and never for a + * customer organization. + */ +async function runOwner( + authority: DeploymentSettingsAuthority, + principal: Principal, +): Promise { + if (authority.edition !== 'cloud' || principal.kind === 'token') return { kind: 'deployment' }; + const store = authority.organizations; + if (!store) return { kind: 'refused', reason: ORGANIZATION_UNVERIFIABLE }; + if (principal.kind === 'apiKey') + return principal.organizationId + ? { kind: 'organization', id: principal.organizationId, store } + : { kind: 'refused', reason: KEY_WITHOUT_ORGANIZATION }; + const account = principal.account; + if (!account?.activeOrganizationId) return { kind: 'refused', reason: NO_ORGANIZATION_SELECTED }; + const membership = await store.membership(account.activeOrganizationId, account.userId); + return membership + ? { kind: 'organization', id: account.activeOrganizationId, store } + : { kind: 'refused', reason: NOT_A_MEMBER }; +} + +async function organizationView( + store: OrganizationSettingsStore, + principal: Principal, + organizationId: string, + plan: ExecutionSettings | null, +): Promise { + if (!principal.account) return null; + const membership = await store.membership(organizationId, principal.account.userId); + if (!membership) return null; + const parsed = parseOrganizationLimits(await store.read(organizationId)); + const limits = parsed.ok ? parsed.value : noOrganizationLimits; + return { + id: organizationId, + role: membership.role, + editable: membership.managesPolicy, + limits, + invalid: !parsed.ok, + effective: plan && parsed.ok ? effectiveExecution(plan, limits) : null, + }; +} + +/** The deployment-file document a settings form describes, ready for the one strict parser. */ +function documentFromInput( + input: z.infer, +): Record { + return { + execution: { + max_concurrent_tasks: input.execution.maxConcurrentTasks, + command_timeout_minutes: input.execution.commandTimeoutMinutes, + allowed_modes: input.execution.allowedModes, + max_network: input.execution.maxNetwork, + }, + ...(input.model ? { model: { ...input.model } } : {}), + ...(input.runner ? { runner: { ...input.runner } } : {}), + }; +} + +function requireAuthority( + settings: DeploymentSettingsAuthority | undefined, +): DeploymentSettingsAuthority { + if (!settings) + throw new ORPCError('NOT_IMPLEMENTED', { message: 'This deployment keeps no settings' }); + return settings; +} + +function requireOrganizations(settings: DeploymentSettingsAuthority | undefined): { + authority: DeploymentSettingsAuthority; + organizations: OrganizationSettingsStore; +} { + const authority = requireAuthority(settings); + if (authority.edition !== 'cloud' || !authority.organizations) + throw new ORPCError('FORBIDDEN', { message: CLOUD_ONLY }); + return { authority, organizations: authority.organizations }; +} + +/** A settings document the parser refused, with every issue for the form to point at. */ +function invalid(issues: readonly DeploymentConfigIssue[]): ORPCError<'BAD_REQUEST', unknown> { + return new ORPCError('BAD_REQUEST', { + message: 'The settings are invalid', + data: { issues: issues.map((issue) => ({ path: issue.path, message: issue.message })) }, + }); +} + +/** + * Refuse, and leave the refusal in the audit trail. + * + * Refusals are recorded as deliberately as grants: a key or an account repeatedly reaching for + * settings it was never given is the signal a trail exists to preserve. + */ +function refuse( + principal: Principal, + action: string, + target: Record & { type: string; id: string }, + message: string, + code: 'FORBIDDEN' | 'PRECONDITION_FAILED' = 'FORBIDDEN', +): never { + useLogger().audit.deny(message, { actor: principalActor(principal), action, target }); + throw new ORPCError(code, { message }); +} diff --git a/packages/api/src/settings.test.ts b/packages/api/src/settings.test.ts new file mode 100644 index 00000000..a780694c --- /dev/null +++ b/packages/api/src/settings.test.ts @@ -0,0 +1,78 @@ +import { defaultDeploymentSettings } from '@code-zero/config'; +import { describe, expect, it } from 'vitest'; + +import { deploymentServices, runExecution, scheduleLimits } from './settings.js'; + +const connections = { database: true, mail: false, hostedSandbox: false }; + +describe('deploymentServices', () => { + it('reports whether the named model has a credential, never the credential', () => { + const secret = 'sk-ant-0123456789abcdefghij'; + const services = deploymentServices( + { ...defaultDeploymentSettings, model: { provider: 'anthropic', name: 'claude-sonnet-4-5' } }, + connections, + { ANTHROPIC_API_KEY: secret }, + ); + + expect(services).toContainEqual({ service: 'model', configured: true }); + expect(JSON.stringify(services)).not.toContain(secret); + }); + + it('says nothing about the model while each repository chooses its own', () => { + const services = deploymentServices(defaultDeploymentSettings, connections, {}); + + expect(services.map((status) => status.service)).not.toContain('model'); + }); + + it('counts a hosted sandbox provider as the sandbox without a container image', () => { + expect( + deploymentServices(defaultDeploymentSettings, { ...connections, hostedSandbox: true }, {}), + ).toContainEqual({ service: 'sandbox', configured: true }); + }); + + it('reports no sandbox when neither a container nor a hosted provider is set up', () => { + expect(deploymentServices(defaultDeploymentSettings, connections, {})).toContainEqual({ + service: 'sandbox', + configured: false, + }); + }); +}); + +describe('scheduleLimits', () => { + it("bounds an organization's runs by its own lower limit and everyone by the plan's", () => { + expect( + scheduleLimits({ + settings: defaultDeploymentSettings, + organization: { + id: 'org-a', + limits: { maxConcurrentTasks: 1, commandTimeoutMinutes: null, allowedModes: null }, + }, + }), + ).toEqual({ maxConcurrent: 4, group: { key: 'org-a', maxConcurrent: 1 } }); + }); + + it('leaves work started for the deployment itself to the plan alone', () => { + expect(scheduleLimits({ settings: defaultDeploymentSettings })).toEqual({ maxConcurrent: 4 }); + }); +}); + +describe('runExecution', () => { + it('never lets an organization widen what the plan allows', () => { + expect( + runExecution({ + settings: { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, allowedModes: ['observe'] }, + }, + organization: { + id: 'org-a', + limits: { + maxConcurrentTasks: 64, + commandTimeoutMinutes: null, + allowedModes: ['observe', 'autonomous'], + }, + }, + }), + ).toMatchObject({ maxConcurrentTasks: 4, allowedModes: ['observe'] }); + }); +}); diff --git a/packages/api/src/settings.ts b/packages/api/src/settings.ts new file mode 100644 index 00000000..c94e3a08 --- /dev/null +++ b/packages/api/src/settings.ts @@ -0,0 +1,170 @@ +import { + effectiveExecution, + type DeploymentConfigIssue, + type DeploymentSettings, + type Edition, + type ExecutionSettings, + type OrganizationLimits, +} from '@code-zero/config'; +import { isModelConfigured } from '@code-zero/models'; + +import type { ScheduleLimits } from './control-plane.js'; + +/** What the deployment's settings resolved to when they were read. */ +export type SettingsState = + | { + readonly ok: true; + readonly settings: DeploymentSettings; + /** Who saved the stored settings, and when. Null for the file source and for defaults. */ + readonly saved: { readonly by: string; readonly at: string } | null; + /** + * Problems that leave the settings usable: a deployment file that states settings while the + * dashboard is their source, for one. Shown, never acted on. + */ + readonly warnings: readonly DeploymentConfigIssue[]; + } + | { readonly ok: false; readonly issues: readonly DeploymentConfigIssue[] }; + +/** A service a deployment depends on. */ +export type ServiceName = 'model' | 'sandbox' | 'database' | 'mail'; + +/** + * Whether one service is set up, and nothing more. + * + * No credential, URL, host, or provider name: on the managed cloud these are the service's own + * secrets, and a customer is owed whether the service works, not how it is wired. + */ +export interface ServiceStatus { + readonly service: ServiceName; + readonly configured: boolean; +} + +/** An account's membership in one organization, as the store holds it right now. */ +export interface OrganizationMembership { + readonly role: string; + /** Whether that role may change the organization's policy. */ + readonly managesPolicy: boolean; +} + +/** + * Organization limits on the managed cloud, backed by the composition root's store. + * + * Membership is a lookup rather than something carried on the session, because a session remembers + * the organization it last acted in, not whether its account still belongs there. + */ +export interface OrganizationSettingsStore { + membership(organizationId: string, userId: string): Promise; + /** The stored limits document, or null when the organization never chose any. */ + read(organizationId: string): Promise; + save(organizationId: string, document: Record, actor: string): Promise; +} + +interface SettingsAuthorityBase { + readonly edition: Edition; + /** Reads the source again on every call, so a saved change is what the next run is held to. */ + read(): Promise; + services(): Promise; + /** Present only on the managed cloud, where organizations may choose stricter limits. */ + readonly organizations?: OrganizationSettingsStore; +} + +/** Settings read from the trusted deployment file. Nothing in the control plane can write them. */ +export interface FileSettingsAuthority extends SettingsAuthorityBase { + readonly source: 'file'; + /** The file an administrator edits, shown so they know where to look. */ + readonly filePath: string; +} + +/** Settings kept in the store and edited from the dashboard. */ +export interface DashboardSettingsAuthority extends SettingsAuthorityBase { + readonly source: 'dashboard'; + save(settings: DeploymentSettings, actor: string): Promise; +} + +/** + * The deployment's settings, as the composition root backs them. + * + * Declared structurally so this package stays free of the store and the file system: `apps/web` + * builds one from `@code-zero/database` and the deployment file, and a test builds one from memory. + * Which of the two sources it is was decided in the deployment's environment before the process + * started; nothing a request carries can change it. + */ +export type DeploymentSettingsAuthority = FileSettingsAuthority | DashboardSettingsAuthority; + +/** + * What one run is held to: the deployment's settings and, on the managed cloud, the stricter limits + * of the organization it was started for. + */ +export interface RunPolicy { + readonly settings: DeploymentSettings; + readonly organization?: { readonly id: string; readonly limits: OrganizationLimits }; + /** + * Whether the run may only execute inside an isolated sandbox: the deployment's container + * sandbox, or a hosted sandbox provider's lease. True on the managed cloud, where a customer's + * repository must never run on the service's own host; a run that would reach neither is refused. + */ + readonly isolationRequired?: boolean; +} + +/** The limits a run is held to once its organization's are applied. */ +export function runExecution(policy: RunPolicy): ExecutionSettings { + return effectiveExecution(policy.settings.execution, policy.organization?.limits ?? null); +} + +/** + * The scheduler limits for a run under this policy. + * + * The deployment's limit bounds every run together; an organization's lower limit bounds only its + * own runs. Feeding the organization's value in as the deployment-wide one would let one cautious + * organization throttle every other tenant. + */ +export function scheduleLimits(policy: RunPolicy): ScheduleLimits { + const { organization } = policy; + return { + maxConcurrent: policy.settings.execution.maxConcurrentTasks, + ...(organization + ? { group: { key: organization.id, maxConcurrent: runExecution(policy).maxConcurrentTasks } } + : {}), + }; +} + +/** What the composition root knows about the connections it opened. */ +export interface DeploymentConnections { + database: boolean; + mail: boolean; + /** + * Whether a hosted sandbox provider (Cloudflare, Vercel, ViteHub) is wired into task execution. + * It brings its own image, so it counts as the sandbox whether or not a container is configured. + */ + hostedSandbox: boolean; +} + +/** + * Whether each service the deployment depends on is set up, from the settings and the connections. + * + * The model is reported only when the deployment names one: otherwise each repository chooses its + * own, and there is no single answer to give. Only the credential's presence is checked — never its + * value, and never whether the provider accepts it, which only a run can find out. + */ +export function deploymentServices( + settings: DeploymentSettings | null, + connections: DeploymentConnections, + environment: NodeJS.ProcessEnv, +): ServiceStatus[] { + return [ + ...(settings?.model + ? [ + { + service: 'model' as const, + configured: isModelConfigured(settings.model.provider, environment), + }, + ] + : []), + { + service: 'sandbox', + configured: connections.hostedSandbox || (settings?.runner ?? null) !== null, + }, + { service: 'database', configured: connections.database }, + { service: 'mail', configured: connections.mail }, + ]; +} diff --git a/packages/auth/package.json b/packages/auth/package.json index 2e56b76e..a60594a2 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -10,6 +10,10 @@ "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, + "./access": { + "types": "./dist/access.d.mts", + "default": "./dist/access.mjs" + }, "./config": { "types": "./dist/config.d.mts", "default": "./dist/config.mjs" @@ -25,10 +29,14 @@ "typecheck": "tsc --project tsconfig.json --pretty false --noEmit" }, "dependencies": { - "@better-auth/infra": "^0.4.0", + "@better-auth/api-key": "^1.7.10", + "@better-auth/infra": "^0.4.20", + "@better-auth/passkey": "^1.7.10", + "@better-auth/scim": "^1.7.10", + "@better-auth/sso": "^1.7.10", "@code-zero/database": "workspace:*", - "@octopi-ai/better-enrollment": "^0.4.0", - "better-auth": "^1.6.26", + "@octopi-ai/better-enrollment": "^0.5.2", + "better-auth": "^1.7.10", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/auth/src/access.test.ts b/packages/auth/src/access.test.ts new file mode 100644 index 00000000..f7e18b78 --- /dev/null +++ b/packages/auth/src/access.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { organizationRoles, statements } from './access.js'; + +/** Whether `role` holds every action listed, on one resource. */ +function may(role: keyof typeof organizationRoles, resource: string, actions: string[]): boolean { + return organizationRoles[role].authorize({ [resource]: actions }).success; +} + +describe('organization access control', () => { + it('declares the Code Zero statements beside the plugin built-ins it cannot drop', () => { + expect(statements).toMatchObject({ + repository: ['read', 'write', 'configure'], + task: ['create', 'approve', 'cancel'], + policy: ['write'], + billing: ['manage'], + member: expect.arrayContaining(['invite', 'remove', 'create', 'update', 'delete']), + apiKey: expect.arrayContaining(['create', 'revoke']), + invitation: ['create', 'cancel'], + organization: ['update', 'delete'], + }); + }); + + it('gives owners every permission, billing and organization deletion included', () => { + for (const [resource, actions] of Object.entries(statements)) { + expect(may('owner', resource, [...actions])).toBe(true); + } + }); + + it('lets admins run the organization but not its billing or its deletion', () => { + expect(may('admin', 'repository', ['read', 'write', 'configure'])).toBe(true); + expect(may('admin', 'task', ['create', 'approve', 'cancel'])).toBe(true); + expect(may('admin', 'policy', ['write'])).toBe(true); + expect(may('admin', 'member', ['invite', 'remove'])).toBe(true); + expect(may('admin', 'apiKey', ['create', 'revoke'])).toBe(true); + expect(may('admin', 'billing', ['manage'])).toBe(false); + expect(may('admin', 'organization', ['delete'])).toBe(false); + }); + + it('confines members to reading repositories and starting tasks', () => { + expect(may('member', 'repository', ['read'])).toBe(true); + expect(may('member', 'task', ['create'])).toBe(true); + expect(may('member', 'repository', ['write'])).toBe(false); + expect(may('member', 'repository', ['configure'])).toBe(false); + expect(may('member', 'task', ['approve'])).toBe(false); + expect(may('member', 'task', ['cancel'])).toBe(false); + expect(may('member', 'policy', ['write'])).toBe(false); + expect(may('member', 'billing', ['manage'])).toBe(false); + expect(may('member', 'member', ['invite'])).toBe(false); + expect(may('member', 'member', ['remove'])).toBe(false); + expect(may('member', 'apiKey', ['create'])).toBe(false); + expect(may('member', 'apiKey', ['revoke'])).toBe(false); + }); + + it('keeps the two vocabularies in step on every role', () => { + // Better Enrollment checks `invitation: ["create"]` and the API-key plugin checks its own CRUD + // verbs, so a role must answer them exactly as it answers Code Zero's own actions. + for (const role of ['owner', 'admin', 'member'] as const) { + expect(may(role, 'invitation', ['create'])).toBe(may(role, 'member', ['invite'])); + expect(may(role, 'member', ['delete'])).toBe(may(role, 'member', ['remove'])); + expect(may(role, 'apiKey', ['delete'])).toBe(may(role, 'apiKey', ['revoke'])); + expect(may(role, 'apiKey', ['read', 'update'])).toBe(may(role, 'apiKey', ['create'])); + } + }); +}); diff --git a/packages/auth/src/access.ts b/packages/auth/src/access.ts new file mode 100644 index 00000000..31224bd0 --- /dev/null +++ b/packages/auth/src/access.ts @@ -0,0 +1,76 @@ +import { createAccessControl } from 'better-auth/plugins/access'; +import { + adminAc, + defaultStatements, + memberAc, + ownerAc, +} from 'better-auth/plugins/organization/access'; + +/** + * Organization access control: what each organization role may do inside one organization. + * + * Kept in its own entry point and free of any database or server import, so the dashboard can + * load the same roles into the organization client plugin and ask "may I?" before rendering a + * button, with the server still enforcing the answer. + * + * The statements are Better Auth's own organization statements extended with Code Zero's. The + * built-in ones cannot be dropped: the organization plugin checks `organization`, `member`, + * `invitation`, and `team` permissions on its own endpoints, Better Enrollment checks + * `invitation: ["create"]` before an organization invites into itself, and the API-key plugin + * checks `apiKey` with its own CRUD verbs. So two vocabularies share a resource where they meet: + * `member` carries the plugin's `create`/`update`/`delete` beside Code Zero's `invite`/`remove`, + * and `apiKey` carries the plugin's `read`/`update`/`delete` beside `create`/`revoke`. Every role + * below grants them in step — a role that may `invite` may also create an invitation, and one + * that may `revoke` a key may also delete it — so either vocabulary gives the same answer. + */ +export const statements = { + ...defaultStatements, + member: [...defaultStatements.member, 'invite', 'remove'], + repository: ['read', 'write', 'configure'], + task: ['create', 'approve', 'cancel'], + policy: ['write'], + billing: ['manage'], + apiKey: ['create', 'read', 'update', 'delete', 'revoke'], +} as const; + +export const accessControl = createAccessControl(statements); + +/** + * Members run work and read what it found. They cannot approve a write, change policy, or manage + * anyone else, which is what keeps `observe` the effective default for the role most accounts hold. + */ +export const member = accessControl.newRole({ + ...memberAc.statements, + repository: ['read'], + task: ['create'], +}); + +/** + * Admins run the organization day to day: repositories, approvals, policy, members, and keys. They + * cannot manage billing or delete the organization, which stay with its owners. + */ +export const admin = accessControl.newRole({ + ...adminAc.statements, + member: [...adminAc.statements.member, 'invite', 'remove'], + repository: ['read', 'write', 'configure'], + task: ['create', 'approve', 'cancel'], + policy: ['write'], + apiKey: ['create', 'read', 'update', 'delete', 'revoke'], +}); + +/** Owners hold every permission, billing and deletion of the organization included. */ +export const owner = accessControl.newRole({ + ...ownerAc.statements, + member: [...ownerAc.statements.member, 'invite', 'remove'], + repository: ['read', 'write', 'configure'], + task: ['create', 'approve', 'cancel'], + policy: ['write'], + billing: ['manage'], + apiKey: ['create', 'read', 'update', 'delete', 'revoke'], +}); + +/** The organization roles, keyed by the name stored in `member.role`. */ +export const organizationRoles = { owner, admin, member }; + +/** A role name an organization membership may hold. */ +export type OrganizationRoleName = keyof typeof organizationRoles; diff --git a/packages/auth/src/auth.test.ts b/packages/auth/src/auth.test.ts index f91cba16..8095be78 100644 --- a/packages/auth/src/auth.test.ts +++ b/packages/auth/src/auth.test.ts @@ -7,7 +7,7 @@ import { authDatabaseOptionsFromEnvironment, authOptionsFromEnvironment, createAuth, - ORGANIZATION_INVITATION_DELIVERY_UNAVAILABLE, + ORGANIZATION_INVITATION_ENDPOINTS, type SendPrivateInvitationEmail, } from './auth.js'; import { defaultAuthConfig } from './config.js'; @@ -23,8 +23,6 @@ const MISSING_URL_MESSAGE = /missing required environment variable: BETTER_AUTH_ const MISSING_SEND_PRIVATE_INVITATION_PATTERN = /sendPrivateInvitationEmail/; const MISSING_DASHBOARD_URL_PATTERN = /dashboardUrl/; const PUBLIC_INVITE_URL_PATTERN = /^http:\/\/localhost:3000\/invite\?token=[A-Za-z0-9_-]+$/; -const ORGANIZATION_INVITE_URL_PATTERN = - /^http:\/\/localhost:3000\/organizations\/accept-invitation\/[A-Za-z0-9_-]+$/; /** The one private invitation the delivery contract hands over, captured by the specs below. */ type PrivateInvitation = Parameters[0]; @@ -53,20 +51,29 @@ interface OrganizationApi { body: { name: string; slug: string }; headers: Headers; }): Promise<{ id: string; slug: string } | null>; - createInvitation(options: { - body: { email: string; role: string; organizationId: string }; - headers: Headers; - }): Promise; } /** Same boundary as {@link hasPublicInviteApi}: the organization endpoints, checked at runtime. */ function hasOrganizationApi(api: object): api is object & OrganizationApi { - return ( - 'createOrganization' in api && - typeof api.createOrganization === 'function' && - 'createInvitation' in api && - typeof api.createInvitation === 'function' - ); + return 'createOrganization' in api && typeof api.createOrganization === 'function'; +} + +interface OrganizationInviteApi { + createInvite(options: { + body: { + type: 'private'; + kind: 'org-join'; + email: string; + organizationId: string; + organizationRole: string; + }; + headers: Headers; + }): Promise; +} + +/** Better Enrollment's invite endpoint, which is how an organization invites into itself. */ +function hasOrganizationInviteApi(api: object): api is object & OrganizationInviteApi { + return 'createInvite' in api && typeof api.createInvite === 'function'; } /** @@ -82,6 +89,7 @@ function organizationAuth( sendPrivateInvitationEmail?: SendPrivateInvitationEmail; } = {}, ) { + const enableInvitations = options.sendPrivateInvitationEmail !== undefined; return betterAuth({ ...authBetterAuthOptions({ databaseUrl: completeEnvironment.DATABASE_URL, @@ -90,6 +98,7 @@ function organizationAuth( enableOrganizations: true, enableSignup: true, allowUserToCreateOrganization: true, + enableInvitations, }, ...options, }), @@ -103,8 +112,9 @@ function organizationAuth( organization: [], member: [], invitation: [], - team: [], - teamMember: [], + invite: [], + inviteUse: [], + twoFactor: [], }), secret: completeEnvironment.BETTER_AUTH_SECRET, baseURL: completeEnvironment.BETTER_AUTH_URL, @@ -290,7 +300,35 @@ describe('createAuth with organizations', () => { expect(() => createAuth({ ...instanceOptions, config: defaultAuthConfig })).not.toThrow(); }); - it('delivers an organization invitation to the address it names', async () => { + it("does not register the organization plugin's own invitation endpoints", async () => { + // Invitations travel through Better Enrollment only. The organization plugin's flow would be a + // second model for the same concept, so its endpoints are removed rather than guarded. + const options = authBetterAuthOptions({ + databaseUrl: completeEnvironment.DATABASE_URL, + config: { ...defaultAuthConfig, enableOrganizations: true }, + }); + const plugin = options.plugins.find((candidate) => candidate.id === 'organization'); + const auth = organizationAuth(); + const headers = await signedInHeaders(auth); + + for (const name of ORGANIZATION_INVITATION_ENDPOINTS) { + expect(Object.keys(plugin?.endpoints ?? {})).not.toContain(name); + expect(Object.keys(auth.api)).not.toContain(name); + } + // The routes behind them answer as if they never existed, for a signed-in caller too. + for (const path of ['/organization/invite-member', '/organization/accept-invitation']) { + const response = await auth.handler( + new Request(`${completeEnvironment.BETTER_AUTH_URL}/api/auth${path}`, { + method: 'POST', + headers: { ...Object.fromEntries(headers), 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'sam@example.com', role: 'member', invitationId: 'x' }), + }), + ); + expect(response.status).toBe(404); + } + }); + + it('invites into an organization through an enrollment invite', async () => { const delivered: PrivateInvitation[] = []; const auth = organizationAuth({ dashboardUrl: instanceOptions.dashboardUrl, @@ -299,49 +337,34 @@ describe('createAuth with organizations', () => { }, }); - if (!hasOrganizationApi(auth.api)) throw new Error('missing organization API'); + if (!hasOrganizationApi(auth.api) || !hasOrganizationInviteApi(auth.api)) + throw new Error('missing organization or enrollment API'); const headers = await signedInHeaders(auth); const organization = await auth.api.createOrganization({ body: { name: 'Acme Ops', slug: 'acme-ops' }, headers, }); - await auth.api.createInvitation({ - body: { email: 'sam@example.com', role: 'member', organizationId: organization?.id ?? '' }, + await auth.api.createInvite({ + body: { + type: 'private', + kind: 'org-join', + email: 'sam@example.com', + organizationId: organization?.id ?? '', + organizationRole: 'member', + }, headers, }); - // Better Auth builds no link of its own, so the accept URL has to point at the dashboard route - // that redeems it — an invitation delivered with any other path reaches a page that cannot - // accept it. + // One link format for every invitation, carrying only the token, pointed at the dashboard + // page that redeems it. expect(delivered).toEqual([ - { + expect.objectContaining({ to: 'sam@example.com', - name: null, inviterName: 'Dana', organizationName: 'Acme Ops', - acceptUrl: expect.stringMatching(ORGANIZATION_INVITE_URL_PATTERN), - }, - ]); - }); - - it('refuses an organization invitation it has no way to deliver', async () => { - // The invitation would otherwise be a pending row nobody is told about: it holds a seat, and - // its recipient waits for mail that is never sent. - const auth = organizationAuth({ dashboardUrl: instanceOptions.dashboardUrl }); - - if (!hasOrganizationApi(auth.api)) throw new Error('missing organization API'); - const headers = await signedInHeaders(auth); - const organization = await auth.api.createOrganization({ - body: { name: 'Acme Ops', slug: 'acme-ops' }, - headers, - }); - - await expect( - auth.api.createInvitation({ - body: { email: 'sam@example.com', role: 'member', organizationId: organization?.id ?? '' }, - headers, + acceptUrl: expect.stringMatching(PUBLIC_INVITE_URL_PATTERN), }), - ).rejects.toMatchObject({ body: { code: ORGANIZATION_INVITATION_DELIVERY_UNAVAILABLE } }); + ]); }); it('leaves the rest of the organization API working without a transport', async () => { @@ -501,9 +524,9 @@ describe('createAuth with invitations', () => { }); }); - it('exposes the app-wide role column whether or not invitations are enabled', () => { + it('declares the app-wide role column whether or not invitations are enabled', () => { // The column exists in every deployment's store, and a field the adapter does not know about - // is one it silently drops on write. + // is one it silently drops on write. The admin plugin owns it, and it is always registered. for (const config of [defaultAuthConfig, invitationsEnabled]) { const options = authBetterAuthOptions({ databaseUrl: completeEnvironment.DATABASE_URL, @@ -511,8 +534,11 @@ describe('createAuth with invitations', () => { dashboardUrl: instanceOptions.dashboardUrl, sendPrivateInvitationEmail, }); + const adminPlugin = options.plugins.find((plugin) => plugin.id === 'admin'); - expect(options.user?.additionalFields).toMatchObject({ role: { type: 'string' } }); + expect(adminPlugin?.schema?.user?.fields).toMatchObject({ + role: { type: 'string', input: false }, + }); } }); }); diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index ebdd66a9..c5dfa214 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,4 +1,8 @@ +import { apiKey } from '@better-auth/api-key'; import { dash, sentinel } from '@better-auth/infra'; +import { passkey } from '@better-auth/passkey'; +import { scim } from '@better-auth/scim'; +import { sso } from '@better-auth/sso'; import { createDatabase, databaseUrlFromEnvironment, schema } from '@code-zero/database'; import { betterEnrollment } from '@octopi-ai/better-enrollment'; import { betterAuth } from 'better-auth'; @@ -6,40 +10,45 @@ import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { APIError } from 'better-auth/api'; import { + admin, bearer, deviceAuthorization, lastLoginMethod, + magicLink, multiSession, oAuthProxy, organization, + twoFactor, } from 'better-auth/plugins'; -import type { OrganizationOptions } from 'better-auth/plugins'; +import { accessControl, organizationRoles } from './access.js'; import { authConfigFromEnvironment, DEVICE_VERIFICATION_PATH, githubCredentialsFromEnvironment, + gitlabCredentialsFromEnvironment, + googleCredentialsFromEnvironment, infraFromEnvironment, + NO_ENTITLEMENTS, oauthProxyFromEnvironment, + passkeyFromEnvironment, + scimFromEnvironment, + API_KEY_DEFAULT_PERMISSIONS, + API_KEY_PREFIX, + TWO_FACTOR_ISSUER, } from './config.js'; import type { AuthConfig, + AuthEntitlements, GithubOauthCredentials, + GitlabOauthCredentials, + GoogleOauthCredentials, InfraConfig, OauthProxyConfig, + PasskeyRelyingParty, + ScimConfig, } from './config.js'; -/** - * Error code an organization invitation is refused with when the deployment cannot deliver it. - * - * Travels to the client as the error body's `code`, so a UI can say why in the visitor's language - * rather than rendering this package's English message: an operator has to configure a transport, - * and the org admin who pressed "invite" should be told that rather than shown a generic failure. - * The dashboard mirrors the literal instead of importing it — this package pulls Better Auth and - * the database adapter, neither of which may reach a browser bundle. - */ -export const ORGANIZATION_INVITATION_DELIVERY_UNAVAILABLE = 'INVITATION_DELIVERY_UNAVAILABLE'; - /** * Delivers a private (email-bound) enrollment invitation. * @@ -81,6 +90,43 @@ export type SendPublicInvitationEmail = (invitation: { readonly expiresAt: Date | null; }) => Promise; +/** + * Delivers the link that verifies an account's email address. + * + * Structural, like the invitation contracts above, so this package never depends on + * `@code-zero/mail`. The link carries a single-use token and is sent only to the address it + * verifies. + */ +export type SendVerificationEmail = (message: { + readonly to: string; + readonly name: string; + /** Absolute URL that verifies the address. Carries the only copy of the token. */ + readonly verifyUrl: string; +}) => Promise; + +/** Delivers the link that resets a forgotten password, to the account's own address only. */ +export type SendPasswordResetEmail = (message: { + readonly to: string; + readonly name: string; + /** Absolute URL that opens the reset form. Carries the only copy of the token. */ + readonly resetUrl: string; +}) => Promise; + +/** Delivers a passwordless sign-in link. Possession of the link is the credential. */ +export type SendMagicLinkEmail = (message: { + readonly to: string; + /** Absolute URL that signs the recipient in. Carries the only copy of the token. */ + readonly signInUrl: string; +}) => Promise; + +/** Delivers a one-time second-factor code by mail, for an account that chose that factor. */ +export type SendTwoFactorOtpEmail = (message: { + readonly to: string; + readonly name: string; + /** The one-time code. Short-lived, single-use, and never logged. */ + readonly code: string; +}) => Promise; + /** Everything Better Auth's policy shape needs that isn't signing or origin configuration. */ export interface AuthDatabaseOptions { /** @@ -92,6 +138,28 @@ export interface AuthDatabaseOptions { readonly databaseUrl: string; readonly config: AuthConfig; readonly github?: GithubOauthCredentials; + readonly gitlab?: GitlabOauthCredentials; + readonly google?: GoogleOauthCredentials; + /** + * The relying party passkeys are bound to. Required when `config.enablePasskey` is on: without + * a configured origin the plugin would trust whatever `Origin` header a request carried. + */ + readonly passkey?: PasskeyRelyingParty; + /** + * Enterprise capabilities. Absent means none: SSO and SCIM are registered only when their + * entitlement is explicitly true, and otherwise their endpoints do not exist. + */ + readonly entitlements?: AuthEntitlements; + /** SCIM settings, required when the `scim` entitlement is granted. */ + readonly scim?: ScimConfig; + /** Required when `config.enableEmailVerification` is on. */ + readonly sendVerificationEmail?: SendVerificationEmail; + /** Required when `config.enablePasswordReset` is on. */ + readonly sendPasswordResetEmail?: SendPasswordResetEmail; + /** Required when `config.enableMagicLink` is on. */ + readonly sendMagicLinkEmail?: SendMagicLinkEmail; + /** Required when `config.enableTwoFactorEmailOtp` is on. */ + readonly sendTwoFactorOtpEmail?: SendTwoFactorOtpEmail; /** * Routes this deployment's OAuth round trip through a production origin. * @@ -134,16 +202,42 @@ export interface AuthInstanceOptions extends AuthDatabaseOptions { readonly trustedOrigins: readonly string[]; } +/** + * Organization endpoints this package never exposes. + * + * The organization plugin ships its own invitation flow (an `invitation` row, an emailed id, an + * accept endpoint). Code Zero invites through Better Enrollment instead, which covers the same + * ground with proof of mailbox access, public links, and seat limits, so serving both would give + * one concept two models and two sets of rules. The organization plugin has no option to turn its + * flow off, so the endpoints are removed from the plugin before it is registered: they are absent + * from `auth.api` and their routes answer 404, rather than existing behind a guard someone could + * later relax. + */ +export const ORGANIZATION_INVITATION_ENDPOINTS = [ + 'createInvitation', + 'acceptInvitation', + 'rejectInvitation', + 'cancelInvitation', + 'getInvitation', + 'listInvitations', + 'listUserInvitations', +] as const; + /** * Build the database, policy, and provider portion of the Better Auth options. * * Deliberately excludes `secret`, `baseURL`, and `trustedOrigins`: a same-origin host (a Nuxt * module hosting Better Auth in-process, for example) resolves those itself and must not have a * second, potentially divergent source for them. + * + * Every capability is decided by `options.config`, read when this runs rather than when the app + * was built, and every capability that needs something to work — a mail transport, a relying + * party, a secret — refuses to construct without it instead of registering endpoints that fail on + * their first request. The refusals name what is missing and never echo a configured value. */ export function authBetterAuthOptions(options: AuthDatabaseOptions): Pick< BetterAuthOptions, - 'database' | 'emailAndPassword' | 'session' | 'socialProviders' | 'user' + 'database' | 'emailAndPassword' | 'emailVerification' | 'session' | 'socialProviders' | 'user' > & { // Declared here rather than picked from `BetterAuthOptions`: that interface's own `plugins` // field is typed `... | undefined` explicitly, which trips `exactOptionalPropertyTypes` at @@ -153,13 +247,7 @@ export function authBetterAuthOptions(options: AuthDatabaseOptions): Pick< plugins: BetterAuthPlugin[]; } { const { config } = options; - - // Fail at construction rather than at the first private enrollment invitation: its link is not - // returned to the creator, so without delivery it would be unreachable. - if (config.enableInvitations && !options.sendPrivateInvitationEmail) - throw new Error('invitations are enabled but no sendPrivateInvitationEmail was provided'); - if (config.enableInvitations && !options.dashboardUrl) - throw new Error('invitations are enabled but no dashboardUrl was provided'); + assertCapabilitiesAreDeliverable(options); const plugins = authPlugins(options); @@ -174,29 +262,100 @@ export function authBetterAuthOptions(options: AuthDatabaseOptions): Pick< { provider: 'pg', schema }, ); + const { sendVerificationEmail, sendPasswordResetEmail } = options; + return { database, emailAndPassword: { enabled: config.enablePasswordLogin, minPasswordLength: config.minimumPasswordLength, + maxPasswordLength: config.maximumPasswordLength, disableSignUp: !config.enableSignup, + requireEmailVerification: config.requireEmailVerification, + // A reset proves control of the mailbox, not of the sessions the old password opened, so + // every one of them ends with it. + revokeSessionsOnPasswordReset: true, + ...(config.enablePasswordReset && sendPasswordResetEmail + ? { + sendResetPassword: async ({ user, url }) => { + await sendPasswordResetEmail({ to: user.email, name: user.name, resetUrl: url }); + }, + } + : {}), }, + ...(config.enableEmailVerification && sendVerificationEmail + ? { + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + await sendVerificationEmail({ to: user.email, name: user.name, verifyUrl: url }); + }, + }, + } + : {}), session: { expiresIn: config.sessionMaximumAgeSeconds, }, - // Declared unconditionally, because the column exists in every deployment's store whether or - // not invitations are enabled, and a field the adapter does not know about is one it silently - // drops on write. Better Auth keeps multiple roles in this one comma-separated string, so any - // code that sets it must send the full set: a partial write is a silent revocation, not a - // merge. Invitation redemption is the only path that merges rather than replaces. - user: { additionalFields: { role: { type: 'string', required: false, input: false } } }, - ...(options.github - ? { socialProviders: { github: { ...options.github, disableSignUp: !config.enableSignup } } } - : {}), + ...socialProviders(options), plugins, }; } +/** The refusal for a capability turned on without what it needs. Names the need, never a value. */ +function missing(capability: string, requirement: string): Error { + return new Error(`cannot enable ${capability} without ${requirement}`); +} + +/** + * Refuse a policy that turns on a capability nothing can deliver. + * + * Each of these would otherwise register endpoints that accept a request and then fail, or worse + * succeed without the message the visitor is waiting for. Failing at startup is the one place an + * operator is guaranteed to see it. + */ +function assertCapabilitiesAreDeliverable(options: AuthDatabaseOptions): void { + const { config } = options; + const entitlements = options.entitlements ?? NO_ENTITLEMENTS; + + // Fail at construction rather than at the first private enrollment invitation: its link is not + // returned to the creator, so without delivery it would be unreachable. + if (config.enableInvitations && !options.sendPrivateInvitationEmail) + throw missing('invitations', 'sendPrivateInvitationEmail'); + if (config.enableInvitations && !options.dashboardUrl) + throw missing('invitations', 'dashboardUrl'); + if (config.enableEmailVerification && !options.sendVerificationEmail) + throw missing('email verification', 'sendVerificationEmail'); + if (config.enablePasswordReset && !options.sendPasswordResetEmail) + throw missing('password reset', 'sendPasswordResetEmail'); + if (config.enableMagicLink && !options.sendMagicLinkEmail) + throw missing('magic link sign-in', 'sendMagicLinkEmail'); + if (config.enableTwoFactor && config.enableTwoFactorEmailOtp && !options.sendTwoFactorOtpEmail) + throw missing('two-factor email codes', 'sendTwoFactorOtpEmail'); + if (config.enablePasskey && !options.passkey) throw missing('passkeys', 'relying party'); + if (entitlements.scim && !options.scim) throw missing('SCIM', 'SCIM_CREDENTIAL_HASH_SECRET'); + // Checked here, by length only, so the plugin's own refusal can never be the one that reports + // it: that message is not ours to keep free of the value. + if (entitlements.scim && (options.scim?.credentialHashSecret.length ?? 0) < 32) + throw new Error('SCIM_CREDENTIAL_HASH_SECRET must be at least 32 characters'); +} + +/** + * The social providers whose credentials are configured. + * + * Each one closes sign-up exactly when the password form does: a provider that could create an + * account the form refuses would make `enableSignup` a suggestion. + */ +function socialProviders(options: AuthDatabaseOptions): Pick { + const disableSignUp = !options.config.enableSignup; + const providers: NonNullable = { + ...(options.github ? { github: { ...options.github, disableSignUp } } : {}), + ...(options.gitlab ? { gitlab: { ...options.gitlab, disableSignUp } } : {}), + ...(options.google ? { google: { ...options.google, disableSignUp } } : {}), + }; + return Object.keys(providers).length > 0 ? { socialProviders: providers } : {}; +} + /** * Assemble the plugin list for a policy. * @@ -206,6 +365,7 @@ export function authBetterAuthOptions(options: AuthDatabaseOptions): Pick< */ function authPlugins(options: AuthDatabaseOptions): BetterAuthPlugin[] { const { config, dashboardUrl, sendPrivateInvitationEmail, sendPublicInvitationEmail } = options; + const entitlements = options.entitlements ?? NO_ENTITLEMENTS; const plugins: BetterAuthPlugin[] = []; // Unconditional: it registers no route and mints nothing. It records which method an account @@ -221,6 +381,70 @@ function authPlugins(options: AuthDatabaseOptions): BetterAuthPlugin[] { // without this the second sign-in silently evicts the first. plugins.push(multiSession({ maximumSessions: config.maximumDeviceSessions })); + // Unconditional, because it is the authority for a column every deployment has: `user.role`. + // It decides which values administer the deployment (`adminRoles`), and every endpoint it adds + // — ban, impersonate, set a role, list users — requires a session holding one of them, so it + // opens nothing to anyone else. The control plane asks it rather than parsing the role itself. + plugins.push( + admin({ + defaultRole: config.defaultUserRole, + adminRoles: [...config.adminRoles], + impersonationSessionDuration: config.impersonationSessionSeconds, + }), + ); + + // TOTP with backup codes and trusted devices. Registering it does not weaken sign-in: an + // account has to re-enter its password, enroll a secret, and verify a first code before + // `twoFactorEnabled` is set, and failed challenges count toward the plugin's account lockout. + if (config.enableTwoFactor) { + const send = config.enableTwoFactorEmailOtp ? options.sendTwoFactorOtpEmail : undefined; + plugins.push( + twoFactor({ + issuer: TWO_FACTOR_ISSUER, + trustDeviceMaxAge: config.trustedDeviceMaxAgeSeconds, + ...(send + ? { + otpOptions: { + sendOTP: async ({ user, otp }) => { + await send({ to: user.email, name: user.name, code: otp }); + }, + }, + } + : {}), + }), + ); + } + + // WebAuthn. Registration needs a session, so this adds a stronger way in for an account that + // opted in and none for anyone else. The relying party comes from configuration only (see + // `PasskeyRelyingParty`); the guard above refuses the capability without one. + if (config.enablePasskey && options.passkey) { + plugins.push( + passkey({ + rpID: options.passkey.rpID, + rpName: options.passkey.rpName, + origin: options.passkey.origin, + }), + ); + } + + // Passwordless sign-in by mail. It closes sign-up exactly when the password form does, which is + // also what lets Better Enrollment's `auto` mode see one consistent policy below. + if (config.enableMagicLink && options.sendMagicLinkEmail) { + const send = options.sendMagicLinkEmail; + plugins.push( + magicLink({ + disableSignUp: !config.enableSignup, + expiresIn: config.magicLinkExpiresInSeconds, + // A dump of the verification table must not be a list of working sign-in links. + storeToken: 'hashed', + sendMagicLink: async ({ email, url }) => { + await send({ to: email, signInUrl: url }); + }, + }), + ); + } + // Only on the deployments that need it — a preview or local origin the OAuth provider has no // callback registered for. `oauthProxyFromEnvironment` withholds the settings unless both the // production origin and the shared secret are configured, and the plugin itself declines to @@ -305,15 +529,93 @@ function authPlugins(options: AuthDatabaseOptions): BetterAuthPlugin[] { if (config.enableOrganizations) { plugins.push( - organization({ - allowUserToCreateOrganization: config.allowUserToCreateOrganization, - membershipLimit: config.organizationMembershipLimit, - invitationExpiresIn: config.invitationExpiresInSeconds, - ...organizationInvitationDelivery(options), + withoutEndpoints( + organization({ + ac: accessControl, + roles: organizationRoles, + allowUserToCreateOrganization: config.allowUserToCreateOrganization, + membershipLimit: config.organizationMembershipLimit, + organizationHooks: { + // Defense in depth behind the removed endpoints: a server-side caller that reached the + // plugin's invitation path some other way is refused rather than served. + beforeCreateInvitation: () => { + throw new APIError('FORBIDDEN', { + message: 'organization invitations are issued through enrollment invites', + }); + }, + }, + }), + ORGANIZATION_INVITATION_ENDPOINTS, + ), + ); + + // Organization-owned keys for CI and the GitHub Action, registered after the organization + // plugin whose permissions they are checked against (`apiKey: [...]` in `access.ts`). Every + // property a caller could use to widen a key — rate limits, remaining uses, permissions — is + // server-only in the plugin, and the key is stored hashed: its plaintext is returned once. + if (config.enableApiKeys) { + plugins.push( + apiKey({ + references: 'organization', + defaultPrefix: API_KEY_PREFIX, + requireName: true, + disableKeyHashing: false, + // An API key never doubles as a browser session: it authenticates the control plane's + // machine routes, not `/api/auth/**`. + enableSessionForAPIKeys: false, + keyExpiration: { + // Seconds, despite the option's own documentation saying milliseconds: 1.7 applies + // it with `getDate(value, "sec")`. A test pins the resulting expiry, so a release + // that changes the unit fails here instead of minting keys that live 1000x longer. + defaultExpiresIn: config.apiKeyExpiresInSeconds, + // Days, unlike its sibling: the plugin compares it with `expiresIn / (3600 * 24)`. + maxExpiresIn: config.apiKeyMaximumExpiresInDays, + }, + // What a key may do on the control plane, which reads it in `packages/api`. Submitting + // tasks is the only grant, and it stays within the non-writable modes there; a key that + // needs more waits for organization-scoped run policy. + permissions: { defaultPermissions: API_KEY_DEFAULT_PERMISSIONS }, + rateLimit: { + enabled: true, + timeWindow: config.apiKeyRateLimitWindowSeconds * 1000, + maxRequests: config.apiKeyRateLimitMaxRequests, + }, + }), + ); + } + } + + // Enterprise single sign-on, present only where the deployment is entitled to it. Without the + // entitlement the plugin is not registered at all, so its endpoints — provider registration + // included — do not exist rather than refusing at a guard. + if (entitlements.sso) { + plugins.push( + sso({ + // A provider routes an email domain to itself, so the domain has to be proven first: + // otherwise any organization could claim `example.com` and receive its users. + domainVerification: { enabled: true }, + // The same sign-up policy as every other method: on an invite-only deployment an SSO + // identity signs in to an account that already exists, and does not create one. + disableImplicitSignUp: !config.enableSignup, + organizationProvisioning: { defaultRole: 'member' }, }), ); } + // Enterprise directory provisioning, under the same rule as SSO. Connections and their bearer + // credentials are managed per organization in the database, and only an HMAC of each credential + // is stored, keyed by a secret of its own. + if (entitlements.scim && options.scim) { + plugins.push( + withDefinedSchema( + scim({ + connections: [], + managedConnections: { credentialHashSecret: options.scim.credentialHashSecret }, + }), + ), + ); + } + // Added after the organization plugin, which it detects at construction to decide whether the // `org-join` and `org-create` invitation kinds exist at all. if (config.enableInvitations && dashboardUrl) { @@ -325,7 +627,7 @@ function authPlugins(options: AuthDatabaseOptions): BetterAuthPlugin[] { mode: 'auto', validRoles: [...config.userRoles], defaultRole: config.defaultUserRole, - adminRoles: [...config.inviteAdminRoles], + adminRoles: [...config.adminRoles], expiresIn: config.inviteExpiresInSeconds, publicExpiresIn: config.inviteExpiresInSeconds, buildInviteUrl: ({ token }) => inviteRedeemUrl(dashboardUrl, token), @@ -359,6 +661,11 @@ function authPlugins(options: AuthDatabaseOptions): BetterAuthPlugin[] { ...(config.enableOrganizations ? { organization: { + ac: accessControl, + // The same roles the organization plugin enforces, so "may this member invite into + // the organization" has one answer: Better Enrollment checks + // `invitation: ["create"]` against exactly these. + roles: enrollmentRoles(), // Seats and memberships are the same cap seen from two sides, so they read from // one setting: a deployment cannot end up rejecting an invitation for an // organization that still has room, or the reverse. @@ -368,84 +675,71 @@ function authPlugins(options: AuthDatabaseOptions): BetterAuthPlugin[] { : {}), }); - // The plugin declares the table groups it adds conditionally — `organization` and `user` - // appear only when org features or additional user fields are configured — as - // `?: ... | undefined`, and under `exactOptionalPropertyTypes` an explicit `undefined` is not - // assignable to Better Auth's merely-optional entries. Dropping the absent groups rather than - // asserting the type away keeps the check honest and says the same thing at runtime: a table - // group that is not there should be missing, not present and undefined. - plugins.push({ - ...enrollment, - schema: Object.fromEntries( - Object.entries(enrollment.schema).filter( - (entry): entry is [string, NonNullable<(typeof entry)[1]>] => entry[1] !== undefined, - ), - ), - }); + plugins.push(withDefinedSchema(enrollment)); } return plugins; } /** - * Decide what the organization plugin does with its own member invitations. + * Return `plugin` with only the table groups it actually declares. * - * Better Auth mints an invitation id and nothing else: it builds no link, and the dashboard never - * shows one to the inviter, so `sendInvitationEmail` is the only path an organization invitation - * travels. Wiring it to the same delivery this package already declares for private enrollment - * invitations keeps one contract for "an invitation addressed to a mailbox", and keeps - * `packages/auth` free of any dependency on `packages/mail`. + * Better Enrollment and SCIM both declare the table groups they add conditionally — enrollment's + * `organization` and `user` appear only when org features or additional user fields are + * configured, SCIM's catalog tables only with managed connections — as `?: ... | undefined`, and + * under `exactOptionalPropertyTypes` an explicit `undefined` is not assignable to Better Auth's + * merely-optional entries. Dropping the absent groups rather than asserting the type away keeps + * the check honest and says the same thing at runtime: a table group that is not there should be + * missing, not present and undefined. + */ +function withDefinedSchema( + plugin: Omit & { schema: Record }, +): BetterAuthPlugin { + const tables: NonNullable = {}; + for (const [model, table] of Object.entries(plugin.schema)) { + if (isTableSchema(table)) tables[model] = table; + } + return { ...plugin, schema: tables }; +} + +/** Narrow one plugin table group at the runtime boundary, without asserting it into shape. */ +function isTableSchema(table: unknown): table is NonNullable[string] { + return typeof table === 'object' && table !== null && 'fields' in table; +} + +/** + * The organization roles in the shape Better Enrollment reads them. * - * Without a transport or a dashboard origin the capability is closed at creation instead: an - * invitation nobody can be told about is a pending row that silently consumes a seat and leaves - * its recipient waiting for mail that is never sent. Organizations themselves stay available on - * such a deployment — pre-provisioned memberships, roles, and switching are unaffected — which is - * why this is a refusal at the endpoint rather than a startup guard like the enrollment one. + * The same role objects, adapted at one seam: Better Auth's `authorize` reports success as + * `{ success: true, error?: undefined }`, which `exactOptionalPropertyTypes` will not match against + * the plugin's `error?: string`. Restating the result keeps every decision in `access.ts`. */ -function organizationInvitationDelivery( - options: AuthDatabaseOptions, -): Pick { - const { dashboardUrl, sendPrivateInvitationEmail: send } = options; - - if (!send || !dashboardUrl) { - return { - organizationHooks: { - beforeCreateInvitation: () => { - throw new APIError('SERVICE_UNAVAILABLE', { - code: ORGANIZATION_INVITATION_DELIVERY_UNAVAILABLE, - message: 'organization invitations require a configured invitation delivery transport', - }); +function enrollmentRoles() { + return Object.fromEntries( + Object.entries(organizationRoles).map(([name, role]) => [ + name, + { + authorize: (permissions: Record, connector?: 'OR' | 'AND') => { + const result = role.authorize(permissions, connector); + return result.success ? { success: true } : { success: false, error: result.error }; }, }, - }; - } - - return { - sendInvitationEmail: async (data) => { - await send({ - to: data.email, - // Better Auth invites an address rather than a person: the recipient may have no account - // yet, and the inviter is never asked for a name to go with the address. - name: null, - inviterName: data.inviter.user.name, - organizationName: data.organization.name, - acceptUrl: organizationInvitationUrl(dashboardUrl, data.id), - }); - }, - }; + ]), + ); } /** - * Build the link an organization invitation points at. + * Return a copy of `plugin` without the named endpoints. * - * The id is path-encoded rather than interpolated raw: it comes back from the store, and a link is - * the one place a stray `/` or `?` would change what the recipient's browser asks for. + * Better Auth mounts a route for every entry in a plugin's `endpoints` and exposes each on + * `auth.api`, so an entry that is not there is a route that does not exist. The plugin's own + * object is left untouched. */ -function organizationInvitationUrl(dashboardUrl: string, invitationId: string): string { - return new URL( - `/organizations/accept-invitation/${encodeURIComponent(invitationId)}`, - dashboardUrl, - ).toString(); +function withoutEndpoints(plugin: BetterAuthPlugin, names: readonly string[]): BetterAuthPlugin { + const endpoints = Object.fromEntries( + Object.entries(plugin.endpoints ?? {}).filter(([name]) => !names.includes(name)), + ); + return { ...plugin, endpoints }; } /** @@ -491,9 +785,14 @@ function requireEnvironmentValue( /** * Resolve `authBetterAuthOptions` options from the environment. * - * Deliberately does not read `AUTH_DASHBOARD_ORIGIN`: a caller that only needs the database and - * policy shape (no signing, no origin) should not be made to supply one, and one that needs - * invitation links resolves `dashboardUrl` through {@link authOptionsFromEnvironment} instead. + * Deliberately does not require `AUTH_DASHBOARD_ORIGIN`: a caller that only needs the database + * and policy shape (no signing, no origin) should not be made to supply one, and one that needs + * invitation links resolves `dashboardUrl` through {@link authOptionsFromEnvironment} instead. The + * passkey relying party reads it when it is set, and is simply absent when nothing names an origin. + * + * Entitlements are not read here: which edition a deployment runs is the composition root's + * decision, passed in as `entitlements`, so no environment variable alone can switch SSO or SCIM + * on. * * The connection string is resolved by `@code-zero/database` so the store has one variable * regardless of which process opens it; it accepts the pre-split `AUTH_DATABASE_URL` as well. The @@ -504,12 +803,20 @@ export function authDatabaseOptionsFromEnvironment( environment: Readonly> = process.env, ): AuthDatabaseOptions { const github = githubCredentialsFromEnvironment(environment); + const gitlab = gitlabCredentialsFromEnvironment(environment); + const google = googleCredentialsFromEnvironment(environment); + const relyingParty = passkeyFromEnvironment(environment); + const scimSettings = scimFromEnvironment(environment); const oauthProxy = oauthProxyFromEnvironment(environment); const infra = infraFromEnvironment(environment); return { databaseUrl: databaseUrlFromEnvironment(environment), config: authConfigFromEnvironment(environment), ...(github ? { github } : {}), + ...(gitlab ? { gitlab } : {}), + ...(google ? { google } : {}), + ...(relyingParty ? { passkey: relyingParty } : {}), + ...(scimSettings ? { scim: scimSettings } : {}), ...(oauthProxy ? { oauthProxy } : {}), ...(infra ? { infra } : {}), }; diff --git a/packages/auth/src/config.test.ts b/packages/auth/src/config.test.ts index f736c8c9..de0e222a 100644 --- a/packages/auth/src/config.test.ts +++ b/packages/auth/src/config.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from 'vitest'; import { + authCapabilities, authConfigFromEnvironment, defaultAuthConfig, githubCredentialsFromEnvironment, + gitlabCredentialsFromEnvironment, + googleCredentialsFromEnvironment, infraFromEnvironment, MINIMUM_PASSWORD_LENGTH, oauthProxyFromEnvironment, + passkeyFromEnvironment, + scimFromEnvironment, } from './config.js'; describe('defaultAuthConfig', () => { @@ -173,3 +178,188 @@ describe('authConfigFromEnvironment', () => { ).toBe(true); }); }); + +describe('additional social providers', () => { + it('reads GitLab and Google credentials only when both halves are present', () => { + expect(gitlabCredentialsFromEnvironment({ GITLAB_CLIENT_ID: 'id' })).toBeUndefined(); + expect(googleCredentialsFromEnvironment({ GOOGLE_CLIENT_SECRET: 'secret' })).toBeUndefined(); + expect( + gitlabCredentialsFromEnvironment({ GITLAB_CLIENT_ID: 'id', GITLAB_CLIENT_SECRET: 'secret' }), + ).toEqual({ clientId: 'id', clientSecret: 'secret' }); + expect( + googleCredentialsFromEnvironment({ GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'secret' }), + ).toEqual({ clientId: 'id', clientSecret: 'secret' }); + }); + + it('points GitLab at a self-managed instance when one is named', () => { + expect( + gitlabCredentialsFromEnvironment({ + GITLAB_CLIENT_ID: 'id', + GITLAB_CLIENT_SECRET: 'secret', + GITLAB_ISSUER: 'https://gitlab.example.test', + }), + ).toMatchObject({ issuer: 'https://gitlab.example.test' }); + }); + + it('advertises each provider only when its credentials exist', () => { + const config = authConfigFromEnvironment({ + GITLAB_CLIENT_ID: 'id', + GITLAB_CLIENT_SECRET: 'secret', + }); + + expect(config.enableGitlabOauth).toBe(true); + expect(config.enableGoogleOauth).toBe(false); + }); +}); + +describe('passkeyFromEnvironment', () => { + it('derives the relying party from the dashboard origin', () => { + expect(passkeyFromEnvironment({ NUXT_PUBLIC_SITE_URL: 'https://app.example.test/' })).toEqual({ + rpID: 'app.example.test', + rpName: 'Code Zero', + origin: 'https://app.example.test', + }); + }); + + it('lets an explicit setting win over the derived one', () => { + expect( + passkeyFromEnvironment({ + AUTH_DASHBOARD_ORIGIN: 'https://app.example.test', + AUTH_PASSKEY_ORIGIN: 'https://auth.example.test', + AUTH_PASSKEY_RP_ID: 'example.test', + AUTH_PASSKEY_RP_NAME: 'Acme', + }), + ).toEqual({ rpID: 'example.test', rpName: 'Acme', origin: 'https://auth.example.test' }); + }); + + it('fails closed without a parseable web origin', () => { + expect(passkeyFromEnvironment({})).toBeUndefined(); + expect(passkeyFromEnvironment({ NUXT_PUBLIC_SITE_URL: 'not a url' })).toBeUndefined(); + expect( + passkeyFromEnvironment({ NUXT_PUBLIC_SITE_URL: 'ftp://app.example.test' }), + ).toBeUndefined(); + }); +}); + +describe('runtime policy flags', () => { + it('keeps the mail-backed flows off unless explicitly opted in', () => { + const config = authConfigFromEnvironment({}); + + expect(config.enableEmailVerification).toBe(false); + expect(config.enablePasswordReset).toBe(false); + expect(config.enableMagicLink).toBe(false); + expect(config.enableTwoFactorEmailOtp).toBe(false); + expect(authConfigFromEnvironment({ AUTH_ENABLE_MAGIC_LINK: 'TRUE' }).enableMagicLink).toBe( + false, + ); + expect(authConfigFromEnvironment({ AUTH_ENABLE_MAGIC_LINK: 'true' }).enableMagicLink).toBe( + true, + ); + }); + + it('treats required verification as also sending it', () => { + expect( + authConfigFromEnvironment({ AUTH_REQUIRE_EMAIL_VERIFICATION: 'true' }) + .enableEmailVerification, + ).toBe(true); + }); + + it('offers two-factor by default and drops its mail codes with it', () => { + expect(authConfigFromEnvironment({}).enableTwoFactor).toBe(true); + const off = authConfigFromEnvironment({ + AUTH_ENABLE_TWO_FACTOR: 'false', + AUTH_ENABLE_TWO_FACTOR_EMAIL_OTP: 'true', + }); + expect(off.enableTwoFactor).toBe(false); + expect(off.enableTwoFactorEmailOtp).toBe(false); + }); + + it('offers passkeys only where a relying party resolves, and lets an operator opt out', () => { + const origin = { NUXT_PUBLIC_SITE_URL: 'https://app.example.test' }; + + expect(authConfigFromEnvironment({}).enablePasskey).toBe(false); + expect(authConfigFromEnvironment(origin).enablePasskey).toBe(true); + expect( + authConfigFromEnvironment({ ...origin, AUTH_ENABLE_PASSKEY: 'false' }).enablePasskey, + ).toBe(false); + }); + + it('refuses API keys while organizations are off', () => { + expect(authConfigFromEnvironment({ AUTH_ENABLE_API_KEYS: 'true' }).enableApiKeys).toBe(false); + expect( + authConfigFromEnvironment({ AUTH_ENABLE_API_KEYS: 'true', AUTH_ENABLE_ORGANIZATIONS: 'true' }) + .enableApiKeys, + ).toBe(true); + }); + + it('reads the SCIM secret without deciding whether SCIM is on', () => { + expect(scimFromEnvironment({})).toBeUndefined(); + expect(scimFromEnvironment({ SCIM_CREDENTIAL_HASH_SECRET: 's'.repeat(40) })).toEqual({ + credentialHashSecret: 's'.repeat(40), + }); + }); +}); + +describe('authCapabilities', () => { + it('reports the conservative defaults with no enterprise capability', () => { + expect(authCapabilities(defaultAuthConfig)).toEqual({ + signup: false, + password: true, + passwordReset: false, + emailVerification: false, + magicLink: false, + passkey: false, + twoFactor: true, + twoFactorEmailOtp: false, + social: { github: false, gitlab: false, google: false }, + organizations: false, + organizationCreation: false, + organizationInvitations: false, + invitations: false, + apiKeys: false, + deviceAuthorization: false, + sso: false, + scim: false, + infra: false, + }); + }); + + it('survives a JSON round trip unchanged, so the server can publish it as is', () => { + const capabilities = authCapabilities( + authConfigFromEnvironment({ AUTH_ENABLE_SIGNUP: 'true', AUTH_ENABLE_ORGANIZATIONS: 'true' }), + { sso: true, scim: false }, + ); + + expect(JSON.parse(JSON.stringify(capabilities))).toEqual(capabilities); + }); + + it('publishes no credential the environment carries', () => { + const environment = { + GITHUB_CLIENT_ID: 'github-client-id', + GITHUB_CLIENT_SECRET: 'github-client-secret', + BETTER_AUTH_API_URL: 'https://dash.example.test', + BETTER_AUTH_KV_URL: 'https://kv.example.test', + BETTER_AUTH_API_KEY: 'project-api-key', + OAUTH_PROXY_SECRET: 'proxy-secret', + }; + const serialized = JSON.stringify(authCapabilities(authConfigFromEnvironment(environment))); + + for (const value of Object.values(environment)) expect(serialized).not.toContain(value); + }); + + it('requires both features for organization invitations, which travel through enrollment', () => { + const organizationsOnly = { ...defaultAuthConfig, enableOrganizations: true }; + + expect(authCapabilities(organizationsOnly).organizationInvitations).toBe(false); + expect( + authCapabilities({ ...organizationsOnly, enableInvitations: true }).organizationInvitations, + ).toBe(true); + }); + + it('reports SSO and SCIM from the entitlements alone', () => { + expect(authCapabilities(defaultAuthConfig, { sso: true, scim: true })).toMatchObject({ + sso: true, + scim: true, + }); + }); +}); diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 966c186c..04c5f00f 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -7,12 +7,55 @@ /** Shortest password the dashboard will accept when registering or changing credentials. */ export const MINIMUM_PASSWORD_LENGTH = 8; +/** + * Longest password accepted. Better Auth 1.7 rejects anything longer with `PASSWORD_TOO_LONG` + * instead of hashing it, so the bound is stated here rather than inherited: it is what keeps a + * multi-megabyte "password" from being handed to the hash function on every sign-in attempt. + */ +export const MAXIMUM_PASSWORD_LENGTH = 128; + +/** Issuer shown next to the account in an authenticator app. */ +export const TWO_FACTOR_ISSUER = 'Code Zero'; + +/** How long a device a second factor was passed on stays trusted, skipping the challenge. */ +export const TRUSTED_DEVICE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; + +/** Relying-party name shown by the browser and the authenticator when registering a passkey. */ +export const PASSKEY_RELYING_PARTY_NAME = 'Code Zero'; + +/** How long a magic sign-in link stays redeemable. */ +export const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 10; + +/** + * Prefix every API key carries, so a leaked one is recognisable as this product's in a secret + * scanner and in a log line, without the prefix itself being secret. + */ +export const API_KEY_PREFIX = 'cz_'; + +/** + * What a new organization API key may do: submit tasks, which the control plane holds to the + * non-writable execution modes. Mutable arrays because the API-key plugin's option type is. + */ +export const API_KEY_DEFAULT_PERMISSIONS: Record = { task: ['create'] }; + +/** How long a new API key stays valid unless its creator picks a shorter lifetime. */ +export const API_KEY_EXPIRES_IN_SECONDS = 60 * 60 * 24 * 90; + +/** The longest lifetime a creator may pick for an API key, in days. */ +export const API_KEY_MAXIMUM_EXPIRES_IN_DAYS = 365; + +/** Requests one API key may make per {@link API_KEY_RATE_LIMIT_WINDOW_SECONDS}. */ +export const API_KEY_RATE_LIMIT_MAX_REQUESTS = 600; + +/** The window {@link API_KEY_RATE_LIMIT_MAX_REQUESTS} is counted over. */ +export const API_KEY_RATE_LIMIT_WINDOW_SECONDS = 60 * 60; + +/** How long an administrator's impersonation session lasts before it has to be restarted. */ +export const IMPERSONATION_SESSION_SECONDS = 60 * 60; + /** How long a session stays valid without re-authentication. */ export const SESSION_MAXIMUM_AGE_SECONDS = 60 * 60 * 24 * 7; -/** How long an unaccepted organization invitation stays valid. */ -export const INVITATION_EXPIRES_IN_SECONDS = 60 * 60 * 48; - /** Upper bound on members in a single organization. */ export const ORGANIZATION_MEMBERSHIP_LIMIT = 100; @@ -50,7 +93,7 @@ export const DEVICE_VERIFICATION_PATH = '/device'; */ export const DEFAULT_USER_ROLE = 'user'; -/** The app-wide role that may mint, list, and revoke invitations. */ +/** The app-wide role that administers the deployment, invitations included. */ export const ADMIN_USER_ROLE = 'admin'; /** @@ -69,6 +112,48 @@ export interface AuthConfig { readonly enablePasswordLogin: boolean; /** Whether the GitHub OAuth button is offered. */ readonly enableGithubOauth: boolean; + /** Whether the GitLab OAuth button is offered; on only when its credentials are configured. */ + readonly enableGitlabOauth: boolean; + /** Whether the Google OAuth button is offered; on only when its credentials are configured. */ + readonly enableGoogleOauth: boolean; + /** + * Whether new accounts are sent a verification link. Requires a delivery transport: enabling it + * without one fails startup rather than promising a message nobody sends. + */ + readonly enableEmailVerification: boolean; + /** + * Whether an unverified address is refused at password sign-in. Implies + * {@link enableEmailVerification}: requiring a link nobody can receive would lock every new + * account out. + */ + readonly requireEmailVerification: boolean; + /** Whether a forgotten password can be reset by mail. Requires a delivery transport. */ + readonly enablePasswordReset: boolean; + /** Whether a passwordless sign-in link can be requested by mail. Requires a delivery transport. */ + readonly enableMagicLink: boolean; + /** + * Whether accounts may enroll a TOTP second factor with backup codes and trusted devices. + * + * On by default: it adds no way *into* the deployment, only a stronger one for an account that + * opts in, and enrollment itself requires a session and the account's password. + */ + readonly enableTwoFactor: boolean; + /** Whether a second factor may also be delivered as a one-time code by mail. */ + readonly enableTwoFactorEmailOtp: boolean; + /** + * Whether accounts may register passkeys and sign in with them. + * + * On by default for the same reason as {@link enableTwoFactor}, but only where a relying party + * resolves: WebAuthn binds every credential to an origin, so a deployment that cannot name its + * own refuses the capability rather than registering credentials for a guessed one. + */ + readonly enablePasskey: boolean; + /** + * Whether organizations may mint API keys for CI and the GitHub Action. + * + * Keys belong to an organization, so this is refused while organizations are off. + */ + readonly enableApiKeys: boolean; /** * Whether Better Auth's hosted infrastructure is wired up. * @@ -105,22 +190,42 @@ export interface AuthConfig { */ readonly enableDeviceAuthorization: boolean; readonly minimumPasswordLength: number; + readonly maximumPasswordLength: number; readonly sessionMaximumAgeSeconds: number; - readonly invitationExpiresInSeconds: number; readonly organizationMembershipLimit: number; readonly inviteExpiresInSeconds: number; /** App-wide role an invitation grants when its creator names none. */ readonly defaultUserRole: string; /** The closed set of app-wide roles an invitation may grant. */ readonly userRoles: readonly string[]; - /** App-wide roles allowed to manage invitations. */ - readonly inviteAdminRoles: readonly string[]; + /** + * App-wide roles that administer the deployment. + * + * One list for every consumer: Better Auth's admin plugin reads it to decide who may ban, + * impersonate, and grant roles, and Better Enrollment reads the same one to decide who may + * manage invitations, so the two can never disagree about who an administrator is. + */ + readonly adminRoles: readonly string[]; /** How many accounts one browser may hold concurrent sessions for. */ readonly maximumDeviceSessions: number; /** How long a device-authorization request stays claimable. */ readonly deviceCodeExpiresInSeconds: number; /** Minimum seconds between a device's polls for its token. */ readonly deviceCodePollingIntervalSeconds: number; + /** How long a trusted device skips the second-factor challenge. */ + readonly trustedDeviceMaxAgeSeconds: number; + /** How long a magic sign-in link stays redeemable. */ + readonly magicLinkExpiresInSeconds: number; + /** Default lifetime of a new API key. */ + readonly apiKeyExpiresInSeconds: number; + /** Longest lifetime an API key may be given, in days. */ + readonly apiKeyMaximumExpiresInDays: number; + /** Requests one API key may make per window. */ + readonly apiKeyRateLimitMaxRequests: number; + /** The API-key rate-limit window. */ + readonly apiKeyRateLimitWindowSeconds: number; + /** How long an impersonation session lasts. */ + readonly impersonationSessionSeconds: number; } /** @@ -131,6 +236,20 @@ export const defaultAuthConfig: AuthConfig = { enableSignup: false, enablePasswordLogin: true, enableGithubOauth: false, + enableGitlabOauth: false, + enableGoogleOauth: false, + // The mail-backed flows stay off until an operator turns them on, because each one needs a + // delivery transport and enabling one without it is a startup error, not a silent no-op. + enableEmailVerification: false, + requireEmailVerification: false, + enablePasswordReset: false, + enableMagicLink: false, + enableTwoFactor: true, + enableTwoFactorEmailOtp: false, + // Off in the default policy because no relying party is known without an environment; + // `authConfigFromEnvironment` turns it on once one resolves. + enablePasskey: false, + enableApiKeys: false, // Off until the hosted credentials exist, which is what keeps a self-hosted deployment from // reporting its authentication events to a service its operator never signed up for. enableInfra: false, @@ -147,24 +266,54 @@ export const defaultAuthConfig: AuthConfig = { // mints a session for a client that never sees the browser, so it has to be a deliberate grant. enableDeviceAuthorization: false, minimumPasswordLength: MINIMUM_PASSWORD_LENGTH, + maximumPasswordLength: MAXIMUM_PASSWORD_LENGTH, sessionMaximumAgeSeconds: SESSION_MAXIMUM_AGE_SECONDS, - invitationExpiresInSeconds: INVITATION_EXPIRES_IN_SECONDS, organizationMembershipLimit: ORGANIZATION_MEMBERSHIP_LIMIT, inviteExpiresInSeconds: INVITE_EXPIRES_IN_SECONDS, defaultUserRole: DEFAULT_USER_ROLE, userRoles: USER_ROLES, - inviteAdminRoles: [ADMIN_USER_ROLE], + adminRoles: [ADMIN_USER_ROLE], maximumDeviceSessions: MAXIMUM_DEVICE_SESSIONS, deviceCodeExpiresInSeconds: DEVICE_CODE_EXPIRES_IN_SECONDS, deviceCodePollingIntervalSeconds: DEVICE_CODE_POLLING_INTERVAL_SECONDS, + trustedDeviceMaxAgeSeconds: TRUSTED_DEVICE_MAX_AGE_SECONDS, + magicLinkExpiresInSeconds: MAGIC_LINK_EXPIRES_IN_SECONDS, + apiKeyExpiresInSeconds: API_KEY_EXPIRES_IN_SECONDS, + apiKeyMaximumExpiresInDays: API_KEY_MAXIMUM_EXPIRES_IN_DAYS, + apiKeyRateLimitMaxRequests: API_KEY_RATE_LIMIT_MAX_REQUESTS, + apiKeyRateLimitWindowSeconds: API_KEY_RATE_LIMIT_WINDOW_SECONDS, + impersonationSessionSeconds: IMPERSONATION_SESSION_SECONDS, }; -/** GitHub OAuth credentials, present only when both halves are configured. */ -export interface GithubOauthCredentials { +/** OAuth client credentials, present only when both halves are configured. */ +export interface OauthCredentials { readonly clientId: string; readonly clientSecret: string; } +/** GitHub OAuth credentials, present only when both halves are configured. */ +export type GithubOauthCredentials = OauthCredentials; + +/** Google OAuth credentials, present only when both halves are configured. */ +export type GoogleOauthCredentials = OauthCredentials; + +/** GitLab OAuth credentials, plus the instance to talk to when it is not gitlab.com. */ +export interface GitlabOauthCredentials extends OauthCredentials { + /** A self-managed GitLab's origin. Absent means gitlab.com. */ + readonly issuer?: string; +} + +/** Both halves of one provider's credentials, or `undefined` unless both are present. */ +function oauthCredentialsFromEnvironment( + environment: Readonly>, + prefix: string, +): OauthCredentials | undefined { + const clientId = environment[`${prefix}_CLIENT_ID`]?.trim(); + const clientSecret = environment[`${prefix}_CLIENT_SECRET`]?.trim(); + if (!clientId || !clientSecret) return undefined; + return { clientId, clientSecret }; +} + /** * Read GitHub OAuth credentials from the environment. * @@ -176,10 +325,97 @@ export interface GithubOauthCredentials { export function githubCredentialsFromEnvironment( environment: Readonly> = process.env, ): GithubOauthCredentials | undefined { - const clientId = environment.GITHUB_CLIENT_ID?.trim(); - const clientSecret = environment.GITHUB_CLIENT_SECRET?.trim(); - if (!clientId || !clientSecret) return undefined; - return { clientId, clientSecret }; + return oauthCredentialsFromEnvironment(environment, 'GITHUB'); +} + +/** + * Read GitLab OAuth credentials from the environment, failing closed like + * {@link githubCredentialsFromEnvironment}. `GITLAB_ISSUER` points the provider at a self-managed + * instance; without it the provider talks to gitlab.com. + */ +export function gitlabCredentialsFromEnvironment( + environment: Readonly> = process.env, +): GitlabOauthCredentials | undefined { + const credentials = oauthCredentialsFromEnvironment(environment, 'GITLAB'); + if (!credentials) return undefined; + const issuer = environment.GITLAB_ISSUER?.trim(); + return issuer ? { ...credentials, issuer } : credentials; +} + +/** Read Google OAuth credentials from the environment, failing closed the same way. */ +export function googleCredentialsFromEnvironment( + environment: Readonly> = process.env, +): GoogleOauthCredentials | undefined { + return oauthCredentialsFromEnvironment(environment, 'GOOGLE'); +} + +/** + * The WebAuthn relying party passkeys are bound to. + * + * Every field is configuration rather than something derived from a request: a credential is + * bound to the relying-party id it was registered under, so an id that followed whatever `Host` + * header arrived would let a spoofed host register credentials that the real one then accepts. + */ +export interface PasskeyRelyingParty { + /** The registrable domain credentials are scoped to, e.g. `app.example.com`. */ + readonly rpID: string; + /** The name the browser and the authenticator show the account holder. */ + readonly rpName: string; + /** The exact origin assertions must come from, e.g. `https://app.example.com`. */ + readonly origin: string; +} + +/** + * Resolve the passkey relying party from the environment. + * + * `AUTH_PASSKEY_ORIGIN` names the origin; without it the dashboard's own public origin is used + * (`AUTH_DASHBOARD_ORIGIN`, then `NUXT_PUBLIC_SITE_URL`), since that is the page the ceremony runs + * on. `AUTH_PASSKEY_RP_ID` defaults to that origin's hostname. Returns `undefined` when no origin + * parses, which is what keeps the capability off rather than bound to a guess. + */ +export function passkeyFromEnvironment( + environment: Readonly> = process.env, +): PasskeyRelyingParty | undefined { + const configured = [ + environment.AUTH_PASSKEY_ORIGIN, + environment.AUTH_DASHBOARD_ORIGIN, + environment.NUXT_PUBLIC_SITE_URL, + ] + .map((value) => value?.trim()) + .find((value) => value); + if (!configured) return undefined; + let origin: URL; + try { + origin = new URL(configured); + } catch { + return undefined; + } + if (origin.protocol !== 'https:' && origin.protocol !== 'http:') return undefined; + return { + rpID: environment.AUTH_PASSKEY_RP_ID?.trim() || origin.hostname, + rpName: environment.AUTH_PASSKEY_RP_NAME?.trim() || PASSKEY_RELYING_PARTY_NAME, + origin: origin.origin, + }; +} + +/** + * Settings the SCIM plugin needs once the `scim` entitlement registers it. + * + * The plugin keeps per-organization provisioning connections in the database and stores only an + * HMAC of each bearer credential, keyed by this secret. It is its own variable rather than + * `BETTER_AUTH_SECRET` so rotating one never invalidates the other. + */ +export interface ScimConfig { + /** At least 32 characters. Never logged, never echoed into an error message. */ + readonly credentialHashSecret: string; +} + +/** Read the SCIM settings, or `undefined` when `SCIM_CREDENTIAL_HASH_SECRET` is unset. */ +export function scimFromEnvironment( + environment: Readonly> = process.env, +): ScimConfig | undefined { + const credentialHashSecret = environment.SCIM_CREDENTIAL_HASH_SECRET?.trim(); + return credentialHashSecret ? { credentialHashSecret } : undefined; } /** @@ -262,18 +498,127 @@ export function oauthProxyFromEnvironment( export function authConfigFromEnvironment( environment: Readonly> = process.env, ): AuthConfig { - const enableOrganizations = environment.AUTH_ENABLE_ORGANIZATIONS === 'true'; + // Opt-in flags accept the literal `true` only and opt-out flags the literal `false` only, so a + // typo leaves each one at its conservative default instead of flipping it. + const optIn = (name: string) => environment[name] === 'true'; + const optOut = (name: string) => environment[name] !== 'false'; + const enableOrganizations = optIn('AUTH_ENABLE_ORGANIZATIONS'); + const requireEmailVerification = optIn('AUTH_REQUIRE_EMAIL_VERIFICATION'); + const enableTwoFactor = optOut('AUTH_ENABLE_TWO_FACTOR'); return { ...defaultAuthConfig, - enableSignup: environment.AUTH_ENABLE_SIGNUP === 'true', - enableInvitations: environment.AUTH_ENABLE_INVITATIONS === 'true', - enableDeviceAuthorization: environment.AUTH_ENABLE_DEVICE_AUTHORIZATION === 'true', + enableSignup: optIn('AUTH_ENABLE_SIGNUP'), + enableInvitations: optIn('AUTH_ENABLE_INVITATIONS'), + enableDeviceAuthorization: optIn('AUTH_ENABLE_DEVICE_AUTHORIZATION'), enableGithubOauth: githubCredentialsFromEnvironment(environment) !== undefined, + enableGitlabOauth: gitlabCredentialsFromEnvironment(environment) !== undefined, + enableGoogleOauth: googleCredentialsFromEnvironment(environment) !== undefined, enableInfra: infraFromEnvironment(environment) !== undefined, + // Requiring verification implies sending it: the reverse would lock out every new account. + enableEmailVerification: requireEmailVerification || optIn('AUTH_ENABLE_EMAIL_VERIFICATION'), + requireEmailVerification, + enablePasswordReset: optIn('AUTH_ENABLE_PASSWORD_RESET'), + enableMagicLink: optIn('AUTH_ENABLE_MAGIC_LINK'), + enableTwoFactor, + // A mail-delivered code is a second way to pass the same factor, so it cannot outlive it. + enableTwoFactorEmailOtp: enableTwoFactor && optIn('AUTH_ENABLE_TWO_FACTOR_EMAIL_OTP'), + enablePasskey: + optOut('AUTH_ENABLE_PASSKEY') && passkeyFromEnvironment(environment) !== undefined, enableOrganizations, // Gated on the feature itself, so a deployment that never turned organizations on cannot // advertise creation through a stale variable. - allowUserToCreateOrganization: - enableOrganizations && environment.AUTH_ALLOW_ORGANIZATION_CREATION === 'true', + allowUserToCreateOrganization: enableOrganizations && optIn('AUTH_ALLOW_ORGANIZATION_CREATION'), + // Same gate: a key belongs to an organization, so there is nothing to mint one for without them. + enableApiKeys: enableOrganizations && optIn('AUTH_ENABLE_API_KEYS'), + }; +} + +/** + * Enterprise capabilities this deployment is entitled to. + * + * Declared structurally rather than imported from an edition or billing package: this package owns + * authentication policy, and which edition a deployment runs is decided by its composition root. + * Both default to `false`, so a deployment that never states its entitlements registers neither + * plugin, and their endpoints do not exist. + */ +export interface AuthEntitlements { + /** SAML and OIDC single sign-on per organization (`@better-auth/sso`). */ + readonly sso: boolean; + /** SCIM 2.0 directory provisioning per organization (`@better-auth/scim`). */ + readonly scim: boolean; +} + +/** What a deployment is entitled to when nobody says otherwise: nothing beyond the open core. */ +export const NO_ENTITLEMENTS: AuthEntitlements = { sso: false, scim: false }; + +/** + * The sign-in and account capabilities a deployment exposes, as the browser may see them. + * + * Plain booleans only, so it serializes as JSON unchanged and carries nothing secret: no + * credential, no origin, no limit an attacker could tune against. The server publishes it from + * the same policy that built the Better Auth instance, which is what lets a page decide what to + * render without a rebuild when the policy changes. + */ +export interface AuthCapabilities { + readonly signup: boolean; + readonly password: boolean; + readonly passwordReset: boolean; + readonly emailVerification: boolean; + readonly magicLink: boolean; + readonly passkey: boolean; + readonly twoFactor: boolean; + readonly twoFactorEmailOtp: boolean; + readonly social: { + readonly github: boolean; + readonly gitlab: boolean; + readonly google: boolean; + }; + readonly organizations: boolean; + readonly organizationCreation: boolean; + /** Organization invitations, which travel through Better Enrollment and need both features. */ + readonly organizationInvitations: boolean; + readonly invitations: boolean; + readonly apiKeys: boolean; + readonly deviceAuthorization: boolean; + readonly sso: boolean; + readonly scim: boolean; + /** Whether the hosted-infrastructure client plugins should load in the browser. */ + readonly infra: boolean; +} + +/** + * Derive the capabilities a policy exposes. + * + * Every field is read off the same {@link AuthConfig} `authBetterAuthOptions` registers plugins + * from, so a page cannot advertise a method the server does not serve. Built fresh rather than + * spread from the config, so a field added to the policy later is not published by accident. + */ +export function authCapabilities( + config: AuthConfig, + entitlements: AuthEntitlements = NO_ENTITLEMENTS, +): AuthCapabilities { + return { + signup: config.enableSignup, + password: config.enablePasswordLogin, + passwordReset: config.enablePasswordLogin && config.enablePasswordReset, + emailVerification: config.enableEmailVerification, + magicLink: config.enableMagicLink, + passkey: config.enablePasskey, + twoFactor: config.enableTwoFactor, + twoFactorEmailOtp: config.enableTwoFactor && config.enableTwoFactorEmailOtp, + social: { + github: config.enableGithubOauth, + gitlab: config.enableGitlabOauth, + google: config.enableGoogleOauth, + }, + organizations: config.enableOrganizations, + organizationCreation: config.enableOrganizations && config.allowUserToCreateOrganization, + organizationInvitations: config.enableOrganizations && config.enableInvitations, + invitations: config.enableInvitations, + apiKeys: config.enableOrganizations && config.enableApiKeys, + deviceAuthorization: config.enableDeviceAuthorization, + sso: entitlements.sso, + scim: entitlements.scim, + infra: config.enableInfra, }; } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 6da7173a..ee2c9259 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,18 +1,35 @@ +export { + accessControl, + organizationRoles, + statements, + type OrganizationRoleName, +} from './access.js'; export { authBetterAuthOptions, authDatabaseOptionsFromEnvironment, authOptionsFromEnvironment, createAuth, + ORGANIZATION_INVITATION_ENDPOINTS, type AuthDatabaseOptions, type AuthInstance, type AuthInstanceOptions, + type SendMagicLinkEmail, + type SendPasswordResetEmail, type SendPrivateInvitationEmail, type SendPublicInvitationEmail, + type SendTwoFactorOtpEmail, + type SendVerificationEmail, type Session, type User, } from './auth.js'; export { ADMIN_USER_ROLE, + API_KEY_EXPIRES_IN_SECONDS, + API_KEY_MAXIMUM_EXPIRES_IN_DAYS, + API_KEY_PREFIX, + API_KEY_RATE_LIMIT_MAX_REQUESTS, + API_KEY_RATE_LIMIT_WINDOW_SECONDS, + authCapabilities, authConfigFromEnvironment, DEFAULT_USER_ROLE, defaultAuthConfig, @@ -20,17 +37,34 @@ export { DEVICE_CODE_POLLING_INTERVAL_SECONDS, DEVICE_VERIFICATION_PATH, githubCredentialsFromEnvironment, + gitlabCredentialsFromEnvironment, + googleCredentialsFromEnvironment, + IMPERSONATION_SESSION_SECONDS, infraFromEnvironment, - INVITATION_EXPIRES_IN_SECONDS, INVITE_EXPIRES_IN_SECONDS, + MAGIC_LINK_EXPIRES_IN_SECONDS, MAXIMUM_DEVICE_SESSIONS, + MAXIMUM_PASSWORD_LENGTH, MINIMUM_PASSWORD_LENGTH, + NO_ENTITLEMENTS, oauthProxyFromEnvironment, ORGANIZATION_MEMBERSHIP_LIMIT, + PASSKEY_RELYING_PARTY_NAME, + passkeyFromEnvironment, + scimFromEnvironment, SESSION_MAXIMUM_AGE_SECONDS, + TRUSTED_DEVICE_MAX_AGE_SECONDS, + TWO_FACTOR_ISSUER, USER_ROLES, + type AuthCapabilities, type AuthConfig, + type AuthEntitlements, type GithubOauthCredentials, + type GitlabOauthCredentials, + type GoogleOauthCredentials, type InfraConfig, + type OauthCredentials, type OauthProxyConfig, + type PasskeyRelyingParty, + type ScimConfig, } from './config.js'; diff --git a/packages/auth/src/plugins.test.ts b/packages/auth/src/plugins.test.ts new file mode 100644 index 00000000..80c9f1f5 --- /dev/null +++ b/packages/auth/src/plugins.test.ts @@ -0,0 +1,467 @@ +import { betterAuth } from 'better-auth'; +import { memoryAdapter } from 'better-auth/adapters/memory'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { authBetterAuthOptions, type AuthDatabaseOptions } from './auth.js'; +import { + API_KEY_DEFAULT_PERMISSIONS, + API_KEY_EXPIRES_IN_SECONDS, + API_KEY_MAXIMUM_EXPIRES_IN_DAYS, + API_KEY_PREFIX, + API_KEY_RATE_LIMIT_MAX_REQUESTS, + API_KEY_RATE_LIMIT_WINDOW_SECONDS, + defaultAuthConfig, + type AuthConfig, +} from './config.js'; + +const DATABASE_URL = 'postgres://postgres:postgres@localhost:5432/code_zero_auth'; +const SECRET = 'a-very-secret-value-that-is-long-enough'; +const BASE_URL = 'http://localhost:3001'; +const SCIM_SECRET = 'scim-credential-hash-secret-0123456789abcdef'; +const RELYING_PARTY = { + rpID: 'app.example.test', + rpName: 'Code Zero', + origin: 'https://app.example.test', +} as const; + +const MISSING_VERIFICATION = /sendVerificationEmail/; +const MISSING_RESET = /sendPasswordResetEmail/; +const MISSING_MAGIC_LINK = /sendMagicLinkEmail/; +const MISSING_OTP = /sendTwoFactorOtpEmail/; +const MISSING_RELYING_PARTY = /relying party/; +const MISSING_SCIM_SECRET = /SCIM_CREDENTIAL_HASH_SECRET/; +const SHORT_SCIM_SECRET = /at least 32 characters/; +const TOTP_LABEL = /^otpauth:\/\/totp\/Code%20Zero:/; + +/** A fixed instant, so expiry assertions never read the wall clock. */ +const NOW = Date.parse('2026-10-18T09:00:00.000Z'); + +const noop = async () => {}; + +/** Every model the plugins below can query; the memory adapter throws on an absent one. */ +function memoryStore() { + return memoryAdapter({ + user: [], + session: [], + account: [], + verification: [], + organization: [], + member: [], + invitation: [], + invite: [], + inviteUse: [], + twoFactor: [], + passkey: [], + apikey: [], + ssoProvider: [], + deviceCode: [], + }); +} + +function pluginIds(overrides: Partial, extra: Partial = {}) { + return authBetterAuthOptions({ + databaseUrl: DATABASE_URL, + config: { ...defaultAuthConfig, ...overrides }, + ...extra, + }).plugins.map((plugin) => plugin.id); +} + +/** A Better Auth instance over the memory store, so endpoints can be exercised for real. */ +function instance(overrides: Partial, extra: Partial = {}) { + return betterAuth({ + ...authBetterAuthOptions({ + databaseUrl: DATABASE_URL, + config: { ...defaultAuthConfig, enableSignup: true, ...overrides }, + ...extra, + }), + database: memoryStore(), + secret: SECRET, + baseURL: BASE_URL, + rateLimit: { enabled: false }, + }); +} + +async function status(auth: ReturnType, method: string, path: string) { + const response = await auth.handler( + new Request(`${BASE_URL}/api/auth${path}`, { + method, + headers: { 'content-type': 'application/json', origin: BASE_URL }, + ...(method === 'GET' ? {} : { body: '{}' }), + }), + ); + return response.status; +} + +async function signUp(auth: ReturnType, email = 'dana@example.com') { + const { headers } = await auth.api.signUpEmail({ + body: { email, password: 'a-long-enough-password', name: 'Dana' }, + returnHeaders: true, + }); + return new Headers({ cookie: headers.get('set-cookie') ?? '' }); +} + +/** Return the failure message so assertions stay outside the catch block. */ +function messageFrom(run: () => unknown): string { + try { + run(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + return ''; +} + +interface TwoFactorApi { + enableTwoFactor(options: { + body: { password: string }; + headers: Headers; + }): Promise<{ totpURI: string; backupCodes: string[] }>; +} + +interface CreatedApiKey { + key: string; + referenceId: string; + rateLimitEnabled: boolean; + rateLimitMax: number | null; + rateLimitTimeWindow: number | null; + expiresAt: Date | string | null; +} + +interface ApiKeyApi { + createOrganization(options: { + body: { name: string; slug: string }; + headers: Headers; + }): Promise<{ id: string } | null>; + createApiKey(options: { + body: { name: string; organizationId: string; expiresIn?: number }; + headers: Headers; + }): Promise; + listApiKeys(options: { query: { organizationId: string }; headers: Headers }): Promise; + verifyApiKey(options: { body: { key: string } }): Promise<{ + valid: boolean; + key: { referenceId: string; permissions: Record | null } | null; + }>; +} + +/** + * `authBetterAuthOptions` widens plugins to `BetterAuthPlugin[]`, so plugin endpoints are not on + * the inferred `auth.api` type. They are checked at runtime instead of asserted into shape. + */ +function hasTwoFactorApi(api: object): api is object & TwoFactorApi { + return 'enableTwoFactor' in api && typeof api.enableTwoFactor === 'function'; +} + +function hasApiKeyApi(api: object): api is object & ApiKeyApi { + return ( + 'createOrganization' in api && + typeof api.createOrganization === 'function' && + 'createApiKey' in api && + typeof api.createApiKey === 'function' && + 'listApiKeys' in api && + typeof api.listApiKeys === 'function' && + 'verifyApiKey' in api && + typeof api.verifyApiKey === 'function' + ); +} + +describe('plugin policy', () => { + it('always registers the admin plugin, the authority for the app-wide role', () => { + expect(pluginIds({})).toContain('admin'); + }); + + it.each([ + ['two-factor', { enableTwoFactor: true }, { enableTwoFactor: false }, {}], + ['passkey', { enablePasskey: true }, { enablePasskey: false }, { passkey: RELYING_PARTY }], + [ + 'magic-link', + { enableMagicLink: true }, + { enableMagicLink: false }, + { sendMagicLinkEmail: noop }, + ], + [ + 'api-key', + { enableOrganizations: true, enableApiKeys: true }, + { enableOrganizations: true, enableApiKeys: false }, + {}, + ], + ['organization', { enableOrganizations: true }, { enableOrganizations: false }, {}], + [ + 'device-authorization', + { enableDeviceAuthorization: true }, + { enableDeviceAuthorization: false }, + {}, + ], + ] satisfies [string, Partial, Partial, Partial][])( + 'registers %s only when the policy enables it', + (id, on, off, extra) => { + expect(pluginIds(on, extra)).toContain(id); + expect(pluginIds(off, extra)).not.toContain(id); + }, + ); + + it('refuses API keys while organizations are off, since a key belongs to one', () => { + expect(pluginIds({ enableOrganizations: false, enableApiKeys: true })).not.toContain('api-key'); + }); + + it('keeps the kept plugins: multi-session, last login method, bearer with the device flow', () => { + const ids = pluginIds({ enableDeviceAuthorization: true }); + + expect(ids).toEqual( + expect.arrayContaining([ + 'multi-session', + 'last-login-method', + 'bearer', + 'device-authorization', + ]), + ); + }); + + it('registers SSO and SCIM only for an entitled deployment', () => { + const withoutEntitlements = pluginIds({}, { scim: { credentialHashSecret: SCIM_SECRET } }); + const entitled = pluginIds( + {}, + { entitlements: { sso: true, scim: true }, scim: { credentialHashSecret: SCIM_SECRET } }, + ); + + expect(withoutEntitlements).not.toContain('sso'); + expect(withoutEntitlements).not.toContain('scim'); + expect(entitled).toContain('sso'); + expect(entitled).toContain('scim'); + }); + + it('binds passkeys to the configured relying party, never to a request header', () => { + const plugin = authBetterAuthOptions({ + databaseUrl: DATABASE_URL, + config: { ...defaultAuthConfig, enablePasskey: true }, + passkey: RELYING_PARTY, + }).plugins.find((candidate) => candidate.id === 'passkey'); + + expect(plugin?.options).toMatchObject(RELYING_PARTY); + }); + + it('closes every social provider to sign-up exactly when the password form is closed', () => { + const credentials = { clientId: 'id', clientSecret: 'secret' }; + for (const enableSignup of [false, true]) { + const options = authBetterAuthOptions({ + databaseUrl: DATABASE_URL, + config: { ...defaultAuthConfig, enableSignup }, + github: credentials, + gitlab: { ...credentials, issuer: 'https://gitlab.example.test' }, + google: credentials, + }); + + expect(options.socialProviders).toMatchObject({ + github: { disableSignUp: !enableSignup }, + gitlab: { disableSignUp: !enableSignup, issuer: 'https://gitlab.example.test' }, + google: { disableSignUp: !enableSignup }, + }); + } + }); + + it('bounds password length, which Better Auth 1.7 enforces with PASSWORD_TOO_LONG', () => { + const options = authBetterAuthOptions({ databaseUrl: DATABASE_URL, config: defaultAuthConfig }); + + expect(options.emailAndPassword).toMatchObject({ + minPasswordLength: 8, + maxPasswordLength: 128, + }); + }); + + it('wires verification and password reset only when the policy turns them on', () => { + const off = authBetterAuthOptions({ databaseUrl: DATABASE_URL, config: defaultAuthConfig }); + const on = authBetterAuthOptions({ + databaseUrl: DATABASE_URL, + config: { + ...defaultAuthConfig, + enableEmailVerification: true, + requireEmailVerification: true, + enablePasswordReset: true, + }, + sendVerificationEmail: noop, + sendPasswordResetEmail: noop, + }); + + expect(off.emailVerification).toBeUndefined(); + expect(off.emailAndPassword?.sendResetPassword).toBeUndefined(); + expect(on.emailVerification?.sendVerificationEmail).toBeTypeOf('function'); + expect(on.emailAndPassword).toMatchObject({ + requireEmailVerification: true, + revokeSessionsOnPasswordReset: true, + }); + expect(on.emailAndPassword?.sendResetPassword).toBeTypeOf('function'); + }); +}); + +describe('capabilities that need delivery', () => { + it.each([ + [{ enableEmailVerification: true }, MISSING_VERIFICATION], + [{ enablePasswordReset: true }, MISSING_RESET], + [{ enableMagicLink: true }, MISSING_MAGIC_LINK], + [{ enableTwoFactor: true, enableTwoFactorEmailOtp: true }, MISSING_OTP], + [{ enablePasskey: true }, MISSING_RELYING_PARTY], + ] satisfies [Partial, RegExp][])( + 'refuses to construct %o without it', + (config, pattern) => { + expect(() => pluginIds(config)).toThrow(pattern); + }, + ); + + it('refuses the SCIM entitlement without its secret, naming the variable', () => { + expect(() => pluginIds({}, { entitlements: { sso: false, scim: true } })).toThrow( + MISSING_SCIM_SECRET, + ); + }); + + it('rejects a short SCIM secret without echoing it', () => { + const credentialHashSecret = 'short-secret-value'; + const message = messageFrom(() => + pluginIds({}, { entitlements: { sso: false, scim: true }, scim: { credentialHashSecret } }), + ); + + expect(message).toMatch(SHORT_SCIM_SECRET); + expect(message).not.toContain(credentialHashSecret); + }); +}); + +describe('enterprise endpoints', () => { + it('serves no SSO or SCIM route without the entitlement', async () => { + const auth = instance({}); + + expect(await status(auth, 'POST', '/sso/register')).toBe(404); + expect(await status(auth, 'POST', '/sign-in/sso')).toBe(404); + expect(await status(auth, 'GET', '/scim/v2/Users')).toBe(404); + }); + + it('serves them, behind authentication, once entitled', async () => { + const auth = instance( + {}, + { entitlements: { sso: true, scim: true }, scim: { credentialHashSecret: SCIM_SECRET } }, + ); + + expect(await status(auth, 'POST', '/sso/register')).not.toBe(404); + expect(await status(auth, 'GET', '/scim/v2/Users')).toBe(401); + }); +}); + +describe('two-factor authentication', () => { + it('names Code Zero as the issuer in the authenticator enrollment URI', async () => { + const auth = instance({ enableTwoFactor: true }); + const headers = await signUp(auth); + if (!hasTwoFactorApi(auth.api)) throw new Error('missing two-factor API'); + + const enrollment = await auth.api.enableTwoFactor({ + body: { password: 'a-long-enough-password' }, + headers, + }); + + // The label and the issuer parameter both carry it, so the authenticator lists the account + // under the product rather than under a bare address. + expect(enrollment.totpURI).toMatch(TOTP_LABEL); + expect(new URL(enrollment.totpURI).searchParams.get('issuer')).toBe('Code Zero'); + expect(enrollment.backupCodes.length).toBeGreaterThan(0); + }); +}); + +describe('API keys', () => { + // Only `Date` is faked: the expiry is computed from it, while the plugin's own timers keep + // running normally. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + /** An organization with its owner signed in, ready to mint keys. */ + async function organizationOwner() { + const auth = instance({ + enableOrganizations: true, + allowUserToCreateOrganization: true, + enableApiKeys: true, + }); + const headers = await signUp(auth); + if (!hasApiKeyApi(auth.api)) throw new Error('missing organization or API-key API'); + const organization = await auth.api.createOrganization({ + body: { name: 'Acme Ops', slug: 'acme-ops' }, + headers, + }); + return { api: auth.api, auth, headers, organizationId: organization?.id ?? '' }; + } + + it('mints organization keys with the cz_ prefix, an expiry, and a rate limit', async () => { + const { api, headers, organizationId } = await organizationOwner(); + + const created = await api.createApiKey({ + body: { name: 'ci', organizationId }, + headers, + }); + + expect(created.key.startsWith(API_KEY_PREFIX)).toBe(true); + expect(created.referenceId).toBe(organizationId); + expect(created.rateLimitEnabled).toBe(true); + expect(created.rateLimitMax).toBe(API_KEY_RATE_LIMIT_MAX_REQUESTS); + expect(created.rateLimitTimeWindow).toBe(API_KEY_RATE_LIMIT_WINDOW_SECONDS * 1000); + expect(new Date(created.expiresAt ?? 0).getTime()).toBe( + NOW + API_KEY_EXPIRES_IN_SECONDS * 1000, + ); + }); + + it('caps a requested expiry in days, not seconds', async () => { + const { api, headers, organizationId } = await organizationOwner(); + const day = 60 * 60 * 24; + + const longest = await api.createApiKey({ + body: { name: 'ci', organizationId, expiresIn: API_KEY_MAXIMUM_EXPIRES_IN_DAYS * day }, + headers, + }); + + expect(new Date(longest.expiresAt ?? 0).getTime()).toBe( + NOW + API_KEY_MAXIMUM_EXPIRES_IN_DAYS * day * 1000, + ); + await expect( + api.createApiKey({ + body: { + name: 'ci', + organizationId, + expiresIn: (API_KEY_MAXIMUM_EXPIRES_IN_DAYS + 1) * day, + }, + headers, + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('grants a new key task creation, and verifies it for the control plane', async () => { + const { api, headers, organizationId } = await organizationOwner(); + const created = await api.createApiKey({ body: { name: 'ci', organizationId }, headers }); + + // The shape `packages/api` reads through its `ApiKeyVerifier`: the owning organization and + // the permissions that decide which execution modes the key gets. + await expect(api.verifyApiKey({ body: { key: created.key } })).resolves.toMatchObject({ + valid: true, + key: { referenceId: organizationId, permissions: API_KEY_DEFAULT_PERMISSIONS }, + }); + await expect( + api.verifyApiKey({ body: { key: `${API_KEY_PREFIX}not-a-key` } }), + ).resolves.toMatchObject({ valid: false, key: null }); + }); + + it('stores the key hashed, so the store never holds a usable credential', async () => { + const { api, headers, organizationId } = await organizationOwner(); + const created = await api.createApiKey({ body: { name: 'ci', organizationId }, headers }); + + const listed = await api.listApiKeys({ query: { organizationId }, headers }); + const serialized = JSON.stringify(listed); + + expect(serialized).not.toContain(created.key); + }); + + it('refuses a caller who is not a member of the organization', async () => { + const { api, auth, organizationId } = await organizationOwner(); + const outsider = await signUp(auth, 'eve@example.com'); + + await expect( + api.createApiKey({ body: { name: 'ci', organizationId }, headers: outsider }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); +}); diff --git a/packages/auth/tsdown.config.ts b/packages/auth/tsdown.config.ts index 96fdeb15..34d7b29a 100644 --- a/packages/auth/tsdown.config.ts +++ b/packages/auth/tsdown.config.ts @@ -1,14 +1,14 @@ import { definePackageConfig } from '../../scripts/tsdown.config.ts'; /** - * `./config` is a second entry point on purpose: it holds only policy, so the dashboard can read - * the feature flags without pulling `@code-zero/database` and the rest of the server instance - * into a browser bundle. + * `./config` and `./access` are separate entry points on purpose: they hold only policy (the + * feature flags and capabilities, and the organization roles), so the dashboard can read them + * without pulling `@code-zero/database` and the rest of the server instance into a browser bundle. */ export default definePackageConfig({ - entry: ['src/index.ts', 'src/config.ts'], + entry: ['src/index.ts', 'src/access.ts', 'src/config.ts'], attw: { - entrypoints: ['.', './config'], + entrypoints: ['.', './access', './config'], enabled: true, level: 'error', profile: 'esm-only', diff --git a/packages/core/config/src/deployment.test.ts b/packages/core/config/src/deployment.test.ts index 50b723a6..61f49f8a 100644 --- a/packages/core/config/src/deployment.test.ts +++ b/packages/core/config/src/deployment.test.ts @@ -1,10 +1,17 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { defaultDeploymentConfig, describeDeploymentConfigIssues, + loadDeploymentFile, + locateDeploymentConfig, parseDeploymentConfig, + parseDeploymentFile, } from './deployment.js'; +import { defaultDeploymentSettings } from './settings.js'; describe('parseDeploymentConfig', () => { it('reads the whole file', () => { @@ -130,3 +137,137 @@ describe('describeDeploymentConfigIssues', () => { ); }); }); + +describe('parseDeploymentFile', () => { + it('reads the deployment configuration and the settings from one file', () => { + const file = parseDeploymentFile(` +poll: + interval_seconds: 30 +execution: + max_concurrent_tasks: 2 +`); + + expect(file.config.poll.intervalSeconds).toBe(30); + expect(file.declaresSettings).toBe(true); + expect(file.settings).toEqual({ + ok: true, + value: { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxConcurrentTasks: 2 }, + }, + }); + }); + + it('refuses the settings of a file it cannot parse instead of running on the defaults', () => { + expect(parseDeploymentFile('execution: [unclosed\n').settings.ok).toBe(false); + }); + + it('keeps a mistake in the settings from disturbing the control-plane policy', () => { + const file = parseDeploymentFile(` +control_plane: + origins: [https://ops.example.com] +execution: + command_timeout_minutes: 0 +`); + + expect(file.config.controlPlane.origins).toEqual(['https://ops.example.com']); + expect(file.issues).toEqual([]); + expect(file.settings).toEqual({ + ok: false, + issues: [ + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + ], + }); + }); + + it('refuses a mistyped section instead of leaving every limit at its default', () => { + const file = parseDeploymentFile(` +poll: + interval_seconds: 30 +exection: + allowed_modes: [observe] +`); + + expect(file.settings).toEqual({ + ok: false, + issues: [{ path: '$.exection', message: 'Unknown section.' }], + }); + }); + + it('says when a file states no settings, so a dashboard source has nothing to ignore', () => { + expect(parseDeploymentFile('poll:\n interval_seconds: 30\n').declaresSettings).toBe(false); + }); +}); + +describe('loadDeploymentFile', () => { + const missing = join(tmpdir(), 'code-zero-missing-deployment-file', 'nothing.yml'); + + it('gives a deployment without a file the default settings', async () => { + const file = await loadDeploymentFile(missing); + + expect(file.settings).toEqual({ ok: true, value: defaultDeploymentSettings }); + }); + + it('refuses a cloud deployment whose service file is missing', async () => { + const file = await loadDeploymentFile(missing, 'cloud'); + + expect(file.settings.ok).toBe(false); + }); +}); + +describe('locateDeploymentConfig', () => { + const directory = '/srv/code-zero'; + const present = + (...names: string[]) => + (path: string): Promise => + Promise.resolve(names.some((name) => path === join(directory, name))); + + it('reads config.yml by default', async () => { + await expect(locateDeploymentConfig({}, directory, present('config.yml'))).resolves.toEqual({ + ok: true, + value: { path: join(directory, 'config.yml'), legacy: false }, + }); + }); + + it('still reads a deployment that kept the old file name, and says so', async () => { + await expect( + locateDeploymentConfig({}, directory, present('code-zero.deployment.yml')), + ).resolves.toEqual({ + ok: true, + value: { path: join(directory, 'code-zero.deployment.yml'), legacy: true }, + }); + }); + + it('refuses to choose between two files rather than reading either', async () => { + const located = await locateDeploymentConfig( + {}, + directory, + present('config.yml', 'code-zero.deployment.yml'), + ); + + expect(located.ok).toBe(false); + }); + + it('reads exactly the file CODE_ZERO_CONFIG names, whatever else is there', async () => { + await expect( + locateDeploymentConfig( + { CODE_ZERO_CONFIG: 'code-zero.deployment.yml' }, + directory, + present('config.yml', 'code-zero.deployment.yml'), + ), + ).resolves.toEqual({ + ok: true, + value: { path: join(directory, 'code-zero.deployment.yml'), legacy: false }, + }); + }); + + it('points a deployment with no file at config.yml, which then reads as the defaults', async () => { + await expect(locateDeploymentConfig({}, directory, present())).resolves.toEqual({ + ok: true, + value: { path: join(directory, 'config.yml'), legacy: false }, + }); + }); +}); diff --git a/packages/core/config/src/deployment.ts b/packages/core/config/src/deployment.ts index 76fa9e00..30833d95 100644 --- a/packages/core/config/src/deployment.ts +++ b/packages/core/config/src/deployment.ts @@ -1,8 +1,18 @@ -import { readFile } from 'node:fs/promises'; +import { access, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; import type { RunMode } from '@code-zero/shared'; import { parse } from 'yaml'; +import { + declaresDeploymentSettings, + DEPLOYMENT_CONFIG_SECTIONS, + parseDeploymentSettings, + type DeploymentSettings, + type Edition, + type Parsed, +} from './settings.js'; + /** * One thing wrong with a deployment configuration file, and where. * @@ -65,13 +75,68 @@ const MAXIMUM_POLL_SECONDS = 3_600; const RUN_MODES = new Set(['observe', 'suggest', 'fix', 'autonomous']); /** - * The default path, relative to the process's working directory. + * The instance configuration file, read from the process's working directory by default. + * + * Deliberately not `.code-zero.yml`: that file comes out of whatever checkout a run works on and is + * untrusted input, while this one is the deployment's own trusted policy. Confusing the two would + * mean reading a target repository's file as this deployment's policy, so the names differ in both + * the leading dot and the word. + */ +export const DEPLOYMENT_CONFIG_FILE = 'config.yml'; + +/** + * The name this file had before `config.yml`, still read when it is the only one present so an + * upgrade does not silently drop a deployment's policy. Reported, so the operator renames it. + */ +export const LEGACY_DEPLOYMENT_CONFIG_FILE = 'code-zero.deployment.yml'; + +/** Where the instance configuration lives, and whether it was found under its old name. */ +export interface DeploymentConfigLocation { + path: string; + legacy: boolean; +} + +/** + * Decide which one file is the instance configuration. * - * Named for the deployment rather than the product so it cannot be mistaken for `.code-zero.yml` - * at a glance: the leading dot and the different word are both load-bearing, because confusing the - * two would mean reading a target repository's file as this deployment's own policy. + * `CODE_ZERO_CONFIG` names it outright, and nothing else is looked at. Without it the working + * directory's `config.yml` is the file, or `code-zero.deployment.yml` when only the old name exists. + * Both present is refused rather than resolved: reading one would silently ignore the other, and + * two active files is exactly the ambiguity a single source exists to rule out. */ -export const DEPLOYMENT_CONFIG_FILE = 'code-zero.deployment.yml'; +export async function locateDeploymentConfig( + environment: Readonly>, + directory: string, + exists: (path: string) => Promise = fileExists, +): Promise> { + const named = environment.CODE_ZERO_CONFIG?.trim(); + if (named) return { ok: true, value: { path: resolve(directory, named), legacy: false } }; + const current = join(directory, DEPLOYMENT_CONFIG_FILE); + const legacy = join(directory, LEGACY_DEPLOYMENT_CONFIG_FILE); + const [hasCurrent, hasLegacy] = await Promise.all([exists(current), exists(legacy)]); + if (hasCurrent && hasLegacy) + return { + ok: false, + issues: [ + { + path: '$', + message: `Both ${DEPLOYMENT_CONFIG_FILE} and ${LEGACY_DEPLOYMENT_CONFIG_FILE} exist in ${directory}; keep one, or name it with CODE_ZERO_CONFIG.`, + }, + ], + }; + if (hasLegacy) return { ok: true, value: { path: legacy, legacy: true } }; + return { ok: true, value: { path: current, legacy: false } }; +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + // Absent or unreadable both mean "not this candidate"; reading it later reports the reason. + return false; + } +} export interface DeploymentConfigResult { config: DeploymentConfig; @@ -88,34 +153,89 @@ export interface DeploymentConfigResult { * a decision only a composition root can make. */ export async function loadDeploymentConfig(path: string): Promise { + const { config, issues } = await loadDeploymentFile(path); + return { config, issues }; +} + +/** + * Everything the deployment file says: the configuration, and the settings it states. + * + * The two halves fail differently on purpose. A refused configuration field falls back to its own + * default, which is always the most closed one. The settings are refused whole: a limit, a model, + * or a sandbox that was mistyped must not quietly become a different one, so their caller gets the + * complete document or every issue in it, and has to decide what a run does without them. + */ +export interface DeploymentFile extends DeploymentConfigResult { + settings: Parsed; + /** + * Whether the file states any settings section, so a deployment whose settings come from the + * dashboard can say the file's are ignored rather than leave its operator editing a dead copy. + */ + declaresSettings: boolean; +} + +/** Read the deployment file once, for both its configuration and its settings. */ +export async function loadDeploymentFile( + path: string, + edition: Edition = 'self-hosted', +): Promise { let raw: string; try { raw = await readFile(path, 'utf8'); } catch (error) { if (isRecord(error) && error.code === 'ENOENT') - return { config: structuredClone(defaultDeploymentConfig), issues: [] }; + return { + config: structuredClone(defaultDeploymentConfig), + issues: [], + settings: parseDeploymentSettings({}, edition), + declaresSettings: false, + }; + const issue = { path: '$', message: `Could not read ${path}: ${String(error)}` }; return { config: structuredClone(defaultDeploymentConfig), - issues: [{ path: '$', message: `Could not read ${path}: ${String(error)}` }], + issues: [issue], + settings: { ok: false, issues: [issue] }, + declaresSettings: false, }; } - return parseDeploymentConfig(raw); + return parseDeploymentFile(raw, edition); } -/** Validate the file's text. Separated from reading it so the tests never touch a filesystem. */ -export function parseDeploymentConfig(text: string): DeploymentConfigResult { - const issues: DeploymentConfigIssue[] = []; +/** Validate the file's text, for both halves. Separate from reading so tests need no filesystem. */ +export function parseDeploymentFile( + text: string, + edition: Edition = 'self-hosted', +): DeploymentFile { let parsed: unknown; try { parsed = parse(text); } catch (error) { + const issue = { + path: '$', + message: error instanceof Error ? error.message : 'YAML parsing failed.', + }; return { config: structuredClone(defaultDeploymentConfig), - issues: [ - { path: '$', message: error instanceof Error ? error.message : 'YAML parsing failed.' }, - ], + issues: [issue], + settings: { ok: false, issues: [issue] }, + declaresSettings: false, }; } + return { + ...configFromDocument(parsed), + settings: parseDeploymentSettings(parsed, edition, DEPLOYMENT_CONFIG_SECTIONS), + declaresSettings: declaresDeploymentSettings(parsed), + }; +} + +/** Validate the file's text. Separated from reading it so the tests never touch a filesystem. */ +export function parseDeploymentConfig(text: string): DeploymentConfigResult { + const { config, issues } = parseDeploymentFile(text); + return { config, issues }; +} + +function configFromDocument(parsed: unknown): DeploymentConfigResult { + const issues: DeploymentConfigIssue[] = []; // An empty file is a deployment that wrote the file and configured nothing yet, which is the // same thing as having no file at all. if (parsed === null || parsed === undefined) diff --git a/packages/core/config/src/index.ts b/packages/core/config/src/index.ts index 15300f8d..7258e0ff 100644 --- a/packages/core/config/src/index.ts +++ b/packages/core/config/src/index.ts @@ -10,12 +10,43 @@ export { defaultDeploymentConfig, DEPLOYMENT_CONFIG_FILE, describeDeploymentConfigIssues, + LEGACY_DEPLOYMENT_CONFIG_FILE, loadDeploymentConfig, + loadDeploymentFile, + locateDeploymentConfig, parseDeploymentConfig, + parseDeploymentFile, type DeploymentConfig, type DeploymentConfigIssue, + type DeploymentConfigLocation, type DeploymentConfigResult, + type DeploymentFile, } from './deployment.js'; +export { + applyDeploymentSettings, + defaultDeploymentSettings, + deploymentSettingsDocument, + editionFromEnvironment, + effectiveExecution, + EXECUTION_LIMITS, + isModeAllowed, + noOrganizationLimits, + organizationLimitsAbovePlan, + organizationLimitsDocument, + parseDeploymentSettings, + parseDeploymentSettingsText, + parseOrganizationLimits, + renderDeploymentSettings, + settingsSourceFromEnvironment, + type DeploymentSettings, + type Edition, + type ExecutionSettings, + type ModelSettings, + type OrganizationLimits, + type Parsed, + type SandboxSettings, + type SettingsSource, +} from './settings.js'; export { assertExecutableCommand, checkKinds, diff --git a/packages/core/config/src/settings.test.ts b/packages/core/config/src/settings.test.ts new file mode 100644 index 00000000..8d51dcd0 --- /dev/null +++ b/packages/core/config/src/settings.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it } from 'vitest'; + +import { defaultConfig, type CodeZeroConfig } from './index.js'; +import { + applyDeploymentSettings, + defaultDeploymentSettings, + editionFromEnvironment, + effectiveExecution, + organizationLimitsAbovePlan, + parseDeploymentSettings, + parseDeploymentSettingsText, + parseOrganizationLimits, + renderDeploymentSettings, + settingsSourceFromEnvironment, + type DeploymentSettings, + type ExecutionSettings, + type SandboxSettings, +} from './settings.js'; + +const SANDBOX: SandboxSettings = { + isolation: 'container', + engine: 'podman', + image: 'ghcr.io/acme/code-zero-runner:1', +}; + +const CONFIGURED: DeploymentSettings = { + execution: { + maxConcurrentTasks: 8, + commandTimeoutMinutes: 20, + allowedModes: ['observe', 'suggest'], + maxNetwork: 'none', + }, + model: { provider: 'anthropic', name: 'claude-sonnet-4-5' }, + runner: SANDBOX, +}; + +const CONFIGURED_YAML = ` +execution: + max_concurrent_tasks: 8 + command_timeout_minutes: 20 + allowed_modes: [suggest, observe] + max_network: none +model: + provider: anthropic + name: claude-sonnet-4-5 +runner: + isolation: container + engine: podman + image: ghcr.io/acme/code-zero-runner:1 +`; + +const PLAN: ExecutionSettings = { + maxConcurrentTasks: 10, + commandTimeoutMinutes: 60, + allowedModes: ['observe', 'suggest', 'fix'], + maxNetwork: 'restricted', +}; + +function repository(overrides: Partial = {}): CodeZeroConfig { + return { ...structuredClone(defaultConfig), ...overrides }; +} + +describe('parseDeploymentSettingsText', () => { + it('reads every section into one document', () => { + expect(parseDeploymentSettingsText(CONFIGURED_YAML)).toEqual({ ok: true, value: CONFIGURED }); + }); + + it.each(['', '# nothing configured yet\n', 'control_plane:\n origins: []\n'])( + 'gives %j the defaults, because it states no setting', + (text) => { + expect(parseDeploymentSettingsText(text)).toEqual({ + ok: true, + value: defaultDeploymentSettings, + }); + }, + ); + + it('refuses a top-level key that is no section of the deployment file', () => { + expect(parseDeploymentSettingsText('exection:\n allowed_modes: [observe]\n')).toEqual({ + ok: false, + issues: [{ path: '$.exection', message: 'Unknown section.' }], + }); + }); + + it('refuses the configuration sections in a settings document saved from the dashboard', () => { + expect(parseDeploymentSettings({ control_plane: { origins: [] } })).toEqual({ + ok: false, + issues: [{ path: '$.control_plane', message: 'Unknown section.' }], + }); + }); + + it('refuses unparseable YAML instead of falling back to the defaults', () => { + const parsed = parseDeploymentSettingsText('execution: [unclosed\n'); + + expect(parsed.ok).toBe(false); + expect(!parsed.ok ? parsed.issues[0]?.path : undefined).toBe('$'); + }); + + it('refuses the whole document when one value is wrong, naming every mistake', () => { + expect( + parseDeploymentSettingsText(` +execution: + max_concurrent_tasks: 0 + command_timeout_minutes: 99999 +`), + ).toEqual({ + ok: false, + issues: [ + { + path: '$.execution.max_concurrent_tasks', + message: 'Expected an integer from 1 to 64.', + }, + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + ], + }); + }); + + it('refuses a misspelled setting rather than ignoring it', () => { + expect(parseDeploymentSettingsText('execution:\n max_concurent_tasks: 2\n')).toEqual({ + ok: false, + issues: [{ path: '$.execution.max_concurent_tasks', message: 'Unknown setting.' }], + }); + }); + + it('keeps observe allowed, since it is the mode every unrequested run starts in', () => { + expect(parseDeploymentSettingsText('execution:\n allowed_modes: [suggest]\n')).toEqual({ + ok: false, + issues: [ + { + path: '$.execution.allowed_modes', + message: 'Expected a list of execution modes that includes observe.', + }, + ], + }); + }); + + it('refuses a runner that would let repositories run on the host', () => { + expect(parseDeploymentSettingsText('runner:\n isolation: local\n image: node:24\n')).toEqual({ + ok: false, + issues: [ + { + path: '$.runner.isolation', + message: + 'Only container isolation can be enforced; remove the runner section to let each repository choose.', + }, + ], + }); + }); + + it('refuses an image the container engine would read as one of its own flags', () => { + const parsed = parseDeploymentSettingsText( + 'runner:\n isolation: container\n image: --privileged\n', + ); + + expect(parsed).toEqual({ + ok: false, + issues: [{ path: '$.runner.image', message: 'Expected a container image reference.' }], + }); + }); + + it('refuses a credential pasted into a setting, so it is never stored or exported', () => { + const parsed = parseDeploymentSettingsText( + 'model:\n provider: openai\n name: sk-live1234567890abcdefghij\n', + ); + + expect(parsed).toEqual({ + ok: false, + issues: [ + { + path: '$.model.name', + message: 'Looks like a credential. Credentials stay in the environment.', + }, + ], + }); + }); + + it('refuses a base URL, which would decide where the model credential is sent', () => { + expect( + parseDeploymentSettingsText( + 'model:\n provider: openai\n name: gpt-5\n base_url: https://example.test\n', + ), + ).toEqual({ + ok: false, + issues: [{ path: '$.model.base_url', message: 'Unknown setting.' }], + }); + }); +}); + +describe('parseDeploymentSettings for a cloud deployment', () => { + it('requires the service to name the model its customers run on', () => { + expect(parseDeploymentSettings({}, 'cloud')).toEqual({ + ok: false, + issues: [{ path: '$.model', message: 'A cloud deployment must configure the model.' }], + }); + }); + + it('does not require a container engine, which a Cloudflare-hosted service does not have', () => { + const parsed = parseDeploymentSettings( + { model: { provider: 'anthropic', name: 'claude-sonnet-4-5' } }, + 'cloud', + ); + + expect(parsed.ok).toBe(true); + }); +}); + +describe('renderDeploymentSettings', () => { + it('exports YAML that reads back as the same settings', () => { + expect(parseDeploymentSettingsText(renderDeploymentSettings(CONFIGURED))).toEqual({ + ok: true, + value: CONFIGURED, + }); + }); + + it('never writes an environment credential into the export', () => { + const secret = 'gateway-key-0123456789'; + const rendered = renderDeploymentSettings( + { ...CONFIGURED, model: { provider: 'ai-gateway', name: `${secret}-model` } }, + { AI_GATEWAY_API_KEY: secret }, + ); + + expect(rendered).not.toContain(secret); + }); +}); + +describe('editionFromEnvironment', () => { + it('is self-hosted unless the deployment says otherwise', () => { + expect(editionFromEnvironment({})).toEqual({ ok: true, value: 'self-hosted' }); + }); + + it('does not take a Cloudflare host for the managed cloud', () => { + expect( + editionFromEnvironment({ CF_PAGES: '1', CLOUDFLARE_ACCOUNT_ID: 'account', CF_WORKER: 'x' }), + ).toEqual({ ok: true, value: 'self-hosted' }); + }); + + it('reads the managed cloud only from its explicit value', () => { + expect(editionFromEnvironment({ CODE_ZERO_EDITION: 'cloud' })).toEqual({ + ok: true, + value: 'cloud', + }); + }); + + it('refuses a value it does not know rather than guessing an edition', () => { + expect(editionFromEnvironment({ CODE_ZERO_EDITION: 'clod' }).ok).toBe(false); + }); +}); + +describe('settingsSourceFromEnvironment', () => { + it('keeps the file as the source of a deployment that never chose one', () => { + expect(settingsSourceFromEnvironment({}, 'self-hosted')).toEqual({ ok: true, value: 'file' }); + }); + + it('makes the dashboard the source only when asked', () => { + expect( + settingsSourceFromEnvironment({ CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, 'self-hosted'), + ).toEqual({ ok: true, value: 'dashboard' }); + }); + + it('refuses the dashboard as the source of a cloud deployment', () => { + expect( + settingsSourceFromEnvironment({ CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, 'cloud').ok, + ).toBe(false); + }); + + it('refuses an unknown source rather than picking one', () => { + expect( + settingsSourceFromEnvironment({ CODE_ZERO_SETTINGS_SOURCE: 'database' }, 'self-hosted').ok, + ).toBe(false); + }); +}); + +describe('organization limits', () => { + it('lowers the plan where the organization chose a stricter value', () => { + const limits = parseOrganizationLimits({ + max_concurrent_tasks: 2, + command_timeout_minutes: null, + allowed_modes: ['observe'], + }); + + expect(limits.ok).toBe(true); + if (!limits.ok) return; + expect(effectiveExecution(PLAN, limits.value)).toEqual({ + maxConcurrentTasks: 2, + commandTimeoutMinutes: 60, + allowedModes: ['observe'], + maxNetwork: 'restricted', + }); + }); + + it('names every value that would exceed the plan', () => { + expect( + organizationLimitsAbovePlan( + { + maxConcurrentTasks: 11, + commandTimeoutMinutes: 61, + allowedModes: ['observe', 'autonomous'], + }, + PLAN, + ), + ).toEqual([ + { path: '$.max_concurrent_tasks', message: 'The plan allows at most 10.' }, + { path: '$.command_timeout_minutes', message: 'The plan allows at most 60.' }, + { path: '$.allowed_modes', message: 'The plan does not allow autonomous.' }, + ]); + }); + + it('never lets a stored value exceed a plan that was lowered after it was saved', () => { + expect( + effectiveExecution( + { + maxConcurrentTasks: 3, + commandTimeoutMinutes: 10, + allowedModes: ['observe'], + maxNetwork: 'none', + }, + { maxConcurrentTasks: 8, commandTimeoutMinutes: 45, allowedModes: ['observe', 'suggest'] }, + ), + ).toEqual({ + maxConcurrentTasks: 3, + commandTimeoutMinutes: 10, + allowedModes: ['observe'], + maxNetwork: 'none', + }); + }); +}); + +describe('applyDeploymentSettings', () => { + it('caps the command timeout the repository asked for', () => { + const applied = applyDeploymentSettings( + repository({ agent: { maxAttempts: 3, timeoutMs: 3_600_000, maxChangedFiles: 10 } }), + { ...defaultDeploymentSettings, execution: { ...PLAN, commandTimeoutMinutes: 15 } }, + ); + + expect(applied.agent.timeoutMs).toBe(900_000); + }); + + it('keeps a repository command timeout that is already shorter', () => { + const applied = applyDeploymentSettings( + repository({ agent: { maxAttempts: 3, timeoutMs: 60_000, maxChangedFiles: 10 } }), + defaultDeploymentSettings, + ); + + expect(applied.agent.timeoutMs).toBe(60_000); + }); + + it('replaces the model a repository chose with the deployment model', () => { + const applied = applyDeploymentSettings( + repository({ + model: { + provider: 'openai', + name: 'gpt-5', + inputCostPerMillionTokens: 1, + outputCostPerMillionTokens: 2, + }, + }), + CONFIGURED, + ); + + expect(applied.model).toEqual({ provider: 'anthropic', name: 'claude-sonnet-4-5' }); + }); + + it('forces the deployment sandbox and drops the container network the repository named', () => { + const applied = applyDeploymentSettings( + repository({ + runner: { + isolation: 'local', + engine: 'docker', + image: 'attacker/image', + network: 'host', + workdir: '/workspace', + maxOutputBytes: 200_000, + }, + }), + CONFIGURED, + ); + + expect(applied.runner).toEqual({ + isolation: 'container', + engine: 'podman', + image: 'ghcr.io/acme/code-zero-runner:1', + workdir: '/workspace', + maxOutputBytes: 200_000, + }); + }); + + it('holds every run to the network ceiling, with or without a deployment sandbox', () => { + const applied = applyDeploymentSettings(repository({ permissions: { network: 'full' } }), { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxNetwork: 'restricted' }, + }); + + expect(applied.permissions.network).toBe('restricted'); + }); + + it('drops the container network a repository named whenever the deployment restricts the network', () => { + const applied = applyDeploymentSettings( + repository({ + permissions: { network: 'full' }, + runner: { + isolation: 'container', + engine: 'docker', + image: 'ghcr.io/acme/runner:1', + network: 'host', + workdir: '/workspace', + maxOutputBytes: 200_000, + }, + }), + { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxNetwork: 'restricted' }, + }, + ); + + // `restricted` is where the container runner reads a network name, so a repository-chosen + // `host` would otherwise become `--network host` under a restricted ceiling. + expect(applied.permissions.network).toBe('restricted'); + expect(applied.runner).not.toHaveProperty('network'); + expect(applied.runner.isolation).toBe('container'); + }); + + it('lets a repository narrow the network below the deployment ceiling', () => { + const applied = applyDeploymentSettings( + repository({ permissions: { network: 'none' } }), + defaultDeploymentSettings, + ); + + expect(applied.permissions.network).toBe('none'); + }); + + it('leaves isolation and model to the repository when the deployment names neither', () => { + const original = repository(); + const applied = applyDeploymentSettings(original, defaultDeploymentSettings); + + expect(applied.runner).toEqual(original.runner); + expect(applied.model).toEqual(original.model); + }); + + it('applies the organization command timeout rather than the plan when it is stricter', () => { + const applied = applyDeploymentSettings(repository(), CONFIGURED, { + ...CONFIGURED.execution, + commandTimeoutMinutes: 5, + }); + + expect(applied.agent.timeoutMs).toBe(300_000); + }); +}); diff --git a/packages/core/config/src/settings.ts b/packages/core/config/src/settings.ts new file mode 100644 index 00000000..bca058d1 --- /dev/null +++ b/packages/core/config/src/settings.ts @@ -0,0 +1,670 @@ +import { + redactSecrets, + secretValuesFromEnvironment, + type ModelProviderKind, + type NetworkPolicy, + type RunMode, +} from '@code-zero/shared'; +import { parse, stringify } from 'yaml'; + +import type { DeploymentConfigIssue } from './deployment.js'; +import type { CodeZeroConfig, ContainerEngine } from './index.js'; + +/** + * Which product a deployment is. + * + * `cloud` is the managed service, where the service's own operators configure the model, the + * sandbox, and every connection, and customers only see whether each one works. It is stated by the + * deployment, never inferred: a self-hosted install that happens to run on Cloudflare is still + * self-hosted, and treating it otherwise would lock its operator out of their own settings. + */ +export type Edition = 'self-hosted' | 'cloud'; + +/** + * Where a self-hosted deployment's settings are read from. Exactly one of the two is authoritative. + * + * `file` is the trusted deployment file, which the dashboard shows read-only. `dashboard` is the + * store, edited from the settings page and exportable as the same YAML the file would hold. + */ +export type SettingsSource = 'file' | 'dashboard'; + +/** The answer to parsing something untrusted: the value, or every reason it was refused. */ +export type Parsed = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly issues: readonly DeploymentConfigIssue[] }; + +/** How much work the deployment takes on, and in which modes. */ +export interface ExecutionSettings { + /** Tasks this deployment runs at once. The scheduler queues the rest. */ + readonly maxConcurrentTasks: number; + /** + * Minutes after which one repository command is stopped, and the longest a hosted sandbox lease + * lasts. Not a limit on a whole run: a run that executes several commands, or waits on the model, + * can take longer than this in total. + */ + readonly commandTimeoutMinutes: number; + /** Execution modes a run may start in. Always includes `observe`. */ + readonly allowedModes: readonly RunMode[]; + /** + * The widest egress any run may have, however it is executed: the local runner, the container + * sandbox, or a hosted sandbox provider, which receives it with every lease request. A repository + * may narrow it, never widen it. `full` states no ceiling. + */ + readonly maxNetwork: NetworkPolicy; +} + +/** The model every run uses, in place of whatever a repository's `.code-zero.yml` chose. */ +export interface ModelSettings { + readonly provider: ModelProviderKind; + readonly name: string; +} + +/** + * The Docker or Podman sandbox for the runs this process executes itself, in place of whatever a + * repository's `.code-zero.yml` chose. + * + * Only container isolation can be imposed: a deployment setting that downgraded a repository to + * the host would be the one place a run ends up less isolated than its own policy asked for. A hosted + * sandbox provider brings its own image and ignores this one, so the setting only describes runs + * started on this process's own container engine. + */ +export interface SandboxSettings { + readonly isolation: 'container'; + readonly engine: ContainerEngine; + readonly image: string; +} + +/** + * The deployment-wide settings the dashboard shows and, when it is the source, edits. + * + * Every field changes what a run does: nothing here is a label. Credentials never live here — the + * parser refuses anything shaped like one — so the document can be shown and exported whole. + */ +export interface DeploymentSettings { + readonly execution: ExecutionSettings; + /** `null` leaves the model to each repository's `.code-zero.yml`. */ + readonly model: ModelSettings | null; + /** `null` leaves isolation to each repository's `.code-zero.yml`. */ + readonly runner: SandboxSettings | null; +} + +/** + * Stricter limits an organization chose for its own work on the managed cloud. + * + * `null` means the plan's value applies. A stored value is never trusted to stay below the plan, + * because the plan can be lowered after it was saved; {@link effectiveExecution} takes the minimum. + */ +export interface OrganizationLimits { + readonly maxConcurrentTasks: number | null; + readonly commandTimeoutMinutes: number | null; + readonly allowedModes: readonly RunMode[] | null; +} + +const RUN_MODES: readonly RunMode[] = ['observe', 'suggest', 'fix', 'autonomous']; +const NETWORK_POLICIES: readonly NetworkPolicy[] = ['none', 'restricted', 'full']; +const CONTAINER_ENGINES: readonly ContainerEngine[] = ['docker', 'podman']; +const MODEL_PROVIDERS: readonly ModelProviderKind[] = [ + 'ai-gateway', + 'anthropic', + 'claude-code', + 'codex-cli', + 'google', + 'openai', + 'openai-compatible', +]; + +/** The range each limit accepts, published so a form can offer the same bounds the parser checks. */ +export const EXECUTION_LIMITS = { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1_440 }, +} as const; + +/** + * What a deployment gets when it states no setting: today's behaviour, unchanged. + * + * Four concurrent tasks is the scheduler's long-standing default, and thirty minutes is the + * repository policy's own default command timeout, so a deployment that never opens the settings page runs + * exactly as it did before the page existed. + */ +export const defaultDeploymentSettings: DeploymentSettings = { + execution: { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: RUN_MODES, + maxNetwork: 'full', + }, + model: null, + runner: null, +}; + +/** An organization that chose nothing: the plan applies as it is. */ +export const noOrganizationLimits: OrganizationLimits = { + maxConcurrentTasks: null, + commandTimeoutMinutes: null, + allowedModes: null, +}; + +/** The deployment file's top-level keys that belong to the settings document. */ +const SETTINGS_SECTIONS = ['execution', 'model', 'runner'] as const; + +/** + * The deployment file's other top-level sections, which `deployment.ts` parses. Named here so the + * file has one list of every section it may hold, and a key that is neither is refused. + */ +export const DEPLOYMENT_CONFIG_SECTIONS: readonly string[] = ['control_plane', 'poll']; + +const EXECUTION_KEYS = [ + 'max_concurrent_tasks', + 'command_timeout_minutes', + 'allowed_modes', + 'max_network', +]; +const ORGANIZATION_KEYS = ['max_concurrent_tasks', 'command_timeout_minutes', 'allowed_modes']; +const MODEL_KEYS = ['provider', 'name']; +const RUNNER_KEYS = ['isolation', 'engine', 'image']; + +/** A container image reference: never empty, never a flag, never whitespace. */ +const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/; +/** A model name: printable ASCII without whitespace, which every supported transport accepts. */ +const MODEL_NAME = /^[\x21-\x7E]{1,200}$/; + +/** + * Read the edition from the deployment's environment. + * + * Only `CODE_ZERO_EDITION` decides it. A value this function does not know is refused rather than + * read as self-hosted: guessing wrong in either direction either hides a managed service's locks or + * hands a customer the service's own settings. + */ +export function editionFromEnvironment( + environment: Readonly>, +): Parsed { + const value = environment.CODE_ZERO_EDITION?.trim(); + if (!value || value === 'self-hosted') return ok('self-hosted'); + if (value === 'cloud') return ok('cloud'); + return err('CODE_ZERO_EDITION', 'Expected self-hosted or cloud.'); +} + +/** + * Read which source a deployment's settings come from. + * + * The file is the default, so a deployment that upgrades keeps reading the file it already has. The + * dashboard has to be chosen here, in the environment, where only whoever runs the deployment can + * choose it: no request can move the source, so the file's read-only promise cannot be talked + * around through the API. A cloud deployment cannot choose the dashboard at all. + */ +export function settingsSourceFromEnvironment( + environment: Readonly>, + edition: Edition, +): Parsed { + const value = environment.CODE_ZERO_SETTINGS_SOURCE?.trim(); + if (!value || value === 'file') return ok('file'); + if (value !== 'dashboard') return err('CODE_ZERO_SETTINGS_SOURCE', 'Expected file or dashboard.'); + if (edition === 'cloud') + return err( + 'CODE_ZERO_SETTINGS_SOURCE', + 'A cloud deployment reads its service settings from the deployment file only.', + ); + return ok('dashboard'); +} + +/** Whether a parsed deployment file states any settings section at all. */ +export function declaresDeploymentSettings(document: unknown): boolean { + return isRecord(document) && SETTINGS_SECTIONS.some((section) => document[section] !== undefined); +} + +/** + * Parse the settings sections of a deployment document, refusing it whole on any mistake. + * + * Unlike the rest of the deployment file, nothing here falls back to a default field by field: a + * limit or a sandbox that was meant and mistyped must not quietly become a different one, so the + * caller gets either the complete document or every issue in it. That includes the top level: an + * unknown section is refused, because a mistyped `exection:` would otherwise leave every limit at + * its default. `otherSections` names the top-level keys that belong to someone else — the + * deployment configuration's own sections, when the document is the deployment file. + */ +export function parseDeploymentSettings( + document: unknown, + edition: Edition = 'self-hosted', + otherSections: readonly string[] = [], +): Parsed { + // An empty file parses to null, and states no setting, exactly like a file that has none. + const root = document ?? {}; + if (!isRecord(root)) return err('$', 'Expected an object.'); + const issues: DeploymentConfigIssue[] = []; + for (const key of Object.keys(root)) + if (!SETTINGS_SECTIONS.some((section) => section === key) && !otherSections.includes(key)) + issues.push({ path: `$.${key}`, message: 'Unknown section.' }); + const settings: DeploymentSettings = { + execution: executionSection(root.execution, issues), + model: modelSection(root.model, issues), + runner: runnerSection(root.runner, issues), + }; + // The managed service names its model here, so no customer repository can pick one that bills the + // service's credentials. Its sandbox is not required here: a hosted sandbox provider is wired by the + // composition root, not described in this file, and a Cloudflare-hosted service has no container + // engine to name. Runs that would reach neither are refused where runs start. + if (edition === 'cloud' && (root.model === undefined || root.model === null)) + issues.push({ path: '$.model', message: 'A cloud deployment must configure the model.' }); + return issues.length > 0 ? { ok: false, issues } : ok(settings); +} + +/** Parse settings written as YAML: an export, or a file that holds nothing else. */ +export function parseDeploymentSettingsText( + text: string, + edition: Edition = 'self-hosted', +): Parsed { + let document: unknown; + try { + document = parse(text); + } catch (error) { + return err('$', error instanceof Error ? error.message : 'YAML parsing failed.'); + } + return parseDeploymentSettings(document, edition, DEPLOYMENT_CONFIG_SECTIONS); +} + +/** + * The settings as the document they are stored and exported as: the deployment file's own keys. + * + * One shape for the store and the export, so what an administrator downloads is byte for byte what + * the file source would read back. + */ +export function deploymentSettingsDocument(settings: DeploymentSettings): Record { + const document: Record = { + execution: { + max_concurrent_tasks: settings.execution.maxConcurrentTasks, + command_timeout_minutes: settings.execution.commandTimeoutMinutes, + allowed_modes: [...settings.execution.allowedModes], + max_network: settings.execution.maxNetwork, + }, + }; + if (settings.model) + document.model = { provider: settings.model.provider, name: settings.model.name }; + if (settings.runner) + document.runner = { + isolation: settings.runner.isolation, + engine: settings.runner.engine, + image: settings.runner.image, + }; + return document; +} + +/** + * Render the settings as YAML for an administrator to download. + * + * The document holds no credential by construction; every string still passes through redaction + * against the environment's own secrets, so a value that slipped past the parser cannot leave the + * deployment through an export. + */ +export function renderDeploymentSettings( + settings: DeploymentSettings, + environment: Readonly> = process.env, +): string { + return redactSecrets( + stringify(deploymentSettingsDocument(settings)), + secretValuesFromEnvironment(environment), + ); +} + +/** Parse the limits an organization stored. Bounds are checked here; the plan is checked on write. */ +export function parseOrganizationLimits(document: unknown): Parsed { + if (document === null || document === undefined) return ok(noOrganizationLimits); + if (!isRecord(document)) return err('$', 'Expected an object.'); + const issues: DeploymentConfigIssue[] = []; + unknownKeys(document, ORGANIZATION_KEYS, '$', issues); + const limits: OrganizationLimits = { + maxConcurrentTasks: nullable(document.max_concurrent_tasks, () => + boundedInteger( + document.max_concurrent_tasks, + '$.max_concurrent_tasks', + EXECUTION_LIMITS.maxConcurrentTasks, + issues, + ), + ), + commandTimeoutMinutes: nullable(document.command_timeout_minutes, () => + boundedInteger( + document.command_timeout_minutes, + '$.command_timeout_minutes', + EXECUTION_LIMITS.commandTimeoutMinutes, + issues, + ), + ), + allowedModes: nullable(document.allowed_modes, () => + modeList(document.allowed_modes, '$.allowed_modes', issues), + ), + }; + return issues.length > 0 ? { ok: false, issues } : ok(limits); +} + +/** The limits as the document they are stored as. */ +export function organizationLimitsDocument(limits: OrganizationLimits): Record { + return { + max_concurrent_tasks: limits.maxConcurrentTasks, + command_timeout_minutes: limits.commandTimeoutMinutes, + allowed_modes: limits.allowedModes ? [...limits.allowedModes] : null, + }; +} + +/** Every value an organization chose that the plan does not allow. Empty when all fit. */ +export function organizationLimitsAbovePlan( + limits: OrganizationLimits, + plan: ExecutionSettings, +): DeploymentConfigIssue[] { + const issues: DeploymentConfigIssue[] = []; + if (limits.maxConcurrentTasks !== null && limits.maxConcurrentTasks > plan.maxConcurrentTasks) + issues.push({ + path: '$.max_concurrent_tasks', + message: `The plan allows at most ${String(plan.maxConcurrentTasks)}.`, + }); + if ( + limits.commandTimeoutMinutes !== null && + limits.commandTimeoutMinutes > plan.commandTimeoutMinutes + ) + issues.push({ + path: '$.command_timeout_minutes', + message: `The plan allows at most ${String(plan.commandTimeoutMinutes)}.`, + }); + for (const mode of limits.allowedModes ?? []) + if (!plan.allowedModes.includes(mode)) + issues.push({ path: '$.allowed_modes', message: `The plan does not allow ${mode}.` }); + return issues; +} + +/** + * The limits a run is held to: the plan, lowered wherever the organization chose less. + * + * Minimums and an intersection, never a replacement, so a stored value that is now above a lowered + * plan cannot raise anything. + */ +export function effectiveExecution( + plan: ExecutionSettings, + limits: OrganizationLimits | null, +): ExecutionSettings { + if (!limits) return plan; + return { + maxConcurrentTasks: Math.min( + plan.maxConcurrentTasks, + limits.maxConcurrentTasks ?? plan.maxConcurrentTasks, + ), + commandTimeoutMinutes: Math.min( + plan.commandTimeoutMinutes, + limits.commandTimeoutMinutes ?? plan.commandTimeoutMinutes, + ), + allowedModes: limits.allowedModes + ? plan.allowedModes.filter((mode) => limits.allowedModes?.includes(mode)) + : plan.allowedModes, + // The network ceiling is the service's, like the sandbox it describes: no organization moves it. + maxNetwork: plan.maxNetwork, + }; +} + +/** + * A repository's configuration with the deployment's settings applied: stricter wins everywhere. + * + * The repository's `.code-zero.yml` is untrusted input from the checkout being worked on, so where + * the deployment states a model or a sandbox, the deployment's choice replaces the repository's + * rather than merging with it. The command timeout is the shorter of the two, and the network the + * narrower, whatever executes the run: a repository may ask for less than the deployment allows, + * never more. A repository's own container network name is dropped under a deployment sandbox and + * under any network ceiling below `full`, because `--network host` is a name too. + * + * `execution` is the effective limit for this run — the plan lowered by an organization's limits on + * the managed cloud — and defaults to the deployment's own. + */ +export function applyDeploymentSettings( + config: CodeZeroConfig, + settings: DeploymentSettings, + execution: ExecutionSettings = settings.execution, +): CodeZeroConfig { + const applied: CodeZeroConfig = { + ...config, + agent: { + ...config.agent, + timeoutMs: Math.min(config.agent.timeoutMs, execution.commandTimeoutMinutes * 60_000), + }, + permissions: { + ...config.permissions, + network: narrowerNetwork(config.permissions.network, execution.maxNetwork), + }, + }; + if (settings.model) applied.model = deploymentModel(config.model, settings.model); + if (settings.runner) + applied.runner = { + ...withoutContainerNetwork(config.runner), + isolation: 'container', + engine: settings.runner.engine, + image: settings.runner.image, + }; + // Under a restricted ceiling the container runner reads the network *name* the repository + // gave (`restricted` is the one policy that uses it), and `host` is a name too. So whenever the + // deployment narrows the network, the repository's own name goes, sandbox or not, and the + // runner falls back to its default restricted network. + else if (execution.maxNetwork !== 'full') applied.runner = withoutContainerNetwork(config.runner); + return applied; +} + +/** Whether the effective limits let a run start in this mode. */ +export function isModeAllowed(execution: ExecutionSettings, mode: RunMode): boolean { + return execution.allowedModes.includes(mode); +} + +/** + * The deployment's model, keeping the repository's prices only when they describe that same model. + * + * A price is a statement about one model; carried over to another it would make the usage figures + * lie, so it is dropped rather than reused. + */ +function deploymentModel( + repository: CodeZeroConfig['model'], + deployment: ModelSettings, +): CodeZeroConfig['model'] { + if (repository.provider === deployment.provider && repository.name === deployment.name) + return { ...repository }; + return { provider: deployment.provider, name: deployment.name }; +} + +/** The repository's runner policy minus the container network it named for itself. */ +function withoutContainerNetwork(runner: CodeZeroConfig['runner']): CodeZeroConfig['runner'] { + const kept: CodeZeroConfig['runner'] = { ...runner }; + delete kept.network; + return kept; +} + +function narrowerNetwork(left: NetworkPolicy, right: NetworkPolicy): NetworkPolicy { + return NETWORK_POLICIES.indexOf(left) <= NETWORK_POLICIES.indexOf(right) ? left : right; +} + +function executionSection(value: unknown, issues: DeploymentConfigIssue[]): ExecutionSettings { + const defaults = defaultDeploymentSettings.execution; + const section = optionalSection(value, '$.execution', issues); + if (!section) return defaults; + unknownKeys(section, EXECUTION_KEYS, '$.execution', issues); + return { + maxConcurrentTasks: + section.max_concurrent_tasks === undefined + ? defaults.maxConcurrentTasks + : boundedInteger( + section.max_concurrent_tasks, + '$.execution.max_concurrent_tasks', + EXECUTION_LIMITS.maxConcurrentTasks, + issues, + ), + commandTimeoutMinutes: + section.command_timeout_minutes === undefined + ? defaults.commandTimeoutMinutes + : boundedInteger( + section.command_timeout_minutes, + '$.execution.command_timeout_minutes', + EXECUTION_LIMITS.commandTimeoutMinutes, + issues, + ), + allowedModes: + section.allowed_modes === undefined + ? defaults.allowedModes + : modeList(section.allowed_modes, '$.execution.allowed_modes', issues), + maxNetwork: + section.max_network === undefined + ? defaults.maxNetwork + : networkPolicy(section.max_network, '$.execution.max_network', issues), + }; +} + +function networkPolicy( + value: unknown, + path: string, + issues: DeploymentConfigIssue[], +): NetworkPolicy { + if (typeof value === 'string' && isOneOf(NETWORK_POLICIES, value)) return value; + issues.push({ path, message: 'Expected none, restricted, or full.' }); + return 'none'; +} + +function modelSection(value: unknown, issues: DeploymentConfigIssue[]): ModelSettings | null { + const section = optionalSection(value, '$.model', issues); + if (!section) return null; + unknownKeys(section, MODEL_KEYS, '$.model', issues); + const provider = section.provider; + if (typeof provider !== 'string' || !isOneOf(MODEL_PROVIDERS, provider)) { + issues.push({ + path: '$.model.provider', + message: `Expected one of ${MODEL_PROVIDERS.join(', ')}.`, + }); + return null; + } + const name = settingString( + section.name, + '$.model.name', + MODEL_NAME, + 'Expected a model name.', + issues, + ); + return name === undefined ? null : { provider, name }; +} + +function runnerSection(value: unknown, issues: DeploymentConfigIssue[]): SandboxSettings | null { + const section = optionalSection(value, '$.runner', issues); + if (!section) return null; + unknownKeys(section, RUNNER_KEYS, '$.runner', issues); + if (section.isolation !== 'container') { + issues.push({ + path: '$.runner.isolation', + message: + 'Only container isolation can be enforced; remove the runner section to let each repository choose.', + }); + return null; + } + const engine = section.engine ?? 'docker'; + if (typeof engine !== 'string' || !isOneOf(CONTAINER_ENGINES, engine)) { + issues.push({ path: '$.runner.engine', message: 'Expected docker or podman.' }); + return null; + } + const image = settingString( + section.image, + '$.runner.image', + IMAGE_REFERENCE, + 'Expected a container image reference.', + issues, + ); + return image === undefined ? null : { isolation: 'container', engine, image }; +} + +function optionalSection( + value: unknown, + path: string, + issues: DeploymentConfigIssue[], +): Record | undefined { + if (value === undefined || value === null) return undefined; + if (isRecord(value)) return value; + issues.push({ path, message: 'Expected an object.' }); + return undefined; +} + +/** A misspelled key is refused, not ignored: ignoring it is how a limit silently stays the default. */ +function unknownKeys( + section: Record, + known: readonly string[], + path: string, + issues: DeploymentConfigIssue[], +): void { + for (const key of Object.keys(section)) + if (!known.includes(key)) issues.push({ path: `${path}.${key}`, message: 'Unknown setting.' }); +} + +function boundedInteger( + value: unknown, + path: string, + bounds: { readonly minimum: number; readonly maximum: number }, + issues: DeploymentConfigIssue[], +): number { + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < bounds.minimum || + value > bounds.maximum + ) { + issues.push({ + path, + message: `Expected an integer from ${String(bounds.minimum)} to ${String(bounds.maximum)}.`, + }); + return bounds.minimum; + } + return value; +} + +/** + * A list of execution modes in the canonical order, with `observe` required. + * + * `observe` is what every unrequested run starts in and the one mode that can never write, so a + * deployment without it would refuse its own default rather than be any safer. + */ +function modeList(value: unknown, path: string, issues: DeploymentConfigIssue[]): RunMode[] { + if ( + !Array.isArray(value) || + !value.every((entry) => typeof entry === 'string' && isOneOf(RUN_MODES, entry)) || + !value.includes('observe') + ) { + issues.push({ path, message: 'Expected a list of execution modes that includes observe.' }); + return ['observe']; + } + return RUN_MODES.filter((mode) => value.includes(mode)); +} + +function settingString( + value: unknown, + path: string, + pattern: RegExp, + message: string, + issues: DeploymentConfigIssue[], +): string | undefined { + if (typeof value !== 'string' || !pattern.test(value.trim())) { + issues.push({ path, message }); + return undefined; + } + const text = value.trim(); + // A setting is shown on a page and exported as a file, so a credential pasted into one would + // leave the deployment through both. Anything the redactor would rewrite is refused instead. + if (redactSecrets(text) !== text) { + issues.push({ path, message: 'Looks like a credential. Credentials stay in the environment.' }); + return undefined; + } + return text; +} + +function nullable(value: unknown, read: () => T): T | null { + return value === undefined || value === null ? null : read(); +} + +function isOneOf(values: readonly T[], value: string): value is T { + return values.some((candidate) => candidate === value); +} + +function ok(value: T): Parsed { + return { ok: true, value }; +} + +function err(path: string, message: string): Parsed { + return { ok: false, issues: [{ path, message }] }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/database/drizzle/0005_tricky_dexter_bennett.sql b/packages/database/drizzle/0005_tricky_dexter_bennett.sql new file mode 100644 index 00000000..47917a6f --- /dev/null +++ b/packages/database/drizzle/0005_tricky_dexter_bennett.sql @@ -0,0 +1,274 @@ +CREATE TABLE "api_key" ( + "id" text PRIMARY KEY NOT NULL, + "config_id" text DEFAULT 'default' NOT NULL, + "name" text, + "start" text, + "reference_id" text NOT NULL, + "prefix" text, + "key" text NOT NULL, + "refill_interval" integer, + "refill_amount" integer, + "last_refill_at" timestamp with time zone, + "enabled" boolean DEFAULT true NOT NULL, + "rate_limit_enabled" boolean DEFAULT true NOT NULL, + "rate_limit_time_window" integer DEFAULT 86400000, + "rate_limit_max" integer DEFAULT 10, + "request_count" integer DEFAULT 0 NOT NULL, + "remaining" integer, + "last_request" timestamp with time zone, + "expires_at" timestamp with time zone, + "permissions" text, + "metadata" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "passkey" ( + "id" text PRIMARY KEY NOT NULL, + "name" text, + "public_key" text NOT NULL, + "user_id" text NOT NULL, + "credential_id" text NOT NULL, + "counter" integer NOT NULL, + "device_type" text NOT NULL, + "backed_up" boolean NOT NULL, + "transports" text, + "aaguid" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scim_connection_binding" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "connection_key" text NOT NULL, + "provisioning_domain_id" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "decommissioned_at" timestamp with time zone, + "decommission_status" text DEFAULT 'active' NOT NULL, + "decommission_cursor_user_id" text, + "decommission_reconciled_user_count" integer DEFAULT 0 NOT NULL, + "decommission_batch_count" integer DEFAULT 0 NOT NULL, + "decommission_revision" integer DEFAULT 0 NOT NULL, + "decommission_completed_at" timestamp with time zone, + "decommission_lease_id" text, + "decommission_lease_expires_at" timestamp with time zone, + CONSTRAINT "scim_connection_binding_connection_key_unique" UNIQUE("connection_key") +); +--> statement-breakpoint +CREATE TABLE "scim_group" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "provisioning_domain_id" text NOT NULL, + "revision" integer DEFAULT 0 NOT NULL, + "display_name" text NOT NULL, + "display_name_key" text NOT NULL, + "external_id" text, + "external_id_key" text, + "order_key" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "scim_group_display_name_key_unique" UNIQUE("display_name_key"), + CONSTRAINT "scim_group_external_id_key_unique" UNIQUE("external_id_key"), + CONSTRAINT "scim_group_order_key_unique" UNIQUE("order_key") +); +--> statement-breakpoint +CREATE TABLE "scim_group_member" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "group_id" text NOT NULL, + "scim_user_id" text NOT NULL, + "membership_key" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + CONSTRAINT "scim_group_member_membership_key_unique" UNIQUE("membership_key") +); +--> statement-breakpoint +CREATE TABLE "scim_identity_tombstone" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "provisioning_domain_id" text NOT NULL, + "external_id" text NOT NULL, + "external_id_key" text NOT NULL, + "user_id" text NOT NULL, + "profile" text NOT NULL, + "deleted_at" timestamp with time zone NOT NULL, + CONSTRAINT "scim_identity_tombstone_external_id_key_unique" UNIQUE("external_id_key") +); +--> statement-breakpoint +CREATE TABLE "scim_managed_connection" ( + "id" text PRIMARY KEY NOT NULL, + "creation_request_id" text NOT NULL, + "connection_id" text NOT NULL, + "provisioning_domain_id" text NOT NULL, + "status" text NOT NULL, + "revision" integer NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "created_by" text NOT NULL, + "decommission_started_at" timestamp with time zone, + "decommission_started_by" text, + "decommissioned_at" timestamp with time zone, + "decommissioned_by" text, + CONSTRAINT "scim_managed_connection_creation_request_id_unique" UNIQUE("creation_request_id"), + CONSTRAINT "scim_managed_connection_connection_id_unique" UNIQUE("connection_id") +); +--> statement-breakpoint +CREATE TABLE "scim_managed_connection_event" ( + "id" text PRIMARY KEY NOT NULL, + "connection_record_id" text NOT NULL, + "event_key" text NOT NULL, + "sequence" integer NOT NULL, + "type" text NOT NULL, + "actor_id" text NOT NULL, + "credential_id" text, + "created_at" timestamp with time zone NOT NULL, + CONSTRAINT "scim_managed_connection_event_event_key_unique" UNIQUE("event_key") +); +--> statement-breakpoint +CREATE TABLE "scim_managed_credential" ( + "id" text PRIMARY KEY NOT NULL, + "connection_record_id" text NOT NULL, + "credential_id" text NOT NULL, + "token_digest" text NOT NULL, + "hash_version" text NOT NULL, + "active_slot_key" text NOT NULL, + "status" text NOT NULL, + "serialized_scopes" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "created_by" text NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "revoked_by" text, + "decommissioned_at" timestamp with time zone, + CONSTRAINT "scim_managed_credential_credential_id_unique" UNIQUE("credential_id"), + CONSTRAINT "scim_managed_credential_active_slot_key_unique" UNIQUE("active_slot_key") +); +--> statement-breakpoint +CREATE TABLE "scim_projection_grant" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "provisioning_domain_id" text NOT NULL, + "scim_user_id" text NOT NULL, + "user_id" text NOT NULL, + "source_kind" text NOT NULL, + "source_id" text NOT NULL, + "source_value" text, + "role" text NOT NULL, + "grant_key" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "scim_projection_grant_grant_key_unique" UNIQUE("grant_key") +); +--> statement-breakpoint +CREATE TABLE "scim_subject" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "profile_source_id" text, + "revision" integer NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "scim_subject_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "scim_user" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "provisioning_domain_id" text NOT NULL, + "user_id" text NOT NULL, + "connection_user_key" text NOT NULL, + "user_name" text NOT NULL, + "user_name_key" text NOT NULL, + "primary_email" text NOT NULL, + "work_email_value_index" text NOT NULL, + "email_value_index" text NOT NULL, + "display_name" text NOT NULL, + "formatted_name" text NOT NULL, + "given_name" text, + "family_name" text, + "serialized_emails" text NOT NULL, + "serialized_attributes" text, + "external_id" text, + "external_id_key" text, + "active" boolean NOT NULL, + "order_key" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "scim_user_connection_user_key_unique" UNIQUE("connection_user_key"), + CONSTRAINT "scim_user_user_name_key_unique" UNIQUE("user_name_key"), + CONSTRAINT "scim_user_external_id_key_unique" UNIQUE("external_id_key"), + CONSTRAINT "scim_user_order_key_unique" UNIQUE("order_key") +); +--> statement-breakpoint +CREATE TABLE "sso_provider" ( + "id" text PRIMARY KEY NOT NULL, + "issuer" text NOT NULL, + "oidc_config" text, + "saml_config" text, + "user_id" text, + "provider_id" text NOT NULL, + "organization_id" text, + "domain" text NOT NULL, + "domain_verified" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "two_factor" ( + "id" text PRIMARY KEY NOT NULL, + "secret" text NOT NULL, + "backup_codes" text NOT NULL, + "user_id" text NOT NULL, + "verified" boolean DEFAULT true NOT NULL, + "failed_verification_count" integer DEFAULT 0 NOT NULL, + "locked_until" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "two_factor_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group_member" ADD CONSTRAINT "scim_group_member_group_id_scim_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."scim_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group_member" ADD CONSTRAINT "scim_group_member_scim_user_id_scim_user_id_fk" FOREIGN KEY ("scim_user_id") REFERENCES "public"."scim_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_identity_tombstone" ADD CONSTRAINT "scim_identity_tombstone_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_managed_connection_event" ADD CONSTRAINT "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk" FOREIGN KEY ("connection_record_id") REFERENCES "public"."scim_managed_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_managed_credential" ADD CONSTRAINT "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk" FOREIGN KEY ("connection_record_id") REFERENCES "public"."scim_managed_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_projection_grant" ADD CONSTRAINT "scim_projection_grant_scim_user_id_scim_user_id_fk" FOREIGN KEY ("scim_user_id") REFERENCES "public"."scim_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_projection_grant" ADD CONSTRAINT "scim_projection_grant_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_subject" ADD CONSTRAINT "scim_subject_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_user" ADD CONSTRAINT "scim_user_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "api_key_config_id_idx" ON "api_key" USING btree ("config_id");--> statement-breakpoint +CREATE INDEX "api_key_reference_id_idx" ON "api_key" USING btree ("reference_id");--> statement-breakpoint +CREATE UNIQUE INDEX "api_key_key_unique" ON "api_key" USING btree ("key");--> statement-breakpoint +CREATE INDEX "passkey_user_id_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "passkey_credential_id_unique" ON "passkey" USING btree ("credential_id");--> statement-breakpoint +CREATE INDEX "scim_connection_binding_connection_id_idx" ON "scim_connection_binding" USING btree ("connection_id");--> statement-breakpoint +CREATE INDEX "scim_group_connection_id_idx" ON "scim_group" USING btree ("connection_id");--> statement-breakpoint +CREATE INDEX "scim_group_provisioning_domain_id_idx" ON "scim_group" USING btree ("provisioning_domain_id");--> statement-breakpoint +CREATE INDEX "scim_group_member_connection_id_idx" ON "scim_group_member" USING btree ("connection_id");--> statement-breakpoint +CREATE INDEX "scim_group_member_group_id_idx" ON "scim_group_member" USING btree ("group_id");--> statement-breakpoint +CREATE INDEX "scim_group_member_scim_user_id_idx" ON "scim_group_member" USING btree ("scim_user_id");--> statement-breakpoint +CREATE INDEX "scim_identity_tombstone_connection_id_idx" ON "scim_identity_tombstone" USING btree ("connection_id");--> statement-breakpoint +CREATE INDEX "scim_identity_tombstone_provisioning_domain_id_idx" ON "scim_identity_tombstone" USING btree ("provisioning_domain_id");--> statement-breakpoint +CREATE INDEX "scim_identity_tombstone_user_id_idx" ON "scim_identity_tombstone" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "scim_managed_connection_provisioning_domain_id_idx" ON "scim_managed_connection" USING btree ("provisioning_domain_id");--> statement-breakpoint +CREATE INDEX "scim_managed_connection_event_connection_record_id_idx" ON "scim_managed_connection_event" USING btree ("connection_record_id");--> statement-breakpoint +CREATE INDEX "scim_managed_credential_connection_record_id_idx" ON "scim_managed_credential" USING btree ("connection_record_id");--> statement-breakpoint +CREATE INDEX "scim_projection_grant_connection_id_idx" ON "scim_projection_grant" USING btree ("connection_id");--> statement-breakpoint +CREATE INDEX "scim_projection_grant_provisioning_domain_id_idx" ON "scim_projection_grant" USING btree ("provisioning_domain_id");--> statement-breakpoint +CREATE INDEX "scim_projection_grant_scim_user_id_idx" ON "scim_projection_grant" USING btree ("scim_user_id");--> statement-breakpoint +CREATE INDEX "scim_projection_grant_user_id_idx" ON "scim_projection_grant" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "scim_subject_profile_source_id_idx" ON "scim_subject" USING btree ("profile_source_id");--> statement-breakpoint +CREATE INDEX "scim_user_connection_id_idx" ON "scim_user" USING btree ("connection_id");--> statement-breakpoint +CREATE INDEX "scim_user_provisioning_domain_id_idx" ON "scim_user" USING btree ("provisioning_domain_id");--> statement-breakpoint +CREATE INDEX "scim_user_user_id_idx" ON "scim_user" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "sso_provider_provider_id_unique" ON "sso_provider" USING btree ("provider_id");--> statement-breakpoint +CREATE INDEX "sso_provider_organization_id_idx" ON "sso_provider" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "sso_provider_domain_idx" ON "sso_provider" USING btree ("domain");--> statement-breakpoint +CREATE INDEX "two_factor_secret_idx" ON "two_factor" USING btree ("secret");--> statement-breakpoint +CREATE INDEX "two_factor_user_id_idx" ON "two_factor" USING btree ("user_id");--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_impersonated_by_user_id_fk" FOREIGN KEY ("impersonated_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "invite_pre_created_user_id_idx" ON "invite" USING btree ("pre_created_user_id"); \ No newline at end of file diff --git a/packages/database/drizzle/0006_normal_felicia_hardy.sql b/packages/database/drizzle/0006_normal_felicia_hardy.sql new file mode 100644 index 00000000..8662d7fd --- /dev/null +++ b/packages/database/drizzle/0006_normal_felicia_hardy.sql @@ -0,0 +1,18 @@ +CREATE TABLE "deployment_setting" ( + "id" text PRIMARY KEY NOT NULL, + "document" jsonb NOT NULL, + "updated_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "deployment_setting_singleton" CHECK ("deployment_setting"."id" = 'deployment') +); +--> statement-breakpoint +CREATE TABLE "organization_setting" ( + "organization_id" text PRIMARY KEY NOT NULL, + "document" jsonb NOT NULL, + "updated_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "organization_setting" ADD CONSTRAINT "organization_setting_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/database/drizzle/meta/0005_snapshot.json b/packages/database/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..58411379 --- /dev/null +++ b/packages/database/drizzle/meta/0005_snapshot.json @@ -0,0 +1,3257 @@ +{ + "id": "0a9ebef7-8655-4e0a-95db-724cf281105f", + "prevId": "1ec210ca-3f46-4e8f-bdf8-c799bd3a0d55", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_config_id_idx": { + "name": "api_key_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_reference_id_idx": { + "name": "api_key_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_unique": { + "name": "api_key_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_code_device_code_unique": { + "name": "device_code_device_code_unique", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_code_unique": { + "name": "device_code_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_id_idx": { + "name": "device_code_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_expires_at_idx": { + "name": "device_code_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'app'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_role": { + "name": "organization_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preset_seat_limit": { + "name": "preset_seat_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pre_created_user_id": { + "name": "pre_created_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inviter_name": { + "name": "inviter_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_email": { + "name": "inviter_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_token_hash_unique": { + "name": "invite_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_email_idx": { + "name": "invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_status_idx": { + "name": "invite_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_organization_id_idx": { + "name": "invite_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_pre_created_user_id_idx": { + "name": "invite_pre_created_user_id_idx", + "columns": [ + { + "expression": "pre_created_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_created_by_user_id_idx": { + "name": "invite_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_expires_at_idx": { + "name": "invite_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_use": { + "name": "invite_use", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invite_id": { + "name": "invite_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitee_email": { + "name": "invitee_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_use_invite_id_idx": { + "name": "invite_use_invite_id_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_use_invite_id_invite_id_fk": { + "name": "invite_use_invite_id_invite_id_fk", + "tableFrom": "invite_use", + "tableTo": "invite", + "columnsFrom": ["invite_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_organization_user_unique": { + "name": "member_organization_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seat_limit": { + "name": "seat_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_user_id_idx": { + "name": "passkey_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository": { + "name": "repository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_path": { + "name": "checkout_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'observe'" + }, + "poll_enabled": { + "name": "poll_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repository_checkout_path_unique": { + "name": "repository_checkout_path_unique", + "columns": [ + { + "expression": "checkout_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_provider_owner_name_unique": { + "name": "repository_provider_owner_name_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_poll_enabled_idx": { + "name": "repository_poll_enabled_idx", + "columns": [ + { + "expression": "poll_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection_binding": { + "name": "scim_connection_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_binding_connection_id_idx": { + "name": "scim_connection_binding_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_binding_connection_key_unique": { + "name": "scim_connection_binding_connection_key_unique", + "nullsNotDistinct": false, + "columns": ["connection_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_connection_id_idx": { + "name": "scim_group_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_provisioning_domain_id_idx": { + "name": "scim_group_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_display_name_key_unique": { + "name": "scim_group_display_name_key_unique", + "nullsNotDistinct": false, + "columns": ["display_name_key"] + }, + "scim_group_external_id_key_unique": { + "name": "scim_group_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + }, + "scim_group_order_key_unique": { + "name": "scim_group_order_key_unique", + "nullsNotDistinct": false, + "columns": ["order_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_member_connection_id_idx": { + "name": "scim_group_member_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_group_id_idx": { + "name": "scim_group_member_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_id_idx": { + "name": "scim_group_member_scim_user_id_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_member_membership_key_unique": { + "name": "scim_group_member_membership_key_unique", + "nullsNotDistinct": false, + "columns": ["membership_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstone": { + "name": "scim_identity_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstone_connection_id_idx": { + "name": "scim_identity_tombstone_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstone_provisioning_domain_id_idx": { + "name": "scim_identity_tombstone_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstone_user_id_idx": { + "name": "scim_identity_tombstone_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstone_user_id_user_id_fk": { + "name": "scim_identity_tombstone_user_id_user_id_fk", + "tableFrom": "scim_identity_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstone_external_id_key_unique": { + "name": "scim_identity_tombstone_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection": { + "name": "scim_managed_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creation_request_id": { + "name": "creation_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decommission_started_at": { + "name": "decommission_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_started_by": { + "name": "decommission_started_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommissioned_by": { + "name": "decommissioned_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_managed_connection_provisioning_domain_id_idx": { + "name": "scim_managed_connection_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_creation_request_id_unique": { + "name": "scim_managed_connection_creation_request_id_unique", + "nullsNotDistinct": false, + "columns": ["creation_request_id"] + }, + "scim_managed_connection_connection_id_unique": { + "name": "scim_managed_connection_connection_id_unique", + "nullsNotDistinct": false, + "columns": ["connection_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection_event": { + "name": "scim_managed_connection_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_managed_connection_event_connection_record_id_idx": { + "name": "scim_managed_connection_event_connection_record_id_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_connection_event", + "tableTo": "scim_managed_connection", + "columnsFrom": ["connection_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_event_event_key_unique": { + "name": "scim_managed_connection_event_event_key_unique", + "nullsNotDistinct": false, + "columns": ["event_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_credential": { + "name": "scim_managed_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_slot_key": { + "name": "active_slot_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_scopes": { + "name": "serialized_scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_managed_credential_connection_record_id_idx": { + "name": "scim_managed_credential_connection_record_id_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_credential", + "tableTo": "scim_managed_connection", + "columnsFrom": ["connection_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_credential_credential_id_unique": { + "name": "scim_managed_credential_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + }, + "scim_managed_credential_active_slot_key_unique": { + "name": "scim_managed_credential_active_slot_key_unique", + "nullsNotDistinct": false, + "columns": ["active_slot_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grant_connection_id_idx": { + "name": "scim_projection_grant_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_provisioning_domain_id_idx": { + "name": "scim_projection_grant_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_scim_user_id_idx": { + "name": "scim_projection_grant_scim_user_id_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_user_id_idx": { + "name": "scim_projection_grant_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_user_id_user_id_fk": { + "name": "scim_projection_grant_user_id_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grant_grant_key_unique": { + "name": "scim_projection_grant_grant_key_unique", + "nullsNotDistinct": false, + "columns": ["grant_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subject": { + "name": "scim_subject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subject_profile_source_id_idx": { + "name": "scim_subject_profile_source_id_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subject_user_id_user_id_fk": { + "name": "scim_subject_user_id_user_id_fk", + "tableFrom": "scim_subject", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subject_user_id_unique": { + "name": "scim_subject_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_user_connection_id_idx": { + "name": "scim_user_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_provisioning_domain_id_idx": { + "name": "scim_user_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_id_idx": { + "name": "scim_user_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_user_connection_user_key_unique": { + "name": "scim_user_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": ["connection_user_key"] + }, + "scim_user_user_name_key_unique": { + "name": "scim_user_user_name_key_unique", + "nullsNotDistinct": false, + "columns": ["user_name_key"] + }, + "scim_user_external_id_key_unique": { + "name": "scim_user_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + }, + "scim_user_order_key_unique": { + "name": "scim_user_order_key_unique", + "nullsNotDistinct": false, + "columns": ["order_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_impersonated_by_user_id_fk": { + "name": "session_impersonated_by_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["impersonated_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "two_factor_secret_idx": { + "name": "two_factor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "two_factor_user_id_idx": { + "name": "two_factor_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_method": { + "name": "last_login_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/drizzle/meta/0006_snapshot.json b/packages/database/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000..2d94f467 --- /dev/null +++ b/packages/database/drizzle/meta/0006_snapshot.json @@ -0,0 +1,3362 @@ +{ + "id": "4c305f87-c67c-412c-ae9e-acfdbc7b25c0", + "prevId": "0a9ebef7-8655-4e0a-95db-724cf281105f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_config_id_idx": { + "name": "api_key_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_reference_id_idx": { + "name": "api_key_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_unique": { + "name": "api_key_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_setting": { + "name": "deployment_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deployment_setting_singleton": { + "name": "deployment_setting_singleton", + "value": "\"deployment_setting\".\"id\" = 'deployment'" + } + }, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_code_device_code_unique": { + "name": "device_code_device_code_unique", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_code_unique": { + "name": "device_code_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_id_idx": { + "name": "device_code_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_expires_at_idx": { + "name": "device_code_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'app'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_role": { + "name": "organization_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preset_seat_limit": { + "name": "preset_seat_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pre_created_user_id": { + "name": "pre_created_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inviter_name": { + "name": "inviter_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_email": { + "name": "inviter_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_token_hash_unique": { + "name": "invite_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_email_idx": { + "name": "invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_status_idx": { + "name": "invite_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_organization_id_idx": { + "name": "invite_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_pre_created_user_id_idx": { + "name": "invite_pre_created_user_id_idx", + "columns": [ + { + "expression": "pre_created_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_created_by_user_id_idx": { + "name": "invite_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_expires_at_idx": { + "name": "invite_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_use": { + "name": "invite_use", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invite_id": { + "name": "invite_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitee_email": { + "name": "invitee_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_use_invite_id_idx": { + "name": "invite_use_invite_id_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_use_invite_id_invite_id_fk": { + "name": "invite_use_invite_id_invite_id_fk", + "tableFrom": "invite_use", + "tableTo": "invite", + "columnsFrom": ["invite_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_organization_user_unique": { + "name": "member_organization_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seat_limit": { + "name": "seat_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_setting": { + "name": "organization_setting", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_setting_organization_id_organization_id_fk": { + "name": "organization_setting_organization_id_organization_id_fk", + "tableFrom": "organization_setting", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_user_id_idx": { + "name": "passkey_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository": { + "name": "repository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_path": { + "name": "checkout_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'observe'" + }, + "poll_enabled": { + "name": "poll_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repository_checkout_path_unique": { + "name": "repository_checkout_path_unique", + "columns": [ + { + "expression": "checkout_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_provider_owner_name_unique": { + "name": "repository_provider_owner_name_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_poll_enabled_idx": { + "name": "repository_poll_enabled_idx", + "columns": [ + { + "expression": "poll_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection_binding": { + "name": "scim_connection_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_binding_connection_id_idx": { + "name": "scim_connection_binding_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_binding_connection_key_unique": { + "name": "scim_connection_binding_connection_key_unique", + "nullsNotDistinct": false, + "columns": ["connection_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_connection_id_idx": { + "name": "scim_group_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_provisioning_domain_id_idx": { + "name": "scim_group_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_display_name_key_unique": { + "name": "scim_group_display_name_key_unique", + "nullsNotDistinct": false, + "columns": ["display_name_key"] + }, + "scim_group_external_id_key_unique": { + "name": "scim_group_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + }, + "scim_group_order_key_unique": { + "name": "scim_group_order_key_unique", + "nullsNotDistinct": false, + "columns": ["order_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_member_connection_id_idx": { + "name": "scim_group_member_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_group_id_idx": { + "name": "scim_group_member_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_id_idx": { + "name": "scim_group_member_scim_user_id_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_member_membership_key_unique": { + "name": "scim_group_member_membership_key_unique", + "nullsNotDistinct": false, + "columns": ["membership_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstone": { + "name": "scim_identity_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstone_connection_id_idx": { + "name": "scim_identity_tombstone_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstone_provisioning_domain_id_idx": { + "name": "scim_identity_tombstone_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstone_user_id_idx": { + "name": "scim_identity_tombstone_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstone_user_id_user_id_fk": { + "name": "scim_identity_tombstone_user_id_user_id_fk", + "tableFrom": "scim_identity_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstone_external_id_key_unique": { + "name": "scim_identity_tombstone_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection": { + "name": "scim_managed_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creation_request_id": { + "name": "creation_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decommission_started_at": { + "name": "decommission_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_started_by": { + "name": "decommission_started_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommissioned_by": { + "name": "decommissioned_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_managed_connection_provisioning_domain_id_idx": { + "name": "scim_managed_connection_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_creation_request_id_unique": { + "name": "scim_managed_connection_creation_request_id_unique", + "nullsNotDistinct": false, + "columns": ["creation_request_id"] + }, + "scim_managed_connection_connection_id_unique": { + "name": "scim_managed_connection_connection_id_unique", + "nullsNotDistinct": false, + "columns": ["connection_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection_event": { + "name": "scim_managed_connection_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_managed_connection_event_connection_record_id_idx": { + "name": "scim_managed_connection_event_connection_record_id_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_connection_event", + "tableTo": "scim_managed_connection", + "columnsFrom": ["connection_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_event_event_key_unique": { + "name": "scim_managed_connection_event_event_key_unique", + "nullsNotDistinct": false, + "columns": ["event_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_credential": { + "name": "scim_managed_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_slot_key": { + "name": "active_slot_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_scopes": { + "name": "serialized_scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_managed_credential_connection_record_id_idx": { + "name": "scim_managed_credential_connection_record_id_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_credential", + "tableTo": "scim_managed_connection", + "columnsFrom": ["connection_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_credential_credential_id_unique": { + "name": "scim_managed_credential_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + }, + "scim_managed_credential_active_slot_key_unique": { + "name": "scim_managed_credential_active_slot_key_unique", + "nullsNotDistinct": false, + "columns": ["active_slot_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grant_connection_id_idx": { + "name": "scim_projection_grant_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_provisioning_domain_id_idx": { + "name": "scim_projection_grant_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_scim_user_id_idx": { + "name": "scim_projection_grant_scim_user_id_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_user_id_idx": { + "name": "scim_projection_grant_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_user_id_user_id_fk": { + "name": "scim_projection_grant_user_id_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grant_grant_key_unique": { + "name": "scim_projection_grant_grant_key_unique", + "nullsNotDistinct": false, + "columns": ["grant_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subject": { + "name": "scim_subject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subject_profile_source_id_idx": { + "name": "scim_subject_profile_source_id_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subject_user_id_user_id_fk": { + "name": "scim_subject_user_id_user_id_fk", + "tableFrom": "scim_subject", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subject_user_id_unique": { + "name": "scim_subject_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_user_connection_id_idx": { + "name": "scim_user_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_provisioning_domain_id_idx": { + "name": "scim_user_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_id_idx": { + "name": "scim_user_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_user_connection_user_key_unique": { + "name": "scim_user_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": ["connection_user_key"] + }, + "scim_user_user_name_key_unique": { + "name": "scim_user_user_name_key_unique", + "nullsNotDistinct": false, + "columns": ["user_name_key"] + }, + "scim_user_external_id_key_unique": { + "name": "scim_user_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + }, + "scim_user_order_key_unique": { + "name": "scim_user_order_key_unique", + "nullsNotDistinct": false, + "columns": ["order_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_impersonated_by_user_id_fk": { + "name": "session_impersonated_by_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["impersonated_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "two_factor_secret_idx": { + "name": "two_factor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "two_factor_user_id_idx": { + "name": "two_factor_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_method": { + "name": "last_login_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 4708d4d7..49be3024 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1788683562349, "tag": "0004_absurd_morg", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1792314139137, + "tag": "0005_tricky_dexter_bennett", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1792341556244, + "tag": "0006_normal_felicia_hardy", + "breakpoints": true } ] } diff --git a/packages/database/src/client.test.ts b/packages/database/src/client.test.ts index 118c6bbe..66d6cb6f 100644 --- a/packages/database/src/client.test.ts +++ b/packages/database/src/client.test.ts @@ -88,14 +88,28 @@ describe('schema', () => { it('exposes every model the Better Auth adapter resolves', () => { expect(Object.keys(schema).toSorted()).toEqual([ 'account', + 'apikey', 'deviceCode', 'invitation', 'invite', 'inviteUse', 'member', 'organization', + 'passkey', 'repository', + 'scimConnectionBinding', + 'scimGroup', + 'scimGroupMember', + 'scimIdentityTombstone', + 'scimManagedConnection', + 'scimManagedConnectionEvent', + 'scimManagedCredential', + 'scimProjectionGrant', + 'scimSubject', + 'scimUser', 'session', + 'ssoProvider', + 'twoFactor', 'user', 'verification', ]); diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index add915ec..573e4bab 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -18,18 +18,42 @@ export { type RepositoryRecord, type WatchedRepositoryRecord, } from './repositories.js'; +export { + organizationMemberRole, + readDeploymentSettings, + readOrganizationSettings, + saveDeploymentSettings, + saveOrganizationSettings, + type StoredSettings, +} from './settings.js'; export { account, + apiKey, + deploymentSetting, deviceCode, invitation, invite, inviteUse, member, organization, + organizationSetting, + passkey, repository, schema, + scimConnectionBinding, + scimGroup, + scimGroupMember, + scimIdentityTombstone, + scimManagedConnection, + scimManagedConnectionEvent, + scimManagedCredential, + scimProjectionGrant, + scimSubject, + scimUser, session, + ssoProvider, timestampColumns, + twoFactor, user, verification, type Schema, diff --git a/packages/database/src/schema/api-key.ts b/packages/database/src/schema/api-key.ts new file mode 100644 index 00000000..d75416c0 --- /dev/null +++ b/packages/database/src/schema/api-key.ts @@ -0,0 +1,67 @@ +import { + boolean, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; + +import { timestampColumns } from './columns.js'; + +/** + * The table `@better-auth/api-key` owns: machine credentials for CI and the GitHub Action. + * + * Registered under the model name `apikey`, which is what the plugin resolves, while the SQL table + * keeps the repository's snake-case convention. + * + * `reference_id` deliberately carries no foreign key. The plugin stores either a user id or an + * organization id there depending on the key configuration (`config_id`) that minted the row, so a + * constraint on one of the two tables would reject every key of the other kind. + */ +export const apiKey = pgTable( + 'api_key', + { + id: text('id').primaryKey(), + /** Which key configuration minted the row, and therefore what `referenceId` points at. */ + configId: text('config_id').notNull().default('default'), + name: text('name'), + /** The first few characters of the key, prefix included, so a UI can tell keys apart. */ + start: text('start'), + /** The organization (or user) that owns the key. */ + referenceId: text('reference_id').notNull(), + /** The key's plaintext prefix (`cz_`). Not secret: it identifies the issuer, not the key. */ + prefix: text('prefix'), + /** + * A hash of the key, never the key itself. The plaintext is returned once, at creation, so a + * dump of this table cannot be replayed against the API. + */ + key: text('key').notNull(), + refillInterval: integer('refill_interval'), + refillAmount: integer('refill_amount'), + lastRefillAt: timestamp('last_refill_at', { withTimezone: true }), + enabled: boolean('enabled').notNull().default(true), + rateLimitEnabled: boolean('rate_limit_enabled').notNull().default(true), + /** Rate-limit window in milliseconds. */ + rateLimitTimeWindow: integer('rate_limit_time_window').default(86_400_000), + rateLimitMax: integer('rate_limit_max').default(10), + requestCount: integer('request_count').notNull().default(0), + /** Uses left before the key stops verifying; null means unlimited. */ + remaining: integer('remaining'), + lastRequest: timestamp('last_request', { withTimezone: true }), + /** Null means the key never expires; the policy in `packages/auth` always sets one. */ + expiresAt: timestamp('expires_at', { withTimezone: true }), + /** JSON-encoded statements the key is limited to, in the access-control vocabulary. */ + permissions: text('permissions'), + /** JSON-encoded metadata, when the configuration enables it. */ + metadata: text('metadata'), + ...timestampColumns, + }, + (table) => [ + index('api_key_config_id_idx').on(table.configId), + index('api_key_reference_id_idx').on(table.referenceId), + // Verification looks a presented key up by its hash alone, so two rows must never share one. + uniqueIndex('api_key_key_unique').on(table.key), + ], +); diff --git a/packages/database/src/schema/auth.ts b/packages/database/src/schema/auth.ts index a249a679..557cc74e 100644 --- a/packages/database/src/schema/auth.ts +++ b/packages/database/src/schema/auth.ts @@ -24,7 +24,8 @@ export const user = pgTable( * private invitation pre-creates an inert row before redemption decides what it grants. * * Distinct from `member.role`, which scopes a role to a single organization. This column is - * app-wide. + * app-wide, and Better Auth's admin plugin is its authority: it decides which values grant + * administration, and Better Enrollment merges invitation grants into the same string. */ role: text('role'), /** @@ -36,6 +37,21 @@ export const user = pgTable( * because every account predates its first sign-in under this plugin. */ lastLoginMethod: text('last_login_method'), + /** + * Set by Better Auth's two-factor plugin once the first authenticator code has verified a + * pending enrollment, never at enrollment itself: an account that started setup and abandoned + * it must still sign in with its password alone. + */ + twoFactorEnabled: boolean('two_factor_enabled').notNull().default(false), + /** + * Admin plugin: whether an administrator has banned the account. A banned account's sessions + * are revoked and every new sign-in is refused until the ban is lifted or `banExpires` passes. + */ + banned: boolean('banned').notNull().default(false), + /** Admin plugin: the reason the ban was imposed with, shown to the administrators who list it. */ + banReason: text('ban_reason'), + /** Admin plugin: when a temporary ban lapses. Null means the ban, if any, does not expire. */ + banExpires: timestamp('ban_expires', { withTimezone: true }), ...timestampColumns, }, (table) => [uniqueIndex('user_email_unique').on(table.email)], @@ -56,6 +72,13 @@ export const session = pgTable( // Which organization the session is currently acting in. Written by the organization plugin // when the user switches context; null means no organization is selected. activeOrganizationId: text('active_organization_id'), + /** + * Admin plugin: the administrator acting through this session when it was minted by + * impersonation, null for every session the account holder signed in to themselves. It is what + * makes an impersonated action attributable, so it cascades with the administrator's account + * rather than outliving it as a session nobody answers for. + */ + impersonatedBy: text('impersonated_by').references(() => user.id, { onDelete: 'cascade' }), ...timestampColumns, }, (table) => [ diff --git a/packages/database/src/schema/enrollment.ts b/packages/database/src/schema/enrollment.ts index f38552f8..562cc662 100644 --- a/packages/database/src/schema/enrollment.ts +++ b/packages/database/src/schema/enrollment.ts @@ -72,6 +72,9 @@ export const invite = pgTable( index('invite_email_idx').on(table.email), index('invite_status_idx').on(table.status), index('invite_organization_id_idx').on(table.organizationId), + // The plugin resolves a pending private invite from the inert account it pre-created, which is + // how the email lock recognises that account on every sign-in path. + index('invite_pre_created_user_id_idx').on(table.preCreatedUserId), index('invite_created_by_user_id_idx').on(table.createdByUserId), index('invite_expires_at_idx').on(table.expiresAt), ], diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 04a80590..8d123f07 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -1,15 +1,48 @@ +import { apiKey } from './api-key.js'; import { account, session, user, verification } from './auth.js'; import { deviceCode } from './device.js'; import { invite, inviteUse } from './enrollment.js'; import { invitation, member, organization } from './organization.js'; +import { passkey } from './passkey.js'; import { repository } from './repository.js'; +import { + scimConnectionBinding, + scimGroup, + scimGroupMember, + scimIdentityTombstone, + scimManagedConnection, + scimManagedConnectionEvent, + scimManagedCredential, + scimProjectionGrant, + scimSubject, + scimUser, +} from './scim.js'; +import { ssoProvider } from './sso.js'; +import { twoFactor } from './two-factor.js'; +export { apiKey } from './api-key.js'; export { account, session, user, verification } from './auth.js'; export { timestampColumns } from './columns.js'; export { deviceCode } from './device.js'; export { invite, inviteUse } from './enrollment.js'; export { invitation, member, organization } from './organization.js'; +export { passkey } from './passkey.js'; export { repository } from './repository.js'; +export { deploymentSetting, organizationSetting } from './settings.js'; +export { + scimConnectionBinding, + scimGroup, + scimGroupMember, + scimIdentityTombstone, + scimManagedConnection, + scimManagedConnectionEvent, + scimManagedCredential, + scimProjectionGrant, + scimSubject, + scimUser, +} from './scim.js'; +export { ssoProvider } from './sso.js'; +export { twoFactor } from './two-factor.js'; /** * Every table in the store, as one object. @@ -20,7 +53,8 @@ export { repository } from './repository.js'; * * The keys are model names, not SQL table names: the adapter looks a model up by the key it is * registered under, which is why `inviteUse` is spelled in camel case here while the table it - * points at is `invite_use`. + * points at is `invite_use`, and why the API-key table is registered as `apikey`: that is the + * plugin's model name, whatever the SQL table is called. */ export const schema = { user, @@ -33,6 +67,20 @@ export const schema = { invite, inviteUse, deviceCode, + twoFactor, + passkey, + apikey: apiKey, + ssoProvider, + scimManagedConnection, + scimManagedCredential, + scimManagedConnectionEvent, + scimConnectionBinding, + scimIdentityTombstone, + scimSubject, + scimUser, + scimProjectionGrant, + scimGroup, + scimGroupMember, repository, }; diff --git a/packages/database/src/schema/passkey.ts b/packages/database/src/schema/passkey.ts new file mode 100644 index 00000000..abcc4e8a --- /dev/null +++ b/packages/database/src/schema/passkey.ts @@ -0,0 +1,51 @@ +import { + boolean, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; + +import { user } from './auth.js'; + +/** + * The table `@better-auth/passkey` owns: one row per WebAuthn credential an account registered. + * + * Carries `createdAt` alone, like `invite_use`: the plugin declares no `updatedAt`, and a column the + * adapter does not know about would stay at its default forever. The counter it does update is the + * credential's own signature counter, not a row timestamp. + */ +export const passkey = pgTable( + 'passkey', + { + id: text('id').primaryKey(), + /** A label the account holder chose, so they can tell two security keys apart. */ + name: text('name'), + /** The credential's public key. Public by construction: the private half never leaves the authenticator. */ + publicKey: text('public_key').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + /** + * The credential id the authenticator presents on every assertion. Unique, because a sign-in is + * resolved by it alone: two rows sharing one would let one credential answer for two accounts. + */ + credentialID: text('credential_id').notNull(), + /** The authenticator's signature counter, which detects a cloned credential when it goes backwards. */ + counter: integer('counter').notNull(), + /** `singleDevice` or `multiDevice` (a synced passkey). */ + deviceType: text('device_type').notNull(), + backedUp: boolean('backed_up').notNull(), + /** Comma-separated transports (`usb`, `nfc`, `ble`, `internal`, `hybrid`) the browser reported. */ + transports: text('transports'), + /** The authenticator model's identifier, when it disclosed one. */ + aaguid: text('aaguid'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('passkey_user_id_idx').on(table.userId), + uniqueIndex('passkey_credential_id_unique').on(table.credentialID), + ], +); diff --git a/packages/database/src/schema/plugins.test.ts b/packages/database/src/schema/plugins.test.ts new file mode 100644 index 00000000..134edf33 --- /dev/null +++ b/packages/database/src/schema/plugins.test.ts @@ -0,0 +1,114 @@ +import { getTableConfig } from 'drizzle-orm/pg-core'; +import type { PgTable } from 'drizzle-orm/pg-core'; +import { describe, expect, it } from 'vitest'; + +import { schema } from './index.js'; + +/** The SQL column names a table declares, in declaration order. */ +function columnsOf(table: PgTable): string[] { + return getTableConfig(table).columns.map((column) => column.name); +} + +/** Whether `column` on `table` carries a foreign key that cascades from `user.id`. */ +function cascadesFromUser(table: PgTable, column: string): boolean { + return getTableConfig(table).foreignKeys.some((foreignKey) => { + const reference = foreignKey.reference(); + return ( + reference.columns.some((local) => local.name === column) && + getTableConfig(reference.foreignTable).name === 'user' && + foreignKey.onDelete === 'cascade' + ); + }); +} + +// The Better Auth Drizzle adapter maps model fields to columns by name, so a column missing here +// is not a build error: the plugin's write silently drops the field, or its read finds nothing. +// Each expectation below is the plugin's own `schema` in 1.7, cross-checked against the generator. +describe('Better Auth plugin columns', () => { + it('records two-factor enrollment and the admin ban on the user', () => { + expect(columnsOf(schema.user)).toEqual( + expect.arrayContaining(['role', 'two_factor_enabled', 'banned', 'ban_reason', 'ban_expires']), + ); + }); + + it('records who is impersonating through a session, and drops it with that administrator', () => { + expect(columnsOf(schema.session)).toContain('impersonated_by'); + expect(cascadesFromUser(schema.session, 'impersonated_by')).toBe(true); + }); + + it('stores TOTP secrets and backup codes with the plugin lockout counters', () => { + expect(columnsOf(schema.twoFactor)).toEqual([ + 'id', + 'secret', + 'backup_codes', + 'user_id', + 'verified', + 'failed_verification_count', + 'locked_until', + 'created_at', + 'updated_at', + ]); + expect(cascadesFromUser(schema.twoFactor, 'user_id')).toBe(true); + }); + + it('stores one passkey per credential id', () => { + expect(columnsOf(schema.passkey)).toEqual([ + 'id', + 'name', + 'public_key', + 'user_id', + 'credential_id', + 'counter', + 'device_type', + 'backed_up', + 'transports', + 'aaguid', + 'created_at', + ]); + const unique = getTableConfig(schema.passkey).indexes.filter((index) => index.config.unique); + expect(unique.map((index) => index.config.name)).toEqual(['passkey_credential_id_unique']); + }); + + it('stores API keys by hash under the model name the plugin resolves', () => { + expect(getTableConfig(schema.apikey).name).toBe('api_key'); + expect(columnsOf(schema.apikey)).toEqual( + expect.arrayContaining([ + 'config_id', + 'reference_id', + 'prefix', + 'start', + 'key', + 'enabled', + 'rate_limit_enabled', + 'rate_limit_time_window', + 'rate_limit_max', + 'request_count', + 'remaining', + 'expires_at', + 'permissions', + 'metadata', + ]), + ); + // Plaintext is returned once at creation; no column could hold it. + expect(columnsOf(schema.apikey)).not.toContain('plaintext'); + }); + + it('stores SSO providers with the domain they route and whether it was verified', () => { + expect(columnsOf(schema.ssoProvider)).toEqual([ + 'id', + 'issuer', + 'oidc_config', + 'saml_config', + 'user_id', + 'provider_id', + 'organization_id', + 'domain', + 'domain_verified', + ]); + }); + + it('keeps SCIM bearer credentials as digests only', () => { + expect(columnsOf(schema.scimManagedCredential)).toContain('token_digest'); + expect(columnsOf(schema.scimManagedCredential)).not.toContain('token'); + }); +}); diff --git a/packages/database/src/schema/scim.ts b/packages/database/src/schema/scim.ts new file mode 100644 index 00000000..2ac4160a --- /dev/null +++ b/packages/database/src/schema/scim.ts @@ -0,0 +1,251 @@ +import { boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; + +import { user } from './auth.js'; + +/** + * The tables `@better-auth/scim` owns, in its managed-connection configuration. + * + * Migrated on every deployment, but only written where the Enterprise `scim` entitlement registers + * the plugin. The set is fixed by the plugin rather than chosen here: the connection catalog + * (`scim_managed_*`) holds the per-organization provisioning connections and their bearer + * credentials, and the remaining tables are the directory state a provisioning client pushes in + * (users, groups, memberships, the role grants projected from them, and the tombstones that keep a + * deleted external id from being silently re-linked). + * + * Field names follow the plugin's model fields exactly; the `*_key` columns are the plugin's own + * normalized uniqueness keys and are what makes each upsert a single guarded write. No table + * carries the shared `timestampColumns`: the plugin writes its own `created_at`/`updated_at` + * where it declares them, and a column it does not know about would stay at its default forever. + */ + +export const scimManagedConnection = pgTable( + 'scim_managed_connection', + { + id: text('id').primaryKey(), + creationRequestId: text('creation_request_id').notNull().unique(), + connectionId: text('connection_id').notNull().unique(), + provisioningDomainId: text('provisioning_domain_id').notNull(), + status: text('status').notNull(), + revision: integer('revision').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + createdBy: text('created_by').notNull(), + decommissionStartedAt: timestamp('decommission_started_at', { withTimezone: true }), + decommissionStartedBy: text('decommission_started_by'), + decommissionedAt: timestamp('decommissioned_at', { withTimezone: true }), + decommissionedBy: text('decommissioned_by'), + }, + (table) => [ + index('scim_managed_connection_provisioning_domain_id_idx').on(table.provisioningDomainId), + ], +); + +export const scimManagedCredential = pgTable( + 'scim_managed_credential', + { + id: text('id').primaryKey(), + connectionRecordId: text('connection_record_id') + .notNull() + .references(() => scimManagedConnection.id, { onDelete: 'cascade' }), + credentialId: text('credential_id').notNull().unique(), + /** An HMAC of the bearer token under the deployment's SCIM credential secret, never the token. */ + tokenDigest: text('token_digest').notNull(), + hashVersion: text('hash_version').notNull(), + activeSlotKey: text('active_slot_key').notNull().unique(), + status: text('status').notNull(), + serializedScopes: text('serialized_scopes').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + createdBy: text('created_by').notNull(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + revokedBy: text('revoked_by'), + decommissionedAt: timestamp('decommissioned_at', { withTimezone: true }), + }, + (table) => [ + index('scim_managed_credential_connection_record_id_idx').on(table.connectionRecordId), + ], +); + +export const scimManagedConnectionEvent = pgTable( + 'scim_managed_connection_event', + { + id: text('id').primaryKey(), + connectionRecordId: text('connection_record_id') + .notNull() + .references(() => scimManagedConnection.id, { onDelete: 'cascade' }), + eventKey: text('event_key').notNull().unique(), + sequence: integer('sequence').notNull(), + type: text('type').notNull(), + actorId: text('actor_id').notNull(), + credentialId: text('credential_id'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + }, + (table) => [ + index('scim_managed_connection_event_connection_record_id_idx').on(table.connectionRecordId), + ], +); + +export const scimConnectionBinding = pgTable( + 'scim_connection_binding', + { + id: text('id').primaryKey(), + connectionId: text('connection_id').notNull(), + connectionKey: text('connection_key').notNull().unique(), + provisioningDomainId: text('provisioning_domain_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + decommissionedAt: timestamp('decommissioned_at', { withTimezone: true }), + decommissionStatus: text('decommission_status').notNull().default('active'), + decommissionCursorUserId: text('decommission_cursor_user_id'), + decommissionReconciledUserCount: integer('decommission_reconciled_user_count') + .notNull() + .default(0), + decommissionBatchCount: integer('decommission_batch_count').notNull().default(0), + decommissionRevision: integer('decommission_revision').notNull().default(0), + decommissionCompletedAt: timestamp('decommission_completed_at', { withTimezone: true }), + decommissionLeaseId: text('decommission_lease_id'), + decommissionLeaseExpiresAt: timestamp('decommission_lease_expires_at', { withTimezone: true }), + }, + (table) => [index('scim_connection_binding_connection_id_idx').on(table.connectionId)], +); + +export const scimIdentityTombstone = pgTable( + 'scim_identity_tombstone', + { + id: text('id').primaryKey(), + connectionId: text('connection_id').notNull(), + provisioningDomainId: text('provisioning_domain_id').notNull(), + externalId: text('external_id').notNull(), + externalIdKey: text('external_id_key').notNull().unique(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + profile: text('profile').notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }).notNull(), + }, + (table) => [ + index('scim_identity_tombstone_connection_id_idx').on(table.connectionId), + index('scim_identity_tombstone_provisioning_domain_id_idx').on(table.provisioningDomainId), + index('scim_identity_tombstone_user_id_idx').on(table.userId), + ], +); + +export const scimSubject = pgTable( + 'scim_subject', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .unique() + .references(() => user.id, { onDelete: 'cascade' }), + profileSourceId: text('profile_source_id'), + revision: integer('revision').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), + }, + (table) => [index('scim_subject_profile_source_id_idx').on(table.profileSourceId)], +); + +export const scimUser = pgTable( + 'scim_user', + { + id: text('id').primaryKey(), + connectionId: text('connection_id').notNull(), + provisioningDomainId: text('provisioning_domain_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + connectionUserKey: text('connection_user_key').notNull().unique(), + userName: text('user_name').notNull(), + userNameKey: text('user_name_key').notNull().unique(), + primaryEmail: text('primary_email').notNull(), + workEmailValueIndex: text('work_email_value_index').notNull(), + emailValueIndex: text('email_value_index').notNull(), + displayName: text('display_name').notNull(), + formattedName: text('formatted_name').notNull(), + givenName: text('given_name'), + familyName: text('family_name'), + serializedEmails: text('serialized_emails').notNull(), + serializedAttributes: text('serialized_attributes'), + externalId: text('external_id'), + externalIdKey: text('external_id_key').unique(), + active: boolean('active').notNull(), + orderKey: text('order_key').notNull().unique(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), + }, + (table) => [ + index('scim_user_connection_id_idx').on(table.connectionId), + index('scim_user_provisioning_domain_id_idx').on(table.provisioningDomainId), + index('scim_user_user_id_idx').on(table.userId), + ], +); + +export const scimProjectionGrant = pgTable( + 'scim_projection_grant', + { + id: text('id').primaryKey(), + connectionId: text('connection_id').notNull(), + provisioningDomainId: text('provisioning_domain_id').notNull(), + scimUserId: text('scim_user_id') + .notNull() + .references(() => scimUser.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + sourceKind: text('source_kind').notNull(), + sourceId: text('source_id').notNull(), + sourceValue: text('source_value'), + role: text('role').notNull(), + grantKey: text('grant_key').notNull().unique(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), + }, + (table) => [ + index('scim_projection_grant_connection_id_idx').on(table.connectionId), + index('scim_projection_grant_provisioning_domain_id_idx').on(table.provisioningDomainId), + index('scim_projection_grant_scim_user_id_idx').on(table.scimUserId), + index('scim_projection_grant_user_id_idx').on(table.userId), + ], +); + +export const scimGroup = pgTable( + 'scim_group', + { + id: text('id').primaryKey(), + connectionId: text('connection_id').notNull(), + provisioningDomainId: text('provisioning_domain_id').notNull(), + revision: integer('revision').notNull().default(0), + displayName: text('display_name').notNull(), + displayNameKey: text('display_name_key').notNull().unique(), + externalId: text('external_id'), + externalIdKey: text('external_id_key').unique(), + orderKey: text('order_key').notNull().unique(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), + }, + (table) => [ + index('scim_group_connection_id_idx').on(table.connectionId), + index('scim_group_provisioning_domain_id_idx').on(table.provisioningDomainId), + ], +); + +export const scimGroupMember = pgTable( + 'scim_group_member', + { + id: text('id').primaryKey(), + connectionId: text('connection_id').notNull(), + groupId: text('group_id') + .notNull() + .references(() => scimGroup.id, { onDelete: 'cascade' }), + scimUserId: text('scim_user_id') + .notNull() + .references(() => scimUser.id, { onDelete: 'cascade' }), + membershipKey: text('membership_key').notNull().unique(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + }, + (table) => [ + index('scim_group_member_connection_id_idx').on(table.connectionId), + index('scim_group_member_group_id_idx').on(table.groupId), + index('scim_group_member_scim_user_id_idx').on(table.scimUserId), + ], +); diff --git a/packages/database/src/schema/settings.ts b/packages/database/src/schema/settings.ts new file mode 100644 index 00000000..b409fa28 --- /dev/null +++ b/packages/database/src/schema/settings.ts @@ -0,0 +1,44 @@ +import { sql } from 'drizzle-orm'; +import { check, jsonb, pgTable, text } from 'drizzle-orm/pg-core'; + +import { timestampColumns } from './columns.js'; +import { organization } from './organization.js'; + +/** + * The deployment's settings, when the dashboard is their source. + * + * One row at most: a deployment has one set of settings, so the primary key is pinned to a single + * value and a second row cannot exist to disagree with the first. The document is the same YAML + * shape the deployment file holds, stored as JSON; this package keeps it opaque, and + * `@code-zero/config` validates it on every write and every read, so a row edited by hand is + * refused rather than trusted. + * + * Deliberately absent from the `schema` object Better Auth resolves models against: nothing about + * authentication reads or writes it. + */ +export const deploymentSetting = pgTable( + 'deployment_setting', + { + id: text('id').primaryKey(), + document: jsonb('document').notNull(), + /** Who saved this version, as the audit trail names them. */ + updatedBy: text('updated_by').notNull(), + ...timestampColumns, + }, + (table) => [check('deployment_setting_singleton', sql`${table.id} = 'deployment'`)], +); + +/** + * The stricter limits an organization chose for its own work on the managed cloud. + * + * Keyed by the organization and removed with it. Only ever lower than the plan when written; the + * reader still takes the minimum, because the plan can be lowered after a row was saved. + */ +export const organizationSetting = pgTable('organization_setting', { + organizationId: text('organization_id') + .primaryKey() + .references(() => organization.id, { onDelete: 'cascade' }), + document: jsonb('document').notNull(), + updatedBy: text('updated_by').notNull(), + ...timestampColumns, +}); diff --git a/packages/database/src/schema/sso.ts b/packages/database/src/schema/sso.ts new file mode 100644 index 00000000..06751fd3 --- /dev/null +++ b/packages/database/src/schema/sso.ts @@ -0,0 +1,44 @@ +import { boolean, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; + +import { user } from './auth.js'; +import { organization } from './organization.js'; + +/** + * The table `@better-auth/sso` owns: one row per identity provider an organization connected. + * + * Migrated on every deployment, but only written where the Enterprise `sso` entitlement registers + * the plugin at all. The provider configuration columns hold JSON documents the plugin encodes + * itself; an OIDC client secret inside `oidc_config` is the reason this table must never be + * exported verbatim. + */ +export const ssoProvider = pgTable( + 'sso_provider', + { + id: text('id').primaryKey(), + issuer: text('issuer').notNull(), + /** JSON-encoded OIDC settings, client secret included. */ + oidcConfig: text('oidc_config'), + /** JSON-encoded SAML settings. */ + samlConfig: text('saml_config'), + /** The account that registered the provider. */ + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + /** The identifier sign-in requests name the provider by. */ + providerId: text('provider_id').notNull(), + /** + * The organization the provider signs members into. Cascades: a provider must not keep + * admitting people into an organization that no longer exists. + */ + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'cascade', + }), + /** The email domain routed to this provider. */ + domain: text('domain').notNull(), + /** Whether the domain's ownership was proven, which domain verification requires before routing. */ + domainVerified: boolean('domain_verified').notNull().default(false), + }, + (table) => [ + uniqueIndex('sso_provider_provider_id_unique').on(table.providerId), + index('sso_provider_organization_id_idx').on(table.organizationId), + index('sso_provider_domain_idx').on(table.domain), + ], +); diff --git a/packages/database/src/schema/two-factor.ts b/packages/database/src/schema/two-factor.ts new file mode 100644 index 00000000..3d978912 --- /dev/null +++ b/packages/database/src/schema/two-factor.ts @@ -0,0 +1,46 @@ +import { boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; + +import { user } from './auth.js'; +import { timestampColumns } from './columns.js'; + +/** + * The table Better Auth's two-factor plugin owns: one TOTP enrollment per account. + * + * Kept beside the other plugin tables rather than in `auth.ts` for the same reason `device.ts` is: + * it belongs to one plugin, and a reader looking for what that plugin stores finds it in one file. + * The plugin is registered on every deployment, but a row exists only for an account that started + * enrollment, and `user.two_factor_enabled` stays false until that enrollment is verified. + */ +export const twoFactor = pgTable( + 'two_factor', + { + id: text('id').primaryKey(), + /** + * The TOTP seed, encrypted with the deployment's auth secret before it reaches this column, so + * a dump of the table alone cannot mint codes. + */ + secret: text('secret').notNull(), + /** The one-time recovery codes, encrypted the same way and consumed one by one. */ + backupCodes: text('backup_codes').notNull(), + userId: text('user_id') + .notNull() + // A deleted account must not leave a second factor behind for whoever reuses its address. + .references(() => user.id, { onDelete: 'cascade' }), + /** + * False while an enrollment awaits its first code; the plugin writes true once it verifies. + * Enabling always writes the value explicitly, so the default only covers rows that predate the + * column. It is true, as in the plugin's own schema, because those rows were completed + * enrollments: defaulting to false would silently turn off their second factor. + */ + verified: boolean('verified').notNull().default(true), + /** Consecutive failed challenges, which the plugin's lockout counts against. */ + failedVerificationCount: integer('failed_verification_count').notNull().default(0), + /** Set while the account is locked out of the challenge after too many failures. */ + lockedUntil: timestamp('locked_until', { withTimezone: true }), + ...timestampColumns, + }, + (table) => [ + index('two_factor_secret_idx').on(table.secret), + index('two_factor_user_id_idx').on(table.userId), + ], +); diff --git a/packages/database/src/settings.test.ts b/packages/database/src/settings.test.ts new file mode 100644 index 00000000..c928cd83 --- /dev/null +++ b/packages/database/src/settings.test.ts @@ -0,0 +1,111 @@ +import { fileURLToPath } from 'node:url'; + +import { drizzle } from 'drizzle-orm/pglite'; +import { migrate } from 'drizzle-orm/pglite/migrator'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { member, organization, schema, user } from './schema/index.js'; +import { + organizationMemberRole, + readDeploymentSettings, + readOrganizationSettings, + saveDeploymentSettings, + saveOrganizationSettings, +} from './settings.js'; + +const MIGRATIONS = fileURLToPath(new URL('../drizzle', import.meta.url)); +const SINGLETON_VIOLATION = /deployment_setting_singleton/; + +/** + * An in-memory Postgres (PGlite) migrated with the checked-in migrations, so these tests read and + * write through the same SQL a deployment runs, constraints included. PGlite reaches this package + * as a peer `drizzle-orm` already resolved; nothing here opens a socket. + */ +const database = drizzle({ schema }); + +beforeAll(async () => { + await migrate(database, { migrationsFolder: MIGRATIONS }); + await database.insert(user).values([ + { id: 'u-owner', name: 'Owner', email: 'owner@example.test' }, + { id: 'u-outsider', name: 'Outsider', email: 'outsider@example.test' }, + ]); + await database.insert(organization).values([ + { id: 'org-a', name: 'A', slug: 'a' }, + { id: 'org-b', name: 'B', slug: 'b' }, + ]); + await database.insert(member).values({ + id: 'm-1', + organizationId: 'org-a', + userId: 'u-owner', + role: 'owner', + }); +}, 60_000); + +afterAll(async () => { + await database.$client.close(); +}); + +describe('deployment settings', () => { + it('reads nothing before the dashboard has saved anything', async () => { + await expect(readDeploymentSettings(database)).resolves.toBeNull(); + }); + + it('keeps the latest saved document and who saved it', async () => { + await saveDeploymentSettings( + database, + { execution: { max_concurrent_tasks: 2 } }, + 'ops@example.test', + ); + await saveDeploymentSettings( + database, + { execution: { max_concurrent_tasks: 3 } }, + 'admin@example.test', + ); + + await expect(readDeploymentSettings(database)).resolves.toMatchObject({ + document: { execution: { max_concurrent_tasks: 3 } }, + updatedBy: 'admin@example.test', + }); + }); + + it('refuses a second settings row at the database, not only in this package', async () => { + await expect( + database.$client.query( + `insert into deployment_setting (id, document, updated_by) values ('other', '{}', 'x')`, + ), + ).rejects.toThrow(SINGLETON_VIOLATION); + }); +}); + +describe('organization settings', () => { + it('keeps each organization to its own row', async () => { + await saveOrganizationSettings( + database, + 'org-a', + { max_concurrent_tasks: 1 }, + 'owner@example.test', + ); + + await expect(readOrganizationSettings(database, 'org-a')).resolves.toMatchObject({ + document: { max_concurrent_tasks: 1 }, + }); + await expect(readOrganizationSettings(database, 'org-b')).resolves.toBeNull(); + }); + + it('removes the limits with the organization', async () => { + await saveOrganizationSettings(database, 'org-b', { max_run_minutes: 5 }, 'owner@example.test'); + await database.$client.query(`delete from organization where id = 'org-b'`); + + await expect(readOrganizationSettings(database, 'org-b')).resolves.toBeNull(); + }); +}); + +describe('organizationMemberRole', () => { + it('answers with the role a member holds', async () => { + await expect(organizationMemberRole(database, 'org-a', 'u-owner')).resolves.toBe('owner'); + }); + + it('answers null for an account outside the organization', async () => { + await expect(organizationMemberRole(database, 'org-a', 'u-outsider')).resolves.toBeNull(); + }); +}); diff --git a/packages/database/src/settings.ts b/packages/database/src/settings.ts new file mode 100644 index 00000000..6e7a5656 --- /dev/null +++ b/packages/database/src/settings.ts @@ -0,0 +1,119 @@ +import { and, eq } from 'drizzle-orm'; +import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'; + +import type { Schema } from './schema/index.js'; +import { member } from './schema/organization.js'; +import { deploymentSetting, organizationSetting } from './schema/settings.js'; + +/** + * Any Drizzle Postgres client opened on this package's schema. + * + * The deployment passes its `postgres-js` client (`Database`); the tests pass an in-memory PGlite + * one, which speaks the same query builder. Typed by what the queries below use rather than by one + * driver, so neither caller has to pretend to be the other. + */ +type SettingsDatabase = PgDatabase; + +/** The one key the deployment's settings row is stored under. */ +const DEPLOYMENT_SETTINGS_ID = 'deployment'; + +/** + * A stored settings document and who saved it. + * + * The document stays `unknown` here: this package stores what it is given and returns what it + * holds. Deciding whether a document is valid is `@code-zero/config`'s job, and its caller asks on + * every read, so a row written by hand cannot reach a run unchecked. + */ +export interface StoredSettings { + document: unknown; + updatedBy: string; + updatedAt: Date; +} + +/** The deployment's stored settings, or null when the dashboard has never saved any. */ +export async function readDeploymentSettings( + database: SettingsDatabase, +): Promise { + const [row] = await database + .select() + .from(deploymentSetting) + .where(eq(deploymentSetting.id, DEPLOYMENT_SETTINGS_ID)) + .limit(1); + return row ? toStored(row) : null; +} + +/** Replace the deployment's settings. The caller has already validated the document. */ +export async function saveDeploymentSettings( + database: SettingsDatabase, + document: Record, + updatedBy: string, +): Promise { + const updatedAt = new Date(); + const [row] = await database + .insert(deploymentSetting) + .values({ id: DEPLOYMENT_SETTINGS_ID, document, updatedBy, updatedAt }) + .onConflictDoUpdate({ + target: deploymentSetting.id, + set: { document, updatedBy, updatedAt }, + }) + .returning(); + if (!row) throw new Error('The deployment settings could not be saved'); + return toStored(row); +} + +/** An organization's stored limits, or null when it never chose any. */ +export async function readOrganizationSettings( + database: SettingsDatabase, + organizationId: string, +): Promise { + const [row] = await database + .select() + .from(organizationSetting) + .where(eq(organizationSetting.organizationId, organizationId)) + .limit(1); + return row ? toStored(row) : null; +} + +/** Replace an organization's limits. The caller has already checked the member and the plan. */ +export async function saveOrganizationSettings( + database: SettingsDatabase, + organizationId: string, + document: Record, + updatedBy: string, +): Promise { + const updatedAt = new Date(); + const [row] = await database + .insert(organizationSetting) + .values({ organizationId, document, updatedBy, updatedAt }) + .onConflictDoUpdate({ + target: organizationSetting.organizationId, + set: { document, updatedBy, updatedAt }, + }) + .returning(); + if (!row) throw new Error('The organization settings could not be saved'); + return toStored(row); +} + +/** + * The role an account holds in an organization, or null when it is not a member. + * + * Read from the membership table on every call rather than from the session: a session remembers + * which organization it last acted in, not whether the account still belongs to it, so a removed + * member's session must not keep answering for the organization. + */ +export async function organizationMemberRole( + database: SettingsDatabase, + organizationId: string, + userId: string, +): Promise { + const [row] = await database + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + .limit(1); + return row?.role ?? null; +} + +function toStored(row: { document: unknown; updatedBy: string; updatedAt: Date }): StoredSettings { + return { document: row.document, updatedBy: row.updatedBy, updatedAt: row.updatedAt }; +} diff --git a/packages/i18n/locales/en/dashboard.json b/packages/i18n/locales/en/dashboard.json index 4cd942b7..1ddac708 100644 --- a/packages/i18n/locales/en/dashboard.json +++ b/packages/i18n/locales/en/dashboard.json @@ -15,7 +15,8 @@ "control": "Control Plane", "audit": "Audit Log", "collapse": "Collapse", - "expand": "Expand" + "expand": "Expand", + "settings": "Settings" }, "metrics": { "aria": "Runner metrics", @@ -125,7 +126,8 @@ "filter": "Focus the table filter", "next": "Select the next task", "previous": "Select the previous task", - "more": "Load older audit entries" + "more": "Load older audit entries", + "settings": "Go to the settings" }, "newTask": { "title": "New task", @@ -147,6 +149,150 @@ "proactive": "Proactive diff review", "feedback": "Review feedback" } + }, + "settings": { + "header": { + "eyebrow": "Code Zero / Configuration", + "title": "Settings" + }, + "loading": "Loading the settings…", + "error": { + "title": "The settings could not be read", + "forbidden": "Managing the deployment settings requires the admin role. Ask an administrator for access.", + "unauthorized": "Your session has expired. Sign in again to see the settings.", + "unavailable": "This deployment keeps no settings.", + "generic": "The settings are temporarily unavailable. Try again in a moment.", + "retry": "Try again" + }, + "invalid": { + "title": "The settings are invalid", + "body": "No task can start until every issue below is fixed." + }, + "warnings": { + "title": "Warnings" + }, + "source": { + "title": "Source", + "edition": "Edition", + "editions": { + "selfHosted": "Self-hosted", + "cloud": "Managed cloud" + }, + "source": "Settings source", + "sources": { + "file": "File", + "dashboard": "Dashboard" + }, + "file": "File", + "saved": "Last saved", + "neverSaved": "Never saved: the defaults apply.", + "fileHint": "Read-only here. Edit this file on the server and restart Code Zero to apply a change.", + "dashboardHint": "Stored in the database and edited on this page. A saved change applies to the next task that starts.", + "cloudHint": "The managed service runs the AI provider, the sandbox, the database and mail. They cannot be changed here.", + "switchHint": "The source is chosen with CODE_ZERO_SETTINGS_SOURCE when the server starts." + }, + "sections": { + "execution": "Execution", + "model": "Model", + "runner": "Runner" + }, + "fields": { + "maxConcurrentTasks": "Max concurrent tasks", + "commandTimeoutMinutes": "Timeout per command", + "allowedModes": "Allowed modes", + "maxNetwork": "Max network access", + "modelSource": "Model choice", + "provider": "Provider", + "modelName": "Model name", + "runnerSource": "Isolation", + "engine": "Container engine", + "image": "Image" + }, + "hints": { + "maxConcurrentTasks": "Tasks this deployment runs at once; the rest wait in the queue. From {min} to {max}.", + "commandTimeoutMinutes": "Minutes before one repository command is stopped. Not a limit on a whole run, which can run several commands. From {min} to {max}.", + "allowedModes": "Observe is always allowed: it never writes.", + "maxNetwork": "The widest network access any run may have. A repository may narrow it, never widen it.", + "model": "The provider's credentials stay in the server environment.", + "runner": "Only container isolation can be enforced from here." + }, + "choices": { + "modelRepository": "Each repository chooses (.code-zero.yml)", + "modelDeployment": "One model for every run", + "runnerRepository": "Each repository chooses (.code-zero.yml)", + "runnerContainer": "Every run in a container" + }, + "placeholders": { + "modelName": "gpt-5", + "image": "ghcr.io/acme/runner:1.0" + }, + "values": { + "minutes": "{count} min", + "repositoryChooses": "Each repository chooses" + }, + "modes": { + "observe": "Observe", + "suggest": "Suggest", + "fix": "Fix", + "autonomous": "Autonomous" + }, + "network": { + "none": "None", + "restricted": "Restricted", + "full": "Full" + }, + "form": { + "save": "Save settings", + "saving": "Saving…", + "reset": "Discard changes", + "saved": "Settings saved. The next task that starts uses them.", + "numberRequired": "Enter a number.", + "invalid": "Some values were refused. Fix the marked fields and save again.", + "forbidden": "You are not allowed to change these settings.", + "unauthorized": "Your session has expired. Sign in again, then save.", + "precondition": "The service settings are invalid, so these limits cannot be saved right now.", + "generic": "The settings could not be saved. Try again in a moment." + }, + "services": { + "title": "Services", + "caption": "Only whether each service is configured. Credentials, hosts and providers are never shown.", + "model": "AI provider", + "sandbox": "Sandbox", + "database": "Database", + "mail": "Mail", + "configured": "Configured", + "missing": "Not configured" + }, + "export": { + "title": "Export", + "body": "The settings as the YAML config.yml would hold. Secrets are never included.", + "unavailable": "Fix the invalid settings to export them.", + "download": "Download config.yml", + "copy": "Copy YAML", + "copied": "Copied", + "copyFailed": "The YAML could not be copied. Use the download instead.", + "error": "The settings could not be exported. Try again in a moment." + }, + "plan": { + "title": "Plan limits", + "caption": "The ceiling for every organization on this plan.", + "unavailable": "The plan's limits are unavailable right now, so no task can start until the service is restored." + }, + "organization": { + "title": "Organization limits", + "caption": "Stricter limits for this organization's own tasks. They can never exceed the plan.", + "none": "Select an organization to see its limits.", + "invalid": "The stored limits are invalid, so this organization's tasks cannot start until they are saved again.", + "emptyUsesPlan": "Leave empty to use the plan's value.", + "planValue": "Plan: {value}", + "usesPlan": "Plan's value", + "restrictModes": "Allow fewer modes than the plan", + "readOnly": "Only an organization owner or admin can change these limits.", + "save": "Save limits", + "saved": "Limits saved. The next task this organization starts uses them.", + "effective": "Effective limits", + "effectiveCaption": "What this organization's tasks are held to: the plan, lowered by the limits above." + } } } } diff --git a/packages/i18n/locales/en/organizations.json b/packages/i18n/locales/en/organizations.json index 7b1b89eb..a9f7b485 100644 --- a/packages/i18n/locales/en/organizations.json +++ b/packages/i18n/locales/en/organizations.json @@ -9,8 +9,7 @@ }, "roles": { "member": "Member", - "admin": "Admin", - "owner": "Owner" + "admin": "Admin" }, "members": { "title": "Members", @@ -29,16 +28,8 @@ "submitPending": "Sending…", "sent": "Invitation sent to {email}." }, - "accept": { - "title": "Organization invitation", - "pending": "Accepting your invitation…", - "accepted": "You have joined the organization.", - "failed": "This invitation is no longer valid. It may have expired or already been used.", - "continue": "Go to dashboard" - }, "errors": { - "generic": "That organization action could not be completed.", - "invitationDeliveryUnavailable": "Invitations are unavailable: this deployment has no email delivery configured." + "generic": "That organization action could not be completed." } } } diff --git a/packages/i18n/locales/it/dashboard.json b/packages/i18n/locales/it/dashboard.json index 8ddf621b..0d7cd608 100644 --- a/packages/i18n/locales/it/dashboard.json +++ b/packages/i18n/locales/it/dashboard.json @@ -15,7 +15,8 @@ "control": "Piano di controllo", "audit": "Registro di audit", "collapse": "Comprimi", - "expand": "Espandi" + "expand": "Espandi", + "settings": "Impostazioni" }, "metrics": { "aria": "Metriche runner", @@ -125,7 +126,8 @@ "filter": "Attiva il filtro della tabella", "next": "Seleziona il task successivo", "previous": "Seleziona il task precedente", - "more": "Carica voci di audit meno recenti" + "more": "Carica voci di audit meno recenti", + "settings": "Vai alle impostazioni" }, "newTask": { "title": "Nuovo task", @@ -147,6 +149,150 @@ "proactive": "Revisione proattiva del diff", "feedback": "Commento di revisione" } + }, + "settings": { + "header": { + "eyebrow": "Code Zero / Configurazione", + "title": "Impostazioni" + }, + "loading": "Caricamento delle impostazioni…", + "error": { + "title": "Impossibile leggere le impostazioni", + "forbidden": "Per gestire le impostazioni del deployment serve il ruolo admin. Chiedi l'accesso a un amministratore.", + "unauthorized": "La sessione è scaduta. Accedi di nuovo per vedere le impostazioni.", + "unavailable": "Questo deployment non conserva impostazioni.", + "generic": "Le impostazioni non sono disponibili al momento. Riprova tra poco.", + "retry": "Riprova" + }, + "invalid": { + "title": "Le impostazioni non sono valide", + "body": "Nessun task può partire finché non risolvi ogni problema qui sotto." + }, + "warnings": { + "title": "Avvisi" + }, + "source": { + "title": "Origine", + "edition": "Edizione", + "editions": { + "selfHosted": "Self-hosted", + "cloud": "Cloud gestito" + }, + "source": "Origine delle impostazioni", + "sources": { + "file": "File", + "dashboard": "Dashboard" + }, + "file": "File", + "saved": "Ultimo salvataggio", + "neverSaved": "Mai salvate: valgono i valori predefiniti.", + "fileHint": "Qui sono in sola lettura. Modifica questo file sul server e riavvia Code Zero per applicare una modifica.", + "dashboardHint": "Salvate nel database e modificate da questa pagina. Una modifica salvata vale dal prossimo task che parte.", + "cloudHint": "Il servizio gestito fornisce il provider AI, la sandbox, il database e la posta. Qui non si possono modificare.", + "switchHint": "L'origine si sceglie con CODE_ZERO_SETTINGS_SOURCE all'avvio del server." + }, + "sections": { + "execution": "Esecuzione", + "model": "Modello", + "runner": "Runner" + }, + "fields": { + "maxConcurrentTasks": "Task contemporanei massimi", + "commandTimeoutMinutes": "Timeout per comando", + "allowedModes": "Modalità consentite", + "maxNetwork": "Accesso di rete massimo", + "modelSource": "Scelta del modello", + "provider": "Provider", + "modelName": "Nome del modello", + "runnerSource": "Isolamento", + "engine": "Motore dei container", + "image": "Immagine" + }, + "hints": { + "maxConcurrentTasks": "Task eseguiti insieme da questo deployment; gli altri attendono in coda. Da {min} a {max}.", + "commandTimeoutMinutes": "Minuti dopo i quali un singolo comando del repository viene fermato. Non limita un'intera esecuzione, che può lanciare più comandi. Da {min} a {max}.", + "allowedModes": "Observe è sempre consentita: non scrive mai.", + "maxNetwork": "L'accesso di rete più ampio concesso a un'esecuzione. Un repository può restringerlo, mai allargarlo.", + "model": "Le credenziali del provider restano nell'ambiente del server.", + "runner": "Da qui si può imporre solo l'isolamento in container." + }, + "choices": { + "modelRepository": "Lo sceglie ogni repository (.code-zero.yml)", + "modelDeployment": "Un solo modello per ogni esecuzione", + "runnerRepository": "Lo sceglie ogni repository (.code-zero.yml)", + "runnerContainer": "Ogni esecuzione in un container" + }, + "placeholders": { + "modelName": "gpt-5", + "image": "ghcr.io/acme/runner:1.0" + }, + "values": { + "minutes": "{count} min", + "repositoryChooses": "Lo sceglie ogni repository" + }, + "modes": { + "observe": "Observe", + "suggest": "Suggest", + "fix": "Fix", + "autonomous": "Autonomous" + }, + "network": { + "none": "Nessuno", + "restricted": "Limitato", + "full": "Completo" + }, + "form": { + "save": "Salva impostazioni", + "saving": "Salvataggio…", + "reset": "Annulla modifiche", + "saved": "Impostazioni salvate. Il prossimo task che parte le usa.", + "numberRequired": "Inserisci un numero.", + "invalid": "Alcuni valori sono stati rifiutati. Correggi i campi segnalati e salva di nuovo.", + "forbidden": "Non hai i permessi per modificare queste impostazioni.", + "unauthorized": "La sessione è scaduta. Accedi di nuovo, poi salva.", + "precondition": "Le impostazioni del servizio non sono valide, quindi questi limiti non si possono salvare ora.", + "generic": "Impossibile salvare le impostazioni. Riprova tra poco." + }, + "services": { + "title": "Servizi", + "caption": "Solo se ogni servizio è configurato. Credenziali, host e provider non vengono mai mostrati.", + "model": "Provider AI", + "sandbox": "Sandbox", + "database": "Database", + "mail": "Posta", + "configured": "Configurato", + "missing": "Non configurato" + }, + "export": { + "title": "Esporta", + "body": "Le impostazioni come YAML, come le conterrebbe config.yml. I segreti non sono mai inclusi.", + "unavailable": "Correggi le impostazioni non valide per esportarle.", + "download": "Scarica config.yml", + "copy": "Copia YAML", + "copied": "Copiato", + "copyFailed": "Impossibile copiare lo YAML. Usa il download.", + "error": "Impossibile esportare le impostazioni. Riprova tra poco." + }, + "plan": { + "title": "Limiti del piano", + "caption": "Il tetto per ogni organizzazione su questo piano.", + "unavailable": "I limiti del piano non sono disponibili ora, quindi nessun task può partire finché il servizio non torna disponibile." + }, + "organization": { + "title": "Limiti dell'organizzazione", + "caption": "Limiti più severi per i task di questa organizzazione. Non possono mai superare il piano.", + "none": "Seleziona un'organizzazione per vederne i limiti.", + "invalid": "I limiti salvati non sono validi, quindi i task di questa organizzazione non possono partire finché non li salvi di nuovo.", + "emptyUsesPlan": "Lascia vuoto per usare il valore del piano.", + "planValue": "Piano: {value}", + "usesPlan": "Valore del piano", + "restrictModes": "Consenti meno modalità del piano", + "readOnly": "Solo un owner o un admin dell'organizzazione può modificare questi limiti.", + "save": "Salva limiti", + "saved": "Limiti salvati. Il prossimo task di questa organizzazione li usa.", + "effective": "Limiti effettivi", + "effectiveCaption": "I limiti applicati ai task di questa organizzazione: il piano, abbassato dai limiti qui sopra." + } } } } diff --git a/packages/i18n/locales/it/organizations.json b/packages/i18n/locales/it/organizations.json index 00a6671a..e6a59aab 100644 --- a/packages/i18n/locales/it/organizations.json +++ b/packages/i18n/locales/it/organizations.json @@ -9,8 +9,7 @@ }, "roles": { "member": "Membro", - "admin": "Amministratore", - "owner": "Proprietario" + "admin": "Amministratore" }, "members": { "title": "Membri", @@ -29,16 +28,8 @@ "submitPending": "Invio in corso…", "sent": "Invito inviato a {email}." }, - "accept": { - "title": "Invito all'organizzazione", - "pending": "Accettazione dell'invito in corso…", - "accepted": "Sei entrato nell'organizzazione.", - "failed": "Questo invito non è più valido. Potrebbe essere scaduto o già stato usato.", - "continue": "Vai alla dashboard" - }, "errors": { - "generic": "Non è stato possibile completare l'operazione sull'organizzazione.", - "invitationDeliveryUnavailable": "Gli inviti non sono disponibili: questa istanza non ha un servizio email configurato." + "generic": "Non è stato possibile completare l'operazione sull'organizzazione." } } } diff --git a/packages/i18n/schemas/dashboard.schema.json b/packages/i18n/schemas/dashboard.schema.json index 147d6606..2b55d79f 100644 --- a/packages/i18n/schemas/dashboard.schema.json +++ b/packages/i18n/schemas/dashboard.schema.json @@ -51,6 +51,9 @@ }, "expand": { "type": "string" + }, + "settings": { + "type": "string" } }, "additionalProperties": false @@ -381,6 +384,9 @@ }, "more": { "type": "string" + }, + "settings": { + "type": "string" } }, "additionalProperties": false @@ -447,6 +453,438 @@ } }, "additionalProperties": false + }, + "settings": { + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "eyebrow": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, + "loading": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "forbidden": { + "type": "string" + }, + "unauthorized": { + "type": "string" + }, + "unavailable": { + "type": "string" + }, + "generic": { + "type": "string" + }, + "retry": { + "type": "string" + } + }, + "additionalProperties": false + }, + "invalid": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + } + }, + "additionalProperties": false + }, + "warnings": { + "type": "object", + "properties": { + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, + "source": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "edition": { + "type": "string" + }, + "editions": { + "type": "object", + "properties": { + "selfHosted": { + "type": "string" + }, + "cloud": { + "type": "string" + } + }, + "additionalProperties": false + }, + "source": { + "type": "string" + }, + "sources": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "dashboard": { + "type": "string" + } + }, + "additionalProperties": false + }, + "file": { + "type": "string" + }, + "saved": { + "type": "string" + }, + "neverSaved": { + "type": "string" + }, + "fileHint": { + "type": "string" + }, + "dashboardHint": { + "type": "string" + }, + "cloudHint": { + "type": "string" + }, + "switchHint": { + "type": "string" + } + }, + "additionalProperties": false + }, + "sections": { + "type": "object", + "properties": { + "execution": { + "type": "string" + }, + "model": { + "type": "string" + }, + "runner": { + "type": "string" + } + }, + "additionalProperties": false + }, + "fields": { + "type": "object", + "properties": { + "maxConcurrentTasks": { + "type": "string" + }, + "commandTimeoutMinutes": { + "type": "string" + }, + "allowedModes": { + "type": "string" + }, + "maxNetwork": { + "type": "string" + }, + "modelSource": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "runnerSource": { + "type": "string" + }, + "engine": { + "type": "string" + }, + "image": { + "type": "string" + } + }, + "additionalProperties": false + }, + "hints": { + "type": "object", + "properties": { + "maxConcurrentTasks": { + "type": "string" + }, + "commandTimeoutMinutes": { + "type": "string" + }, + "allowedModes": { + "type": "string" + }, + "maxNetwork": { + "type": "string" + }, + "model": { + "type": "string" + }, + "runner": { + "type": "string" + } + }, + "additionalProperties": false + }, + "choices": { + "type": "object", + "properties": { + "modelRepository": { + "type": "string" + }, + "modelDeployment": { + "type": "string" + }, + "runnerRepository": { + "type": "string" + }, + "runnerContainer": { + "type": "string" + } + }, + "additionalProperties": false + }, + "placeholders": { + "type": "object", + "properties": { + "modelName": { + "type": "string" + }, + "image": { + "type": "string" + } + }, + "additionalProperties": false + }, + "values": { + "type": "object", + "properties": { + "minutes": { + "type": "string" + }, + "repositoryChooses": { + "type": "string" + } + }, + "additionalProperties": false + }, + "modes": { + "type": "object", + "properties": { + "observe": { + "type": "string" + }, + "suggest": { + "type": "string" + }, + "fix": { + "type": "string" + }, + "autonomous": { + "type": "string" + } + }, + "additionalProperties": false + }, + "network": { + "type": "object", + "properties": { + "none": { + "type": "string" + }, + "restricted": { + "type": "string" + }, + "full": { + "type": "string" + } + }, + "additionalProperties": false + }, + "form": { + "type": "object", + "properties": { + "save": { + "type": "string" + }, + "saving": { + "type": "string" + }, + "reset": { + "type": "string" + }, + "saved": { + "type": "string" + }, + "numberRequired": { + "type": "string" + }, + "invalid": { + "type": "string" + }, + "forbidden": { + "type": "string" + }, + "unauthorized": { + "type": "string" + }, + "precondition": { + "type": "string" + }, + "generic": { + "type": "string" + } + }, + "additionalProperties": false + }, + "services": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "model": { + "type": "string" + }, + "sandbox": { + "type": "string" + }, + "database": { + "type": "string" + }, + "mail": { + "type": "string" + }, + "configured": { + "type": "string" + }, + "missing": { + "type": "string" + } + }, + "additionalProperties": false + }, + "export": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "unavailable": { + "type": "string" + }, + "download": { + "type": "string" + }, + "copy": { + "type": "string" + }, + "copied": { + "type": "string" + }, + "copyFailed": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "additionalProperties": false + }, + "plan": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "unavailable": { + "type": "string" + } + }, + "additionalProperties": false + }, + "organization": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "none": { + "type": "string" + }, + "invalid": { + "type": "string" + }, + "emptyUsesPlan": { + "type": "string" + }, + "planValue": { + "type": "string" + }, + "usesPlan": { + "type": "string" + }, + "restrictModes": { + "type": "string" + }, + "readOnly": { + "type": "string" + }, + "save": { + "type": "string" + }, + "saved": { + "type": "string" + }, + "effective": { + "type": "string" + }, + "effectiveCaption": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/packages/i18n/schemas/organizations.schema.json b/packages/i18n/schemas/organizations.schema.json index 6de93099..271897b3 100644 --- a/packages/i18n/schemas/organizations.schema.json +++ b/packages/i18n/schemas/organizations.schema.json @@ -33,9 +33,6 @@ }, "admin": { "type": "string" - }, - "owner": { - "type": "string" } }, "additionalProperties": false @@ -91,35 +88,11 @@ }, "additionalProperties": false }, - "accept": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "pending": { - "type": "string" - }, - "accepted": { - "type": "string" - }, - "failed": { - "type": "string" - }, - "continue": { - "type": "string" - } - }, - "additionalProperties": false - }, "errors": { "type": "object", "properties": { "generic": { "type": "string" - }, - "invitationDeliveryUnavailable": { - "type": "string" } }, "additionalProperties": false diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b5c5498..d864675c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,10 @@ settings: excludeLinksFromLockfile: false overrides: + '@better-auth/core': 1.7.10 '@nuxt/schema': npm:@nuxt/schema@4.5.2 + '@onmax/nuxt-better-auth>auth': 1.7.10 + '@vite-hub/auth>better-auth': 1.7.10 typescript: npm:typescript-native-bridge@6.0.3-bridge.12.tsgo.7.0.2 importers: @@ -209,12 +212,24 @@ importers: apps/web: dependencies: + '@better-auth/api-key': + specifier: ^1.7.10 + version: 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3)) '@better-auth/core': - specifier: '>=1.4.0' - version: 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + specifier: ^1.7.10 + version: 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) '@better-auth/infra': - specifier: ^0.4.0 - version: 0.4.0(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3) + specifier: ^0.4.20 + version: 0.4.20(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3) + '@better-auth/passkey': + specifier: ^1.7.10 + version: 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3))(nanostores@1.5.1) + '@better-auth/utils': + specifier: ^0.4.2 || ^0.5.0 + version: 0.5.0 + '@better-fetch/fetch': + specifier: 1.3.2 + version: 1.3.2 '@code-zero/api': specifier: workspace:* version: 0.3.0 @@ -243,11 +258,11 @@ importers: specifier: workspace:* version: 0.4.0 '@octopi-ai/better-enrollment': - specifier: ^0.4.0 - version: 0.4.0(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3) + specifier: ^0.5.2 + version: 0.5.2(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3) '@onmax/nuxt-better-auth': specifier: ^0.1.2 - version: 0.1.2(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41)) + version: 0.1.2(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41)) '@orpc/client': specifier: 2.0.0-beta.26 version: 2.0.0-beta.26(@opentelemetry/api@1.9.1) @@ -279,14 +294,20 @@ importers: specifier: ^9.1.2 version: 9.1.2(vue@3.5.41) better-auth: - specifier: ^1.6.26 - version: 1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + specifier: ^1.7.10 + version: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-call: + specifier: 1.4.2 + version: 1.4.2(zod@4.4.3) evlog: specifier: ^2.26.0 version: 2.26.0(@nestjs/common@11.2.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nuxt/kit@4.5.2)(ai@6.0.228(zod@4.4.3))(express@5.2.1)(h3@1.15.11)(hono@4.13.2)(nitropack@2.13.4)(ofetch@1.5.1)(react@19.2.8)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) h3: specifier: ^1.15.11 version: 1.15.11 + nanostores: + specifier: ^1.0.1 + version: 1.5.1 nuxt: specifier: ^4.5.2 version: 4.5.2(@types/node@24.13.3)(rolldown@1.2.4) @@ -391,8 +412,8 @@ importers: specifier: 2.0.0-beta.26 version: 2.0.0-beta.26(crossws@0.4.10(srvx@0.11.22)) better-auth: - specifier: ^1.6.26 - version: 1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + specifier: ^1.7.10 + version: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) evlog: specifier: ^2.26.0 version: 2.26.0(@nestjs/common@11.2.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nuxt/kit@4.5.2)(ai@6.0.228(zod@4.4.3))(express@5.2.1)(h3@1.15.11)(hono@4.13.2)(nitropack@2.13.4)(ofetch@1.5.1)(react@19.2.8)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -421,21 +442,45 @@ importers: packages/auth: dependencies: + '@better-auth/api-key': + specifier: ^1.7.10 + version: 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3)) '@better-auth/core': - specifier: '>=1.4.0' - version: 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + specifier: ^1.7.10 + version: 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) '@better-auth/infra': - specifier: ^0.4.0 - version: 0.4.0(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3) + specifier: ^0.4.20 + version: 0.4.20(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3) + '@better-auth/passkey': + specifier: ^1.7.10 + version: 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3))(nanostores@1.5.1) + '@better-auth/scim': + specifier: ^1.7.10 + version: 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3)) + '@better-auth/sso': + specifier: ^1.7.10 + version: 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3)) + '@better-auth/utils': + specifier: ^0.4.2 || ^0.5.0 + version: 0.5.0 + '@better-fetch/fetch': + specifier: 1.3.2 + version: 1.3.2 '@code-zero/database': specifier: workspace:* version: 0.4.0 '@octopi-ai/better-enrollment': - specifier: ^0.4.0 - version: 0.4.0(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3) + specifier: ^0.5.2 + version: 0.5.2(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3) better-auth: - specifier: ^1.6.26 - version: 1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + specifier: ^1.7.10 + version: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-call: + specifier: 1.4.2 + version: 1.4.2(zod@4.4.3) + nanostores: + specifier: ^1.0.1 + version: 1.5.1 zod: specifier: ^4.4.3 version: 4.4.3 @@ -1118,6 +1163,10 @@ packages: resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} engines: {node: '>=20'} + '@authenio/xml-encryption@2.0.2': + resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} + engines: {node: '>=12'} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -1892,62 +1941,40 @@ packages: resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} engines: {node: ^22.18.0 || >=24.11.0} - '@better-auth/core@1.6.26': - resolution: {integrity: sha512-Ud4FqnjIJDvmeo+3bN+OsuVVAiuvFjNERbW7G8/65ictSxCox62gNgTOsJ4YXmbwXzBzyyekStzrupg2qNGEkA==} + '@better-auth/api-key@1.7.10': + resolution: {integrity: sha512-x3UHgLWESfqtXl5XnAG30h1vOsp7A1KRvHoWDL8rb/kgSUYPc7wGnmhhwVE9C+uxxSX4/2+gZry+9L2KgzRpOQ==} peerDependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@cloudflare/workers-types': '>=4' - '@opentelemetry/api': ^1.9.0 - better-call: 1.3.7 - jose: ^6.1.0 - kysely: ^0.28.5 || ^0.29.0 - nanostores: ^1.0.1 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - '@opentelemetry/api': - optional: true + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + better-auth: ^1.7.10 + better-call: 1.4.2 - '@better-auth/core@1.7.0-rc.5': - resolution: {integrity: sha512-Wustx35oZYDPizZQsmYBPEVCkWpCqMiOn72rsEw5Dby2Z3r97Rzi1ovP/YTgAzpVR5orKw3NHvsEZXqYkffLuQ==} + '@better-auth/core@1.7.10': + resolution: {integrity: sha512-c5vxtnk7UxWe/iF/X5sKSgmPcN5n6biuwJBs2dMkceMwvMWBHLosue6h/vmvKns9SYKbkBNWsurFe7yIbv13dQ==} peerDependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@cloudflare/workers-types': '>=4' + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.2 '@opentelemetry/api': ^1.9.0 - better-call: 1.4.0 + better-call: 1.4.2 jose: ^6.1.0 kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.6.26': - resolution: {integrity: sha512-SMvAeeUqEsz0BLtWVVp+HdStAVOwcxs4vPkL/AVmFJ0e9dDKItXl881xWeB/Ze4NgBW6NYmUId9qI4LrdQu3hA==} + '@better-auth/drizzle-adapter@1.7.10': + resolution: {integrity: sha512-Uc/Eh9cnvorIWA6J97Hv8zjR8sCVUP6G7M3xHJGTD9qJXC69zoY4ULmNGA73AmGJB1q+VEYs2cRMtJ6fR6HP6g==} peerDependencies: - '@better-auth/core': ^1.6.26 - '@better-auth/utils': 0.4.2 - drizzle-orm: ^0.45.2 - peerDependenciesMeta: - drizzle-orm: - optional: true - - '@better-auth/drizzle-adapter@1.7.0-rc.5': - resolution: {integrity: sha512-FbvWNZieA/0CzLrDnpV5GTaatWob8tjL2HzgnEy2xYI38SKxhsKlYdaANfiEbPb4g4vMe+Faq2zbOk/yVbSS3A==} - peerDependencies: - '@better-auth/core': ^1.7.0-rc.5 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/infra@0.4.0': - resolution: {integrity: sha512-7EZVZ9cCopvIcJweu6R1VlbXWZ4ZYA6ceajpzcrTPjCmWWkARLMItepjW6tz8VoLidfWRswToVIFvcUZhGoEbg==} + '@better-auth/infra@0.4.20': + resolution: {integrity: sha512-TgFcHa10zADxk8RU/v1IwQmPuEa0y2GzK/nvjDKxaJ2JB05TGpO4vsJ59eOX3vlS35u5yAlkMZHu818YeQ6Y7g==} peerDependencies: '@better-auth/core': '>=1.4.0' '@react-native-async-storage/async-storage': '>=1.21.0' @@ -1969,63 +1996,47 @@ packages: react-native: optional: true - '@better-auth/kysely-adapter@1.6.26': - resolution: {integrity: sha512-Y0Kdqn8JQMR8dHMtUnbBNqq7Mk8mDf18DwTves2uhjtRfcOWByVTGxsFMDVUbedMuoM+Ab+v04bwCk94sAo8NA==} - peerDependencies: - '@better-auth/core': ^1.6.26 - '@better-auth/utils': 0.4.2 - kysely: ^0.28.17 || ^0.29.0 - peerDependenciesMeta: - kysely: - optional: true - - '@better-auth/kysely-adapter@1.7.0-rc.5': - resolution: {integrity: sha512-EJtbXp19vm55DSdMERU6AhU6NiCYQAvCzeOY1eVeiQPFNri/iPrfGuwCT1tDPBL/e+7nYLgvhOMkO2Ct8gWgNA==} + '@better-auth/kysely-adapter@1.7.10': + resolution: {integrity: sha512-ZWWc6h5lgcwQMTcqMLNWw+jTEMFfrqJHD+VamiCUdaAxZzVAkjLZzVA2r0vZ5w7xnTY9vWS7wuNVqAKNLRTlYQ==} peerDependencies: - '@better-auth/core': ^1.7.0-rc.5 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.6.26': - resolution: {integrity: sha512-kb5ahphEp9jyMleXU4T8I/xgWPa8gjOLFKKsnaVqR/BU+h7xqTOsjJwTY3evY+PFYHvjNFUlrjZ1Km8A/w1p/w==} - peerDependencies: - '@better-auth/core': ^1.6.26 - '@better-auth/utils': 0.4.2 - - '@better-auth/memory-adapter@1.7.0-rc.5': - resolution: {integrity: sha512-5E3i1Men9dyaV/cHu5k5dFtymn+fneXeUPZDlvIsDuvK6Iy+Gon3upck7fBI/Jqfp5S0ZRvBdcpCJHWX5K9ZLw==} + '@better-auth/memory-adapter@1.7.10': + resolution: {integrity: sha512-3Di7y8BK5I2pCdpRaJ2ggkVliO/Swr6a/J+y9RFdwkbxhEMU1o9WD9IwoG3LFH8dvFF3kVo+GilsZsVNlYD+sw==} peerDependencies: - '@better-auth/core': ^1.7.0-rc.5 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 - '@better-auth/mongo-adapter@1.6.26': - resolution: {integrity: sha512-jYGhuIQqj48h69ROLPYdc8jgqDaMmRRLLFiRViZFYarNyBuYUV07DywJXvRuw8l9ADx+M1TB+BJZGnLE2nnmwQ==} + '@better-auth/mongo-adapter@1.7.10': + resolution: {integrity: sha512-7z48PlXtlSKGvdOXfVmbjZE1vRmYyOQqHiPbeLpw+NvAPSTI9N4r7OO47u2MebA3q25e3GoGEwk5BAEE07u+PQ==} peerDependencies: - '@better-auth/core': ^1.6.26 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/mongo-adapter@1.7.0-rc.5': - resolution: {integrity: sha512-Y9PE/Ij+uvEXhElYohySgaN7IBgy0q2KjSGYOIjjrJm1ivFYEPFM8sjGeXNiFQA7l+d9jI+h8bwxsCyySBiZww==} + '@better-auth/passkey@1.7.10': + resolution: {integrity: sha512-beXys26eius+8XqKOBRLLJTiSOO1cYlJDk8sKD8uV9TYJtJQnXuxzI1IVhOxXuo6ph9s6NgzrstIcovt49iiig==} peerDependencies: - '@better-auth/core': ^1.7.0-rc.5 - '@better-auth/utils': 0.4.2 - mongodb: ^6.0.0 || ^7.0.0 - peerDependenciesMeta: - mongodb: - optional: true + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.2 + better-auth: ^1.7.10 + better-call: 1.4.2 + nanostores: ^1.0.1 - '@better-auth/prisma-adapter@1.6.26': - resolution: {integrity: sha512-ILQoYmnoYyghDP4iN1h/Gkd9Jt7Xs/vxoNAt4AuuJJbqs73u27LlQM4P1YiP7nJ+bM1iVTBI1dDF73myIAtjzQ==} + '@better-auth/prisma-adapter@1.7.10': + resolution: {integrity: sha512-hNlhn0Kcq/O76SehvqznFsjqfEMdM2m2ctazU/BLv+jT8+hGvvxCInXZi6x86/xpAyDTgU7t7KsK50r6mdNR1g==} peerDependencies: - '@better-auth/core': ^1.6.26 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: @@ -2034,41 +2045,35 @@ packages: prisma: optional: true - '@better-auth/prisma-adapter@1.7.0-rc.5': - resolution: {integrity: sha512-AFDyK0F6rwlHpBFmWRZnAkO9ctL/GYKIqzvz8emMXP8uLYUQvCn/EgjwPeL5YSUGtZYxruNPga3i/SuqgxSrkA==} + '@better-auth/scim@1.7.10': + resolution: {integrity: sha512-dBZutLg6AeWpsW66onlKUgn4O56WjbIb7xarlDdkNdAPDOSstupuKg2bHAyaoWiHQmyP6xiTa1e08avtS+ZAyw==} peerDependencies: - '@better-auth/core': ^1.7.0-rc.5 - '@better-auth/utils': 0.4.2 - '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 - prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 - peerDependenciesMeta: - '@prisma/client': - optional: true - prisma: - optional: true + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + better-auth: ^1.7.10 + better-call: 1.4.2 - '@better-auth/telemetry@1.6.26': - resolution: {integrity: sha512-pBs69RORUSJHRF9r5PdeY7pzLRga09YSWew8igFYrBvT2tLt7DktrbD5WjryLZpAZOuKAMZ+Xo0DlJbnvLWGpQ==} + '@better-auth/sso@1.7.10': + resolution: {integrity: sha512-OS5OXLq73XGdXgSG2Iyu6ot3ZxnjzB7ti6fQoVKdTuX0X9lkvxtCiOx3pekWzd0YN8b2//EC+RsUvX6wcPuNNw==} peerDependencies: - '@better-auth/core': ^1.6.26 - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.2 + better-auth: ^1.7.10 + better-call: 1.4.2 - '@better-auth/telemetry@1.7.0-rc.5': - resolution: {integrity: sha512-YYNoKaRyWmSSsb/jxoD0hUDI7aeMfDMIkYEE5vZKbq8yXxe+g4VWUE8To44ylEQeIqjRiov/y8sIyC6oEB03pQ==} + '@better-auth/telemetry@1.7.10': + resolution: {integrity: sha512-1OamUtbGRaYHB0tRjKK3Jv6hzI6mZMv3pujkg8k7tr3idQuToMoVXsWAD9oFbUTmOLeix58n+grzsl7r0fR0Sw==} peerDependencies: - '@better-auth/core': ^1.7.0-rc.5 - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - - '@better-auth/utils@0.4.2': - resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + '@better-auth/core': ^1.7.10 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.2 '@better-auth/utils@0.5.0': resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} - '@better-fetch/fetch@1.3.1': - resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} '@bomb.sh/args@0.3.1': resolution: {integrity: sha512-CwxKrfgcorUPP6KfYD59aRdBYWBTsfsxT+GmoLVnKo5Tmyoqbpo0UNcjngRMyU+6tiPbd18RuIYxhgAn44wU/Q==} @@ -3177,6 +3182,9 @@ packages: resolution: {integrity: sha512-6xPjqqLl34jBXWYMO34ywLVQsT8FgH948x8YI9o6Tdqs7+n4QRyvkC+BcvVQaAPNc+pyqsvZAXcbdMEcGgDUGw==} engines: {node: '>=12.0.0'} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@hey-api/spec-types@0.0.0-next-20260408030107': resolution: {integrity: sha512-P5DKr4dpZ7lmZpZt2bc2KWuzU9vl4GZcN7VrjchZF1WWKNGZFnwWhBxZo1t6P2muOCopIvqD2Fvp5os4QiitKA==} @@ -3817,6 +3825,9 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@libsql/client@0.17.4': resolution: {integrity: sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw==} @@ -4671,8 +4682,8 @@ packages: resolution: {integrity: sha512-5N/X/FzlJaYfpaHwDC0YHzOzKDWa41s9t+4FpCDu4f9OMReds4JeNBaaWk9rlIzdKjh2M6AC5Q18ORfECRkHGA==} engines: {node: '>=18.0.0'} - '@octopi-ai/better-enrollment@0.4.0': - resolution: {integrity: sha512-/zfimxvE46EIaT9KdSiA645cdL51Dt2IyOZ4cvfZJYqTMCevMeH79z5h9qjJD8Gh4H/3erW0HB7Z5k0SkUGLtg==} + '@octopi-ai/better-enrollment@0.5.2': + resolution: {integrity: sha512-LqFUrs1A7ySvAq1GFX32vNSg1dMYMiydaHAq4bFHvNZEtktFj0sqOkplgCtrrgPY6QLWjE+7NEgEcGK4MqkAfw==} engines: {node: '>=18'} peerDependencies: better-auth: '>=1.4.0' @@ -6256,6 +6267,57 @@ packages: resolution: {integrity: sha512-dtjbDxKSDPQ8AmA+pS4OFaHE1FKrjtGpLGBxw85uKFkRorjNbvDM/aFPgqosu40wprbp1xw2ZSxIKqghCUHe2w==} engines: {node: '>= 10.0.0'} + '@peculiar/asn1-android@2.11.0': + resolution: {integrity: sha512-llW/qjavL+tOxt5i9whUv4wsbsLpUwI6k/DrvKsdA/H7A/TB/6CosMFgvei28fcJ8ncD0c5lig1oBBlo4KGMCw==} + engines: {node: '>=14'} + + '@peculiar/asn1-cms@2.11.0': + resolution: {integrity: sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==} + engines: {node: '>=14'} + + '@peculiar/asn1-csr@2.11.0': + resolution: {integrity: sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==} + engines: {node: '>=14'} + + '@peculiar/asn1-ecc@2.11.0': + resolution: {integrity: sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==} + engines: {node: '>=14'} + + '@peculiar/asn1-pfx@2.11.0': + resolution: {integrity: sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs8@2.11.0': + resolution: {integrity: sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs9@2.11.0': + resolution: {integrity: sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==} + engines: {node: '>=14'} + + '@peculiar/asn1-rsa@2.11.0': + resolution: {integrity: sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==} + engines: {node: '>=14'} + + '@peculiar/asn1-schema@2.11.0': + resolution: {integrity: sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-attr@2.11.0': + resolution: {integrity: sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509@2.11.0': + resolution: {integrity: sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==} + engines: {node: '>=14'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -6858,6 +6920,13 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.3': + resolution: {integrity: sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==} + engines: {node: '>=20.0.0'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -8742,6 +8811,18 @@ packages: resolution: {integrity: sha512-y5ArHvQ7BVule/+L9yE2nYMhceiJhgsqo58lOfnisQ7bg+Kjfmkgr7JBuVFiTkl+ErdShpp829QstZQyLugl8g==} engines: {node: '>=20'} + '@xmldom/is-dom-node@1.0.1': + resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} + engines: {node: '>= 16'} + + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} + engines: {node: '>=14.6'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -9089,6 +9170,13 @@ packages: resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} engines: {node: '>=8'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + asn1js@3.1.0: + resolution: {integrity: sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -9135,8 +9223,8 @@ packages: atomically@2.1.1: resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} - auth@1.7.0-rc.5: - resolution: {integrity: sha512-BhCBIipX37LftNa4wtHMXCyDbeUZDjiyWP2RShPMc9lMgbL1Ls8VZpUiYY6u2jvv+ZJZ29hd9APQxr+Qi7ocHg==} + auth@1.7.10: + resolution: {integrity: sha512-tcQhp25c/0lJXi04LP9yfXeWJdlikTPubBqxtYmOpzjJjW4gpYeRyLs5yY+nmq+8UkFjTuD7C7D+1tHYYm31yQ==} engines: {node: '>=22.12.0'} hasBin: true @@ -9240,79 +9328,16 @@ packages: resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} engines: {node: '>=10.0.0'} - better-auth@1.6.26: - resolution: {integrity: sha512-nhXWrDDj+EnZsHq1j0z1c6DowOMFZWZHe6LCaXbBfLIgHHZm6dyazBQcbRspM4spUIcUt250Mc1BFOSuP7eniQ==} - peerDependencies: - '@lynx-js/react': '*' - '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 - '@sveltejs/kit': ^2.0.0 - '@tanstack/react-start': ^1.0.0 - '@tanstack/solid-start': ^1.0.0 - better-sqlite3: ^12.0.0 - drizzle-kit: '>=0.31.4' - drizzle-orm: ^0.45.2 - mongodb: ^6.0.0 || ^7.0.0 - mysql2: ^3.0.0 - next: ^14.0.0 || ^15.0.0 || ^16.0.0 - pg: ^8.0.0 - prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - solid-js: ^1.0.0 - svelte: ^4.0.0 || ^5.0.0 - vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 - vue: ^3.0.0 - peerDependenciesMeta: - '@lynx-js/react': - optional: true - '@prisma/client': - optional: true - '@sveltejs/kit': - optional: true - '@tanstack/react-start': - optional: true - '@tanstack/solid-start': - optional: true - better-sqlite3: - optional: true - drizzle-kit: - optional: true - drizzle-orm: - optional: true - mongodb: - optional: true - mysql2: - optional: true - next: - optional: true - pg: - optional: true - prisma: - optional: true - react: - optional: true - react-dom: - optional: true - solid-js: - optional: true - svelte: - optional: true - vitest: - optional: true - vue: - optional: true - - better-auth@1.7.0-rc.5: - resolution: {integrity: sha512-rF1Lo6FO72ZhL7/1sWAw7d/XRneEiQ3Cw46sqWl3fZ8LGLL3idqgwc3JL3/TiS2bSU/xEgiPIrwa9VDnq77TCg==} + better-auth@1.7.10: + resolution: {integrity: sha512-Paf0X8cHKQc8jCgM3vl4AoxRicWdNf0zw8Aggn1q6aNSWCDDxSZCb6rkXa/O9cPc8idjVPK0rV/eHPHAbs07ZA==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 - '@sveltejs/kit': ^2.0.0 + '@sveltejs/kit': ^2.0.0 || ^3.0.0 '@tanstack/react-start': ^1.0.0 '@tanstack/solid-start': ^1.0.0 - better-sqlite3: ^12.0.0 drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' - drizzle-orm: ^0.45.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 mongodb: ^6.0.0 || ^7.0.0 mysql2: ^3.0.0 next: ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -9322,7 +9347,7 @@ packages: react-dom: ^18.0.0 || ^19.0.0 solid-js: ^1.0.0 svelte: ^4.0.0 || ^5.0.0 - vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 vue: ^3.0.0 peerDependenciesMeta: '@lynx-js/react': @@ -9335,8 +9360,6 @@ packages: optional: true '@tanstack/solid-start': optional: true - better-sqlite3: - optional: true drizzle-kit: optional: true drizzle-orm: @@ -9364,16 +9387,8 @@ packages: vue: optional: true - better-call@1.3.7: - resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} - peerDependencies: - zod: ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - - better-call@1.4.0: - resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + better-call@1.4.2: + resolution: {integrity: sha512-lOmFCTG1KcE60YWxoff2Wx0Fv7v7drP/fQTDYnhJUCdkA7V6QWXdLPUiMpOQCq7Fd0Ts3AkrRwD23xoiootlGA==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -12209,6 +12224,9 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + jose@6.2.9: resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} @@ -12382,8 +12400,8 @@ packages: engines: {node: '>=16'} hasBin: true - libphonenumber-js@1.13.11: - resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==} + libphonenumber-js@1.13.15: + resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==} libsql@0.5.29: resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} @@ -13240,6 +13258,9 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + node-rsa@1.1.1: + resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + nodemailer@9.0.5: resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} engines: {node: '>=6.0.0'} @@ -14321,6 +14342,13 @@ packages: pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -14711,9 +14739,6 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} - rou3@0.7.12: - resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} - rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} @@ -14766,6 +14791,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + samlify@2.13.1: + resolution: {integrity: sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g==} + sax@1.6.1: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} @@ -15578,6 +15606,9 @@ packages: unrun: optional: true + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -15586,6 +15617,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -16737,14 +16772,36 @@ packages: resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} engines: {node: '>= 6.0'} + xml-crypto@6.4.0: + resolution: {integrity: sha512-d21ModhBO/+QWWAmOjyDeQJuHNmsR/HtOxyBchiTbKfGNKuHYwQNd+SlLhwyyrwoAGLp4Qc6BQ7nAtnM47dHSQ==} + engines: {node: '>=16'} + + xml-escape@1.1.0: + resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} + xml-naming@0.3.0: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + xpath@0.0.32: + resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==} + engines: {node: '>=0.6.0'} + + xpath@0.0.33: + resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==} + engines: {node: '>=0.6.0'} + + xpath@0.0.34: + resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} + engines: {node: '>=0.6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -17107,6 +17164,12 @@ snapshots: typescript: typescript-native-bridge@6.0.3-bridge.12.tsgo.7.0.2 validate-npm-package-name: 5.0.1 + '@authenio/xml-encryption@2.0.2': + dependencies: + '@xmldom/xmldom': 0.8.15 + escape-html: 1.0.3 + xpath: 0.0.32 + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -18189,129 +18252,112 @@ snapshots: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.4 - '@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)': + '@better-auth/api-key@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3))': dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@opentelemetry/semantic-conventions': 1.43.0 - '@standard-schema/spec': 1.1.0 - better-call: 1.3.7(zod@4.4.3) - jose: 6.2.9 - kysely: 0.29.5 - nanostores: 1.5.1 - zod: 4.4.3 - optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-call: 1.4.2(zod@4.6.5) + zod: 4.6.5 - '@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)': + '@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1)': dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@opentelemetry/api': 1.9.1 + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.4.3) - jose: 6.2.9 + better-call: 1.4.2(zod@4.6.5) + jose: 6.2.12 kysely: 0.29.5 nanostores: 1.5.1 - zod: 4.4.3 - - '@better-auth/drizzle-adapter@1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))': - dependencies: - '@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - optionalDependencies: - drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2) + zod: 4.6.5 - '@better-auth/drizzle-adapter@1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))': + '@better-auth/drizzle-adapter@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))': dependencies: - '@better-auth/core': 1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2) - '@better-auth/infra@0.4.0(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)': + '@better-auth/infra@0.4.20(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)': dependencies: - '@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - better-auth: 1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) - better-call: 1.3.7(zod@4.4.3) - jose: 6.2.9 - libphonenumber-js: 1.13.11 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + '@noble/hashes': 2.3.0 + '@xmldom/xmldom': 0.9.12 + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-call: 1.4.2(zod@4.4.3) + jose: 6.2.12 + libphonenumber-js: 1.13.15 zod: 4.4.3 - '@better-auth/kysely-adapter@1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(kysely@0.29.5)': - dependencies: - '@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - optionalDependencies: - kysely: 0.29.5 - - '@better-auth/kysely-adapter@1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + '@better-auth/kysely-adapter@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(kysely@0.29.5)': dependencies: - '@better-auth/core': 1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 kysely: 0.29.5 - '@better-auth/memory-adapter@1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - - '@better-auth/memory-adapter@1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - - '@better-auth/mongo-adapter@1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))': + '@better-auth/memory-adapter@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)': dependencies: - '@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - optionalDependencies: - mongodb: 7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9) + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 - '@better-auth/mongo-adapter@1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))': + '@better-auth/mongo-adapter@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)': dependencies: - '@better-auth/core': 1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - mongodb: 7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9) + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 - '@better-auth/prisma-adapter@1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(prisma@7.9.1)': + '@better-auth/passkey@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3))(nanostores@1.5.1)': dependencies: - '@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - optionalDependencies: - '@prisma/client': 5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)) - prisma: 7.9.1 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + '@simplewebauthn/browser': 13.3.0 + '@simplewebauthn/server': 13.3.3 + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-call: 1.4.2(zod@4.6.5) + nanostores: 1.5.1 + zod: 4.6.5 - '@better-auth/prisma-adapter@1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(prisma@7.9.1)': + '@better-auth/prisma-adapter@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)': dependencies: - '@better-auth/core': 1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - '@prisma/client': 5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)) - prisma: 7.9.1 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 - '@better-auth/telemetry@1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/scim@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3))': dependencies: - '@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 + '@noble/hashes': 2.3.0 + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-call: 1.4.2(zod@4.6.5) + zod: 4.6.5 - '@better-auth/telemetry@1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/sso@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(better-call@1.4.2(zod@4.4.3))': dependencies: - '@better-auth/core': 1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + '@xmldom/xmldom': 0.9.12 + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-call: 1.4.2(zod@4.6.5) + fast-xml-parser: 5.11.0 + jose: 6.2.12 + samlify: 2.13.1 + tldts: 7.4.10 + zod: 4.6.5 - '@better-auth/utils@0.4.2': + '@better-auth/telemetry@1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)': dependencies: - '@noble/hashes': 2.3.0 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 '@better-auth/utils@0.5.0': dependencies: '@noble/hashes': 2.3.0 - '@better-fetch/fetch@1.3.1': {} + '@better-fetch/fetch@1.3.2': {} '@bomb.sh/args@0.3.1': {} @@ -18645,12 +18691,15 @@ snapshots: '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 + optional: true '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 + optional: true - '@electric-sql/pglite@0.4.3': {} + '@electric-sql/pglite@0.4.3': + optional: true '@emnapi/core@1.10.0': dependencies: @@ -19145,6 +19194,8 @@ snapshots: dependencies: googleapis-common: 8.0.3(supports-color@8.1.1) + '@hexagon/base64@1.1.28': {} + '@hey-api/spec-types@0.0.0-next-20260408030107': dependencies: '@hey-api/types': 0.1.4 @@ -19643,6 +19694,8 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@levischuck/tiny-cbor@0.2.11': {} + '@libsql/client@0.17.4': dependencies: '@libsql/core': 0.17.4 @@ -19919,6 +19972,7 @@ snapshots: '@mongodb-js/saslprep@1.4.13': dependencies: sparse-bitfield: 3.0.3 + optional: true '@mongodb-js/zstd@7.0.0': dependencies: @@ -20980,18 +21034,18 @@ snapshots: dependencies: '@oclif/core': 4.11.4 - '@octopi-ai/better-enrollment@0.4.0(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)': + '@octopi-ai/better-enrollment@0.5.2(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))(zod@4.4.3)': dependencies: - better-auth: 1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) zod: 4.4.3 '@one-ini/wasm@0.1.1': {} - '@onmax/nuxt-better-auth@0.1.2(better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))': + '@onmax/nuxt-better-auth@0.1.2(better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41))': dependencies: '@nuxt/kit': 4.5.2(magic-string@0.30.21)(magicast@0.5.4)(rolldown@1.2.4) - auth: 1.7.0-rc.5(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(better-call@1.4.0(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(jose@6.2.9)(kysely@0.29.5)(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(nanostores@1.5.1)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(supports-color@8.1.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) - better-auth: 1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + auth: 1.7.10(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1)(supports-color@8.1.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) defu: 6.1.7 h3: 1.15.11 jiti: 2.7.0 @@ -21000,16 +21054,22 @@ snapshots: std-env: 4.2.0 ufo: 1.6.4 transitivePeerDependencies: - - '@cloudflare/workers-types' - '@lynx-js/react' + - '@opentelemetry/api' + - '@prisma/client' - '@sveltejs/kit' - '@tanstack/react-start' - '@tanstack/solid-start' - - better-sqlite3 - chokidar - giget - magicast + - mongodb + - mysql2 - next + - pg + - prisma + - react + - react-dom - solid-js - svelte @@ -21897,6 +21957,106 @@ snapshots: is-glob: 4.0.3 picomatch: 4.0.5 + '@peculiar/asn1-android@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-attr': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.11.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.11.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-pfx': 2.11.0 + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-attr': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.11.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-csr': 2.11.0 + '@peculiar/asn1-ecc': 2.11.0 + '@peculiar/asn1-pkcs9': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@pkgjs/parseargs@0.11.0': optional: true @@ -21921,6 +22081,7 @@ snapshots: '@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1))': optionalDependencies: prisma: 7.9.1(better-sqlite3@12.11.1) + optional: true '@prisma/config@7.9.1(magicast@0.5.4)': dependencies: @@ -21928,10 +22089,13 @@ snapshots: deepmerge-ts: 7.1.5 effect: 3.20.0 empathic: 2.0.0 + optional: true - '@prisma/debug@7.2.0': {} + '@prisma/debug@7.2.0': + optional: true - '@prisma/debug@7.9.1': {} + '@prisma/debug@7.9.1': + optional: true '@prisma/dev@0.24.17': dependencies: @@ -21950,8 +22114,10 @@ snapshots: std-env: 3.10.0 valibot: 1.4.2 zeptomatch: 2.1.0 + optional: true - '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + optional: true '@prisma/engines@7.9.1': dependencies: @@ -21959,22 +22125,27 @@ snapshots: '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad '@prisma/fetch-engine': 7.9.1 '@prisma/get-platform': 7.9.1 + optional: true '@prisma/fetch-engine@7.9.1': dependencies: '@prisma/debug': 7.9.1 '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad '@prisma/get-platform': 7.9.1 + optional: true '@prisma/get-platform@7.2.0': dependencies: '@prisma/debug': 7.2.0 + optional: true '@prisma/get-platform@7.9.1': dependencies: '@prisma/debug': 7.9.1 + optional: true - '@prisma/query-plan-executor@7.2.0': {} + '@prisma/query-plan-executor@7.2.0': + optional: true '@prisma/streams-local@0.1.11': dependencies: @@ -21982,6 +22153,7 @@ snapshots: better-result: 2.10.0 env-paths: 3.0.0 proper-lockfile: 4.1.2 + optional: true '@prisma/studio-core@0.33.0(@types/react@19.2.18)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))': dependencies: @@ -22001,6 +22173,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - '@types/react-dom' + optional: true '@protobufjs/aspromise@1.1.2': {} @@ -22030,13 +22203,15 @@ snapshots: dependencies: quansync: 1.0.0 - '@radix-ui/primitive@1.1.3': {} + '@radix-ui/primitive@1.1.3': + optional: true '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.2.18 + optional: true '@radix-ui/react-primitive@2.1.3(@types/react@19.2.18)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))': dependencies: @@ -22045,6 +22220,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 + optional: true '@radix-ui/react-slot@1.2.3(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -22052,6 +22228,7 @@ snapshots: react: 19.2.8 optionalDependencies: '@types/react': 19.2.18 + optional: true '@radix-ui/react-toggle@1.1.10(@types/react@19.2.18)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))': dependencies: @@ -22064,6 +22241,7 @@ snapshots: '@types/react': 19.2.18 transitivePeerDependencies: - '@types/react-dom' + optional: true '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -22072,6 +22250,7 @@ snapshots: react: 19.2.8 optionalDependencies: '@types/react': 19.2.18 + optional: true '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -22079,12 +22258,14 @@ snapshots: react: 19.2.8 optionalDependencies: '@types/react': 19.2.18 + optional: true '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.2.18 + optional: true '@redstardev/unplugin-version-injector@0.0.2(@nuxt/kit@4.5.2)(@nuxt/schema@4.5.2)(esbuild@0.28.2)(vite@8.2.1(@types/node@26.2.0)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.109.2)': dependencies: @@ -22366,6 +22547,19 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.3': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.11.0 + '@peculiar/asn1-ecc': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/x509': 1.14.3 + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@7.2.0': {} @@ -23008,35 +23202,46 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/d3-array@3.0.3': {} + '@types/d3-array@3.0.3': + optional: true - '@types/d3-color@3.1.0': {} + '@types/d3-color@3.1.0': + optional: true - '@types/d3-delaunay@6.0.1': {} + '@types/d3-delaunay@6.0.1': + optional: true - '@types/d3-format@3.0.1': {} + '@types/d3-format@3.0.1': + optional: true '@types/d3-geo@3.1.0': dependencies: '@types/geojson': 7946.0.16 + optional: true '@types/d3-interpolate@3.0.1': dependencies: '@types/d3-color': 3.1.0 + optional: true - '@types/d3-path@3.1.1': {} + '@types/d3-path@3.1.1': + optional: true '@types/d3-scale@4.0.2': dependencies: '@types/d3-time': 3.0.0 + optional: true '@types/d3-shape@3.1.7': dependencies: '@types/d3-path': 3.1.1 + optional: true - '@types/d3-time-format@2.1.0': {} + '@types/d3-time-format@2.1.0': + optional: true - '@types/d3-time@3.0.0': {} + '@types/d3-time@3.0.0': + optional: true '@types/debug@4.1.13': dependencies: @@ -23048,7 +23253,8 @@ snapshots: '@types/estree@1.0.9': {} - '@types/geojson@7946.0.16': {} + '@types/geojson@7946.0.16': + optional: true '@types/hast@3.0.5': dependencies: @@ -23107,6 +23313,7 @@ snapshots: '@types/react@19.2.18': dependencies: csstype: 3.2.3 + optional: true '@types/request@2.48.13': dependencies: @@ -23129,13 +23336,15 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@types/webidl-conversions@7.0.3': {} + '@types/webidl-conversions@7.0.3': + optional: true '@types/whatwg-mimetype@3.0.2': {} '@types/whatwg-url@13.0.0': dependencies: '@types/webidl-conversions': 7.0.3 + optional: true '@types/ws@8.18.1': dependencies: @@ -23574,11 +23783,13 @@ snapshots: '@visx/curve@4.0.1-alpha.0': dependencies: '@visx/vendor': 4.0.0-alpha.0 + optional: true '@visx/event@4.0.1-alpha.0': dependencies: '@types/react': 19.2.18 '@visx/point': 4.0.1-alpha.0 + optional: true '@visx/grid@4.0.1-alpha.0(react@19.2.8)': dependencies: @@ -23590,14 +23801,17 @@ snapshots: '@visx/shape': 4.0.1-alpha.0(react@19.2.8) classnames: 2.5.1 react: 19.2.8 + optional: true '@visx/group@4.0.1-alpha.0(react@19.2.8)': dependencies: '@types/react': 19.2.18 classnames: 2.5.1 react: 19.2.8 + optional: true - '@visx/point@4.0.1-alpha.0': {} + '@visx/point@4.0.1-alpha.0': + optional: true '@visx/responsive@4.0.1-alpha.0(react@19.2.8)': dependencies: @@ -23605,10 +23819,12 @@ snapshots: '@types/react': 19.2.18 lodash: 4.18.1 react: 19.2.8 + optional: true '@visx/scale@4.0.1-alpha.0': dependencies: '@visx/vendor': 4.0.0-alpha.0 + optional: true '@visx/shape@4.0.1-alpha.0(react@19.2.8)': dependencies: @@ -23621,6 +23837,7 @@ snapshots: classnames: 2.5.1 lodash: 4.18.1 react: 19.2.8 + optional: true '@visx/vendor@4.0.0-alpha.0': dependencies: @@ -23647,6 +23864,7 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 internmap: 2.0.3 + optional: true '@vite-hub/agent@0.0.3(f98c108717be27ec3f990b4c033b1a17)': dependencies: @@ -23689,18 +23907,24 @@ snapshots: '@vite-hub/auth@0.0.3(@vite-hub/agent@0.0.3(f98c108717be27ec3f990b4c033b1a17))(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vite-hub/runtime': 0.0.3 - better-auth: 1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) optionalDependencies: '@vite-hub/agent': 0.0.3(f98c108717be27ec3f990b4c033b1a17) vite: 8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - - '@cloudflare/workers-types' - '@lynx-js/react' + - '@opentelemetry/api' + - '@prisma/client' - '@sveltejs/kit' - '@tanstack/react-start' - '@tanstack/solid-start' - - better-sqlite3 + - mongodb + - mysql2 - next + - pg + - prisma + - react + - react-dom - solid-js - svelte @@ -24639,6 +24863,12 @@ snapshots: dependencies: system-architecture: 1.0.0 + '@xmldom/is-dom-node@1.0.1': {} + + '@xmldom/xmldom@0.8.15': {} + + '@xmldom/xmldom@0.9.12': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -24926,6 +25156,16 @@ snapshots: arrify@2.0.1: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + asn1js@3.1.0: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} ast-kit@2.2.0: @@ -24966,17 +25206,17 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - auth@1.7.0-rc.5(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(better-call@1.4.0(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(jose@6.2.9)(kysely@0.29.5)(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(nanostores@1.5.1)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(supports-color@8.1.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41): + auth@1.7.10(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1)(supports-color@8.1.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/preset-react': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@better-auth/core': 1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/telemetry': 1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/telemetry': 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.5.0 '@clack/prompts': 1.7.0 '@mrleebo/prisma-ast': 0.16.0 - better-auth: 1.7.0-rc.5(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) + better-auth: 1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41) c12: 4.0.0-beta.5(dotenv@17.4.2)(jiti@2.7.0) chalk: 5.6.2 commander: 15.0.0 @@ -24988,18 +25228,24 @@ snapshots: prompts: 2.4.2 semver: 7.8.5 yocto-spinner: 1.2.2 - zod: 4.4.3 + zod: 4.6.5 transitivePeerDependencies: - - '@cloudflare/workers-types' - '@lynx-js/react' + - '@opentelemetry/api' + - '@prisma/client' - '@sveltejs/kit' - '@tanstack/react-start' - '@tanstack/solid-start' - - better-sqlite3 - chokidar - giget - magicast + - mongodb + - mysql2 - next + - pg + - prisma + - react + - react-dom - solid-js - svelte @@ -25016,7 +25262,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - aws-ssl-profiles@1.1.2: {} + aws-ssl-profiles@1.1.2: + optional: true aws4fetch@1.0.20: {} @@ -25083,92 +25330,53 @@ snapshots: basic-ftp@5.3.1: {} - better-auth@1.6.26(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41): - dependencies: - '@better-auth/core': 1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/drizzle-adapter': 1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2)) - '@better-auth/kysely-adapter': 1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(kysely@0.29.5) - '@better-auth/memory-adapter': 1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9)) - '@better-auth/prisma-adapter': 1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(prisma@7.9.1) - '@better-auth/telemetry': 1.6.26(@better-auth/core@1.6.26(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@noble/ciphers': 2.3.0 - '@noble/hashes': 2.3.0 - better-call: 1.3.7(zod@4.4.3) - defu: 6.1.7 - jose: 6.2.9 - kysely: 0.29.5 - nanostores: 1.5.1 - zod: 4.4.3 - optionalDependencies: - '@prisma/client': 5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)) - drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2) - mongodb: 7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9) - mysql2: 3.15.3 - pg: 8.23.0 - prisma: 7.9.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) - vue: 3.5.41 - transitivePeerDependencies: - - '@cloudflare/workers-types' - - better-auth@1.7.0-rc.5(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.9.1)(react@19.2.8)(react-dom@19.2.8(react@19.2.8))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41): - dependencies: - '@better-auth/core': 1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) - '@better-auth/drizzle-adapter': 1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2)) - '@better-auth/kysely-adapter': 1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(kysely@0.29.5) - '@better-auth/memory-adapter': 1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9)) - '@better-auth/prisma-adapter': 1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(prisma@7.9.1) - '@better-auth/telemetry': 1.7.0-rc.5(@better-auth/core@1.7.0-rc.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + better-auth@1.7.10(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)))(vue@3.5.41): + dependencies: + '@better-auth/core': 1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/drizzle-adapter': 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2)) + '@better-auth/kysely-adapter': 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0) + '@better-auth/mongo-adapter': 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0) + '@better-auth/prisma-adapter': 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0) + '@better-auth/telemetry': 1.7.10(@better-auth/core@1.7.10(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2)(better-call@1.4.2(zod@4.4.3))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.5.0)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 '@noble/ciphers': 2.3.0 '@noble/hashes': 2.3.0 - '@prisma/client': 5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)) - better-call: 1.4.0(zod@4.4.3) + better-call: 1.4.2(zod@4.6.5) defu: 6.1.7 drizzle-kit: 0.31.10 drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.3)(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0(prisma@7.9.1(better-sqlite3@12.11.1)))(@types/pg@8.23.0)(better-sqlite3@12.11.1)(kysely@0.29.5)(mysql2@3.15.3)(pg@8.23.0)(postgres@3.4.9)(prisma@7.9.1(better-sqlite3@12.11.1))(sql.js@1.14.2) - jose: 6.2.9 + jose: 6.2.12 kysely: 0.29.5 - mongodb: 7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9) - mysql2: 3.15.3 nanostores: 1.5.1 - pg: 8.23.0 - prisma: 7.9.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) vue: 3.5.41 - zod: 4.4.3 + zod: 4.6.5 transitivePeerDependencies: - - '@cloudflare/workers-types' + - '@opentelemetry/api' + - '@prisma/client' + - mongodb + - prisma - better-call@1.3.7(zod@4.4.3): + better-call@1.4.2(zod@4.4.3): dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - rou3: 0.7.12 + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 set-cookie-parser: 3.1.2 - optionalDependencies: zod: 4.4.3 - better-call@1.4.0(zod@4.4.3): + better-call@1.4.2(zod@4.6.5): dependencies: '@better-auth/utils': 0.5.0 - '@better-fetch/fetch': 1.3.1 + '@better-fetch/fetch': 1.3.2 rou3: 0.9.2 set-cookie-parser: 3.1.2 - optionalDependencies: - zod: 4.4.3 + zod: 4.6.5 - better-result@2.10.0: {} + better-result@2.10.0: + optional: true better-sqlite3@12.11.1: dependencies: @@ -25271,7 +25479,8 @@ snapshots: node-releases: 2.0.53 update-browserslist-db: 1.3.1(browserslist@4.28.8) - bson@7.3.2: {} + bson@7.3.2: + optional: true buffer-crc32@1.0.0: {} @@ -25499,7 +25708,8 @@ snapshots: dependencies: clsx: 2.1.1 - classnames@2.5.1: {} + classnames@2.5.1: + optional: true clean-git-ref@2.0.1: {} @@ -25828,28 +26038,36 @@ snapshots: d3-array@3.2.1: dependencies: internmap: 2.0.3 + optional: true d3-array@3.2.4: dependencies: internmap: 2.0.3 + optional: true - d3-color@3.1.0: {} + d3-color@3.1.0: + optional: true d3-delaunay@6.0.2: dependencies: delaunator: 5.1.0 + optional: true - d3-format@3.1.0: {} + d3-format@3.1.0: + optional: true d3-geo@3.1.0: dependencies: d3-array: 3.2.4 + optional: true d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 + optional: true - d3-path@3.1.0: {} + d3-path@3.1.0: + optional: true d3-scale@4.0.2: dependencies: @@ -25858,18 +26076,22 @@ snapshots: d3-interpolate: 3.0.1 d3-time: 3.1.0 d3-time-format: 4.1.0 + optional: true d3-shape@3.2.0: dependencies: d3-path: 3.1.0 + optional: true d3-time-format@4.1.0: dependencies: d3-time: 3.1.0 + optional: true d3-time@3.1.0: dependencies: d3-array: 3.2.4 + optional: true data-uri-to-buffer@4.0.1: {} @@ -25943,7 +26165,8 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} + deepmerge-ts@7.1.5: + optional: true deepmerge@4.3.1: {} @@ -25986,6 +26209,7 @@ snapshots: delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 + optional: true delayed-stream@1.0.0: {} @@ -26264,6 +26488,7 @@ snapshots: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + optional: true effect@4.0.0-beta.99: dependencies: @@ -26284,7 +26509,8 @@ snapshots: electron-to-chromium@1.5.408: {} - elkjs@0.11.1: {} + elkjs@0.11.1: + optional: true email-comb@7.1.3: dependencies: @@ -26348,7 +26574,8 @@ snapshots: emoticon@4.1.0: {} - empathic@2.0.0: {} + empathic@2.0.0: + optional: true empathic@2.0.1: {} @@ -26870,7 +27097,8 @@ snapshots: dependencies: pure-rand: 8.4.2 - fast-decode-uri-component@1.0.1: {} + fast-decode-uri-component@1.0.1: + optional: true fast-deep-equal@3.1.3: {} @@ -26895,6 +27123,7 @@ snapshots: fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 + optional: true fast-safe-stringify@2.1.1: {} @@ -27033,6 +27262,7 @@ snapshots: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.1.1 + optional: true find-up@5.0.0: dependencies: @@ -27246,6 +27476,7 @@ snapshots: generate-function@2.3.1: dependencies: is-property: 1.0.2 + optional: true generator-function@2.0.1: {} @@ -27492,9 +27723,11 @@ snapshots: graceful-fs@4.2.11: {} - grammex@3.1.13: {} + grammex@3.1.13: + optional: true - graphmatch@1.1.1: {} + graphmatch@1.1.1: + optional: true gray-matter@4.0.3: dependencies: @@ -27930,7 +28163,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 - internmap@2.0.3: {} + internmap@2.0.3: + optional: true ioredis@5.11.1(supports-color@8.1.1): dependencies: @@ -28085,7 +28319,8 @@ snapshots: is-promise@4.0.0: {} - is-property@1.0.2: {} + is-property@1.0.2: + optional: true is-reference@1.2.1: dependencies: @@ -28214,6 +28449,8 @@ snapshots: jose@5.10.0: {} + jose@6.2.12: {} + jose@6.2.9: {} js-base64@3.9.3: {} @@ -28415,7 +28652,7 @@ snapshots: dependencies: isomorphic.js: 0.2.5 - libphonenumber-js@1.13.11: {} + libphonenumber-js@1.13.15: {} libsql@0.5.29: dependencies: @@ -28639,7 +28876,8 @@ snapshots: lru-cache@7.18.3: {} - lru.min@1.1.4: {} + lru.min@1.1.4: + optional: true magic-regexp@0.10.0: dependencies: @@ -28871,7 +29109,8 @@ snapshots: media-typer@1.1.1: {} - memory-pager@1.5.0: {} + memory-pager@1.5.0: + optional: true merge-descriptors@2.0.0: {} @@ -29171,6 +29410,7 @@ snapshots: dependencies: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 + optional: true mongodb@7.5.0(@mongodb-js/zstd@7.0.0)(socks@2.8.9): dependencies: @@ -29180,6 +29420,7 @@ snapshots: optionalDependencies: '@mongodb-js/zstd': 7.0.0 socks: 2.8.9 + optional: true motion-dom@13.0.0: dependencies: @@ -29244,6 +29485,7 @@ snapshots: named-placeholders: 1.1.6 seq-queue: 0.0.5 sqlstring: 2.3.3 + optional: true mz@2.7.0: dependencies: @@ -29254,6 +29496,7 @@ snapshots: named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 + optional: true nano-staged@1.0.2: {} @@ -29423,6 +29666,10 @@ snapshots: node-releases@2.0.53: {} + node-rsa@1.1.1: + dependencies: + asn1: 0.2.6 + nodemailer@9.0.5: {} nopt@7.2.1: @@ -30452,15 +30699,19 @@ snapshots: pg-cloudflare@1.4.0: optional: true - pg-connection-string@2.14.0: {} + pg-connection-string@2.14.0: + optional: true - pg-int8@1.0.1: {} + pg-int8@1.0.1: + optional: true pg-pool@3.14.0(pg@8.23.0): dependencies: pg: 8.23.0 + optional: true - pg-protocol@1.16.0: {} + pg-protocol@1.16.0: + optional: true pg-types@2.2.0: dependencies: @@ -30469,6 +30720,7 @@ snapshots: postgres-bytea: 1.0.1 postgres-date: 1.0.7 postgres-interval: 1.2.0 + optional: true pg@8.23.0: dependencies: @@ -30479,10 +30731,12 @@ snapshots: pgpass: 1.0.5 optionalDependencies: pg-cloudflare: 1.4.0 + optional: true pgpass@1.0.5: dependencies: split2: 4.2.0 + optional: true picocolors@1.1.1: {} @@ -30706,17 +30960,22 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} + postgres-array@2.0.0: + optional: true - postgres-bytea@1.0.1: {} + postgres-bytea@1.0.1: + optional: true - postgres-date@1.0.7: {} + postgres-date@1.0.7: + optional: true postgres-interval@1.2.0: dependencies: xtend: 4.0.2 + optional: true - postgres@3.4.7: {} + postgres@3.4.7: + optional: true postgres@3.4.9: {} @@ -30753,19 +31012,6 @@ snapshots: dependencies: parse-ms: 4.0.0 - prisma@7.9.1: - dependencies: - '@prisma/config': 7.9.1(magicast@0.5.4) - '@prisma/dev': 0.24.17 - '@prisma/engines': 7.9.1 - '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react@19.2.8)(react-dom@19.2.8(react@19.2.8)) - mysql2: 3.15.3 - postgres: 3.4.7 - optionalDependencies: - typescript: typescript-native-bridge@6.0.3-bridge.12.tsgo.7.0.2 - transitivePeerDependencies: - - '@types/react-dom' - prisma@7.9.1(better-sqlite3@12.11.1): dependencies: '@prisma/config': 7.9.1(magicast@0.5.4) @@ -30930,6 +31176,12 @@ snapshots: pure-rand@8.4.2: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -31011,6 +31263,7 @@ snapshots: dependencies: react: 19.2.8 scheduler: 0.27.0 + optional: true react@19.2.8: {} @@ -31247,7 +31500,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remeda@2.33.4: {} + remeda@2.33.4: + optional: true remend@1.3.0: {} @@ -31333,7 +31587,8 @@ snapshots: semver-compare: 1.0.0 sprintf-js: 1.1.3 - robust-predicates@3.0.3: {} + robust-predicates@3.0.3: + optional: true rolldown-plugin-dts@0.27.14(@volar/typescript@2.4.28)(rolldown@1.2.4)(vue-tsc@3.3.9(typescript@6.0.3-bridge.12.tsgo.7.0.2)): dependencies: @@ -31419,8 +31674,6 @@ snapshots: rope-sequence@1.3.4: {} - rou3@0.7.12: {} - rou3@0.8.1: optional: true @@ -31479,9 +31732,20 @@ snapshots: safer-buffer@2.1.2: {} + samlify@2.13.1: + dependencies: + '@authenio/xml-encryption': 2.0.2 + '@xmldom/xmldom': 0.8.15 + node-rsa: 1.1.1 + xml: 1.0.1 + xml-crypto: 6.4.0 + xml-escape: 1.1.0 + xpath: 0.0.34 + sax@1.6.1: {} - scheduler@0.27.0: {} + scheduler@0.27.0: + optional: true schema-utils@4.3.3: dependencies: @@ -31531,7 +31795,8 @@ snapshots: range-parser: 1.3.0 statuses: 2.0.2 - seq-queue@0.0.5: {} + seq-queue@0.0.5: + optional: true serialize-error@7.0.1: dependencies: @@ -31894,10 +32159,12 @@ snapshots: sparse-bitfield@3.0.3: dependencies: memory-pager: 1.5.0 + optional: true split-on-first@3.0.0: {} - split2@4.2.0: {} + split2@4.2.0: + optional: true sprintf-js@1.0.3: {} @@ -31930,7 +32197,8 @@ snapshots: sqlite-vec-linux-x64: 0.1.9 sqlite-vec-windows-x64: 0.1.9 - sqlstring@2.3.3: {} + sqlstring@2.3.3: + optional: true srvx@0.11.22: {} @@ -32386,6 +32654,7 @@ snapshots: tr46@5.1.1: dependencies: punycode: 2.3.1 + optional: true tree-kill@1.2.2: {} @@ -32427,6 +32696,8 @@ snapshots: transitivePeerDependencies: - '@typescript/native-preview' + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.23.12: @@ -32435,6 +32706,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -32957,6 +33232,7 @@ snapshots: valibot@1.4.2: optionalDependencies: typescript: typescript-native-bridge@6.0.3-bridge.12.tsgo.7.0.2 + optional: true valid-data-url@3.0.1: {} @@ -33067,7 +33343,9 @@ snapshots: - '@op-engineering/op-sqlite' - '@openai/agents' - '@opencode-ai/sdk' + - '@opentelemetry/api' - '@planetscale/database' + - '@prisma/client' - '@rspack/core' - '@sveltejs/kit' - '@tanstack/react-start' @@ -33085,7 +33363,6 @@ snapshots: - bare-abort-controller - basic-ftp - beautiful-mermaid - - better-sqlite3 - buffer - bufferutil - bun-types @@ -33102,10 +33379,16 @@ snapshots: - katex - knex - koa + - mongodb + - mysql2 - next - node-appwrite - openworkflow + - pg - pocketbase + - prisma + - react + - react-dom - react-native-b4a - solid-js - sqlite3 @@ -33446,7 +33729,8 @@ snapshots: webidl-conversions@3.0.1: {} - webidl-conversions@7.0.0: {} + webidl-conversions@7.0.0: + optional: true webpack-sources@3.5.1: {} @@ -33496,6 +33780,7 @@ snapshots: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + optional: true whatwg-url@5.0.0: dependencies: @@ -33754,11 +34039,28 @@ snapshots: dependencies: os-paths: 4.4.0 + xml-crypto@6.4.0: + dependencies: + '@xmldom/is-dom-node': 1.0.1 + '@xmldom/xmldom': 0.8.15 + xpath: 0.0.33 + + xml-escape@1.1.0: {} + xml-naming@0.3.0: {} + xml@1.0.1: {} + xmlhttprequest-ssl@2.1.2: {} - xtend@4.0.2: {} + xpath@0.0.32: {} + + xpath@0.0.33: {} + + xpath@0.0.34: {} + + xtend@4.0.2: + optional: true y-protocols@1.0.7(yjs@13.6.32): dependencies: @@ -33877,6 +34179,7 @@ snapshots: dependencies: grammex: 3.1.13 graphmatch: 1.1.1 + optional: true zigpty@0.2.1: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a7229cf4..79a5e88b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,16 @@ overrides: # `DefineNuxtConfig` types, so `defineNuxtConfig(...)` type-checks as "not callable" wherever the # older one wins resolution. '@nuxt/schema': npm:@nuxt/schema@4.5.2 + # @onmax/nuxt-better-auth@0.1.2 pins the `auth` CLI to the exact prerelease it was built against + # (1.7.0-rc.5), which in turn pins better-auth and @better-auth/core to that prerelease. The + # module only imports `generateDrizzleSchema` from it, so the stable release of the same line is a + # drop-in, and pinning it here keeps one better-auth resolution in the graph instead of two. + '@onmax/nuxt-better-auth>auth': 1.7.10 + # Same reason, two more paths into an older line: `@vite-hub/auth` asks for `^1.6.23` and + # `@better-auth/infra` declares `@better-auth/core` as a `>=1.4.0` peer, and both kept resolving + # to the 1.6 copy the lockfile already held. + '@vite-hub/auth>better-auth': 1.7.10 + '@better-auth/core': 1.7.10 onlyBuiltDependencies: - esbuild