From 343cc108842ffcf73c825f2b38549caf8937cd63 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sun, 18 Oct 2026 10:56:53 +0200 Subject: [PATCH 1/9] build(deps): upgrade Better Auth to 1.7.10 Move better-auth to ^1.7.10, @better-auth/infra to ^0.4.20, and @octopi-ai/better-enrollment to ^0.5.2 in every package that declares them. The lockfile held two older better-auth lines next to the new one: @onmax/nuxt-better-auth pins the auth CLI to 1.7.0-rc.5, @vite-hub/auth kept its ^1.6.23 range on the 1.6.26 copy, and @better-auth/infra's @better-auth/core peer stayed on 1.6.26. Workspace overrides point all three at 1.7.10, so the graph carries a single better-auth version. One unctx snapshot referenced an unplugin peer id with no matching snapshot key; it now points at the existing key, and aube ci leaves the lockfile byte-identical. --- apps/web/package.json | 6 +- packages/api/package.json | 2 +- packages/auth/package.json | 6 +- pnpm-lock.yaml | 853 +++++++++++++++++-------------------- pnpm-workspace.yaml | 10 + 5 files changed, 415 insertions(+), 462 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 53c7cdf..7c90564 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,7 @@ "typecheck": "nuxt typecheck" }, "dependencies": { - "@better-auth/infra": "^0.4.0", + "@better-auth/infra": "^0.4.20", "@code-zero/api": "workspace:*", "@code-zero/auth": "workspace:*", "@code-zero/build-env": "workspace:*", @@ -32,7 +32,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 +43,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/packages/api/package.json b/packages/api/package.json index c17957b..d98f507 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/auth/package.json b/packages/auth/package.json index 2e56b76..c3204e2 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -25,10 +25,10 @@ "typecheck": "tsc --project tsconfig.json --pretty false --noEmit" }, "dependencies": { - "@better-auth/infra": "^0.4.0", + "@better-auth/infra": "^0.4.20", "@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/pnpm-lock.yaml b/pnpm-lock.yaml index 2b5c549..75663e9 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: @@ -211,10 +214,10 @@ importers: dependencies: '@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) + 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(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) '@code-zero/api': specifier: workspace:* version: 0.3.0 @@ -243,11 +246,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(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(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,8 +282,8 @@ 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(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)) @@ -391,8 +394,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(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))) 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)) @@ -423,19 +426,19 @@ importers: dependencies: '@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) + 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(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) '@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(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(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))) zod: specifier: ^4.4.3 version: 4.4.3 @@ -1892,62 +1895,32 @@ 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/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.3.7 + 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/core@1.7.0-rc.5': - resolution: {integrity: sha512-Wustx35oZYDPizZQsmYBPEVCkWpCqMiOn72rsEw5Dby2Z3r97Rzi1ovP/YTgAzpVR5orKw3NHvsEZXqYkffLuQ==} + '@better-auth/drizzle-adapter@1.7.10': + resolution: {integrity: sha512-Uc/Eh9cnvorIWA6J97Hv8zjR8sCVUP6G7M3xHJGTD9qJXC69zoY4ULmNGA73AmGJB1q+VEYs2cRMtJ6fR6HP6g==} 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.4.0 - 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==} - 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 +1942,37 @@ 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/prisma-adapter@1.7.10': + resolution: {integrity: sha512-hNlhn0Kcq/O76SehvqznFsjqfEMdM2m2ctazU/BLv+jT8+hGvvxCInXZi6x86/xpAyDTgU7t7KsK50r6mdNR1g==} 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/prisma-adapter@1.6.26': - resolution: {integrity: sha512-ILQoYmnoYyghDP4iN1h/Gkd9Jt7Xs/vxoNAt4AuuJJbqs73u27LlQM4P1YiP7nJ+bM1iVTBI1dDF73myIAtjzQ==} - 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 +1981,18 @@ packages: prisma: optional: true - '@better-auth/prisma-adapter@1.7.0-rc.5': - resolution: {integrity: sha512-AFDyK0F6rwlHpBFmWRZnAkO9ctL/GYKIqzvz8emMXP8uLYUQvCn/EgjwPeL5YSUGtZYxruNPga3i/SuqgxSrkA==} + '@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 - '@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/telemetry@1.6.26': - resolution: {integrity: sha512-pBs69RORUSJHRF9r5PdeY7pzLRga09YSWew8igFYrBvT2tLt7DktrbD5WjryLZpAZOuKAMZ+Xo0DlJbnvLWGpQ==} - peerDependencies: - '@better-auth/core': ^1.6.26 - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - - '@better-auth/telemetry@1.7.0-rc.5': - resolution: {integrity: sha512-YYNoKaRyWmSSsb/jxoD0hUDI7aeMfDMIkYEE5vZKbq8yXxe+g4VWUE8To44ylEQeIqjRiov/y8sIyC6oEB03pQ==} - 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==} @@ -4671,8 +4595,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' @@ -8742,6 +8666,10 @@ packages: resolution: {integrity: sha512-y5ArHvQ7BVule/+L9yE2nYMhceiJhgsqo58lOfnisQ7bg+Kjfmkgr7JBuVFiTkl+ErdShpp829QstZQyLugl8g==} engines: {node: '>=20'} + '@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==} @@ -9135,8 +9063,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 +9168,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 +9187,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 +9200,6 @@ packages: optional: true '@tanstack/solid-start': optional: true - better-sqlite3: - optional: true drizzle-kit: optional: true drizzle-orm: @@ -9364,16 +9227,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 +12064,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 +12240,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==} @@ -14711,9 +14569,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==} @@ -18189,129 +18044,74 @@ 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)': - 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.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 + zod: 4.6.5 - '@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/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)': 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) + '@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/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(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(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))': - 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/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/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.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.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)': - 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/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)': - 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/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.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 - '@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/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 - '@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-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 +18445,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: @@ -19919,6 +19722,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 +20784,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(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(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(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))(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(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 +20804,24 @@ 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 + - drizzle-kit + - drizzle-orm - giget - magicast + - mongodb + - mysql2 - next + - pg + - prisma + - react + - react-dom - solid-js - svelte @@ -21921,6 +21733,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 +21741,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 +21766,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 +21777,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 +21805,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 +21825,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - '@types/react-dom' + optional: true '@protobufjs/aspromise@1.1.2': {} @@ -22030,13 +21855,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 +21872,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 +21880,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 +21893,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 +21902,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 +21910,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: @@ -23008,35 +22841,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 +22892,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 +22952,7 @@ snapshots: '@types/react@19.2.18': dependencies: csstype: 3.2.3 + optional: true '@types/request@2.48.13': dependencies: @@ -23129,13 +22975,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 +23422,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 +23440,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 +23458,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 +23476,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 +23503,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 +23546,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 +24502,8 @@ snapshots: dependencies: system-architecture: 1.0.0 + '@xmldom/xmldom@0.9.12': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -24966,17 +24831,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))(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(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 +24853,26 @@ 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 + - drizzle-kit + - drizzle-orm - giget - magicast + - mongodb + - mysql2 - next + - pg + - prisma + - react + - react-dom - solid-js - svelte @@ -25016,7 +24889,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 +24957,108 @@ 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 + 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 - better-call: 1.3.7(zod@4.4.3) + better-call: 1.4.2(zod@4.6.5) 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) + jose: 6.2.12 + kysely: 0.29.5 + nanostores: 1.5.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 + zod: 4.6.5 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 + - '@opentelemetry/api' + - '@prisma/client' + - mongodb + - prisma + + better-auth@1.7.10(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))): + 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) + '@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 + nanostores: 1.5.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)) + zod: 4.6.5 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@prisma/client' + - drizzle-orm + - mongodb + - prisma + + better-auth@1.7.10(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) + '@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 + better-call: 1.4.2(zod@4.6.5) + defu: 6.1.7 + 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' + - drizzle-orm + - 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 +25161,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 +25390,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 +25720,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 +25758,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 +25847,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 +25891,7 @@ snapshots: delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 + optional: true delayed-stream@1.0.0: {} @@ -26264,6 +26170,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 +26191,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 +26256,8 @@ snapshots: emoticon@4.1.0: {} - empathic@2.0.0: {} + empathic@2.0.0: + optional: true empathic@2.0.1: {} @@ -26870,7 +26779,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 +26805,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 +26944,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 +27158,7 @@ snapshots: generate-function@2.3.1: dependencies: is-property: 1.0.2 + optional: true generator-function@2.0.1: {} @@ -27492,9 +27405,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 +27845,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 +28001,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 +28131,8 @@ snapshots: jose@5.10.0: {} + jose@6.2.12: {} + jose@6.2.9: {} js-base64@3.9.3: {} @@ -28415,7 +28334,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 +28558,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 +28791,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 +29092,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 +29102,7 @@ snapshots: optionalDependencies: '@mongodb-js/zstd': 7.0.0 socks: 2.8.9 + optional: true motion-dom@13.0.0: dependencies: @@ -29244,6 +29167,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 +29178,7 @@ snapshots: named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 + optional: true nano-staged@1.0.2: {} @@ -30452,15 +30377,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 +30398,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 +30409,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 +30638,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 +30690,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) @@ -31011,6 +30935,7 @@ snapshots: dependencies: react: 19.2.8 scheduler: 0.27.0 + optional: true react@19.2.8: {} @@ -31247,7 +31172,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 +31259,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 +31346,6 @@ snapshots: rope-sequence@1.3.4: {} - rou3@0.7.12: {} - rou3@0.8.1: optional: true @@ -31481,7 +31406,8 @@ snapshots: sax@1.6.1: {} - scheduler@0.27.0: {} + scheduler@0.27.0: + optional: true schema-utils@4.3.3: dependencies: @@ -31531,7 +31457,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 +31821,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 +31859,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 +32316,7 @@ snapshots: tr46@5.1.1: dependencies: punycode: 2.3.1 + optional: true tree-kill@1.2.2: {} @@ -32957,6 +32888,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 +32999,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 +33019,6 @@ snapshots: - bare-abort-controller - basic-ftp - beautiful-mermaid - - better-sqlite3 - buffer - bufferutil - bun-types @@ -33102,10 +33035,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 +33385,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 +33436,7 @@ snapshots: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + optional: true whatwg-url@5.0.0: dependencies: @@ -33758,7 +33699,8 @@ snapshots: xmlhttprequest-ssl@2.1.2: {} - xtend@4.0.2: {} + xtend@4.0.2: + optional: true y-protocols@1.0.7(yjs@13.6.32): dependencies: @@ -33877,6 +33819,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 a7229cf..79a5e88 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 From bc5e42f0ba503c594d2a3ad2d42f69a2920d780f Mon Sep 17 00:00:00 2001 From: RedStar Date: Sun, 18 Oct 2026 11:05:15 +0200 Subject: [PATCH 2/9] feat(database): add the tables the Better Auth 1.7 plugins own Declare the schema for the plugins the auth v2 policy registers, so every deployment migrates one deterministic store whether or not a given plugin is turned on: - two-factor: user.two_factor_enabled and the two_factor table - admin: user.banned, ban_reason, ban_expires and session.impersonated_by, which cascades with the administrator - passkey: one row per WebAuthn credential, unique on credential_id - api-key: the api_key table, registered under the plugin's apikey model name, keyed by hash and without a foreign key on the polymorphic reference_id - sso: sso_provider, with domain verification and an organization foreign key - scim: the managed-connection catalog and the directory tables the plugin requires Better Enrollment 0.5 indexes invite.pre_created_user_id, so the store does too. Column names were checked against each plugin's own schema and against the Better Auth Drizzle generator; 0005 is generated by drizzle-kit. --- .../drizzle/0005_tricky_dexter_bennett.sql | 274 ++ .../database/drizzle/meta/0005_snapshot.json | 3257 +++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/client.test.ts | 14 + packages/database/src/index.ts | 14 + packages/database/src/schema/api-key.ts | 67 + packages/database/src/schema/auth.ts | 25 +- packages/database/src/schema/enrollment.ts | 3 + packages/database/src/schema/index.ts | 49 +- packages/database/src/schema/passkey.ts | 51 + packages/database/src/schema/plugins.test.ts | 114 + packages/database/src/schema/scim.ts | 251 ++ packages/database/src/schema/sso.ts | 44 + packages/database/src/schema/two-factor.ts | 41 + 14 files changed, 4209 insertions(+), 2 deletions(-) create mode 100644 packages/database/drizzle/0005_tricky_dexter_bennett.sql create mode 100644 packages/database/drizzle/meta/0005_snapshot.json create mode 100644 packages/database/src/schema/api-key.ts create mode 100644 packages/database/src/schema/passkey.ts create mode 100644 packages/database/src/schema/plugins.test.ts create mode 100644 packages/database/src/schema/scim.ts create mode 100644 packages/database/src/schema/sso.ts create mode 100644 packages/database/src/schema/two-factor.ts 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 0000000..47917a6 --- /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/meta/0005_snapshot.json b/packages/database/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..5841137 --- /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/_journal.json b/packages/database/drizzle/meta/_journal.json index 4708d4d..687d28f 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1788683562349, "tag": "0004_absurd_morg", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1792314139137, + "tag": "0005_tricky_dexter_bennett", + "breakpoints": true } ] } diff --git a/packages/database/src/client.test.ts b/packages/database/src/client.test.ts index 118c6bb..66d6cb6 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 add915e..9626237 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -20,16 +20,30 @@ export { } from './repositories.js'; export { account, + apiKey, deviceCode, invitation, invite, inviteUse, member, organization, + 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 0000000..d75416c --- /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 a249a67..557cc74 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 f38552f..562cc66 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 04a8059..062d042 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -1,15 +1,47 @@ +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 { + 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 +52,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 +66,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 0000000..abcc4e8 --- /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 0000000..134edf3 --- /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 0000000..2ac4160 --- /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/sso.ts b/packages/database/src/schema/sso.ts new file mode 100644 index 0000000..06751fd --- /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 0000000..d9c6b01 --- /dev/null +++ b/packages/database/src/schema/two-factor.ts @@ -0,0 +1,41 @@ +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. */ + 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), + ], +); From 848a08ac549ab5262c92625b26cba09b814e6bf3 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sun, 18 Oct 2026 11:17:43 +0200 Subject: [PATCH 3/9] feat(auth): compose the Better Auth 1.7 plugins from runtime policy Every plugin is now decided by AuthConfig when the instance is built, and every capability that needs something to work refuses to construct without it, naming what is missing and never a configured value: - email verification and password reset, through structural delivery callbacks like the invitation ones; passwords are bounded at 128 - GitLab (optionally self-managed) and Google beside GitHub, each closed to sign-up exactly when the password form is - magic link, hashed tokens, closed to sign-up with the form - passkeys, bound to a relying party from configuration only - two-factor with issuer Code Zero, trusted devices, optional mail OTP - admin, always on: the single authority for user.role, and the source of adminRoles for Better Enrollment too - organization roles owner/admin/member from createAccessControl with the Code Zero statements, kept in step with the plugin's own verbs - organization API keys: cz_ prefix, hashed, 90-day default expiry, rate limited, no session fallback - SSO and SCIM only for a deployment whose entitlements say so The organization plugin's own invitation endpoints are removed before it is registered, so invitations travel through Better Enrollment 0.5 only; org-join invites use the same roles. authCapabilities() derives a serializable view of the policy for the server to publish. API keys: 1.7.10 applies keyExpiration.defaultExpiresIn in seconds although its docs say milliseconds; a test pins the expiry. --- apps/web/package.json | 2 + packages/auth/package.json | 8 + packages/auth/src/access.test.ts | 65 ++++ packages/auth/src/access.ts | 76 +++++ packages/auth/src/auth.test.ts | 124 ++++--- packages/auth/src/auth.ts | 501 ++++++++++++++++++++++------ packages/auth/src/config.test.ts | 190 +++++++++++ packages/auth/src/config.ts | 381 ++++++++++++++++++++-- packages/auth/src/index.ts | 36 +- packages/auth/src/plugins.test.ts | 420 ++++++++++++++++++++++++ packages/auth/tsdown.config.ts | 10 +- pnpm-lock.yaml | 526 +++++++++++++++++++++++++----- 12 files changed, 2080 insertions(+), 259 deletions(-) create mode 100644 packages/auth/src/access.test.ts create mode 100644 packages/auth/src/access.ts create mode 100644 packages/auth/src/plugins.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 7c90564..fabc3fe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,9 @@ "typecheck": "nuxt typecheck" }, "dependencies": { + "@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:*", diff --git a/packages/auth/package.json b/packages/auth/package.json index c3204e2..a60594a 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,7 +29,11 @@ "typecheck": "tsc --project tsconfig.json --pretty false --noEmit" }, "dependencies": { + "@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.5.2", "better-auth": "^1.7.10", diff --git a/packages/auth/src/access.test.ts b/packages/auth/src/access.test.ts new file mode 100644 index 0000000..f7e18b7 --- /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 0000000..31224bd --- /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 f91cba1..8095be7 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 ebdd66a..390c39c 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,44 @@ 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_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 +89,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 +137,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 +201,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 +246,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 +261,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 +364,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 +380,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 +528,88 @@ 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, + maxExpiresIn: config.apiKeyMaximumExpiresInDays, + }, + 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 +621,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 +655,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 +669,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 +779,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 +797,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 f736c8c..de0e222 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 966c186..d1aa26d 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -7,12 +7,49 @@ /** 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_'; + +/** 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 +87,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 +106,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 +184,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 +230,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 +260,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 +319,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 +492,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 6da7173..ee2c925 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 0000000..9fd445d --- /dev/null +++ b/packages/auth/src/plugins.test.ts @@ -0,0 +1,420 @@ +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_EXPIRES_IN_SECONDS, + 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 }; + headers: Headers; + }): Promise; + listApiKeys(options: { query: { organizationId: string }; headers: Headers }): Promise; +} + +/** + * `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' + ); +} + +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('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 96fdeb1..34d7b29 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/pnpm-lock.yaml b/pnpm-lock.yaml index 75663e9..d864675 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,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' + 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.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(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) + 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 @@ -247,10 +259,10 @@ importers: version: 0.4.0 '@octopi-ai/better-enrollment': specifier: ^0.5.2 - version: 0.5.2(better-auth@1.7.10(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) + 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.7.10(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) @@ -283,13 +295,19 @@ importers: version: 9.1.2(vue@3.5.41) better-auth: specifier: ^1.7.10 - version: 1.7.10(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: 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) @@ -395,7 +413,7 @@ importers: version: 2.0.0-beta.26(crossws@0.4.10(srvx@0.11.22)) better-auth: specifier: ^1.7.10 - version: 1.7.10(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))) + 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)) @@ -424,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' + 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.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(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) + 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.5.2 - version: 0.5.2(better-auth@1.7.10(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) + 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.7.10 - version: 1.7.10(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))) + 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 @@ -1121,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==} @@ -1895,6 +1941,14 @@ packages: resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} engines: {node: ^22.18.0 || >=24.11.0} + '@better-auth/api-key@1.7.10': + resolution: {integrity: sha512-x3UHgLWESfqtXl5XnAG30h1vOsp7A1KRvHoWDL8rb/kgSUYPc7wGnmhhwVE9C+uxxSX4/2+gZry+9L2KgzRpOQ==} + peerDependencies: + '@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.10': resolution: {integrity: sha512-c5vxtnk7UxWe/iF/X5sKSgmPcN5n6biuwJBs2dMkceMwvMWBHLosue6h/vmvKns9SYKbkBNWsurFe7yIbv13dQ==} peerDependencies: @@ -1968,6 +2022,16 @@ packages: mongodb: optional: true + '@better-auth/passkey@1.7.10': + resolution: {integrity: sha512-beXys26eius+8XqKOBRLLJTiSOO1cYlJDk8sKD8uV9TYJtJQnXuxzI1IVhOxXuo6ph9s6NgzrstIcovt49iiig==} + peerDependencies: + '@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.7.10': resolution: {integrity: sha512-hNlhn0Kcq/O76SehvqznFsjqfEMdM2m2ctazU/BLv+jT8+hGvvxCInXZi6x86/xpAyDTgU7t7KsK50r6mdNR1g==} peerDependencies: @@ -1981,6 +2045,23 @@ packages: prisma: optional: true + '@better-auth/scim@1.7.10': + resolution: {integrity: sha512-dBZutLg6AeWpsW66onlKUgn4O56WjbIb7xarlDdkNdAPDOSstupuKg2bHAyaoWiHQmyP6xiTa1e08avtS+ZAyw==} + peerDependencies: + '@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/sso@1.7.10': + resolution: {integrity: sha512-OS5OXLq73XGdXgSG2Iyu6ot3ZxnjzB7ti6fQoVKdTuX0X9lkvxtCiOx3pekWzd0YN8b2//EC+RsUvX6wcPuNNw==} + peerDependencies: + '@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.10': resolution: {integrity: sha512-1OamUtbGRaYHB0tRjKK3Jv6hzI6mZMv3pujkg8k7tr3idQuToMoVXsWAD9oFbUTmOLeix58n+grzsl7r0fR0Sw==} peerDependencies: @@ -3101,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==} @@ -3741,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==} @@ -6180,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'} @@ -6782,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'} @@ -8666,6 +8811,14 @@ 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'} @@ -9017,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'} @@ -13098,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'} @@ -14179,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'} @@ -14621,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'} @@ -15433,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==} @@ -15441,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==} @@ -16592,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'} @@ -16962,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 @@ -18044,6 +18252,14 @@ snapshots: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.4 + '@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/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.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.5.0 @@ -18056,25 +18272,20 @@ snapshots: nanostores: 1.5.1 zod: 4.6.5 - '@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)': - 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/utils': 0.5.0 - '@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.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.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(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.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(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) better-call: 1.4.2(zod@4.4.3) jose: 6.2.12 libphonenumber-js: 1.13.15 @@ -18096,11 +18307,46 @@ snapshots: '@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/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.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.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.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/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.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/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.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/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: '@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) @@ -18948,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 @@ -19446,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 @@ -20784,18 +21034,18 @@ snapshots: dependencies: '@oclif/core': 4.11.4 - '@octopi-ai/better-enrollment@0.5.2(better-auth@1.7.10(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.7.10(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.7.10(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.10(@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)(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(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 @@ -20811,8 +21061,6 @@ snapshots: - '@tanstack/react-start' - '@tanstack/solid-start' - chokidar - - drizzle-kit - - drizzle-orm - giget - magicast - mongodb @@ -21709,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 @@ -22199,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': {} @@ -24502,6 +24863,10 @@ 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': {} @@ -24791,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: @@ -24831,7 +25206,7 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - auth@1.7.10(@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)(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) @@ -24841,7 +25216,7 @@ snapshots: '@better-auth/utils': 0.5.0 '@clack/prompts': 1.7.0 '@mrleebo/prisma-ast': 0.16.0 - better-auth: 1.7.10(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 @@ -24862,8 +25237,6 @@ snapshots: - '@tanstack/react-start' - '@tanstack/solid-start' - chokidar - - drizzle-kit - - drizzle-orm - giget - magicast - mongodb @@ -24986,61 +25359,6 @@ snapshots: - mongodb - prisma - better-auth@1.7.10(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))): - 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) - '@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 - better-call: 1.4.2(zod@4.6.5) - defu: 6.1.7 - jose: 6.2.12 - kysely: 0.29.5 - nanostores: 1.5.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)) - zod: 4.6.5 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@prisma/client' - - drizzle-orm - - mongodb - - prisma - - better-auth@1.7.10(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) - '@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 - better-call: 1.4.2(zod@4.6.5) - defu: 6.1.7 - jose: 6.2.12 - kysely: 0.29.5 - nanostores: 1.5.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 - zod: 4.6.5 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@prisma/client' - - drizzle-orm - - mongodb - - prisma - better-call@1.4.2(zod@4.4.3): dependencies: '@better-auth/utils': 0.5.0 @@ -29348,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: @@ -30854,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 @@ -31404,6 +31732,16 @@ 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: @@ -32358,6 +32696,8 @@ snapshots: transitivePeerDependencies: - '@typescript/native-preview' + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.23.12: @@ -32366,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 @@ -33695,10 +34039,26 @@ 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: {} + xpath@0.0.32: {} + + xpath@0.0.33: {} + + xpath@0.0.34: {} + xtend@4.0.2: optional: true From d8bb65320c48dd838a9910a6c98bbab728389e82 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sun, 18 Oct 2026 11:27:02 +0200 Subject: [PATCH 4/9] feat(api): ask Better Auth's admin plugin who administers the deployment The session principal no longer parses user.role against a hard-coded admin role. It asks the admin plugin's userHasPermission whether the account may set roles, the permission that implies every other, so the plugin's adminRoles, access control and adminUserIds are the only place administration is decided. The role the session already carries is passed along, so the check costs no extra lookup. It fails closed: an instance without the plugin, or a check that throws, grants only the non-writable modes. --- .../organizations/accept-invitation/[id].vue | 58 ------------- packages/api/src/orpc/auth.ts | 67 +++++++++++---- packages/api/src/orpc/router.test.ts | 86 ++++++++++++++++++- 3 files changed, 132 insertions(+), 79 deletions(-) delete mode 100644 apps/web/app/pages/(organizations)/organizations/accept-invitation/[id].vue 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 68a8582..0000000 --- a/apps/web/app/pages/(organizations)/organizations/accept-invitation/[id].vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/packages/api/src/orpc/auth.ts b/packages/api/src/orpc/auth.ts index 71be180..3d8fd5c 100644 --- a/packages/api/src/orpc/auth.ts +++ b/packages/api/src/orpc/auth.ts @@ -5,7 +5,7 @@ import type { Session, User } from 'better-auth'; import { 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 }>; }; } @@ -30,7 +44,7 @@ export interface BetterAuthSessionApi { */ interface BetterAuthSessionPayload { session: Pick; - /** `role` is an additional field the deployment adds, so it is absent from Better Auth's `User`. */ + /** `role` is the admin plugin's field, so it is absent from Better Auth's base `User`. */ user: Pick & { readonly role?: unknown }; } @@ -54,28 +68,43 @@ 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; } -/** 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; + } } /** @@ -95,7 +124,7 @@ export async function betterAuthPrincipal( // 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)); } /** diff --git a/packages/api/src/orpc/router.test.ts b/packages/api/src/orpc/router.test.ts index c9b0e65..b600b7d 100644 --- a/packages/api/src/orpc/router.test.ts +++ b/packages/api/src/orpc/router.test.ts @@ -161,14 +161,32 @@ 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'), + }), }, }; } @@ -515,6 +533,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 +686,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(() => From d66139c8d28febbfbbf5beec9476ae8d99f72d37 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sun, 18 Oct 2026 11:33:08 +0200 Subject: [PATCH 5/9] feat(web): read auth capabilities at runtime and wire the v2 plugins nuxt.config.ts no longer captures the sign-in policy at build time. GET /api/capabilities serves authCapabilities() from the same environment server/auth.config.ts builds Better Auth from, and useAuthCapabilities() fetches it during SSR, so /login, /signup and the navigation follow a policy change after a restart, not a rebuild. Only the hosted-infrastructure flag stays in appConfig, because it decides which client code ships. server/auth.config.ts reads the policy through authEnvironment(), which supplies this app's own origin for the passkey relying party, binds the existing emailVerification and passwordReset templates, and passes no entitlements, so SSO and SCIM stay unregistered. The client registers the admin, two-factor, passkey, magic-link and API-key plugins. Organization invitations now go through Better Enrollment org-join invites and are offered only where the deployment serves them; owner is no longer an invitable role. The organization plugin's accept page was removed with the endpoints it called (the deletion landed in the previous commit by mistake), along with its unused i18n keys. Docs: architecture, the authentication guide, OAuth providers, permissions, organizations, deployment and the env example describe the runtime policy, the new methods, roles, admin, API keys, and the entitlement-gated SSO and SCIM. --- README.md | 13 +++-- .../1.guide/11.authentication/1.overview.md | 39 +++++++++++--- .../1.guide/11.authentication/2.oauth.md | 24 +++++++-- .../11.authentication/3.permissions.md | 17 ++++++ apps/docs/content/1.guide/12.organizations.md | 6 ++- apps/docs/content/1.guide/13.frontend.md | 2 +- apps/docs/content/1.guide/16.deployment.md | 4 +- .../1.guide/4.environment-variables.md | 4 +- apps/web/.env.example | 40 +++++++++++--- apps/web/app/auth.config.ts | 19 ++++++- apps/web/app/pages/(auth)/login.vue | 6 +-- apps/web/app/pages/(auth)/signup.vue | 12 ++--- .../(organizations)/organizations/index.vue | 5 +- .../auth/composables/useAuthCapabilities.ts | 54 +++++++++++++++++++ .../organizations/components/InviteForm.vue | 1 - .../composables/useOrganizations.ts | 39 ++++++-------- .../organizations/types/organization.ts | 10 ++-- .../modules/shared/components/app/Sidebar.vue | 8 +-- apps/web/nuxt.config.ts | 49 ++++++++--------- apps/web/server/api/capabilities.get.ts | 14 +++++ apps/web/server/auth.config.ts | 15 +++++- apps/web/server/utils/auth-policy.ts | 37 +++++++++++++ apps/web/test/nuxt/pages/login.spec.ts | 38 ++++++++++--- apps/web/test/nuxt/pages/signup.spec.ts | 37 +++++++++++-- apps/web/test/unit/auth-policy.test.ts | 39 ++++++++++++++ docs/architecture.md | 12 +++-- packages/i18n/locales/en/organizations.json | 13 +---- packages/i18n/locales/it/organizations.json | 13 +---- .../i18n/schemas/organizations.schema.json | 27 ---------- 29 files changed, 425 insertions(+), 172 deletions(-) create mode 100644 apps/web/modules/auth/composables/useAuthCapabilities.ts create mode 100644 apps/web/server/api/capabilities.get.ts create mode 100644 apps/web/server/utils/auth-policy.ts create mode 100644 apps/web/test/unit/auth-policy.test.ts diff --git a/README.md b/README.md index eadc8e9..190ec35 100644 --- a/README.md +++ b/README.md @@ -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 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 a17c355..65566d4 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 | + +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 448ddf3..c675f6c 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 9553a1d..0352524 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 4162d3c..ac784ac 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 7844b99..7bd9f42 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 6ac2bc6..747d25b 100644 --- a/apps/docs/content/1.guide/16.deployment.md +++ b/apps/docs/content/1.guide/16.deployment.md @@ -17,7 +17,7 @@ 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). - [ ] **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). @@ -36,7 +36,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/4.environment-variables.md b/apps/docs/content/1.guide/4.environment-variables.md index acf707c..52f9ea0 100644 --- a/apps/docs/content/1.guide/4.environment-variables.md +++ b/apps/docs/content/1.guide/4.environment-variables.md @@ -114,9 +114,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 720be74..e136f9a 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -78,11 +78,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 +174,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 5446f9d..a5c9785 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 3f013b3..4f2e66c 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 c28bc09..d4f99ed 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/(organizations)/organizations/index.vue b/apps/web/app/pages/(organizations)/organizations/index.vue index 96e6288..582888c 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/Execution.vue b/apps/web/modules/settings/components/Execution.vue new file mode 100644 index 0000000..e3cdbf2 --- /dev/null +++ b/apps/web/modules/settings/components/Execution.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/web/modules/settings/components/Export.vue b/apps/web/modules/settings/components/Export.vue new file mode 100644 index 0000000..5cf9053 --- /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 0000000..5243c73 --- /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 0000000..edb59a5 --- /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 0000000..ae0444a --- /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 0000000..40a810b --- /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 0000000..bc5f165 --- /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 0000000..c7de2ae --- /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 0000000..53dde91 --- /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 0000000..ee45cdf --- /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 0000000..11225cf --- /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 0000000..9f95b49 --- /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 0000000..f551072 --- /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 0000000..3baf2ce --- /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 0000000..a34415c --- /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 0000000..992bdc3 --- /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 0000000..f344a93 --- /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 0000000..03b28aa --- /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 0000000..e1e3789 --- /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 0000000..c2a9b81 --- /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 0000000..90bb20e --- /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 0000000..0629ce5 --- /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 0000000..3b0464f --- /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 3dd23fd..2905120 100644 --- a/apps/web/modules/shared/components/app/Sidebar.vue +++ b/apps/web/modules/shared/components/app/Sidebar.vue @@ -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 b837940..f7466cc 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 4086a2a..41e3882 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -227,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' } }, diff --git a/apps/web/server/api/v1/[...].ts b/apps/web/server/api/v1/[...].ts index 9b0ebd5..6eac55b 100644 --- a/apps/web/server/api/v1/[...].ts +++ b/apps/web/server/api/v1/[...].ts @@ -51,6 +51,7 @@ export default defineEventHandler(async (event) => { apiKeys: apiKeyVerifier(serverAuth(event)), }), auditLog: auditLogStore, + settings: await settingsAuthority(), }, }); if (matched) return response; diff --git a/apps/web/server/plugins/poller.ts b/apps/web/server/plugins/poller.ts index f32eb9b..63d5552 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 21656be..0b7fcb2 100644 --- a/apps/web/server/routes/rpc/[...].ts +++ b/apps/web/server/routes/rpc/[...].ts @@ -47,6 +47,7 @@ export default defineEventHandler(async (event) => { 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 03a899f..8db7d24 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 95041da..269b4f8 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/deployment.ts b/apps/web/server/utils/deployment.ts index c828d11..3d69df2 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 50dd271..ef29d8e 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 0000000..f15a403 --- /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 0000000..d7541e3 --- /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/settings.spec.ts b/apps/web/test/nuxt/pages/settings.spec.ts new file mode 100644 index 0000000..528807a --- /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/unit/settings.test.ts b/apps/web/test/unit/settings.test.ts new file mode 100644 index 0000000..0dbe271 --- /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 75a1b56..5ba7b91 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 1a4d566..0000000 --- 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 0000000..5c229dc --- /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 34d1473..0692c10 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 diff --git a/packages/api/src/access.ts b/packages/api/src/access.ts index a0b96a9..02831dc 100644 --- a/packages/api/src/access.ts +++ b/packages/api/src/access.ts @@ -30,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; } /** @@ -122,8 +130,18 @@ 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. */ @@ -142,6 +160,7 @@ const TASK_CREATE_PERMISSION = { resource: 'task', action: 'create' } as const; export function apiKeyPrincipal( name: string, permissions: Readonly> | null | undefined, + organizationId?: string, ): Principal { const actions = permissions?.[TASK_CREATE_PERMISSION.resource] ?? []; return { @@ -149,5 +168,6 @@ export function apiKeyPrincipal( 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 e689f1d..0c1a999 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 53e91d4..3ad830c 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 bce8013..39374bf 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -48,6 +48,7 @@ export { type DeliveryClaim, type DeliveryClaimStore, type KeyValueStorage, + type ScheduleLimits, type SchedulerOptions, type SchedulerSnapshot, type StoredTask, @@ -83,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 46ea569..e39a2c4 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 5893974..8621def 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 0000000..cb9a72f --- /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 416aeff..3f23867 100644 --- a/packages/api/src/orpc/auth.ts +++ b/packages/api/src/orpc/auth.ts @@ -43,7 +43,8 @@ export interface BetterAuthSessionApi { * below never looks at. */ interface BetterAuthSessionPayload { - session: Pick; + /** `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 }; } @@ -148,11 +149,14 @@ 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, await isAdministrator(auth, user)); + return sessionPrincipal(name, await isAdministrator(auth, user), { + userId: user.id, + activeOrganizationId: session.activeOrganizationId ?? null, + }); } /** @@ -173,7 +177,11 @@ async function apiKeyPrincipalFrom( 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); + return apiKeyPrincipal( + `api-key:${key.id}@${key.referenceId}`, + key.permissions, + key.referenceId, + ); } catch { return null; } diff --git a/packages/api/src/orpc/router.ts b/packages/api/src/orpc/router.ts index dc66b53..321353a 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; } /** @@ -183,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, @@ -325,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( @@ -374,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 0000000..5f35dec --- /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 0000000..bfbd35b --- /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 0000000..a780694 --- /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 0000000..c94e3a0 --- /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/core/config/src/deployment.test.ts b/packages/core/config/src/deployment.test.ts index 50b723a..61f49f8 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 76fa9e0..30833d9 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 15300f8..7258e0f 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 0000000..8d51dcd --- /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 0000000..bca058d --- /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/0006_normal_felicia_hardy.sql b/packages/database/drizzle/0006_normal_felicia_hardy.sql new file mode 100644 index 0000000..8662d7f --- /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/0006_snapshot.json b/packages/database/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..2d94f46 --- /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 687d28f..49be302 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "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/index.ts b/packages/database/src/index.ts index 9626237..573e4ba 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -18,15 +18,25 @@ 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, diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 062d042..8d123f0 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -28,6 +28,7 @@ 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, diff --git a/packages/database/src/schema/settings.ts b/packages/database/src/schema/settings.ts new file mode 100644 index 0000000..b409fa2 --- /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/settings.test.ts b/packages/database/src/settings.test.ts new file mode 100644 index 0000000..c928cd8 --- /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 0000000..6e7a565 --- /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 4cd942b..1ddac70 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/it/dashboard.json b/packages/i18n/locales/it/dashboard.json index 8ddf621..0d7cd60 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/schemas/dashboard.schema.json b/packages/i18n/schemas/dashboard.schema.json index 147d660..2b55d79 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