diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7471a68 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 Quality Runtime contributors +# SPDX-License-Identifier: Apache-2.0 + +DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ca43fa..fa25aee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - run: bun install --frozen-lockfile - run: bun run check + # PGlite runs PostgreSQL in-process, so the migration and schema tests + # need no service container. + - run: bun run test dco: # Trust is PR-level: the GitHub App is the only identity that can open a PR diff --git a/.gitignore b/.gitignore index 91dc36d..3ce9c40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules/ /tmp/ .env -.env.* -!.env.example +.env.local +.env.*.local .DS_Store diff --git a/AGENTS.md b/AGENTS.md index f7629c6..6e5247c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,8 @@ Read `ARCHITECTURE.md` before making structural changes. Deployment adapters may depend on core code; core code must not depend on deployment adapters or provider-specific concerns such as Cloudflare. +Apps go in `apps/`, shared code in `packages/` — extract a package only when the boundary is real. + ## Changes - Make the smallest coherent change that solves the problem. @@ -67,4 +69,4 @@ Hosted-service concerns such as billing, metering, customer provisioning, and in ## Before finishing -Run `bun run check` (Vite+ format, lint, and type checks) and `uvx --from 'reuse[charset-normalizer]' reuse lint`. +Run `bun run check` (Vite+ format, lint, and type checks), `bun run test`, and `uvx --from 'reuse[charset-normalizer]' reuse lint`. diff --git a/bun.lock b/bun.lock index 989f0ce..9d44974 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,21 @@ "vite-plus": "^0.3.2", }, }, + "packages/db": { + "name": "@qualityruntime/db", + "version": "0.0.0", + "dependencies": { + "drizzle-orm": "^0.45.2", + "nanoid": "^6.0.1", + "pg": "^8.23.0", + }, + "devDependencies": { + "@electric-sql/pglite": "^0.5.8", + "@types/pg": "^8.23.1", + "better-auth": "^1.7.5", + "drizzle-kit": "^0.31.10", + }, + }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -18,10 +33,94 @@ "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@better-auth/core": ["@better-auth/core@1.7.5", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "@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" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ=="], + + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q=="], + + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-1wE5gvnjW+c1i4GrLtL9HLn3s0Xrq4YneCDan1NO1dpz0mEguLFNlzfnUGykTFrDwvFh2X5rkIbA8ALCj6WzXQ=="], + + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2" } }, "sha512-YDmnfR9zOXbn5SNYYwfHBPuc19hg1d1C1vUXb+Hm8Q91pTsstFNX5OTlZbo7f28q06FpogAEfGGCN/2QV39cig=="], + + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-Yq0LfF0VlA9Kfjcjp/v43MSsChv7vKd1ct0Mt15px4zlqaCPIGfPbjw9YNM6jTo0fGpqLR0n15PllSWmLLRp9g=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.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" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-QfW6HS9vK0FMcbI/GsQLdplICxOz0EPzYWGONZT4ovL3cSItd4YH/09USbPPVl+VUQCdznAQIk+n+NKvHdmSag=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2" } }, "sha512-e/REPqMy9Em+gC6G0xWBikiMLRuy532Er7jqdoNkPBa65FbywgWcm1cZbgdW5CnHsrM3OLZsmKn14yeGoDISeg=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.2", "", {}, "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow=="], + "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@electric-sql/pglite": ["@electric-sql/pglite@0.5.8", "", {}, "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], + + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.149.0", "", {}, "sha512-XNtswoJPeaVa+Ry8A1FdEaJTAu6xZ8zg4ZijGPOAWWMRCUuDekEDwxmtIgP5DG67Sh1CVmq+bnTbqhnGHhn/Lg=="], "@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="], @@ -118,6 +217,8 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@qualityruntime/db": ["@qualityruntime/db@workspace:packages/db"], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="], @@ -168,6 +269,8 @@ "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], @@ -300,20 +403,34 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "better-auth": ["better-auth@1.7.5", "", { "dependencies": { "@better-auth/core": "1.7.5", "@better-auth/drizzle-adapter": "1.7.5", "@better-auth/kysely-adapter": "1.7.5", "@better-auth/memory-adapter": "1.7.5", "@better-auth/mongo-adapter": "1.7.5", "@better-auth/prisma-adapter": "1.7.5", "@better-auth/telemetry": "1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "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", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "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", "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 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-aKE0Zt2EPTpFvmq4/oATNyG/mAfc6JUqWkW9pzGlrVnzUb0lso7GJ9BPxD6JPhLqYV1a9zOAb0uAz1Q5fm+eHA=="], + + "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], @@ -322,8 +439,14 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "kysely": ["kysely@0.29.6", "", {}, "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -354,7 +477,9 @@ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], - "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + "nanoid": ["nanoid@6.0.1", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw=="], + + "nanostores": ["nanostores@1.5.3", "", {}, "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA=="], "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], @@ -366,6 +491,22 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -374,18 +515,38 @@ "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="], + "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], @@ -402,6 +563,8 @@ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -416,14 +579,122 @@ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yuku-ast": ["yuku-ast@0.9.5", "", { "dependencies": { "@yuku-toolchain/types": "^0.9.5" } }, "sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ=="], "yuku-codegen": ["yuku-codegen@0.9.5", "", { "dependencies": { "@yuku-toolchain/types": "^0.9.5" }, "optionalDependencies": { "@yuku-codegen/binding-android-arm64": "0.9.5", "@yuku-codegen/binding-darwin-arm64": "0.9.5", "@yuku-codegen/binding-darwin-x64": "0.9.5", "@yuku-codegen/binding-freebsd-x64": "0.9.5", "@yuku-codegen/binding-linux-arm-gnu": "0.9.5", "@yuku-codegen/binding-linux-arm-musl": "0.9.5", "@yuku-codegen/binding-linux-arm64-gnu": "0.9.5", "@yuku-codegen/binding-linux-arm64-musl": "0.9.5", "@yuku-codegen/binding-linux-x64-gnu": "0.9.5", "@yuku-codegen/binding-linux-x64-musl": "0.9.5", "@yuku-codegen/binding-win32-arm64": "0.9.5", "@yuku-codegen/binding-win32-x64": "0.9.5" } }, "sha512-zGUVyDpSK4b6c9B0yyxi2P0wujn1XZTbG9dCb7d6gyAoP63EMgyLzUwPUvwxPG5jgYqhlI7w+S3oCyapqGr4PA=="], "yuku-parser": ["yuku-parser@0.9.5", "", { "dependencies": { "@yuku-toolchain/types": "^0.9.5", "yuku-ast": "^0.9.5" }, "optionalDependencies": { "@yuku-parser/binding-android-arm64": "0.9.5", "@yuku-parser/binding-darwin-arm64": "0.9.5", "@yuku-parser/binding-darwin-x64": "0.9.5", "@yuku-parser/binding-freebsd-x64": "0.9.5", "@yuku-parser/binding-linux-arm-gnu": "0.9.5", "@yuku-parser/binding-linux-arm-musl": "0.9.5", "@yuku-parser/binding-linux-arm64-gnu": "0.9.5", "@yuku-parser/binding-linux-arm64-musl": "0.9.5", "@yuku-parser/binding-linux-x64-gnu": "0.9.5", "@yuku-parser/binding-linux-x64-musl": "0.9.5", "@yuku-parser/binding-win32-arm64": "0.9.5", "@yuku-parser/binding-win32-x64": "0.9.5" } }, "sha512-IBnAdNVMswWbJcM7woPluOudectJzFjJ1N8jVRxP9Uq4CIv5hZdBIcdmtRbFDYF/Ph2j2gVup4YJ4N9mh0jYFA=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], + + "postcss/nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="], + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "vitest/vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], } } diff --git a/docs/adr/0001-drizzle-orm-and-better-auth.md b/docs/adr/0001-drizzle-orm-and-better-auth.md new file mode 100644 index 0000000..e7cde84 --- /dev/null +++ b/docs/adr/0001-drizzle-orm-and-better-auth.md @@ -0,0 +1,59 @@ +# 1. Drizzle ORM for persistence, Better Auth for authentication + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +The runtime needs database access and authentication before any domain work can start. PostgreSQL is already a committed choice (DATA-01), and tenant isolation is an invariant (TENANT-01), so the organization concept has to exist in the schema from the first migration rather than be retrofitted around it. + +Authentication is a large surface — sessions, credentials, social providers, email verification, MFA, invitations — and building it is not what makes this product valuable. + +## Decision + +Use **Drizzle ORM** for schema definition, queries, and migrations, and **Better Auth** for authentication, tenancy, and instance administration. + +Drizzle's schema is plain TypeScript that compiles to SQL we can read in review, and `drizzle-kit generate` produces checked-in, forward-only SQL migrations (MIGRATION-01). It stays close to SQL, so the PostgreSQL capabilities the architecture leans on remain reachable. + +Better Auth ships the tenancy model the product needs in its `organization` plugin, so organizations, memberships, and invitations are one source of truth rather than a parallel model beside the auth library's own. The schema supports these plugins; application authentication is not implemented yet: + +| Plugin | Why | +| -------------- | --------------------------------------------------------------------- | +| `organization` | The tenant boundary: organizations, memberships, invitations | +| `admin` | Instance operator actions — roles, suspension, impersonation | +| `twoFactor` | MFA, a named control in the standards the product is built to support | + +Teams and dynamic access control are deliberately off. Both add tables, and neither has a requirement behind it yet. + +The Drizzle schema is written by hand rather than generated into the repository on every change. Two things keep it honest, and they cover different ground: + +- Better Auth 1.7 runs the authoritative **structural** check when its adapter initializes, against the Drizzle schema object rather than the live database: every table and column it writes must exist, and no column it never fills may be required. It does not compare types, uniqueness, indexes, or references, and it does not prove equivalence with the generated DDL. +- `packages/db/schema/auth.test.ts` asserts the same structural properties from `getAuthTables`, plus an identifier format per model — before any application exists to initialize an adapter. + +Nothing automatically verifies column types, uniqueness, indexes, or references against Better Auth's definitions. When upgrading, compare a reference schema generated from the matching Better Auth configuration with the maintained schema. `migrations.test.ts` exercises selected database-level deviations and invariants against a real engine; it does not verify every deviation. + +Migrations are generated with `drizzle-kit` and applied with it during development. Deployment applies the same checked-in migration history by a target-appropriate mechanism. + +## Consequences + +Better Auth owns the shape of the `user`, `session`, `account`, `verification`, `organization`, `member`, `invitation`, and `two_factor` tables. Changes to fields it writes must keep the Better Auth configuration, Drizzle schema, and new migrations aligned. Upgrades can add fields, and the schema test is what makes that visible. + +How a deployed instance applies migrations is left open: a deployment may run them from its pipeline or use `drizzle-orm`'s migrator. That decision belongs in `docs/deployment.md` once a target exists. Development uses `drizzle-kit`; tests apply the same SQL history through Drizzle's PGlite migrator. + +Deviations from Better Auth's generated schema are deliberate and documented in `auth.ts`: `timestamptz` columns, database defaults on `createdAt` and `updatedAt`, a unique membership per organization and user, a unique `(providerId, accountId)` per account, a foreign key on `session.activeOrganizationId`, and indexes on the foreign-key columns that parent deletion walks. These preserve the library’s required fields while adding database integrity checks and deletion support. + +No Drizzle `relations()` are declared. They serve Better Auth's opt-in join mode and Drizzle's relational queries; neither is in use. Add relations when one of those features needs them. + +`createDatabase` takes a `pg` `Client` or `Pool` rather than a connection string. Connection lifecycle differs enough between a long-lived server and a Worker behind Hyperdrive that choosing one here would put a deployment concern in the core (ARCH-01); the deployment owns it and injects the result. + +Domain tables join the same package and the same migration history. `packages/db` depends on `better-auth` only as a development dependency, for the schema test; the server configuration that calls `betterAuth()` will live with the application that serves it, and must both use the same schema-affecting plugin configuration the test declares — plugin options such as `teams` add tables — and pass `generateId` from [ADR 0002](0002-prefixed-identifiers.md): + +```ts +betterAuth({ + plugins: [organization(), admin(), twoFactor()], + advanced: { database: { generateId } }, +}); +``` diff --git a/docs/adr/0002-prefixed-identifiers.md b/docs/adr/0002-prefixed-identifiers.md new file mode 100644 index 0000000..14457b3 --- /dev/null +++ b/docs/adr/0002-prefixed-identifiers.md @@ -0,0 +1,59 @@ +# 2. Prefixed identifiers + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +Identifiers in a quality and compliance system are long-lived and widely handled. They appear in audit history, exported records, support tickets, and URLs, often separated from the query that produced them. An opaque identifier gives a reader nothing, and an identifier pasted into the wrong endpoint resolves to nothing rather than announcing the mistake. + +[ADR 0001](0001-drizzle-orm-and-better-auth.md) settled on text primary keys generated by the application. This decides what goes in them. + +## Decision + +Every primary key Quality Runtime generates is `_`, for example `usr_v1stgxr8z5jdhi6b`. This governs row identifiers, not human-facing document numbers, standard clause references, or identifiers minted by other systems. + +The prefix identifies the record type, so an identifier is self-describing wherever it turns up. Naming the _type_ rather than the table keeps the identifier format independent of physical table naming — `twoFactor` records carry `tfa_` and live in `two_factor`. + +The random part is [Nano ID](https://github.com/ai/nanoid) over lowercase base36 (`0-9a-z`). Nano ID's own default is a 64-character URL-safe alphabet including `-` and `_`; both are dropped so the first underscore is unambiguously the prefix separator. + +Case is dropped as well. These identifiers are read aloud on support calls, retyped out of exported records, and compared by eye in audit history, and a single case rule makes all of that safe: nothing downstream can lose a row by normalizing case, and the CHECK constraints reject a mixed-case identifier outright. Mixed case would add about 12 bits per 16 characters, which is a micro-optimization on top of an amount already far past sufficient. + +A **row identifier is not a credential**, and length follows what an identifier does rather than how many rows a table holds: + +| Length | Tables | Why | +| ------ | --------------- | ------------------------------------------------------------- | +| 16 | everything else | An ordinary primary key | +| 24 | `invitation` | The identifier itself participates in a possession-based flow | + +Sixteen base36 characters is about 83 bits (36^16 ≈ 8 × 10^24) — past any collision concern for a database identifier, and one rule covers almost the whole schema. + +`invitation` is the exception because Better Auth takes an invitation _by id_ in `accept`, `reject`, and `get`. It treats a predictable invitation id as security-relevant, gating those calls on a verified email whenever ids are not its own opaque ones. Acceptance also checks the invitation's email against the session's, so the id alone is not a bearer token — but it is the one identifier here that is worth guessing, so it gets the wider size. + +Sizing by table cardinality — wider for `session` and `verification`, narrower for `organization` — was considered and rejected. It is the wrong axis: `session.token` authenticates a session and `verification.value` proves a verification, so widening those `id` columns hardens nothing, and narrowing `organization` buys four characters in exchange for a third rule and a CHECK constraint that is painful to widen later. + +`packages/db/id.ts` owns the prefix and length of every table and is the single place where identifiers are generated. Two things consume it: + +- Each Drizzle `id` column defaults to `createId(type)`, so ordinary inserts need not think about it. +- `generateId` is shaped for Better Auth's `advanced.database.generateId`, which is how the library is told to generate these instead of its own. + +Each table carries a CHECK constraint on the format: + +```sql +CONSTRAINT "user_id_format" CHECK ("id" ~ '^usr_[0-9a-z]{16}$') +``` + +## Consequences + +The format is enforced by the database, not by convention. A writer that has not been wired to `id.ts` — most importantly a Better Auth configuration that forgets `generateId` — fails on its first insert instead of quietly storing identifiers in a second format that later has to be migrated. `migrations.test.ts` applies the migrations to PostgreSQL and asserts exactly that rejection. + +Adding a table means choosing its prefix and its length. `auth.test.ts` fails when Better Auth writes a table that `idFormats` does not cover, and `generateId` throws rather than inventing a format. + +Changing a table's alphabet or length is a migration, because the CHECK constraints encode both. This is the intended cost: it makes the identifier format part of the schema rather than a convention that drifts. It also means a length should be chosen with room to be wrong in the safe direction — widening later is cheap, narrowing invalidates existing rows. + +Passing a custom `generateId` changes one Better Auth default: because the function is not its own generator, `requireEmailVerificationOnInvitation` defaults to on, so accepting an invitation by id requires a verified email. That is a safe default and is left as it stands — the heuristic assumes custom ids may be predictable, which these are not, so a deployment that wants the plain emailed-invitation flow can set the option to `false` deliberately. + +PostgreSQL text comparison is case-sensitive, but the alphabet is not, so lowercasing an identifier anywhere in the stack is a no-op rather than a lost row. diff --git a/docs/data-model.md b/docs/data-model.md index 80f466d..14354e2 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -2,4 +2,40 @@ What Quality Runtime knows: entities, relationships, organization scoping, identifiers, lifecycle state, versioning, and the invariants the schema enforces. -This document will be expanded as the model is implemented. +The schema lives in [`packages/db`](../packages/db): table definitions in `schema/`, checked-in SQL in `migrations/`. + +## Identity and tenancy + +These tables are owned by [Better Auth](https://better-auth.com) and defined in `schema/auth.ts`. See [ADR 0001](adr/0001-drizzle-orm-and-better-auth.md) for why, and for the deviations from its generated schema. + +| Table | Holds | +| -------------- | -------------------------------------------------------------------------------------- | +| `user` | A person. Instance-wide identity, independent of any organization | +| `account` | A credential or linked social provider; unique per (provider, external id) | +| `session` | An authenticated session, including the organization it is acting in | +| `verification` | Short-lived tokens for email verification, password reset, and OTP delivery | +| `two_factor` | TOTP secrets and backup codes | +| `organization` | **The tenant boundary.** Every tenant-owned record belongs to exactly one organization | +| `member` | A user's membership and role in an organization; unique per (org, user) | +| `invitation` | An invitation to join an organization, including its status | + +## Identifiers + +Every record's primary key uses `_`, for example `usr_v1stgxr8z5jdhi6b`. The prefix identifies the record type, and the random portion is a lowercase base36 value generated with [Nano ID](https://github.com/ai/nanoid). [ADR 0002](adr/0002-prefixed-identifiers.md) documents the decision. `packages/db/id.ts` is the single place where identifiers are generated. + +| Table | Prefix | Random length | +| -------------- | ------ | ------------- | +| `invitation` | `inv_` | 24 | +| `user` | `usr_` | 16 | +| `session` | `ses_` | 16 | +| `account` | `acc_` | 16 | +| `verification` | `ver_` | 16 | +| `organization` | `org_` | 16 | +| `member` | `mem_` | 16 | +| `two_factor` | `tfa_` | 16 | + +A row identifier is not a credential: `session.token` authenticates a session and `verification.value` proves a verification. `invitation` is wider because Better Auth takes an invitation by id. Identifiers are allocated before insertion and reveal no row count. Each table enforces its own prefix, length, and alphabet with a CHECK constraint, so an identifier belonging to another table — or one carrying uppercase — is rejected rather than stored. + +## Domain entities + +Quality and compliance entities — documents, requirements, controls, evidence, risks, audits, findings, incidents, CAPAs, training, suppliers, approvals, and workflows — are not implemented yet. They join the same package and the same migration history, and each carries the organization it belongs to. diff --git a/docs/development.md b/docs/development.md index 48c4955..dc06c0c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,27 +16,49 @@ Install dependencies: bun install ``` -Create the required local environment configuration from the provided example when one exists. - ## Development ```sh bun run check # format, lint, and type checks bun run fmt # apply formatting bun run lint # lint only +bun run test # run tests ``` -Vite+ offers more (`vp dev`, `vp build`, `vp test`); those are documented here once the application scaffold uses them. Commands shown in this document work as written; do not assume undocumented ones exist. +Vite+ offers more (`vp dev`, `vp build`); those are documented here once the application scaffold uses them. Commands shown in this document work as written; do not assume undocumented ones exist. ## Database -PostgreSQL is the application database. +PostgreSQL is the application database. The schema, the Drizzle client, and the migrations live in `packages/db`. + +Point `DATABASE_URL` at a database — copy `.env.example` to `.env` in the repository root and adjust it: + +```sh +docker run -d --name qualityruntime-postgres \ + -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=qualityruntime \ + -p 5432:5432 postgres:18 +``` + +`.env` is ignored, as are `.env.local` and `.env.*.local`. Mode-specific `.env.` files are **not** ignored; never put secrets in one. + +Change a table in `packages/db/schema/`, then generate and apply its migration: + +```sh +bun run db:generate --name=add_controls # writes packages/db/migrations/NNNN_add_controls.sql +bun run db:migrate # applies pending migrations to DATABASE_URL +``` + +Both are also `bun run generate` / `bun run migrate` inside `packages/db`; the Drizzle config loads the root `.env` either way. `db:generate` needs no database; `db:migrate` fails if `DATABASE_URL` is unset. + +Always pass `--name`: it names the file and its `tag` in `migrations/meta/_journal.json` together. That file is drizzle-kit's, so never hand-edit it. Add the SPDX header to the generated `.sql`. Never edit a migration that may already have been applied; add a new one. See `AGENTS.md` for the rules that govern migration history, and test migrations against realistic existing data when the change is non-trivial. + +`packages/db/schema/migrations.test.ts` applies the migrations to PostgreSQL (via PGlite, so nothing needs to be running) and checks the constraints they create. `packages/db/schema/auth.test.ts` checks the Drizzle tables against what Better Auth says it writes. -Schema changes add a new migration; see `AGENTS.md` for the rules that govern migration history. Test migrations against realistic existing data when the change is non-trivial. Database setup and migration commands are documented here once implemented. +After upgrading `better-auth`, run `bun run test`, then compare `schema/auth.ts` with Drizzle reference output from the matching `auth` CLI version, generated from the same schema-affecting plugin configuration. Generating it needs a Better Auth config; the exact command belongs here once the application provides one. Those tests are structural only; [ADR 0001](adr/0001-drizzle-orm-and-better-auth.md) records what they do not cover. ## Before finishing -Run `bun run check`, plus the REUSE lint. +Run `bun run check` and `bun run test`, plus the REUSE lint. CI runs `check` and `test`; the REUSE lint has its own workflow. For REUSE validation on macOS: diff --git a/docs/security.md b/docs/security.md index 189d2f9..3300b2a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,4 +4,10 @@ The security model: trust boundaries, authentication, authorization, tenant isol For vulnerability reporting, see [`.github/SECURITY.md`](../.github/SECURITY.md). +## Tenant authorization + +`session.activeOrganizationId` records which tenant a request is acting in. It is **context, not authorization**. Tenant authorization must use the caller’s current `member` row — membership and role — resolved per request (TENANT-01). + +Never treat possession of a session carrying an organization id as proof of access to that organization, and never filter by it alone. + This document will be expanded as authentication, tenancy, and file access are implemented. diff --git a/package.json b/package.json index 64105ed..fb513af 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,18 @@ "name": "@qualityruntime/runtime", "private": true, "license": "Apache-2.0", + "workspaces": [ + "apps/*", + "packages/*" + ], "type": "module", "scripts": { "check": "vp check", "fmt": "vp fmt", - "lint": "vp lint" + "lint": "vp lint", + "test": "vp test", + "db:generate": "bun run --cwd packages/db generate", + "db:migrate": "bun run --cwd packages/db migrate" }, "devDependencies": { "@types/bun": "^1.4.2", diff --git a/packages/db/client.ts b/packages/db/client.ts new file mode 100644 index 0000000..d9a9449 --- /dev/null +++ b/packages/db/client.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +import { drizzle } from "drizzle-orm/node-postgres"; +import type { Client, Pool } from "pg"; +import * as schema from "./schema/index.ts"; + +/** + * A Drizzle handle bound to a connection. + * + * Query code takes this type rather than a connection of its own, so a test can + * hand it a handle bound to a throwaway database. + */ +export type Database = ReturnType; + +/** + * Binds Drizzle to a connection the caller owns. + * + * Connection lifecycle stays with the deployment (ARCH-01): a long-lived `Pool` + * suits a Node or Bun server, while a Worker behind Hyperdrive wants a `Client` + * per request. This package has no way to choose correctly between them, and + * picking one here would put a deployment concern in the core. + */ +export function createDatabase(client: Client | Pool) { + return drizzle({ client, schema, casing: "snake_case" }); +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..97208fd --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +import { relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "drizzle-kit"; + +// drizzle-kit resolves `schema` and `out` against the working directory and +// rejects absolute paths, so they are re-based onto this file's own location. +// `|| "."` covers running from this directory, where the relative path is "". +const packageDir = relative(process.cwd(), fileURLToPath(new URL(".", import.meta.url))) || "."; + +// The repository keeps one `.env` at its root; drizzle-kit only reads the +// working directory's. Loading it here makes the commands behave the same from +// the root and from this package. The file is optional when DATABASE_URL +// is supplied by the environment. +try { + process.loadEnvFile(fileURLToPath(new URL("../../.env", import.meta.url))); +} catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; +} + +// Migrations are checked in and forward-only (MIGRATION-01): `drizzle-kit +// generate --name=` writes a new one, and an existing file is never +// edited. +export default defineConfig({ + dialect: "postgresql", + schema: `${packageDir}/schema/index.ts`, + out: `${packageDir}/migrations`, + // Columns carry explicit names, which win; this only decides how a column + // added without one is spelled. Must match `createDatabase`. + casing: "snake_case", + dbCredentials: { + // Only read by commands that connect, so `generate` works without it. + get url() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set; it is required to reach the database."); + return url; + }, + }, +}); diff --git a/packages/db/id.test.ts b/packages/db/id.test.ts new file mode 100644 index 0000000..2a56af0 --- /dev/null +++ b/packages/db/id.test.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vite-plus/test"; +import { createId, generateId, type IdType, idFormats, idPattern } from "./id.ts"; + +const models = Object.keys(idFormats) as IdType[]; + +describe.each(models)("%s", (model) => { + const { prefix, length } = idFormats[model]; + + it("carries the table's prefix and its own length", () => { + const [head, random, ...rest] = createId(model).split("_"); + expect(head).toBe(prefix); + expect(random).toHaveLength(length); + expect(rest).toEqual([]); + }); + + it("matches its own pattern", () => { + expect(createId(model)).toMatch(new RegExp(idPattern(model))); + }); + + it("matches no other table's pattern", () => { + const id = createId(model); + const others = models.filter((other) => other !== model); + expect(others.filter((other) => new RegExp(idPattern(other)).test(id))).toEqual([]); + }); +}); + +describe("the identifier scheme", () => { + it("uses one case, so nothing downstream can lose a row by normalizing", () => { + for (const model of models) { + const id = createId(model); + expect(id).toBe(id.toLowerCase()); + } + }); + + it("gives every table a distinct prefix", () => { + const prefixes = models.map((model) => idFormats[model].prefix); + expect(new Set(prefixes).size).toBe(prefixes.length); + }); + + it("gives an ordinary row identifier 16 characters", () => { + // One rule for everything that is only ever a primary key. See ADR 0002. + const ordinary = models.filter((model) => model !== "invitation"); + expect(ordinary.map((model) => idFormats[model].length)).toEqual(ordinary.map(() => 16)); + }); + + it("gives an identifier used in a possession-based flow more", () => { + // Better Auth takes an invitation by id in accept/reject/get, so guessing + // one is worth something. A row identifier that is only a key is not. + expect(idFormats.invitation.length).toBeGreaterThan(idFormats.user.length); + }); + + it("supplies Better Auth's generateId for every model it writes", () => { + for (const model of models) { + expect(generateId({ model })).toMatch(new RegExp(idPattern(model))); + } + }); + + it("refuses a table that has not been given a format", () => { + expect(() => generateId({ model: "passkey" })).toThrow(/passkey/); + }); +}); diff --git a/packages/db/id.ts b/packages/db/id.ts new file mode 100644 index 0000000..9d90924 --- /dev/null +++ b/packages/db/id.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Prefixed identifiers: `usr_v1stgxr8z5jdhi6b`. + * + * The prefix identifies the record type, so an identifier is self-describing in + * a log line or an export. `schema/auth.ts` enforces the format with a CHECK + * per table, rejecting inserts with the wrong prefix, alphabet, or length. + * + * Reasoning: `docs/adr/0002-prefixed-identifiers.md`. + */ + +import { customAlphabet } from "nanoid"; + +/** + * Lowercase base36: Nano ID's default URL-safe alphabet without `-`, `_`, and + * case. Dropping `_` keeps the first underscore the prefix separator; dropping + * case means nothing downstream can lose a row by normalizing one. + */ +const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + +/** + * The prefix and random length each table's identifiers carry. + * + * Length follows what an identifier does, not how many rows a table holds: 16 + * for an ordinary row identifier (~83 bits), 24 where the identifier itself + * participates in a possession-based flow. A row identifier is never a + * credential — `session.token` authenticates a session, `verification.value` + * proves a verification — so widening those would harden nothing. + * + * Keys name the record type. The auth types below happen to match Better Auth's + * model names, which `generateId` relies on; domain types join the same list. + * Every record type using this scheme needs an entry; `generateId` throws + * without one. + */ +export const idFormats = { + /** + * The one exception: Better Auth takes an invitation by id in `accept`, + * `reject`, and `get`, and gates those on a verified email whenever ids are + * not its own opaque ones. Entropy keeps that gate from being the only defence. + */ + invitation: { prefix: "inv", length: 24 }, + user: { prefix: "usr", length: 16 }, + session: { prefix: "ses", length: 16 }, + account: { prefix: "acc", length: 16 }, + verification: { prefix: "ver", length: 16 }, + organization: { prefix: "org", length: 16 }, + member: { prefix: "mem", length: 16 }, + twoFactor: { prefix: "tfa", length: 16 }, +} as const; + +export type IdType = keyof typeof idFormats; + +/** One generator per type; Nano ID fixes the length at construction. */ +const randomId = Object.fromEntries( + Object.entries(idFormats).map(([model, { length }]) => [model, customAlphabet(alphabet, length)]), +) as Record string>; + +/** Generates an identifier for `type`, e.g. `createId("user")` → `usr_…`. */ +export function createId(type: IdType): string { + return `${idFormats[type].prefix}_${randomId[type]()}`; +} + +/** The POSIX regular expression an identifier for `type` must match. */ +export function idPattern(type: IdType): string { + const { prefix, length } = idFormats[type]; + return `^${prefix}_[0-9a-z]{${length}}$`; +} + +/** + * Better Auth's `advanced.database.generateId`, so it writes these identifiers + * instead of its own: + * + * ```ts + * betterAuth({ advanced: { database: { generateId } } }); + * ``` + * + * Throws on a model without a format — a table was added without being given + * one, which the schema test catches before a deployment can. + */ +export function generateId({ model }: { model: string }): string { + if (!(model in idFormats)) { + throw new Error(`No identifier format is defined for the "${model}" table.`); + } + return createId(model as IdType); +} diff --git a/packages/db/index.ts b/packages/db/index.ts new file mode 100644 index 0000000..e6b6e42 --- /dev/null +++ b/packages/db/index.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +export { createDatabase, type Database } from "./client.ts"; +export { createId, generateId, type IdType } from "./id.ts"; +export * as schema from "./schema/index.ts"; diff --git a/packages/db/migrations/0000_auth_and_tenancy.sql b/packages/db/migrations/0000_auth_and_tenancy.sql new file mode 100644 index 0000000..a18e552 --- /dev/null +++ b/packages/db/migrations/0000_auth_and_tenancy.sql @@ -0,0 +1,125 @@ +-- SPDX-FileCopyrightText: 2026 Quality Runtime contributors +-- SPDX-License-Identifier: Apache-2.0 + +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp with time zone, + "refresh_token_expires_at" timestamp with time zone, + "scope" text, + "password" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "account_id_format" CHECK ("id" ~ '^acc_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "invitation" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "email" text NOT NULL, + "role" text, + "status" text DEFAULT 'pending' NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "inviter_id" text NOT NULL, + CONSTRAINT "invitation_id_format" CHECK ("id" ~ '^inv_[0-9a-z]{24}$') +); +--> statement-breakpoint +CREATE TABLE "member" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "member_id_format" CHECK ("id" ~ '^mem_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "organization" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "metadata" text, + CONSTRAINT "organization_slug_unique" UNIQUE("slug"), + CONSTRAINT "organization_id_format" CHECK ("id" ~ '^org_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "token" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "active_organization_id" text, + "impersonated_by" text, + CONSTRAINT "session_token_unique" UNIQUE("token"), + CONSTRAINT "session_id_format" CHECK ("id" ~ '^ses_[0-9a-z]{16}$') +); +--> 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, + "failed_verification_count" integer DEFAULT 0, + "locked_until" timestamp with time zone, + CONSTRAINT "two_factor_id_format" CHECK ("id" ~ '^tfa_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "role" text, + "banned" boolean DEFAULT false, + "ban_reason" text, + "ban_expires" timestamp with time zone, + "two_factor_enabled" boolean DEFAULT false, + CONSTRAINT "user_email_unique" UNIQUE("email"), + CONSTRAINT "user_id_format" CHECK ("id" ~ '^usr_[0-9a-z]{16}$') +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "verification_id_format" CHECK ("id" ~ '^ver_[0-9a-z]{16}$') +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_active_organization_id_organization_id_fk" FOREIGN KEY ("active_organization_id") REFERENCES "public"."organization"("id") ON DELETE set null 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 UNIQUE INDEX "account_provider_id_account_id_uidx" ON "account" USING btree ("provider_id","account_id");--> statement-breakpoint +CREATE INDEX "account_user_id_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "invitation_organization_id_idx" ON "invitation" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email");--> statement-breakpoint +CREATE INDEX "invitation_inviter_id_idx" ON "invitation" USING btree ("inviter_id");--> statement-breakpoint +CREATE UNIQUE INDEX "member_organization_id_user_id_uidx" ON "member" USING btree ("organization_id","user_id");--> statement-breakpoint +CREATE INDEX "member_user_id_idx" ON "member" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_active_organization_id_idx" ON "session" USING btree ("active_organization_id");--> 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 +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..a69ef0f --- /dev/null +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,914 @@ +{ + "id": "6fbb5aa9-0f18-4228-80f9-d0226a2c76c7", + "prevId": "00000000-0000-0000-0000-000000000000", + "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_provider_id_account_id_uidx": { + "name": "account_provider_id_account_id_uidx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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": { + "account_id_format": { + "name": "account_id_format", + "value": "\"id\" ~ '^acc_[0-9a-z]{16}$'" + } + }, + "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, + "default": "'pending'" + }, + "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()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "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": {} + }, + "invitation_inviter_id_idx": { + "name": "invitation_inviter_id_idx", + "columns": [ + { + "expression": "inviter_id", + "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": { + "invitation_id_format": { + "name": "invitation_id_format", + "value": "\"id\" ~ '^inv_[0-9a-z]{24}$'" + } + }, + "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, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_organization_id_user_id_uidx": { + "name": "member_organization_id_user_id_uidx", + "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": { + "member_id_format": { + "name": "member_id_format", + "value": "\"id\" ~ '^mem_[0-9a-z]{16}$'" + } + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organization_id_format": { + "name": "organization_id_format", + "value": "\"id\" ~ '^org_[0-9a-z]{16}$'" + } + }, + "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 + }, + "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()" + }, + "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 + } + }, + "indexes": { + "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": {} + }, + "session_active_organization_id_idx": { + "name": "session_active_organization_id_idx", + "columns": [ + { + "expression": "active_organization_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_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": { + "session_id_format": { + "name": "session_id_format", + "value": "\"id\" ~ '^ses_[0-9a-z]{16}$'" + } + }, + "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": false, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "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": { + "two_factor_id_format": { + "name": "two_factor_id_format", + "value": "\"id\" ~ '^tfa_[0-9a-z]{16}$'" + } + }, + "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 + }, + "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()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "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 + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_id_format": { + "name": "user_id_format", + "value": "\"id\" ~ '^usr_[0-9a-z]{16}$'" + } + }, + "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": { + "verification_id_format": { + "name": "verification_id_format", + "value": "\"id\" ~ '^ver_[0-9a-z]{16}$'" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json new file mode 100644 index 0000000..09097ea --- /dev/null +++ b/packages/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1789690350523, + "tag": "0000_auth_and_tenancy", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..157e9a0 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,26 @@ +{ + "name": "@qualityruntime/db", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./index.ts", + "./schema": "./schema/index.ts" + }, + "scripts": { + "generate": "drizzle-kit generate", + "migrate": "drizzle-kit migrate" + }, + "dependencies": { + "drizzle-orm": "^0.45.2", + "nanoid": "^6.0.1", + "pg": "^8.23.0" + }, + "devDependencies": { + "@electric-sql/pglite": "^0.5.8", + "@types/pg": "^8.23.1", + "better-auth": "^1.7.5", + "drizzle-kit": "^0.31.10" + } +} diff --git a/packages/db/schema/auth.test.ts b/packages/db/schema/auth.test.ts new file mode 100644 index 0000000..ab96707 --- /dev/null +++ b/packages/db/schema/auth.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Guards the hand-maintained Drizzle tables against the schema Better Auth + * actually writes, so a library upgrade that adds or renames a field fails here + * in CI rather than when the adapter initializes. + */ + +import { getAuthTables } from "better-auth/db"; +import { admin, organization, twoFactor } from "better-auth/plugins"; +import { getTableColumns, is, Table } from "drizzle-orm"; +import { describe, expect, it } from "vite-plus/test"; +import { idFormats } from "../id.ts"; +import * as schema from "./index.ts"; + +// The future server configuration must use this same schema-affecting plugin +// configuration — options such as `teams` add tables — since it defines the +// schema contract tested here. +const expected = getAuthTables({ + plugins: [organization(), admin(), twoFactor()], +}); + +describe.each(Object.entries(expected))("%s", (key, table) => { + const name = table.modelName || key; + // Better Auth's Drizzle adapter finds a table by the key it is exported + // under, not by its physical name. + const exported = (schema as Record)[name]; + + it("is exported under the name the adapter looks up", () => { + expect(is(exported, Table)).toBe(true); + }); + + it("has an identifier format", () => { + // Better Auth asks `id.ts` for an id under the model name, so a table + // it writes without a format here throws on its first insert. + expect(Object.keys(idFormats)).toContain(key); + }); + + it("has a column for every field Better Auth writes", () => { + const columns = getTableColumns(exported as Table); + const written = Object.entries(table.fields).map( + ([field, attribute]) => attribute.fieldName || field, + ); + expect(Object.keys(columns)).toEqual(expect.arrayContaining(written)); + }); + + it("accepts an insert that omits every column Better Auth does not write", () => { + const columns = getTableColumns(exported as Table); + const written = new Set([ + "id", + ...Object.entries(table.fields).map(([field, attribute]) => attribute.fieldName || field), + ]); + const unsatisfiable = Object.entries(columns) + .filter(([column]) => !written.has(column)) + .filter(([, column]) => column.notNull && !column.hasDefault) + .map(([column]) => column); + expect(unsatisfiable).toEqual([]); + }); +}); diff --git a/packages/db/schema/auth.ts b/packages/db/schema/auth.ts new file mode 100644 index 0000000..967ba00 --- /dev/null +++ b/packages/db/schema/auth.ts @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Authentication and tenancy tables owned by Better Auth. + * + * Better Auth addresses tables by the key they are exported under, so the + * export names below are its model names and must not be renamed; the physical + * table and column names are ours. `auth.test.ts` catches models and fields + * this file is missing, but not column types or constraints; after upgrading + * Better Auth, diff this against freshly generated reference output. + * + * Deliberate deviations from the generated output: + * - `timestamptz` throughout, so audit history is unambiguous across zones. + * - snake_case index names, matching the column naming. + * - `createdAt` and `updatedAt` have database defaults; expiry timestamps + * must still be supplied by the writer. + * - a unique membership per (organization, user); Better Auth checks for one + * before inserting, which races under concurrency. + * - a unique account per (providerId, accountId); Better Auth looks accounts up + * by that pair and throws rather than guess when two match, but does not + * enforce it with a database constraint. + * - `session.activeOrganizationId` is a real foreign key, so a session cannot + * keep pointing at a deleted tenant (TENANT-01). + * - prefixed identifiers (see `../id.ts`), each enforced by a CHECK. + * + * No Drizzle `relations()` are declared: they serve Better Auth's opt-in join + * mode (`advanced.database.joins`) and Drizzle's relational queries, neither of + * which is in use. Add them alongside whichever one arrives first. + */ + +import { sql } from "drizzle-orm"; +import { + boolean, + check, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { createId, type IdType, idPattern } from "../id.ts"; + +/** A prefixed primary key, generated here when the writer does not supply one. */ +const id = (model: IdType) => + text("id") + .primaryKey() + .$defaultFn(() => createId(model)); + +/** + * Rejects an identifier that does not carry this table's prefix and shape. + * `sql.raw` because a CHECK is DDL and cannot take a bound parameter. + */ +const idFormat = (table: string, model: IdType) => + check(`${table}_id_format`, sql.raw(`"id" ~ '${idPattern(model)}'`)); + +const createdAt = () => timestamp("created_at", { withTimezone: true }).defaultNow().notNull(); + +const updatedAt = () => + timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(); + +/** Fields beyond the Better Auth core come from the `admin` and `twoFactor` plugins. */ +export const user = pgTable( + "user", + { + id: id("user"), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: createdAt(), + updatedAt: updatedAt(), + role: text("role"), + banned: boolean("banned").default(false), + banReason: text("ban_reason"), + banExpires: timestamp("ban_expires", { withTimezone: true }), + twoFactorEnabled: boolean("two_factor_enabled").default(false), + }, + () => [idFormat("user", "user")], +); + +export const session = pgTable( + "session", + { + id: id("session"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + token: text("token").notNull().unique(), + createdAt: createdAt(), + updatedAt: updatedAt(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + /** + * Which tenant the session is currently acting in — request context, not + * authorization. Access is decided by the caller's `member` row, never by + * this column alone. + */ + activeOrganizationId: text("active_organization_id").references(() => organization.id, { + onDelete: "set null", + }), + impersonatedBy: text("impersonated_by"), + }, + (table) => [ + idFormat("session", "session"), + index("session_user_id_idx").on(table.userId), + // Deleting an organization sets this null across every session. + index("session_active_organization_id_idx").on(table.activeOrganizationId), + ], +); + +export const account = pgTable( + "account", + { + id: id("account"), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at", { + withTimezone: true, + }), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { + withTimezone: true, + }), + scope: text("scope"), + password: text("password"), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + idFormat("account", "account"), + // Better Auth identifies an external account by this pair and throws when + // more than one row matches, so a duplicate breaks sign-in. Its own schema + // has no constraint for it, and an application-level check races. + uniqueIndex("account_provider_id_account_id_uidx").on(table.providerId, table.accountId), + index("account_user_id_idx").on(table.userId), + ], +); + +/** Short-lived tokens for email verification, password reset, and OTP delivery. */ +export const verification = pgTable( + "verification", + { + id: id("verification"), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + idFormat("verification", "verification"), + index("verification_identifier_idx").on(table.identifier), + ], +); + +/** The tenant boundary: every tenant-owned record resolves to one organization. */ +export const organization = pgTable( + "organization", + { + id: id("organization"), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: createdAt(), + metadata: text("metadata"), + }, + () => [idFormat("organization", "organization")], +); + +export const member = pgTable( + "member", + { + id: id("member"), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role").default("member").notNull(), + createdAt: createdAt(), + }, + (table) => [ + idFormat("member", "member"), + uniqueIndex("member_organization_id_user_id_uidx").on(table.organizationId, table.userId), + index("member_user_id_idx").on(table.userId), + ], +); + +export const invitation = pgTable( + "invitation", + { + id: id("invitation"), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role"), + status: text("status").default("pending").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: createdAt(), + inviterId: text("inviter_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [ + idFormat("invitation", "invitation"), + index("invitation_organization_id_idx").on(table.organizationId), + index("invitation_email_idx").on(table.email), + // Deleting a user cascades to the invitations they sent. + index("invitation_inviter_id_idx").on(table.inviterId), + ], +); + +/** TOTP secrets and backup codes; `user.twoFactorEnabled` gates their use. */ +export const twoFactor = pgTable( + "two_factor", + { + id: id("twoFactor"), + secret: text("secret").notNull(), + backupCodes: text("backup_codes").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + verified: boolean("verified").default(true), + failedVerificationCount: integer("failed_verification_count").default(0), + lockedUntil: timestamp("locked_until", { withTimezone: true }), + }, + (table) => [ + idFormat("two_factor", "twoFactor"), + // Verification looks a factor up by secret before it knows the user. + index("two_factor_secret_idx").on(table.secret), + index("two_factor_user_id_idx").on(table.userId), + ], +); diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts new file mode 100644 index 0000000..0e0b1b7 --- /dev/null +++ b/packages/db/schema/index.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The whole database schema, as one object. + * + * Drizzle's relational queries and Better Auth's Drizzle adapter both address + * tables by the key they are exported under, so every table belongs here. + */ + +export * from "./auth.ts"; diff --git a/packages/db/schema/migrations.test.ts b/packages/db/schema/migrations.test.ts new file mode 100644 index 0000000..98fa931 --- /dev/null +++ b/packages/db/schema/migrations.test.ts @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Applies the checked-in migrations to a real PostgreSQL engine and exercises + * the constraints they create. PGlite is PostgreSQL compiled to WebAssembly, so + * this runs in `vp test` with no database to start and nothing to clean up. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { eq, getTableName, is, Table } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { createId } from "../id.ts"; +import * as schema from "./index.ts"; +import { account, member, organization, session, user } from "./auth.ts"; + +/** The same folder `drizzle-kit migrate` applies, resolved from this module. */ +const migrationsFolder = fileURLToPath(new URL("../migrations", import.meta.url)); + +let db: ReturnType; + +beforeAll(async () => { + db = drizzle(new PGlite()); + await migrate(db, { migrationsFolder }); +}, 60_000); + +/** The constraint PostgreSQL rejected the statement with. */ +async function rejectedBy(statement: Promise): Promise { + try { + await statement; + } catch (error) { + return (error as { cause?: { constraint?: string } }).cause?.constraint; + } + throw new Error("expected the statement to be rejected, but it succeeded"); +} + +const newUser = () => ({ name: "Ada", email: `${createId("user")}@example.test` }); + +const newOrganization = () => ({ name: "Acme", slug: createId("organization") }); + +describe("migrations", () => { + it("create exactly the tables the schema declares", async () => { + // Derived, so adding a table without a migration fails here, and so does a + // migration that leaves a table behind. + const declared = Object.values(schema) + .filter((value) => is(value, Table)) + .map((table) => getTableName(table as Table)) + .sort(); + const { rows } = await db.$client.query<{ table_name: string }>( + `select table_name from information_schema.tables + where table_schema = 'public' and table_name <> '__drizzle_migrations'`, + ); + expect(rows.map((row) => row.table_name).sort()).toEqual(declared); + }); + + it("give every timestamp a time zone", async () => { + const { rows } = await db.$client.query<{ table_name: string; column_name: string }>( + `select table_name, column_name from information_schema.columns + where table_schema = 'public' and data_type = 'timestamp without time zone'`, + ); + expect(rows).toEqual([]); + }); +}); + +describe("identifier CHECK constraints", () => { + it("accept an identifier this package generated", async () => { + const [row] = await db.insert(user).values(newUser()).returning(); + expect(row?.id).toMatch(/^usr_[0-9a-z]{16}$/); + }); + + it("reject an identifier carrying another table's prefix", async () => { + const values = { id: createId("organization"), ...newUser() }; + expect(await rejectedBy(db.insert(user).values(values))).toBe("user_id_format"); + }); + + it("reject the unprefixed identifier Better Auth's own generator produces", async () => { + const values = { id: "sdf4artyuiop1qwe", ...newUser() }; + expect(await rejectedBy(db.insert(user).values(values))).toBe("user_id_format"); + }); + + it("reject an otherwise valid identifier carrying uppercase", async () => { + // The alphabet is lowercase, so case can never be significant downstream. + const values = { id: "usr_V1StGXR8Z5jdHi6B", ...newUser() }; + expect(await rejectedBy(db.insert(user).values(values))).toBe("user_id_format"); + }); +}); + +describe("account identity", () => { + it("allows one account per provider and external id", async () => { + const [userRow] = await db.insert(user).values(newUser()).returning(); + const identity = { + userId: userRow!.id, + providerId: "github", + accountId: "external-12345", + }; + + await db.insert(account).values(identity); + + // Better Auth throws when this pair matches more than one row, so a + // duplicate would break sign-in rather than degrade it. + expect(await rejectedBy(db.insert(account).values(identity))).toBe( + "account_provider_id_account_id_uidx", + ); + }); + + it("allows the same external id under a different provider", async () => { + const [userRow] = await db.insert(user).values(newUser()).returning(); + const shared = { userId: userRow!.id, accountId: "external-12345" }; + + await db.insert(account).values({ ...shared, providerId: "gitlab" }); + await db.insert(account).values({ ...shared, providerId: "google" }); + + const rows = await db.select().from(account).where(eq(account.userId, userRow!.id)); + expect(rows).toHaveLength(2); + }); +}); + +describe("tenant integrity", () => { + it("allows a user one membership per organization", async () => { + const [userRow] = await db.insert(user).values(newUser()).returning(); + const [org] = await db.insert(organization).values(newOrganization()).returning(); + const membership = { organizationId: org!.id, userId: userRow!.id }; + + await db.insert(member).values(membership); + + expect(await rejectedBy(db.insert(member).values(membership))).toBe( + "member_organization_id_user_id_uidx", + ); + }); + + it("clears a session's active organization when that tenant is deleted", async () => { + const [userRow] = await db.insert(user).values(newUser()).returning(); + const [org] = await db.insert(organization).values(newOrganization()).returning(); + const [row] = await db + .insert(session) + .values({ + userId: userRow!.id, + activeOrganizationId: org!.id, + // A session id and a session token are different things; do not blur them. + token: "test-session-token", + expiresAt: new Date(Date.now() + 60_000), + }) + .returning(); + + await db.delete(organization).where(eq(organization.id, org!.id)); + + const [after] = await db.select().from(session).where(eq(session.id, row!.id)); + expect(after?.activeOrganizationId).toBeNull(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index d691c35..bf027e5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,5 +6,10 @@ import { defineConfig } from "vite-plus"; // Vite+ is the toolchain entry point: dev server, build, tests, lint, format. // Per-app and per-package configuration arrives with the application scaffold. export default defineConfig({ - fmt: { ignorePatterns: [] }, + // `bun run check` is the documented gate, so it has to type-check too; + // without this it only formats and lints. + lint: { options: { typeAware: true, typeCheck: true } }, + // Drizzle owns its migration metadata and rewrites it on every generate; + // formatting it would diff against the tool on the next schema change. + fmt: { ignorePatterns: ["**/migrations/meta/**"] }, });