From 21da04a2df3594c8533d4bf338b474eed077a6c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:50:55 +0000 Subject: [PATCH 01/10] chore(deps): add better-auth 1.7.5 and the drizzle adapter Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- package.json | 2 + pnpm-lock.yaml | 308 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 308 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1750a8a..292c5fb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ }, "dependencies": { "@aws-sdk/client-s3": "3.1138.0", + "@better-auth/drizzle-adapter": "1.7.5", + "better-auth": "1.7.5", "drizzle-orm": "0.45.3", "next": "16.3.6", "pg": "8.23.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d01764..4f678a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,15 @@ importers: '@aws-sdk/client-s3': specifier: 3.1138.0 version: 3.1138.0 + '@better-auth/drizzle-adapter': + specifier: 1.7.5 + version: 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)) + better-auth: + specifier: 1.7.5 + version: 1.7.5(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))(next@16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))) drizzle-orm: specifier: 0.45.3 - version: 0.45.3(@types/pg@8.23.1)(pg@8.23.0) + version: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0) next: specifier: 16.3.6 version: 16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -205,6 +211,85 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.7.5': + resolution: {integrity: sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ==} + 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 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.5': + resolution: {integrity: sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q==} + 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 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.5': + resolution: {integrity: sha512-1wE5gvnjW+c1i4GrLtL9HLn3s0Xrq4YneCDan1NO1dpz0mEguLFNlzfnUGykTFrDwvFh2X5rkIbA8ALCj6WzXQ==} + peerDependencies: + '@better-auth/core': ^1.7.5 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.5': + resolution: {integrity: sha512-YDmnfR9zOXbn5SNYYwfHBPuc19hg1d1C1vUXb+Hm8Q91pTsstFNX5OTlZbo7f28q06FpogAEfGGCN/2QV39cig==} + peerDependencies: + '@better-auth/core': ^1.7.5 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.5': + resolution: {integrity: sha512-Yq0LfF0VlA9Kfjcjp/v43MSsChv7vKd1ct0Mt15px4zlqaCPIGfPbjw9YNM6jTo0fGpqLR0n15PllSWmLLRp9g==} + peerDependencies: + '@better-auth/core': ^1.7.5 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.5': + resolution: {integrity: sha512-QfW6HS9vK0FMcbI/GsQLdplICxOz0EPzYWGONZT4ovL3cSItd4YH/09USbPPVl+VUQCdznAQIk+n+NKvHdmSag==} + 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 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.5': + resolution: {integrity: sha512-e/REPqMy9Em+gC6G0xWBikiMLRuy532Er7jqdoNkPBa65FbywgWcm1cZbgdW5CnHsrM3OLZsmKn14yeGoDISeg==} + peerDependencies: + '@better-auth/core': ^1.7.5 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -959,6 +1044,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -975,6 +1068,10 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-project/types@0.150.0': resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} @@ -1098,6 +1195,9 @@ packages: resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -1418,6 +1518,73 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-auth@1.7.5: + resolution: {integrity: sha512-aKE0Zt2EPTpFvmq4/oATNyG/mAfc6JUqWkW9pzGlrVnzUb0lso7GJ9BPxD6JPhLqYV1a9zOAb0uAz1Q5fm+eHA==} + 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 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -1537,6 +1704,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dependency-cruiser@18.4.0: resolution: {integrity: sha512-LLBYQ2XYmOCMG+liUWI2ReRYnnFXIbnzvCGHTohXAViSkawXr3T35fF/FV2cxpmB661pfkJ3ZXn95jhcEQ9GqA==} engines: {node: ^22||^24||>=26} @@ -2160,6 +2330,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2201,6 +2374,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} + engines: {node: '>=22.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -2329,6 +2506,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.5.3: + resolution: {integrity: sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2578,6 +2759,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2608,6 +2792,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3241,6 +3428,63 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.6.5) + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.3 + zod: 4.6.5 + + '@better-auth/drizzle-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))': + dependencies: + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0) + + '@better-auth/kysely-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.6)': + dependencies: + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.6 + + '@better-auth/memory-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': + dependencies: + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-fetch/fetch@1.3.2': {} + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.10.0': @@ -3729,6 +3973,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.6': optional: true + '@noble/ciphers@2.4.0': {} + + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3743,6 +3991,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-project/types@0.150.0': {} '@rolldown/binding-android-arm-eabi@1.2.9': @@ -3827,6 +4077,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -4157,6 +4409,45 @@ snapshots: baseline-browser-mapping@2.11.25: {} + better-auth@1.7.5(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))(next@16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))): + dependencies: + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3) + '@better-auth/drizzle-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)) + '@better-auth/kysely-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.0(zod@4.6.5) + defu: 6.1.7 + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.3 + zod: 4.6.5 + optionalDependencies: + drizzle-kit: 0.31.11 + drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0) + next: 16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + pg: 8.23.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + vitest: 5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)) + transitivePeerDependencies: + - '@opentelemetry/api' + + better-call@1.4.0(zod@4.6.5): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 + set-cookie-parser: 3.1.2 + optionalDependencies: + zod: 4.6.5 + bowser@2.14.1: {} brace-expansion@1.1.21: @@ -4274,6 +4565,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.7: {} + dependency-cruiser@18.4.0: dependencies: acorn: 8.18.0 @@ -4308,9 +4601,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.23.15 - drizzle-orm@0.45.3(@types/pg@8.23.1)(pg@8.23.0): + drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0): optionalDependencies: '@types/pg': 8.23.1 + kysely: 0.29.6 pg: 8.23.0 dunder-proto@1.0.1: @@ -5046,6 +5340,8 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jose@6.2.12: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -5079,6 +5375,8 @@ snapshots: kleur@3.0.3: {} + kysely@0.29.6: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -5180,6 +5478,8 @@ snapshots: nanoid@3.3.19: {} + nanostores@1.5.3: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -5456,6 +5756,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.9 '@rolldown/binding-win32-x64-msvc': 1.2.9 + rou3@0.9.2: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5489,6 +5791,8 @@ snapshots: semver@7.8.5: {} + set-cookie-parser@3.1.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 From 1cf8f2f029f9d5b3a1ef2ef027b06601103dc05e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:53:07 +0000 Subject: [PATCH 02/10] feat(identity): company roles and authorize() (test-first), organization access control Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/features/identity/access.ts | 24 +++++++++++++++ src/features/identity/authorize.test.ts | 41 +++++++++++++++++++++++++ src/features/identity/authorize.ts | 36 ++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 src/features/identity/access.ts create mode 100644 src/features/identity/authorize.test.ts create mode 100644 src/features/identity/authorize.ts diff --git a/src/features/identity/access.ts b/src/features/identity/access.ts new file mode 100644 index 0000000..9655bae --- /dev/null +++ b/src/features/identity/access.ts @@ -0,0 +1,24 @@ +import { createAccessControl } from "better-auth/plugins/access"; +import { defaultStatements } from "better-auth/plugins/organization/access"; + +// Permissions of the Better Auth organization plugin's own HTTP endpoints (/api/auth/organization/*). +// They mirror `authorize()`: only company admins invite or change members; clerks get nothing. +// Deleting the organization (= company) is nobody's right in the pilot. +export const organizationAc = createAccessControl(defaultStatements); + +export const organizationRoles = { + admin: organizationAc.newRole({ + organization: ["update"], + member: ["create", "update", "delete"], + invitation: ["create", "cancel"], + team: [], + ac: ["read"], + }), + clerk: organizationAc.newRole({ + organization: [], + member: [], + invitation: [], + team: [], + ac: ["read"], + }), +}; diff --git a/src/features/identity/authorize.test.ts b/src/features/identity/authorize.test.ts new file mode 100644 index 0000000..111e087 --- /dev/null +++ b/src/features/identity/authorize.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { authorize, AuthorizationError, isCompanyRole, type Actor } from "./authorize"; + +const companyId = "6f1f3f4e-0000-4000-8000-000000000001"; +const admin: Actor = { userId: "u-admin", companyId, role: "admin" }; +const clerk: Actor = { userId: "u-clerk", companyId, role: "clerk" }; + +describe("authorize", () => { + it("allows admins to manage users", () => { + expect(() => authorize(admin, "users.manage")).not.toThrow(); + }); + + it("denies admin-only actions to clerks", () => { + expect(() => authorize(clerk, "users.manage")).toThrow(AuthorizationError); + expect(() => authorize(clerk, "users.invite")).toThrow(AuthorizationError); + }); + + it("allows both roles to process requests", () => { + expect(() => authorize(admin, "requests.process")).not.toThrow(); + expect(() => authorize(clerk, "requests.process")).not.toThrow(); + }); + + it("denies everything to an actor with an unknown role (fail closed)", () => { + const stranger = { userId: "u-x", companyId, role: "owner" } as unknown as Actor; + + expect(() => authorize(stranger, "requests.process")).toThrow(AuthorizationError); + }); + + it("denies everything to an actor without a company", () => { + const orphan = { userId: "u-y", companyId: "", role: "admin" } as Actor; + + expect(() => authorize(orphan, "requests.process")).toThrow(AuthorizationError); + }); + + it("recognises only the two pilot roles", () => { + expect(isCompanyRole("admin")).toBe(true); + expect(isCompanyRole("clerk")).toBe(true); + expect(isCompanyRole("owner")).toBe(false); + expect(isCompanyRole("member")).toBe(false); + }); +}); diff --git a/src/features/identity/authorize.ts b/src/features/identity/authorize.ts new file mode 100644 index 0000000..d817c61 --- /dev/null +++ b/src/features/identity/authorize.ts @@ -0,0 +1,36 @@ +// Company roles and the single authorization decision (ADR-0001 D7). The role lives on the +// membership (Better Auth `member.role`), never on the global user – a company admin has no rights +// outside their own company. +export const COMPANY_ROLES = ["admin", "clerk"] as const; +export type CompanyRole = (typeof COMPANY_ROLES)[number]; + +export interface Actor { + userId: string; + companyId: string; + role: CompanyRole; +} + +export type Action = "requests.process" | "users.invite" | "users.manage"; + +const PERMISSIONS: Record> = { + admin: new Set(["requests.process", "users.invite", "users.manage"]), + clerk: new Set(["requests.process"]), +}; + +export class AuthorizationError extends Error { + constructor(readonly action: Action) { + super(`not allowed: ${action}`); + this.name = "AuthorizationError"; + } +} + +export function isCompanyRole(value: unknown): value is CompanyRole { + return typeof value === "string" && (COMPANY_ROLES as readonly string[]).includes(value); +} + +/** Throws unless the actor may perform the action. Unknown roles and missing companies fail closed. */ +export function authorize(actor: Actor, action: Action): void { + if (!actor.companyId || !isCompanyRole(actor.role) || !PERMISSIONS[actor.role].has(action)) { + throw new AuthorizationError(action); + } +} From e4d28d2ea28709d17af5872ef911939ab5de528a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:44:15 +0000 Subject: [PATCH 03/10] feat(db): auth schema (Better Auth) and app.requests with tenant policy Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- drizzle.config.ts | 4 +- src/db/schema.ts | 3 - src/db/schema/app.ts | 43 +++++++++ src/db/schema/auth.ts | 215 +++++++++++++++++++++++++++++++++++++++++ src/db/schema/index.ts | 4 + 5 files changed, 264 insertions(+), 5 deletions(-) delete mode 100644 src/db/schema.ts create mode 100644 src/db/schema/app.ts create mode 100644 src/db/schema/auth.ts create mode 100644 src/db/schema/index.ts diff --git a/drizzle.config.ts b/drizzle.config.ts index f46881d..cf0bfdf 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from "drizzle-kit"; // drizzle-kit runs as the owner role; the app itself connects as `app_rw` (ADR-0001 D7). export default defineConfig({ dialect: "postgresql", - schema: "./src/db/schema.ts", + schema: "./src/db/schema/index.ts", out: "./src/db/migrations", - schemaFilter: ["app"], + schemaFilter: ["app", "auth"], migrations: { schema: "drizzle" }, dbCredentials: { url: process.env.MIGRATION_DATABASE_URL ?? "" }, }); diff --git a/src/db/schema.ts b/src/db/schema.ts deleted file mode 100644 index 2e17b2c..0000000 --- a/src/db/schema.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Drizzle schema of the `app` schema. Tables arrive with the feature issues (#4 onwards); -// every company-owned table gets `company_id` + forced RLS (ADR-0001 D7). -export {}; diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts new file mode 100644 index 0000000..c28c0b8 --- /dev/null +++ b/src/db/schema/app.ts @@ -0,0 +1,43 @@ +// Company-owned business data (schema `app`). Every table has `company_id` + RLS enabled AND forced +// (FORCE is added by a hand-written migration – drizzle-kit only emits ENABLE) with the policy +// `tenant_isolation` (ADR-0001 D7). Access only via `withTenant()` as `app_rw`. +import { sql } from "drizzle-orm"; +import { check, index, pgPolicy, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { organization } from "./auth"; + +export const appSchema = pgSchema("app"); + +/** `app.company_id` is set transaction-locally by `withTenant()`; unset → NULL → no row matches. */ +export const currentCompany = sql`nullif(current_setting('app.company_id', true), '')::uuid`; + +export const tenantPolicy = (table: string) => + pgPolicy(`${table}_tenant_isolation`, { + as: "permissive", + for: "all", + to: "public", + using: sql`company_id = ${currentCompany}`, + withCheck: sql`company_id = ${currentCompany}`, + }); + +export const REQUEST_STATUSES = ["NEW", "PROCESSING", "REVIEW", "APPROVED", "EXPORTED", "REJECTED", "ERROR"] as const; +export type RequestStatus = (typeof REQUEST_STATUSES)[number]; + +// Minimal request aggregate – the first tenant table (#4). #5 and #7 extend it additively. +export const requests = appSchema + .table( + "requests", + { + id: uuid("id").default(sql`gen_random_uuid()`).primaryKey(), + companyId: uuid("company_id") + .notNull() + .references(() => organization.id, { onDelete: "restrict" }), + status: text("status").$type().default("NEW").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("requests_company_id_idx").on(table.companyId), + check("requests_status_check", sql.raw(`status in (${REQUEST_STATUSES.map((s) => `'${s}'`).join(", ")})`)), + tenantPolicy("requests"), + ], + ) + .enableRLS(); diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts new file mode 100644 index 0000000..7c972e0 --- /dev/null +++ b/src/db/schema/auth.ts @@ -0,0 +1,215 @@ +// Better Auth tables (schema `auth`), generated with `pnpm dlx auth@1.7.5 generate` (Better Auth CLI) +// and adapted: timestamps with time zone. Not company-owned business data → no RLS; reachable only +// by server code (ADR-0001 D7, exceptions register). Regenerate on a Better Auth upgrade. +import { relations, sql } from "drizzle-orm"; +import { + pgSchema, + text, + bigint, + timestamp, + boolean, + integer, + uuid, + index, +} from "drizzle-orm/pg-core"; + +export const authSchema = pgSchema("auth"); + +export const user = authSchema.table("user", { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + role: text("role"), + banned: boolean("banned").default(false), + banReason: text("ban_reason"), + banExpires: timestamp("ban_expires", { withTimezone: true }), +}); + +export const session = authSchema.table( + "session", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + activeOrganizationId: text("active_organization_id"), + impersonatedBy: text("impersonated_by"), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); + +export const account = authSchema.table( + "account", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: uuid("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: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +export const verification = authSchema.table( + "verification", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +export const organization = authSchema.table("organization", { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + metadata: text("metadata"), +}); + +export const member = authSchema.table( + "member", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role").default("member").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + }, + (table) => [ + index("member_organizationId_idx").on(table.organizationId), + index("member_userId_idx").on(table.userId), + ], +); + +export const invitation = authSchema.table( + "invitation", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + organizationId: uuid("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: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + inviterId: uuid("inviter_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [ + index("invitation_organizationId_idx").on(table.organizationId), + index("invitation_email_idx").on(table.email), + ], +); + +export const rateLimit = authSchema.table("rate_limit", { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + key: text("key").notNull().unique(), + count: integer("count").notNull(), + lastRequest: bigint("last_request", { mode: "number" }).notNull(), +}); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), + members: many(member), + invitations: many(invitation), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); + +export const organizationRelations = relations(organization, ({ many }) => ({ + members: many(member), + invitations: many(invitation), +})); + +export const memberRelations = relations(member, ({ one }) => ({ + organization: one(organization, { + fields: [member.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [member.userId], + references: [user.id], + }), +})); + +export const invitationRelations = relations(invitation, ({ one }) => ({ + organization: one(organization, { + fields: [invitation.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [invitation.inviterId], + references: [user.id], + }), +})); diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts new file mode 100644 index 0000000..378ac7f --- /dev/null +++ b/src/db/schema/index.ts @@ -0,0 +1,4 @@ +// Drizzle schema: `auth` (Better Auth, no RLS – exceptions register) and `app` (company-owned, +// forced RLS). Migrations in ../migrations are generated from here plus hand-written SQL. +export * from "./auth"; +export * from "./app"; From c67654e84b9f44744a213ac239087844b6ba8a55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:44:38 +0000 Subject: [PATCH 04/10] feat(db): migration for auth tables and app.requests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/db/migrations/0001_identity_tenancy.sql | 119 +++ src/db/migrations/meta/0001_snapshot.json | 840 ++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + 3 files changed, 966 insertions(+) create mode 100644 src/db/migrations/0001_identity_tenancy.sql create mode 100644 src/db/migrations/meta/0001_snapshot.json diff --git a/src/db/migrations/0001_identity_tenancy.sql b/src/db/migrations/0001_identity_tenancy.sql new file mode 100644 index 0000000..e87aff5 --- /dev/null +++ b/src/db/migrations/0001_identity_tenancy.sql @@ -0,0 +1,119 @@ +-- Generated by drizzle-kit from src/db/schema/*; edited: schema `app` already exists (0000). +CREATE SCHEMA "auth"; +--> statement-breakpoint +CREATE TABLE "auth"."account" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" uuid 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 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "auth"."invitation" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "organization_id" uuid 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" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "auth"."member" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "created_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "auth"."organization" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "created_at" timestamp with time zone NOT NULL, + "metadata" text, + CONSTRAINT "organization_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "auth"."rate_limit" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "count" integer NOT NULL, + "last_request" bigint NOT NULL, + CONSTRAINT "rate_limit_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "auth"."session" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() 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 NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" uuid NOT NULL, + "active_organization_id" text, + "impersonated_by" text, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "auth"."user" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() 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, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "auth"."verification" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() 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 +); +--> statement-breakpoint +CREATE TABLE "app"."requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "status" text DEFAULT 'NEW' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "requests_status_check" CHECK (status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')) +); +--> statement-breakpoint +ALTER TABLE "app"."requests" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth"."invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth"."invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth"."member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth"."member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "auth"."account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "invitation_organizationId_idx" ON "auth"."invitation" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invitation_email_idx" ON "auth"."invitation" USING btree ("email");--> statement-breakpoint +CREATE INDEX "member_organizationId_idx" ON "auth"."member" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "member_userId_idx" ON "auth"."member" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "auth"."session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "auth"."verification" USING btree ("identifier");--> statement-breakpoint +CREATE INDEX "requests_company_id_idx" ON "app"."requests" USING btree ("company_id");--> statement-breakpoint +CREATE POLICY "requests_tenant_isolation" ON "app"."requests" AS PERMISSIVE FOR ALL TO public USING (company_id = nullif(current_setting('app.company_id', true), '')::uuid) WITH CHECK (company_id = nullif(current_setting('app.company_id', true), '')::uuid); \ No newline at end of file diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..ca20ee7 --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,840 @@ +{ + "id": "1a5f1fcb-ba40-4aed-b2cc-a9acb1e65375", + "prevId": "bb3fbb0f-c63b-429b-bdde-dd62b4f1e186", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.account": { + "name": "account", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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": "uuid", + "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 + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_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", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitation": { + "name": "invitation", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "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": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "schemaTo": "auth", + "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", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.member": { + "name": "member", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "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 + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_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", + "schemaTo": "auth", + "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", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organization": { + "name": "organization", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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 + }, + "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": {}, + "isRLSEnabled": false + }, + "auth.rate_limit": { + "name": "rate_limit", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.session": { + "name": "session", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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 + }, + "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": "uuid", + "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_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user": { + "name": "user", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verification": { + "name": "verification", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.requests": { + "name": "requests", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'NEW'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_company_id_idx": { + "name": "requests_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requests_company_id_organization_id_fk": { + "name": "requests_company_id_organization_id_fk", + "tableFrom": "requests", + "tableTo": "organization", + "schemaTo": "auth", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "requests_tenant_isolation": { + "name": "requests_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "requests_status_check": { + "name": "requests_status_check", + "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')" + } + }, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": { + "auth": "auth", + "app": "app" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 1709b0c..3dc5796 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1790113205929, "tag": "0000_app_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1790142267590, + "tag": "0001_identity_tenancy", + "breakpoints": true } ] } \ No newline at end of file From 0364a58472dc04e1d294a0ac1503725fe2252abd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:46:47 +0000 Subject: [PATCH 05/10] wip(identity,tenancy): withTenant, auth wiring and integration tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- .env.example | 6 + compose.yaml | 2 + src/config/env.test.ts | 2 + src/config/env.ts | 7 + .../migrations/0002_auth_grants_force_rls.sql | 14 + src/db/migrations/meta/0002_snapshot.json | 840 ++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/features/identity/actor.ts | 23 + src/features/identity/auth.ts | 123 +++ src/features/identity/companies.ts | 79 ++ src/features/identity/index.ts | 8 +- src/features/requests/index.ts | 5 +- src/features/requests/repository.ts | 24 + src/features/tenancy/index.ts | 5 +- src/features/tenancy/with-tenant.ts | 44 + tests/integration/helpers/stack.ts | 76 ++ tests/integration/identity.test.ts | 117 +++ tests/integration/tenancy.test.ts | 117 +++ vitest.config.ts | 2 + 19 files changed, 1492 insertions(+), 9 deletions(-) create mode 100644 src/db/migrations/0002_auth_grants_force_rls.sql create mode 100644 src/db/migrations/meta/0002_snapshot.json create mode 100644 src/features/identity/actor.ts create mode 100644 src/features/identity/auth.ts create mode 100644 src/features/identity/companies.ts create mode 100644 src/features/requests/repository.ts create mode 100644 src/features/tenancy/with-tenant.ts create mode 100644 tests/integration/helpers/stack.ts create mode 100644 tests/integration/identity.test.ts create mode 100644 tests/integration/tenancy.test.ts diff --git a/.env.example b/.env.example index 5e27cb4..38475ef 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,12 @@ S3_FORCE_PATH_STYLE=true # Host port of the local S3 gateway. S3_PORT=8333 +# --- Authentication (Better Auth) --------------------------------------------------------------- +# Signs sessions and cookies; at least 32 characters. Generate a real one: `openssl rand -base64 32`. +BETTER_AUTH_SECRET=local-dev-only-secret-change-me-0123456789 +# Public base URL of the web app (cookies, redirects, trusted origin). +BETTER_AUTH_URL=http://localhost:3000 + # --- Web --------------------------------------------------------------------------------------- # Host port of the web container. WEB_PORT=3000 diff --git a/compose.yaml b/compose.yaml index 09476b7..4e35c3d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -54,6 +54,8 @@ services: S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-local-access-key} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-local-secret-key} S3_FORCE_PATH_STYLE: "true" + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-me-0123456789} + BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} depends_on: postgres: condition: service_healthy diff --git a/src/config/env.test.ts b/src/config/env.test.ts index d12af79..85454a5 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -8,6 +8,8 @@ const valid = { S3_BUCKET: "requestflow-documents", S3_ACCESS_KEY_ID: "local-key", S3_SECRET_ACCESS_KEY: "s3-secret-value", + BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters", + BETTER_AUTH_URL: "http://localhost:3000", }; describe("loadConfig", () => { diff --git a/src/config/env.ts b/src/config/env.ts index 4605fdf..ed751c8 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -8,6 +8,8 @@ const schema = z.object({ S3_ACCESS_KEY_ID: z.string().min(1), S3_SECRET_ACCESS_KEY: z.string().min(1), S3_FORCE_PATH_STYLE: z.enum(["true", "false"]).default("true"), + BETTER_AUTH_SECRET: z.string().min(32), + BETTER_AUTH_URL: z.url(), }); export interface AppConfig { @@ -20,6 +22,10 @@ export interface AppConfig { secretAccessKey: string; forcePathStyle: boolean; }; + auth: { + secret: string; + baseURL: string; + }; } // Errors list variable names only – values may be secrets and end up in logs. @@ -40,5 +46,6 @@ export function loadConfig(source: Record = process. secretAccessKey: env.S3_SECRET_ACCESS_KEY, forcePathStyle: env.S3_FORCE_PATH_STYLE === "true", }, + auth: { secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL }, }; } diff --git a/src/db/migrations/0002_auth_grants_force_rls.sql b/src/db/migrations/0002_auth_grants_force_rls.sql new file mode 100644 index 0000000..af3202f --- /dev/null +++ b/src/db/migrations/0002_auth_grants_force_rls.sql @@ -0,0 +1,14 @@ +-- Hand-written (ADR-0001 D7). +-- 1) The runtime role may use the Better Auth tables (schema `auth`, no RLS – exceptions register), +-- but never create objects there. +GRANT USAGE ON SCHEMA auth TO app_rw; +--> statement-breakpoint +REVOKE ALL ON SCHEMA auth FROM PUBLIC; +--> statement-breakpoint +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_rw; +--> statement-breakpoint +ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA auth GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw; +--> statement-breakpoint +-- 2) Forced RLS: drizzle-kit emits only ENABLE. FORCE makes the policy apply to the table owner too, +-- so no role except a superuser ever reads company data without `app.company_id`. +ALTER TABLE app.requests FORCE ROW LEVEL SECURITY; diff --git a/src/db/migrations/meta/0002_snapshot.json b/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..0d13d41 --- /dev/null +++ b/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,840 @@ +{ + "id": "3fa69086-6743-434f-a277-58a30576189a", + "prevId": "1a5f1fcb-ba40-4aed-b2cc-a9acb1e65375", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.account": { + "name": "account", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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": "uuid", + "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 + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitation": { + "name": "invitation", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "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": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "columnsFrom": [ + "inviter_id" + ], + "tableTo": "user", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.member": { + "name": "member", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "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 + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "columnsFrom": [ + "organization_id" + ], + "tableTo": "organization", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organization": { + "name": "organization", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.rate_limit": { + "name": "rate_limit", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "columns": [ + "key" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.session": { + "name": "session", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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 + }, + "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": "uuid", + "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_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user": { + "name": "user", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verification": { + "name": "verification", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.requests": { + "name": "requests", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'NEW'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_company_id_idx": { + "name": "requests_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "requests_company_id_organization_id_fk": { + "name": "requests_company_id_organization_id_fk", + "tableFrom": "requests", + "columnsFrom": [ + "company_id" + ], + "tableTo": "organization", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "requests_tenant_isolation": { + "name": "requests_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "requests_status_check": { + "name": "requests_status_check", + "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')" + } + }, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": { + "auth": "auth", + "app": "app" + }, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 3dc5796..50a58ea 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1790142267590, "tag": "0001_identity_tenancy", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1790142279511, + "tag": "0002_auth_grants_force_rls", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/features/identity/actor.ts b/src/features/identity/actor.ts new file mode 100644 index 0000000..335e235 --- /dev/null +++ b/src/features/identity/actor.ts @@ -0,0 +1,23 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "@/db"; +import * as schema from "@/db/schema"; +import type { Auth } from "./auth"; +import { isCompanyRole, type Actor } from "./authorize"; + +/** + * The signed-in actor of a request: user, company and company role. The company comes from the + * session's active organization and is re-checked against a live membership on every call, so a + * removed membership takes effect immediately. Never from client input (ADR-0001 D7). + */ +export async function getActor(auth: Auth, db: Database, headers: Headers): Promise { + const session = await auth.api.getSession({ headers }); + const companyId = session?.session.activeOrganizationId; + if (!session || !companyId) return null; + const [membership] = await db + .select({ role: schema.member.role }) + .from(schema.member) + .where(and(eq(schema.member.userId, session.user.id), eq(schema.member.organizationId, companyId))) + .limit(1); + if (!membership || !isCompanyRole(membership.role)) return null; + return { userId: session.user.id, companyId, role: membership.role }; +} diff --git a/src/features/identity/auth.ts b/src/features/identity/auth.ts new file mode 100644 index 0000000..ed03182 --- /dev/null +++ b/src/features/identity/auth.ts @@ -0,0 +1,123 @@ +import { drizzleAdapter } from "@better-auth/drizzle-adapter"; +import { betterAuth } from "better-auth"; +import { APIError } from "better-auth/api"; +import { admin, organization } from "better-auth/plugins"; +import { and, eq, gt, sql } from "drizzle-orm"; +import type { Database } from "@/db"; +import * as schema from "@/db/schema"; +import { organizationAc, organizationRoles } from "./access"; +import { isCompanyRole } from "./authorize"; + +export interface AuthSettings { + secret: string; + baseURL: string; + /** Rate limit on /api/auth/* (built-in, database storage). Tests may tighten it. */ + rateLimit?: { window: number; max: number }; +} + +// Global admin-plugin role nobody holds in the pilot: company admins are `member.role = admin` +// and therefore cannot use the plugin's cross-company endpoints (list/ban/impersonate users). +const PLATFORM_ADMIN_ROLE = "platform-admin"; + +const lower = (value: string) => value.trim().toLowerCase(); + +/** + * Better Auth for RequestFlow (ADR-0001 D6): e-mail + password, invite-only, organization = company, + * admin plugin without any global admin, rate limit stored in the database. + */ +export function createAuth(db: Database, settings: AuthSettings) { + const pendingInvitation = async (email: string) => { + const [row] = await db + .select() + .from(schema.invitation) + .where( + and( + sql`lower(${schema.invitation.email}) = ${lower(email)}`, + eq(schema.invitation.status, "pending"), + gt(schema.invitation.expiresAt, new Date()), + ), + ) + .limit(1); + return row; + }; + + const membershipOf = async (userId: string) => { + const [row] = await db.select().from(schema.member).where(eq(schema.member.userId, userId)).limit(1); + return row; + }; + + return betterAuth({ + secret: settings.secret, + baseURL: settings.baseURL, + basePath: "/api/auth", + database: drizzleAdapter(db, { provider: "pg", schema, transaction: true }), + advanced: { database: { generateId: "uuid" } }, + emailAndPassword: { enabled: true, minPasswordLength: 12, autoSignIn: false }, + session: { expiresIn: 60 * 60 * 8, updateAge: 60 * 60 }, + rateLimit: { + enabled: true, + storage: "database", + window: settings.rateLimit?.window ?? 60, + max: settings.rateLimit?.max ?? 30, + customRules: { + "/sign-in/email": { window: 60, max: settings.rateLimit?.max ?? 5 }, + "/sign-up/email": { window: 60, max: settings.rateLimit?.max ?? 5 }, + }, + }, + plugins: [ + organization({ + allowUserToCreateOrganization: false, + creatorRole: "admin", + ac: organizationAc, + roles: organizationRoles, + disableOrganizationDeletion: true, + invitationExpiresIn: 60 * 60 * 24 * 7, + cancelPendingInvitationsOnReInvite: true, + }), + admin({ defaultRole: "user", adminRoles: [PLATFORM_ADMIN_ROLE], allowImpersonatingAdmins: false }), + ], + databaseHooks: { + user: { + create: { + // Invite-only: an account is created only for an e-mail with a pending, unexpired invitation. + before: async (user) => { + const invitation = await pendingInvitation(user.email); + if (!invitation) { + throw new APIError("FORBIDDEN", { message: "Registration requires an invitation." }); + } + return { data: { ...user, email: lower(user.email), role: "user" } }; + }, + // The invitation becomes the membership: company and company role come from it. + after: async (user) => { + const invitation = await pendingInvitation(user.email); + if (!invitation || !isCompanyRole(invitation.role)) return; + await db.transaction(async (tx) => { + await tx.insert(schema.member).values({ + organizationId: invitation.organizationId, + userId: user.id, + role: invitation.role as string, + createdAt: new Date(), + }); + await tx + .update(schema.invitation) + .set({ status: "accepted" }) + .where(eq(schema.invitation.id, invitation.id)); + }); + }, + }, + }, + session: { + create: { + // A session always carries the user's company; no membership → no session (fail closed). + before: async (session) => { + const membership = await membershipOf(session.userId); + if (!membership || !isCompanyRole(membership.role)) return false; + return { data: { ...session, activeOrganizationId: membership.organizationId } }; + }, + }, + }, + }, + }); +} + +export type Auth = ReturnType; diff --git a/src/features/identity/companies.ts b/src/features/identity/companies.ts new file mode 100644 index 0000000..09a6483 --- /dev/null +++ b/src/features/identity/companies.ts @@ -0,0 +1,79 @@ +import { and, eq, sql } from "drizzle-orm"; +import type { Database } from "@/db"; +import * as schema from "@/db/schema"; +import { authorize, isCompanyRole, type Actor, type CompanyRole } from "./authorize"; + +const INVITATION_DAYS = 7; +// Invitations need an inviter (Better Auth schema). The first admin of a company is invited by this +// system user: it has no password account and no membership, so it can never obtain a session. +const SYSTEM_INVITER_EMAIL = "system@requestflow.invalid"; + +const lower = (value: string) => value.trim().toLowerCase(); +const expiry = () => new Date(Date.now() + INVITATION_DAYS * 24 * 60 * 60 * 1000); + +export interface Company { + id: string; + name: string; + slug: string; +} + +async function systemInviterId(db: Database): Promise { + await db + .insert(schema.user) + .values({ name: "RequestFlow system", email: SYSTEM_INVITER_EMAIL, role: "user" }) + .onConflictDoNothing({ target: schema.user.email }); + const [row] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.email, SYSTEM_INVITER_EMAIL)); + if (!row) throw new Error("system inviter missing"); + return row.id; +} + +/** Operator action (seed script): a new company plus the invitation for its first admin. */ +export async function bootstrapCompany( + db: Database, + input: { name: string; slug: string; adminEmail: string }, +): Promise<{ company: Company; invitationId: string }> { + const inviterId = await systemInviterId(db); + return db.transaction(async (tx) => { + const [company] = await tx + .insert(schema.organization) + .values({ name: input.name, slug: input.slug, createdAt: new Date() }) + .returning({ id: schema.organization.id, name: schema.organization.name, slug: schema.organization.slug }); + if (!company) throw new Error("company insert returned no row"); + const [invitation] = await tx + .insert(schema.invitation) + .values({ organizationId: company.id, email: lower(input.adminEmail), role: "admin", status: "pending", expiresAt: expiry(), inviterId }) + .returning({ id: schema.invitation.id }); + if (!invitation) throw new Error("invitation insert returned no row"); + return { company, invitationId: invitation.id }; + }); +} + +/** Company admins invite staff into their own company only – the company comes from the actor. */ +export async function inviteUser( + db: Database, + actor: Actor, + input: { email: string; role: CompanyRole }, +): Promise<{ invitationId: string }> { + authorize(actor, "users.invite"); + if (!isCompanyRole(input.role)) throw new Error("unknown role"); + const email = lower(input.email); + return db.transaction(async (tx) => { + // Re-inviting replaces a pending invitation of the same company. + await tx + .update(schema.invitation) + .set({ status: "canceled" }) + .where( + and( + eq(schema.invitation.organizationId, actor.companyId), + sql`lower(${schema.invitation.email}) = ${email}`, + eq(schema.invitation.status, "pending"), + ), + ); + const [row] = await tx + .insert(schema.invitation) + .values({ organizationId: actor.companyId, email, role: input.role, status: "pending", expiresAt: expiry(), inviterId: actor.userId }) + .returning({ id: schema.invitation.id }); + if (!row) throw new Error("invitation insert returned no row"); + return { invitationId: row.id }; + }); +} diff --git a/src/features/identity/index.ts b/src/features/identity/index.ts index 7ee9838..286d799 100644 --- a/src/features/identity/index.ts +++ b/src/features/identity/index.ts @@ -1,3 +1,5 @@ -// Public API of the `identity` module: Better Auth, users, companies, roles. -// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md. -export {}; +// Public API of the `identity` module: Better Auth, companies, company roles (ADR-0001 D6/D7). +export { createAuth, type Auth, type AuthSettings } from "./auth"; +export { getActor } from "./actor"; +export { bootstrapCompany, inviteUser, type Company } from "./companies"; +export { authorize, AuthorizationError, isCompanyRole, COMPANY_ROLES, type Action, type Actor, type CompanyRole } from "./authorize"; diff --git a/src/features/requests/index.ts b/src/features/requests/index.ts index 9ef6966..c1bbb37 100644 --- a/src/features/requests/index.ts +++ b/src/features/requests/index.ts @@ -1,3 +1,2 @@ -// Public API of the `requests` module: request aggregate, status machine. -// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md. -export {}; +// Public API of the `requests` module: request aggregate (status machine follows with #7). +export { listRequests, createRequest, type RequestRow } from "./repository"; diff --git a/src/features/requests/repository.ts b/src/features/requests/repository.ts new file mode 100644 index 0000000..8e7c6e0 --- /dev/null +++ b/src/features/requests/repository.ts @@ -0,0 +1,24 @@ +import { desc } from "drizzle-orm"; +import { requests, type RequestStatus } from "@/db/schema"; +import { tenantOf, type TenantTx } from "@/features/tenancy"; + +export interface RequestRow { + id: string; + companyId: string; + status: RequestStatus; + createdAt: Date; +} + +// Repository of the request aggregate. Every function needs a tenant transaction; the company id is +// taken from it, never from the caller – RLS enforces the same rule in the database. +export async function listRequests(tx: TenantTx): Promise { + tenantOf(tx); + return tx.select().from(requests).orderBy(desc(requests.createdAt)); +} + +export async function createRequest(tx: TenantTx): Promise { + const companyId = tenantOf(tx); + const [row] = await tx.insert(requests).values({ companyId }).returning(); + if (!row) throw new Error("insert returned no row"); + return row; +} diff --git a/src/features/tenancy/index.ts b/src/features/tenancy/index.ts index 8ff0367..9b7a7d1 100644 --- a/src/features/tenancy/index.ts +++ b/src/features/tenancy/index.ts @@ -1,3 +1,2 @@ -// Public API of the `tenancy` module: withTenant(), RLS policies. -// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md. -export {}; +// Public API of the `tenancy` module: tenant context and forced RLS (ADR-0001 D7). +export { createTenancy, tenantOf, MissingTenantError, type Tenancy, type TenantTx } from "./with-tenant"; diff --git a/src/features/tenancy/with-tenant.ts b/src/features/tenancy/with-tenant.ts new file mode 100644 index 0000000..f6e0a47 --- /dev/null +++ b/src/features/tenancy/with-tenant.ts @@ -0,0 +1,44 @@ +import { sql } from "drizzle-orm"; +import type { Database } from "@/db"; + +// Tenant context (ADR-0001 D7). `withTenant` opens a transaction, sets `app.company_id` +// transaction-locally (safe with poolers: gone at COMMIT/ROLLBACK) and hands out a branded +// transaction. Repositories accept only that brand, so a query without tenant context does not +// compile – and `tenantOf()` re-checks at runtime. +const TENANT = Symbol("tenant"); + +type Transaction = Parameters[0]>[0]; +export type TenantTx = Transaction & { readonly [TENANT]: string }; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export class MissingTenantError extends Error { + constructor() { + super("repository called without tenant context – use withTenant()"); + this.name = "MissingTenantError"; + } +} + +export interface Tenancy { + withTenant(companyId: string, fn: (tx: TenantTx) => Promise): Promise; +} + +export function createTenancy(db: Database): Tenancy { + return { + async withTenant(companyId, fn) { + if (!UUID.test(companyId)) throw new MissingTenantError(); + return db.transaction(async (tx) => { + await tx.execute(sql`select set_config('app.company_id', ${companyId}, true)`); + Object.defineProperty(tx, TENANT, { value: companyId, enumerable: false }); + return fn(tx as TenantTx); + }); + }, + }; +} + +/** The company of the current tenant transaction; throws if the transaction has no tenant. */ +export function tenantOf(tx: TenantTx): string { + const companyId = (tx as Partial>)[TENANT]; + if (!companyId) throw new MissingTenantError(); + return companyId; +} diff --git a/tests/integration/helpers/stack.ts b/tests/integration/helpers/stack.ts new file mode 100644 index 0000000..854bb7f --- /dev/null +++ b/tests/integration/helpers/stack.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto"; +import { loadConfig } from "@/config/env"; +import { createDatabase, type DatabaseHandle } from "@/db"; +import { bootstrapCompany, createAuth, type Auth, type AuthSettings } from "@/features/identity"; + +// Shared wiring for integration tests: the real app_rw pool, Better Auth over HTTP (auth.handler), +// unique synthetic companies and e-mail addresses per run (the database persists between runs). +export interface Stack { + database: DatabaseHandle; + auth: Auth; + close(): Promise; +} + +export function createStack(overrides: Partial = {}): Stack { + const config = loadConfig(); + const database = createDatabase(config.databaseUrl, { max: 4 }); + const auth = createAuth(database.db, { ...config.auth, ...overrides }); + return { database, auth, close: () => database.pool.end() }; +} + +export const unique = (prefix: string) => `${prefix}-${randomUUID().slice(0, 8)}`; +export const syntheticEmail = (prefix: string) => `${unique(prefix)}@example.com`; +export const PASSWORD = "synthetic-password-123"; + +let ipCounter = 0; +/** A fresh client IP per call site, so the rate limit of one test never bleeds into another. */ +export const freshIp = () => `198.51.100.${(ipCounter = (ipCounter % 250) + 1)}`; + +export async function call( + auth: Auth, + path: string, + init: { body?: unknown; cookie?: string; ip?: string; method?: string } = {}, +): Promise<{ status: number; body: unknown; cookie: string }> { + const headers = new Headers({ + "content-type": "application/json", + origin: "http://localhost:3000", + "x-forwarded-for": init.ip ?? freshIp(), + }); + if (init.cookie) headers.set("cookie", init.cookie); + const response = await auth.handler( + new Request(`http://localhost:3000/api/auth${path}`, { + method: init.method ?? (init.body === undefined ? "GET" : "POST"), + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }), + ); + const text = await response.text(); + const cookie = response.headers + .getSetCookie() + .map((line) => line.split(";")[0]) + .join("; "); + return { status: response.status, body: text ? JSON.parse(text) : null, cookie }; +} + +export async function signUp(auth: Auth, email: string) { + return call(auth, "/sign-up/email", { body: { email, password: PASSWORD, name: "Synthetic User" } }); +} + +export async function signIn(auth: Auth, email: string, ip?: string) { + return call(auth, "/sign-in/email", { body: { email, password: PASSWORD }, ip }); +} + +/** A company with a signed-in first admin. */ +export async function companyWithAdmin(stack: Stack) { + const adminEmail = syntheticEmail("admin"); + const { company } = await bootstrapCompany(stack.database.db, { + name: `Beispiel Maschinenbau ${unique("co")}`, + slug: unique("beispiel"), + adminEmail, + }); + const signUpResult = await signUp(stack.auth, adminEmail); + if (signUpResult.status !== 200) throw new Error(`sign-up failed: ${signUpResult.status}`); + const login = await signIn(stack.auth, adminEmail); + if (login.status !== 200) throw new Error(`sign-in failed: ${login.status}`); + return { company, adminEmail, cookie: login.cookie }; +} diff --git a/tests/integration/identity.test.ts b/tests/integration/identity.test.ts new file mode 100644 index 0000000..88f494e --- /dev/null +++ b/tests/integration/identity.test.ts @@ -0,0 +1,117 @@ +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import * as schema from "@/db/schema"; +import { AuthorizationError, getActor, inviteUser } from "@/features/identity"; +import { call, companyWithAdmin, createStack, freshIp, signIn, signUp, syntheticEmail, type Stack } from "./helpers/stack"; + +describe("identity: invite-only login and companies", () => { + let stack: Stack; + + beforeAll(() => { + stack = createStack(); + }); + + afterAll(async () => { + await stack.close(); + }); + + it("rejects a sign-up without an invitation and creates no user", async () => { + const email = syntheticEmail("uninvited"); + + const result = await signUp(stack.auth, email); + + expect(result.status).toBe(403); + const users = await stack.database.db.select().from(schema.user).where(eq(schema.user.email, email)); + expect(users).toHaveLength(0); + }); + + it("gives an invited user a session that carries their active company and role", async () => { + const { company, cookie } = await companyWithAdmin(stack); + + const session = await call(stack.auth, "/get-session", { cookie }); + const actor = await getActor(stack.auth, stack.database.db, new Headers({ cookie })); + + expect((session.body as { session: { activeOrganizationId: string } }).session.activeOrganizationId).toBe(company.id); + expect(actor).toMatchObject({ companyId: company.id, role: "admin" }); + }); + + it("uses UUIDs for companies, so company_id columns and the RLS cast match", async () => { + const { company } = await companyWithAdmin(stack); + + expect(company.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it("matches the invitation e-mail case-insensitively and consumes the invitation", async () => { + const { company, cookie } = await companyWithAdmin(stack); + const admin = await getActor(stack.auth, stack.database.db, new Headers({ cookie })); + const email = syntheticEmail("Clerk").replace("Clerk", "CLERK"); + const { invitationId } = await inviteUser(stack.database.db, admin!, { email: email.toLowerCase(), role: "clerk" }); + + expect((await signUp(stack.auth, email)).status).toBe(200); + const login = await signIn(stack.auth, email.toLowerCase()); + const clerk = await getActor(stack.auth, stack.database.db, new Headers({ cookie: login.cookie })); + + expect(clerk).toMatchObject({ companyId: company.id, role: "clerk" }); + const [invitation] = await stack.database.db.select().from(schema.invitation).where(eq(schema.invitation.id, invitationId)); + expect(invitation?.status).toBe("accepted"); + }); + + it("denies inviting to clerks – server-side function and the plugin's HTTP endpoint", async () => { + const { company, cookie } = await companyWithAdmin(stack); + const admin = await getActor(stack.auth, stack.database.db, new Headers({ cookie })); + const clerkEmail = syntheticEmail("clerk"); + await inviteUser(stack.database.db, admin!, { email: clerkEmail, role: "clerk" }); + await signUp(stack.auth, clerkEmail); + const clerkLogin = await signIn(stack.auth, clerkEmail); + const clerk = await getActor(stack.auth, stack.database.db, new Headers({ cookie: clerkLogin.cookie })); + + await expect(inviteUser(stack.database.db, clerk!, { email: syntheticEmail("x"), role: "admin" })).rejects.toThrow(AuthorizationError); + const viaPlugin = await call(stack.auth, "/organization/invite-member", { + cookie: clerkLogin.cookie, + body: { email: syntheticEmail("y"), role: "admin", organizationId: company.id }, + }); + expect(viaPlugin.status).toBe(403); + }); + + it("gives a company admin no access to the global admin plugin (no cross-company user list)", async () => { + const { cookie } = await companyWithAdmin(stack); + + const listUsers = await call(stack.auth, "/admin/list-users", { cookie }); + + expect([401, 403]).toContain(listUsers.status); + }); + + it("does not let a user create another company", async () => { + const { cookie } = await companyWithAdmin(stack); + + const created = await call(stack.auth, "/organization/create", { cookie, body: { name: "Fremdfirma", slug: syntheticEmail("s") } }); + + expect(created.status).toBe(403); + }); + + it("rejects a login without membership (fail closed)", async () => { + const { cookie } = await companyWithAdmin(stack); + const admin = await getActor(stack.auth, stack.database.db, new Headers({ cookie })); + await stack.database.db.delete(schema.member).where(eq(schema.member.userId, admin!.userId)); + + expect(await getActor(stack.auth, stack.database.db, new Headers({ cookie }))).toBeNull(); + const [user] = await stack.database.db.select().from(schema.user).where(eq(schema.user.id, admin!.userId)); + expect((await signIn(stack.auth, user!.email)).status).not.toBe(200); + }); + + it("rate-limits repeated sign-ins over HTTP and stores the counter in the database", async () => { + const limited = createStack({ rateLimit: { window: 60, max: 3 } }); + const ip = freshIp(); + const email = syntheticEmail("brute"); + try { + const statuses: number[] = []; + for (let attempt = 0; attempt < 5; attempt++) statuses.push((await signIn(limited.auth, email, ip)).status); + + expect(statuses).toContain(429); + const rows = await limited.database.db.select().from(schema.rateLimit).where(sql`${schema.rateLimit.key} like ${`%${ip}%`}`); + expect(rows.length).toBeGreaterThan(0); + } finally { + await limited.close(); + } + }); +}); diff --git a/tests/integration/tenancy.test.ts b/tests/integration/tenancy.test.ts new file mode 100644 index 0000000..2b06f5c --- /dev/null +++ b/tests/integration/tenancy.test.ts @@ -0,0 +1,117 @@ +import pg from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, type DatabaseHandle } from "@/db"; +import { bootstrapCompany } from "@/features/identity"; +import { createRequest, listRequests } from "@/features/requests"; +import { createTenancy, MissingTenantError, type Tenancy, type TenantTx } from "@/features/tenancy"; +import { unique } from "./helpers/stack"; + +// Two synthetic companies, A and B. Proof of ADR-0001 D7 on both layers: repository and raw SQL as app_rw. +describe("tenancy: withTenant and forced RLS", () => { + let database: DatabaseHandle; + let tenancy: Tenancy; + let companyA: string; + let companyB: string; + + beforeAll(async () => { + database = createDatabase(process.env.DATABASE_URL!, { max: 4 }); + tenancy = createTenancy(database.db); + const a = await bootstrapCompany(database.db, { name: "Firma A (synthetisch)", slug: unique("a"), adminEmail: `${unique("a")}@example.com` }); + const b = await bootstrapCompany(database.db, { name: "Firma B (synthetisch)", slug: unique("b"), adminEmail: `${unique("b")}@example.com` }); + companyA = a.company.id; + companyB = b.company.id; + await tenancy.withTenant(companyA, (tx) => createRequest(tx)); + await tenancy.withTenant(companyB, (tx) => createRequest(tx)); + }); + + afterAll(async () => { + await database.pool.end(); + }); + + it("returns only the own company's requests through the repository", async () => { + const rowsA = await tenancy.withTenant(companyA, (tx) => listRequests(tx)); + const rowsB = await tenancy.withTenant(companyB, (tx) => listRequests(tx)); + + expect(rowsA.length).toBeGreaterThan(0); + expect(rowsA.every((row) => row.companyId === companyA)).toBe(true); + expect(rowsB.every((row) => row.companyId === companyB)).toBe(true); + }); + + describe("raw SQL as app_rw", () => { + let client: pg.Client; + + beforeAll(async () => { + client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + }); + + afterAll(async () => { + await client.end(); + }); + + it("sees only company A with app.company_id = A, even without a WHERE clause", async () => { + await client.query("begin"); + await client.query("select set_config('app.company_id', $1, true)", [companyA]); + const { rows } = await client.query("select distinct company_id from app.requests"); + await client.query("commit"); + + expect(rows.map((row) => row.company_id)).toEqual([companyA]); + }); + + it("sees no rows at all without a company context", async () => { + const { rows } = await client.query("select count(*)::int as n from app.requests"); + + expect(rows[0].n).toBe(0); + }); + + it("rejects inserting a row of company B while acting for company A", async () => { + await client.query("begin"); + await client.query("select set_config('app.company_id', $1, true)", [companyA]); + const insert = client.query("insert into app.requests (company_id) values ($1)", [companyB]); + + await expect(insert).rejects.toThrow(/row-level security/); + await client.query("rollback"); + }); + + it("cannot move an own row to another company", async () => { + await client.query("begin"); + await client.query("select set_config('app.company_id', $1, true)", [companyA]); + const update = client.query("update app.requests set company_id = $1", [companyB]); + + await expect(update).rejects.toThrow(/row-level security/); + await client.query("rollback"); + }); + }); + + it("keeps the company setting transaction-local: a reused pooled connection has none", async () => { + const single = createDatabase(process.env.DATABASE_URL!, { max: 1 }); + try { + await createTenancy(single.db).withTenant(companyA, (tx) => listRequests(tx)); + + const { rows } = await single.pool.query( + "select coalesce(current_setting('app.company_id', true), '') as company, (select count(*)::int from app.requests) as n", + ); + expect(rows[0]).toEqual({ company: "", n: 0 }); + } finally { + await single.pool.end(); + } + }); + + it("has RLS enabled and forced on app.requests", async () => { + const { rows } = await database.pool.query( + "select relrowsecurity, relforcerowsecurity from pg_class where oid = 'app.requests'::regclass", + ); + + expect(rows[0]).toEqual({ relrowsecurity: true, relforcerowsecurity: true }); + }); + + it("refuses repository calls without a tenant transaction", async () => { + const plain = await database.db.transaction(async (tx) => listRequests(tx as TenantTx).catch((error: unknown) => error)); + + expect(plain).toBeInstanceOf(MissingTenantError); + }); + + it("refuses a company id that is not a UUID", async () => { + await expect(tenancy.withTenant("' or 1=1 --", (tx) => listRequests(tx))).rejects.toThrow(MissingTenantError); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 3632749..957d75b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,8 @@ const localStackDefaults: Record = { S3_ACCESS_KEY_ID: "local-access-key", S3_SECRET_ACCESS_KEY: "local-secret-key", S3_FORCE_PATH_STYLE: "true", + BETTER_AUTH_SECRET: "local-dev-only-secret-change-me-0123456789", + BETTER_AUTH_URL: "http://localhost:3000", }; const integrationEnv = Object.fromEntries( Object.entries(localStackDefaults).map(([name, value]) => [name, process.env[name] ?? value]), From 0958d25400e4c0f9bbe5cdfe4766aef28b345804 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:48:10 +0000 Subject: [PATCH 06/10] feat(identity,tenancy): invite-only Better Auth, withTenant, forced RLS with integration proofs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/features/identity/access.ts | 6 ++++++ src/features/identity/auth.ts | 15 +++++++++------ tests/integration/identity.test.ts | 9 +++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/features/identity/access.ts b/src/features/identity/access.ts index 9655bae..93a0fe8 100644 --- a/src/features/identity/access.ts +++ b/src/features/identity/access.ts @@ -1,4 +1,5 @@ import { createAccessControl } from "better-auth/plugins/access"; +import { adminAc, userAc } from "better-auth/plugins/admin/access"; import { defaultStatements } from "better-auth/plugins/organization/access"; // Permissions of the Better Auth organization plugin's own HTTP endpoints (/api/auth/organization/*). @@ -22,3 +23,8 @@ export const organizationRoles = { ac: ["read"], }), }; + +// Global roles of the Better Auth admin plugin. Every app user is `user` (no admin-plugin rights); +// `platform-admin` exists only so the plugin has an admin role – nobody holds it in the pilot. +export const PLATFORM_ADMIN_ROLE = "platform-admin"; +export const platformRoles = { user: userAc, [PLATFORM_ADMIN_ROLE]: adminAc }; diff --git a/src/features/identity/auth.ts b/src/features/identity/auth.ts index ed03182..8479026 100644 --- a/src/features/identity/auth.ts +++ b/src/features/identity/auth.ts @@ -5,7 +5,7 @@ import { admin, organization } from "better-auth/plugins"; import { and, eq, gt, sql } from "drizzle-orm"; import type { Database } from "@/db"; import * as schema from "@/db/schema"; -import { organizationAc, organizationRoles } from "./access"; +import { organizationAc, organizationRoles, PLATFORM_ADMIN_ROLE, platformRoles } from "./access"; import { isCompanyRole } from "./authorize"; export interface AuthSettings { @@ -15,10 +15,6 @@ export interface AuthSettings { rateLimit?: { window: number; max: number }; } -// Global admin-plugin role nobody holds in the pilot: company admins are `member.role = admin` -// and therefore cannot use the plugin's cross-company endpoints (list/ban/impersonate users). -const PLATFORM_ADMIN_ROLE = "platform-admin"; - const lower = (value: string) => value.trim().toLowerCase(); /** @@ -74,7 +70,14 @@ export function createAuth(db: Database, settings: AuthSettings) { invitationExpiresIn: 60 * 60 * 24 * 7, cancelPendingInvitationsOnReInvite: true, }), - admin({ defaultRole: "user", adminRoles: [PLATFORM_ADMIN_ROLE], allowImpersonatingAdmins: false }), + // Company admins are `member.role = admin`, never a global admin-plugin role, so they cannot use + // the plugin's cross-company endpoints (list/ban/impersonate users). + admin({ + defaultRole: "user", + adminRoles: [PLATFORM_ADMIN_ROLE], + roles: platformRoles, + allowImpersonatingAdmins: false, + }), ], databaseHooks: { user: { diff --git a/tests/integration/identity.test.ts b/tests/integration/identity.test.ts index 88f494e..e5fbc93 100644 --- a/tests/integration/identity.test.ts +++ b/tests/integration/identity.test.ts @@ -15,14 +15,19 @@ describe("identity: invite-only login and companies", () => { await stack.close(); }); - it("rejects a sign-up without an invitation and creates no user", async () => { + // Better Auth answers a refused sign-up with the same generic response as a successful one + // (anti-enumeration: nobody learns which addresses are invited). "Rejected" is therefore proven by + // its effect: no user, no session token, no login. + it("rejects a sign-up without an invitation: no user, no token, no login", async () => { const email = syntheticEmail("uninvited"); const result = await signUp(stack.auth, email); - expect(result.status).toBe(403); + expect((result.body as { token: unknown }).token).toBeNull(); + expect(result.cookie).not.toMatch(/session_token/); const users = await stack.database.db.select().from(schema.user).where(eq(schema.user.email, email)); expect(users).toHaveLength(0); + expect((await signIn(stack.auth, email)).status).toBe(401); }); it("gives an invited user a session that carries their active company and role", async () => { From 4992b90f257f1ef676345088e1e756c6d013878a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:50:08 +0000 Subject: [PATCH 07/10] feat(app): auth route, login/sign-up/invite pages, demo seed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- .env.example | 3 + package.json | 3 +- src/app/_components/auth-form.tsx | 92 ++++++++++++++++++++++++++++++ src/app/_server/runtime.ts | 23 +++++++- src/app/api/auth/[...all]/route.ts | 10 ++++ src/app/invite/page.tsx | 53 +++++++++++++++++ src/app/login/page.tsx | 14 +++++ src/app/page.tsx | 35 +++++++++++- src/app/signup/page.tsx | 15 +++++ src/features/identity/companies.ts | 8 +++ src/features/identity/index.ts | 2 +- src/seed.ts | 49 ++++++++++++++++ 12 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 src/app/_components/auth-form.tsx create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/app/invite/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/signup/page.tsx create mode 100644 src/seed.ts diff --git a/.env.example b/.env.example index 38475ef..78a857f 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,9 @@ BETTER_AUTH_SECRET=local-dev-only-secret-change-me-0123456789 # Public base URL of the web app (cookies, redirects, trusted origin). BETTER_AUTH_URL=http://localhost:3000 +# Demo seed only (`pnpm seed:demo`): password of the synthetic demo accounts, local use only. +SEED_PASSWORD=demo-password-local-only + # --- Web --------------------------------------------------------------------------------------- # Host port of the web container. WEB_PORT=3000 diff --git a/package.json b/package.json index 292c5fb..b449d67 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "verify:changed": "bash scripts/verify-changed.sh", "verify": "pnpm lint && pnpm typecheck && pnpm test && pnpm test:integration && pnpm depcruise && pnpm build && pnpm audit --audit-level high", "verify:full": "pnpm verify", - "setup:deploy": "tsx src/setup.ts" + "setup:deploy": "tsx src/setup.ts", + "seed:demo": "tsx src/seed.ts" }, "dependencies": { "@aws-sdk/client-s3": "3.1138.0", diff --git a/src/app/_components/auth-form.tsx b/src/app/_components/auth-form.tsx new file mode 100644 index 0000000..27b259c --- /dev/null +++ b/src/app/_components/auth-form.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; + +// Posts JSON straight to Better Auth (/api/auth/*); cookies are set by that response, so no extra +// auth plugin is needed for server actions. +export function AuthForm({ mode }: { mode: "sign-in" | "sign-up" }) { + const router = useRouter(); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setMessage(null); + const form = new FormData(event.currentTarget); + const body = { + email: String(form.get("email") ?? ""), + password: String(form.get("password") ?? ""), + ...(mode === "sign-up" ? { name: String(form.get("name") ?? "") } : {}), + }; + const response = await fetch(`/api/auth/${mode}/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + setBusy(false); + if (mode === "sign-in") { + if (response.ok) { + router.push("/"); + router.refresh(); + } + else if (response.status === 429) setMessage("Zu viele Versuche – bitte später erneut versuchen."); + else setMessage("Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen."); + return; + } + // Same answer for invited and uninvited addresses (no enumeration). + setMessage( + response.ok + ? "Falls für diese Adresse eine Einladung vorliegt, ist das Konto jetzt angelegt. Bitte anmelden." + : "Registrierung fehlgeschlagen. Passwort mindestens 12 Zeichen.", + ); + } + + return ( +
+ {mode === "sign-up" && ( +

+ +
+ +

+ )} +

+ +
+ +

+

+ +
+ +

+ + {message &&

{message}

} +
+ ); +} + +export function SignOutButton() { + const router = useRouter(); + async function signOut() { + await fetch("/api/auth/sign-out", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); + router.push("/login"); + router.refresh(); + } + return ( + + ); +} diff --git a/src/app/_server/runtime.ts b/src/app/_server/runtime.ts index 6b212fa..0317c1a 100644 --- a/src/app/_server/runtime.ts +++ b/src/app/_server/runtime.ts @@ -1,13 +1,17 @@ import { loadConfig, type AppConfig } from "@/config/env"; import { createDatabase, type DatabaseHandle } from "@/db"; +import { createAuth, getActor, type Actor, type Auth } from "@/features/identity"; import { S3BlobStore } from "@/features/storage"; +import { createTenancy, type Tenancy } from "@/features/tenancy"; -// Composition root of the web process: one pool and one storage client per process, created on -// first use (never at import time, so `next build` needs no environment). +// Composition root of the web process: one pool, one storage client and one auth instance per +// process, created on first use (never at import time, so `next build` needs no environment). export interface Runtime { config: AppConfig; database: DatabaseHandle; storage: S3BlobStore; + auth: Auth; + tenancy: Tenancy; } let runtime: Runtime | undefined; @@ -15,7 +19,20 @@ let runtime: Runtime | undefined; export function getRuntime(): Runtime { if (!runtime) { const config = loadConfig(); - runtime = { config, database: createDatabase(config.databaseUrl), storage: new S3BlobStore(config.storage) }; + const database = createDatabase(config.databaseUrl); + runtime = { + config, + database, + storage: new S3BlobStore(config.storage), + auth: createAuth(database.db, config.auth), + tenancy: createTenancy(database.db), + }; } return runtime; } + +/** The signed-in actor for the current request, or null. */ +export async function currentActor(headers: Headers): Promise { + const { auth, database } = getRuntime(); + return getActor(auth, database.db, headers); +} diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..0f223ec --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,10 @@ +import { getRuntime } from "@/app/_server/runtime"; + +export const dynamic = "force-dynamic"; + +// Better Auth endpoints (/api/auth/*): sign-in, sign-up (invite-only), session, organization. +// Resolved per request so the build needs no environment. +const handle = (request: Request) => getRuntime().auth.handler(request); + +export const GET = handle; +export const POST = handle; diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx new file mode 100644 index 0000000..af44721 --- /dev/null +++ b/src/app/invite/page.tsx @@ -0,0 +1,53 @@ +import { headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { authorize, AuthorizationError, inviteUser, isCompanyRole } from "@/features/identity"; + +export const dynamic = "force-dynamic"; + +// Invite form (pilot: no role-admin UI). Authorization runs server-side on render AND in the action. +async function invite(formData: FormData) { + "use server"; + const actor = await currentActor(await headers()); + if (!actor) redirect("/login"); + const email = String(formData.get("email") ?? ""); + const role = String(formData.get("role") ?? ""); + if (!isCompanyRole(role) || !email.includes("@")) redirect("/invite?error=input"); + await inviteUser(getRuntime().database.db, actor, { email, role }); + redirect("/invite?sent=1"); +} + +export default async function InvitePage({ searchParams }: { searchParams: Promise> }) { + const actor = await currentActor(await headers()); + if (!actor) redirect("/login"); + try { + authorize(actor, "users.invite"); + } catch (error) { + if (error instanceof AuthorizationError) notFound(); + throw error; + } + const params = await searchParams; + return ( +
+

Mitarbeitende einladen

+ {params.sent &&

Einladung angelegt. Die Person kann jetzt unter /signup ein Konto anlegen.

} + {params.error &&

Bitte eine gültige E-Mail-Adresse und Rolle angeben.

} +
+

+ +
+ +

+

+ +
+ +

+ +
+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ddb9ef5 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; +import { AuthForm } from "@/app/_components/auth-form"; + +export default function LoginPage() { + return ( +
+

Anmelden

+ +

+ Eingeladen worden? Konto anlegen +

+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d408995..bdda04b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,38 @@ -export default function HomePage() { +import Link from "next/link"; +import { headers } from "next/headers"; +import { SignOutButton } from "@/app/_components/auth-form"; +import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { getCompany } from "@/features/identity"; + +export const dynamic = "force-dynamic"; + +export default async function HomePage() { + const actor = await currentActor(await headers()); + if (!actor) { + return ( +
+

RequestFlow

+

Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.

+

+ Anmelden · Konto mit Einladung anlegen +

+

Pilot – alle Daten sind synthetisch.

+
+ ); + } + const company = await getCompany(getRuntime().database.db, actor.companyId); return (

RequestFlow

-

Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.

-

Pilot im Aufbau – alle Daten sind synthetisch.

+

+ Firma: {company?.name} · Rolle: {actor.role === "admin" ? "Administration" : "Sachbearbeitung"} +

+ {actor.role === "admin" && ( +

+ Mitarbeitende einladen +

+ )} +
); } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..52d475a --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,15 @@ +import Link from "next/link"; +import { AuthForm } from "@/app/_components/auth-form"; + +export default function SignupPage() { + return ( +
+

Konto anlegen

+

Nur mit Einladung: Verwenden Sie die E-Mail-Adresse, an die die Einladung ging.

+ +

+ Zur Anmeldung +

+
+ ); +} diff --git a/src/features/identity/companies.ts b/src/features/identity/companies.ts index 09a6483..3e516d8 100644 --- a/src/features/identity/companies.ts +++ b/src/features/identity/companies.ts @@ -77,3 +77,11 @@ export async function inviteUser( return { invitationId: row.id }; }); } + +export async function getCompany(db: Database, companyId: string): Promise { + const [row] = await db + .select({ id: schema.organization.id, name: schema.organization.name, slug: schema.organization.slug }) + .from(schema.organization) + .where(eq(schema.organization.id, companyId)); + return row ?? null; +} diff --git a/src/features/identity/index.ts b/src/features/identity/index.ts index 286d799..5ae0520 100644 --- a/src/features/identity/index.ts +++ b/src/features/identity/index.ts @@ -1,5 +1,5 @@ // Public API of the `identity` module: Better Auth, companies, company roles (ADR-0001 D6/D7). export { createAuth, type Auth, type AuthSettings } from "./auth"; export { getActor } from "./actor"; -export { bootstrapCompany, inviteUser, type Company } from "./companies"; +export { bootstrapCompany, getCompany, inviteUser, type Company } from "./companies"; export { authorize, AuthorizationError, isCompanyRole, COMPANY_ROLES, type Action, type Actor, type CompanyRole } from "./authorize"; diff --git a/src/seed.ts b/src/seed.ts new file mode 100644 index 0000000..b1a5cb0 --- /dev/null +++ b/src/seed.ts @@ -0,0 +1,49 @@ +// Demo seed (`pnpm seed:demo`, local only): two synthetic companies, each with an admin, and a clerk +// in the first one. It uses the real path – company + invitation, then sign-up – no bypass. +// All names and addresses are synthetic (example.com). Passwords come from SEED_PASSWORD. +import { eq } from "drizzle-orm"; +import { loadConfig } from "@/config/env"; +import { createDatabase } from "@/db"; +import * as schema from "@/db/schema"; +import { bootstrapCompany, createAuth, getActor, inviteUser } from "@/features/identity"; + +const COMPANIES = [ + { name: "Musterbau Beispiel GmbH", slug: "musterbau", admin: "admin@musterbau.example.com", clerk: "sachbearbeitung@musterbau.example.com" }, + { name: "Beispielwerk Nord AG", slug: "beispielwerk", admin: "admin@beispielwerk.example.com" }, +]; + +async function main(): Promise { + const password = process.env.SEED_PASSWORD; + if (!password || password.length < 12) throw new Error("SEED_PASSWORD (at least 12 characters) is required"); + const config = loadConfig(); + const database = createDatabase(config.databaseUrl, { max: 2 }); + const auth = createAuth(database.db, config.auth); + const signUp = (email: string, name: string) => auth.api.signUpEmail({ body: { email, password, name } }); + try { + for (const company of COMPANIES) { + const [existing] = await database.db.select().from(schema.organization).where(eq(schema.organization.slug, company.slug)); + if (existing) { + console.log(`skip ${company.slug}: exists`); + continue; + } + await bootstrapCompany(database.db, { name: company.name, slug: company.slug, adminEmail: company.admin }); + await signUp(company.admin, "Demo Admin"); + if (company.clerk) { + const login = await auth.api.signInEmail({ body: { email: company.admin, password }, returnHeaders: true }); + const cookie = login.headers.getSetCookie().map((line) => line.split(";")[0]).join("; "); + const admin = await getActor(auth, database.db, new Headers({ cookie })); + if (!admin) throw new Error("seeded admin has no company"); + await inviteUser(database.db, admin, { email: company.clerk, role: "clerk" }); + await signUp(company.clerk, "Demo Sachbearbeitung"); + } + console.log(`seeded ${company.slug}`); + } + } finally { + await database.pool.end(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "seed failed"); + process.exit(1); +}); From 6a3490ad66472e7ef037b3749d64b0eed8493c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:50:58 +0000 Subject: [PATCH 08/10] docs: data model with classification, architecture map and changelog for #4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- CHANGELOG.md | 5 ++++ docs/technical/architecture.md | 13 ++++---- docs/technical/data-model.md | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 docs/technical/data-model.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f4a7d..a0c304d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This file records what changes **in the product** – process and session state ## [Unreleased] ### Added +- Invite-only login (e-mail + password): admins invite staff into their own company; sign-up + without an invitation creates no account. Roles `admin` and `clerk` per company. +- Tenant isolation: every company-owned table has forced row-level security; data access runs + inside `withTenant()`. +- Login rate limit (stored in the database) and `pnpm seed:demo` with two synthetic companies. - Runnable local stack: `docker compose up` starts PostgreSQL 17, SeaweedFS (S3), a one-shot `setup` step (migrations + private bucket), the web app and a no-op worker. - `GET /api/health` reports database and storage status (200 / 503, no connection details). diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 53f74f4..0b8ce9e 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -12,8 +12,9 @@ approve them, and exports each approved request exactly once to an ERP (mock in It is a TypeScript modular monolith (`web` + `worker` from one codebase) on PostgreSQL, plus the AI service. Decisions and rationale: [ADR-0001](../decisions/ADR-0001-pilot-architecture.md). -**Current state (2026-09-22): app skeleton (#3)** – runnable stack, health endpoint, database roles and -schema `app`, module skeletons with enforced boundaries. Status per module below (`skeleton` = public +**Current state (2026-09-23): app skeleton (#3) + identity/tenancy (#4)** – runnable stack, health +endpoint, database roles, invite-only login, companies, `withTenant()` with forced RLS, module +skeletons with enforced boundaries. Tables: [data-model.md](data-model.md). Status per module below (`skeleton` = public `index.ts` only). ## Modules @@ -25,19 +26,19 @@ Every new file belongs to one of these modules – otherwise add the module here | `intake` | `src/features/intake/` | upload, duplicate fingerprint, creates request + documents | authenticated UI/route | confidential + personal | session, tenant context, size/type limits | skeleton | | `documents` | `src/features/documents/` | document records, storage references, hashes | internal | confidential | tenant context | skeleton | | `extraction` | `src/features/extraction/` | AI-service client, persists runs/fields/evidence | internal | confidential + personal | tenant context, contract validation | skeleton | -| `requests` | `src/features/requests/` | request aggregate, status machine | internal | confidential | tenant context | skeleton | +| `requests` | `src/features/requests/` | request aggregate, status machine | internal | confidential | tenant context | partial: `app.requests` + repository (status machine: #7) | | `review` | `src/features/review/` | review UI, corrections, approve/reject | authenticated UI | confidential + personal | session, role check, audit | skeleton | | `export` | `src/features/export/` | ERP port + REST adapter, idempotency | outbound HTTP | confidential | idempotency key, unique export, timeout | skeleton | | `erp-mock` | `src/features/erp-mock/` | simulated ERP REST API | route behind flag | synthetic | disabled unless `ERP_MOCK_ENABLED` | skeleton | -| `identity` | `src/features/identity/` | Better Auth, users, companies, roles | public login route | personal (staff) | rate limit, invite-only | skeleton | -| `tenancy` | `src/features/tenancy/` | `withTenant()`, RLS policies | internal | – | forced RLS, `app_rw` without BYPASSRLS | skeleton | +| `identity` | `src/features/identity/` | Better Auth, users, companies, roles | public login route | personal (staff) | rate limit, invite-only | partial: Better Auth (invite-only, organization + admin plugins), `authorize()`, invite, seed | +| `tenancy` | `src/features/tenancy/` | `withTenant()`, RLS policies | internal | – | forced RLS, `app_rw` without BYPASSRLS | built: `withTenant()`, forced RLS on `app.*` | | `audit` | `src/features/audit/` | append-only audit events | internal | personal (staff) | INSERT/SELECT only | skeleton | | `jobs` | `src/features/jobs/`, entrypoint `src/worker.ts` | pg-boss, job handlers, `drain()`, worker entrypoint | internal | IDs only | transactional enqueue | skeleton (no-op worker) | | `storage` | `src/features/storage/` | `BlobStore` port + S3 adapter | internal | confidential | private bucket, access via app routes | partial: S3 adapter, bucket setup, health ping | | `observability` | `src/features/observability/` | logger, health, request-list ops data | `/api/health` | IDs only | no PII in logs | partial: health aggregation (database, storage) | | `db` | `src/db/`, deploy step `src/setup.ts` | Drizzle schema, migrations, DB roles | internal | – | migrations as owner role | built: roles check, schema `app`, default grants for `app_rw` | | `config` | `src/config/` | typed runtime configuration, validated at start (zod) | internal | secrets (in memory only) | errors name variables, never values | built | -| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client) | `/`, `/api/health` | – | calls module APIs only (dependency-cruiser) | skeleton: placeholder page, health route | +| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client) | `/`, `/login`, `/signup`, `/invite`, `/api/auth/*`, `/api/health` | – | calls module APIs only (dependency-cruiser) | partial: login, sign-up, invite, home | | AI service | `services/ai/` | docling parsing, extraction, grounding, evals | internal HTTP | confidential + personal (transient) | bearer token, stateless, no DB/storage access | planned | | Contracts | `contracts/` | OpenAPI: AI service, ERP export | – | – | contract tests | planned | diff --git a/docs/technical/data-model.md b/docs/technical/data-model.md new file mode 100644 index 0000000..9a015a9 --- /dev/null +++ b/docs/technical/data-model.md @@ -0,0 +1,54 @@ +# Data model – RequestFlow + +> Living document: whoever adds or changes a table updates this file **in the same PR**. +> Source of truth: `src/db/schema/` + `src/db/migrations/`. Classification per the `datenschutz` add-on: +> public / internal / confidential / personal. + +## Schemas + +| Schema | Owner | Runtime access (`app_rw`) | Tenant isolation | +|---|---|---|---| +| `app` | `app_owner` | DML via default privileges, no CREATE | every table: `company_id` + RLS **enabled and forced**, policy `_tenant_isolation` | +| `auth` | `app_owner` | DML on all tables, no CREATE | none – Better Auth data, server code only (exceptions register) | +| `drizzle` | `app_owner` | none | migration journal | + +Tenant policy (all `app` tables): `company_id = nullif(current_setting('app.company_id', true), '')::uuid` +for `USING` and `WITH CHECK`. `withTenant()` sets `app.company_id` transaction-locally; without it a +query sees zero rows and every write fails. + +## Tables + +### `app.requests` – request aggregate (#4, extended by #5/#7) + +| Column | Type | Notes | Class | +|---|---|---|---| +| `id` | uuid PK | `gen_random_uuid()` | internal | +| `company_id` | uuid FK → `auth.organization.id` | tenant key, `ON DELETE RESTRICT` | internal | +| `status` | text | `NEW · PROCESSING · REVIEW · APPROVED · EXPORTED · REJECTED · ERROR` (check constraint) | internal | +| `created_at` | timestamptz | | internal | + +Purpose: one quote request per row. Retention: open question for the customer (ADR-0001 open points). + +### `auth.*` – Better Auth 1.7.5 (generated with the Better Auth CLI, timestamps with time zone) + +| Table | Content | Class | Purpose | +|---|---|---|---| +| `user` | name, e-mail, global role (`user`), ban fields | personal (staff) | login identity | +| `account` | password hash (credential provider) | confidential | authentication | +| `session` | token, expiry, IP, user agent, `active_organization_id` | personal (staff) | session; carries the active company | +| `verification` | verification tokens | confidential | e-mail verification (unused in the pilot) | +| `organization` | company name, slug | internal | company = tenant | +| `member` | user ↔ company, company role `admin`/`clerk` | internal | membership + role | +| `invitation` | e-mail, company, role, status, expiry, inviter | personal (staff) | invite-only sign-up | +| `rate_limit` | key (IP + path), counter | personal (IP) | built-in rate limit, database storage | + +A system user `system@requestflow.invalid` (no password account, no membership) is the inviter of each +company's first admin; it can never obtain a session. + +## Relations + +```text +auth.organization 1─n auth.member n─1 auth.user 1─n auth.session / auth.account +auth.organization 1─n auth.invitation +auth.organization 1─n app.requests (company_id) +``` From a8a280889365f5f3e8daa0a4de42e16b5335351c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:03:20 +0000 Subject: [PATCH 09/10] fix(identity): address the fresh security review of PR #31 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sign-up requires the invitation id (link) plus the invited e-mail – no takeover by address alone - one company per user (unique index), deterministic membership lookup, actor from membership - organization plugin accepts only admin/clerk roles - configurable client-IP source for the auth rate limit; local secret refused in production - invite page: zod input, 404 for clerks, shows the invitation link; signup needs the link - tests: wrong/missing invitation id, foreign set-active/list-members, last admin, roles - docs: operations (rate limit/proxy, recovery), data model, exceptions register (admin plugin) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- .env.example | 5 + CHANGELOG.md | 4 +- docs/technical/architecture.md | 1 + docs/technical/data-model.md | 4 +- docs/technical/operations.md | 14 ++ src/app/_components/auth-form.tsx | 4 +- src/app/invite/page.tsx | 42 ++++-- src/app/signup/page.tsx | 14 +- src/config/env.test.ts | 14 ++ src/config/env.ts | 19 ++- .../migrations/0002_auth_grants_force_rls.sql | 3 + src/features/identity/actor.ts | 21 +-- src/features/identity/auth.ts | 45 ++++-- src/seed.ts | 11 +- tests/integration/helpers/stack.ts | 20 ++- tests/integration/identity.test.ts | 130 ++++++++++++++---- 16 files changed, 274 insertions(+), 77 deletions(-) diff --git a/.env.example b/.env.example index 78a857f..929bdf6 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,11 @@ S3_PORT=8333 BETTER_AUTH_SECRET=local-dev-only-secret-change-me-0123456789 # Public base URL of the web app (cookies, redirects, trusted origin). BETTER_AUTH_URL=http://localhost:3000 +# Client IP for the login rate limit. Set to what YOUR reverse proxy writes and list the proxy +# addresses; without trusted proxies only a single-value header is trusted. If the web container is +# reachable without a proxy, clients can forge this header – put a proxy in front (see operations.md). +AUTH_IP_HEADERS=x-forwarded-for +AUTH_TRUSTED_PROXIES= # Demo seed only (`pnpm seed:demo`): password of the synthetic demo accounts, local use only. SEED_PASSWORD=demo-password-local-only diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c304d..857c24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ This file records what changes **in the product** – process and session state ## [Unreleased] ### Added -- Invite-only login (e-mail + password): admins invite staff into their own company; sign-up - without an invitation creates no account. Roles `admin` and `clerk` per company. +- Invite-only login (e-mail + password): admins invite staff into their own company and hand over + an invitation link; sign-up without a valid invitation link creates no account. Roles `admin` and `clerk` per company. - Tenant isolation: every company-owned table has forced row-level security; data access runs inside `withTenant()`. - Login rate limit (stored in the database) and `pnpm seed:demo` with two synthetic companies. diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 0b8ce9e..be7e922 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -50,6 +50,7 @@ Deliberately accepted risks – without an entry here a deviation counts as a de |---|---|---|---| | No RLS on the `auth` and `pgboss` schemas | Not company-owned business data; reachable only by server code (ADR-0001 D7) | Fluory | 2026-12-31 (review at M3) | | Showcase without unattended retries (Vercel Hobby cron once/day) | Showcase only; production runs a worker (D2) | Fluory | when a production-like demo is needed | +| Better Auth admin plugin mounted without any holder of its admin role | ADR-0001 D6 names the plugin; nobody holds `platform-admin`, so `/api/auth/admin/*` rejects every caller (tested); user management runs through `identity` | Fluory | with #30 (decide: keep for ban/deactivate or remove) | | Gemini API free tier for local development | Synthetic data only; never showcase or customer data (D8) | Fluory | when a Vertex development budget exists | ## Data flow diff --git a/docs/technical/data-model.md b/docs/technical/data-model.md index 9a015a9..be6da22 100644 --- a/docs/technical/data-model.md +++ b/docs/technical/data-model.md @@ -38,8 +38,8 @@ Purpose: one quote request per row. Retention: open question for the customer (A | `session` | token, expiry, IP, user agent, `active_organization_id` | personal (staff) | session; carries the active company | | `verification` | verification tokens | confidential | e-mail verification (unused in the pilot) | | `organization` | company name, slug | internal | company = tenant | -| `member` | user ↔ company, company role `admin`/`clerk` | internal | membership + role | -| `invitation` | e-mail, company, role, status, expiry, inviter | personal (staff) | invite-only sign-up | +| `member` | user ↔ company, company role `admin`/`clerk`; unique `user_id` (one company per user) | internal | membership + role | +| `invitation` | e-mail, company, role, status, expiry, inviter; the random `id` is the sign-up token (link) | personal (staff) | invite-only sign-up | | `rate_limit` | key (IP + path), counter | personal (IP) | built-in rate limit, database storage | A system user `system@requestflow.invalid` (no password account, no membership) is the inviter of each diff --git a/docs/technical/operations.md b/docs/technical/operations.md index 83834ec..9e5888b 100644 --- a/docs/technical/operations.md +++ b/docs/technical/operations.md @@ -25,6 +25,20 @@ migration aborts if `app_owner`/`app_rw` are missing or could bypass RLS – fix customer) an operator creates `app_owner` and `app_rw` once with the same statements (passwords from the secret manager), before the first `setup` run; the first migration refuses to run otherwise. +## Login rate limit and client IP + +Better Auth limits `/api/auth/*` per client IP (5 sign-ins/sign-ups per minute, counters in +`auth.rate_limit`). The IP comes from `AUTH_IP_HEADERS`; that header is only trustworthy when a +reverse proxy sets it and clients cannot reach the web container directly. Any deployment beyond the +local machine puts a proxy in front and lists it in `AUTH_TRUSTED_PROXIES`. A per-account limit is a +follow-up (not in the pilot). + +## Invitations and account recovery + +Invite-only: an admin creates an invitation on `/invite` and hands over the link +(`/signup?invitation=`, 7 days valid); e-mail delivery is not part of the pilot. There is no +self-service password reset yet – recovery is an operator task (delete the user row, invite again). + ## Frequent failures | Symptom | Cause | Action | diff --git a/src/app/_components/auth-form.tsx b/src/app/_components/auth-form.tsx index 27b259c..06df4af 100644 --- a/src/app/_components/auth-form.tsx +++ b/src/app/_components/auth-form.tsx @@ -5,7 +5,7 @@ import { useState, type FormEvent } from "react"; // Posts JSON straight to Better Auth (/api/auth/*); cookies are set by that response, so no extra // auth plugin is needed for server actions. -export function AuthForm({ mode }: { mode: "sign-in" | "sign-up" }) { +export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up"; invitationId?: string }) { const router = useRouter(); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); @@ -18,7 +18,7 @@ export function AuthForm({ mode }: { mode: "sign-in" | "sign-up" }) { const body = { email: String(form.get("email") ?? ""), password: String(form.get("password") ?? ""), - ...(mode === "sign-up" ? { name: String(form.get("name") ?? "") } : {}), + ...(mode === "sign-up" ? { name: String(form.get("name") ?? ""), invitationId } : {}), }; const response = await fetch(`/api/auth/${mode}/email`, { method: "POST", diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index af44721..00ffdb9 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -1,23 +1,14 @@ import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; +import { z } from "zod"; import { currentActor, getRuntime } from "@/app/_server/runtime"; -import { authorize, AuthorizationError, inviteUser, isCompanyRole } from "@/features/identity"; +import { authorize, AuthorizationError, inviteUser, COMPANY_ROLES, type Actor } from "@/features/identity"; export const dynamic = "force-dynamic"; -// Invite form (pilot: no role-admin UI). Authorization runs server-side on render AND in the action. -async function invite(formData: FormData) { - "use server"; - const actor = await currentActor(await headers()); - if (!actor) redirect("/login"); - const email = String(formData.get("email") ?? ""); - const role = String(formData.get("role") ?? ""); - if (!isCompanyRole(role) || !email.includes("@")) redirect("/invite?error=input"); - await inviteUser(getRuntime().database.db, actor, { email, role }); - redirect("/invite?sent=1"); -} +const inviteInput = z.object({ email: z.email().max(254), role: z.enum(COMPANY_ROLES) }); -export default async function InvitePage({ searchParams }: { searchParams: Promise> }) { +async function adminOrNotFound(): Promise { const actor = await currentActor(await headers()); if (!actor) redirect("/login"); try { @@ -26,17 +17,38 @@ export default async function InvitePage({ searchParams }: { searchParams: Promi if (error instanceof AuthorizationError) notFound(); throw error; } + return actor; +} + +// Invite form (pilot: no role-admin UI; e-mail delivery is out of scope). Authorization runs +// server-side on render AND in the action. The admin hands the link over to the invited person. +async function invite(formData: FormData) { + "use server"; + const actor = await adminOrNotFound(); + const input = inviteInput.safeParse({ email: formData.get("email"), role: formData.get("role") }); + if (!input.success) redirect("/invite?error=input"); + const { invitationId } = await inviteUser(getRuntime().database.db, actor, input.data); + redirect(`/invite?invitation=${invitationId}`); +} + +export default async function InvitePage({ searchParams }: { searchParams: Promise> }) { + await adminOrNotFound(); const params = await searchParams; + const link = params.invitation ? `${getRuntime().config.auth.baseURL}/signup?invitation=${encodeURIComponent(params.invitation)}` : null; return (

Mitarbeitende einladen

- {params.sent &&

Einladung angelegt. Die Person kann jetzt unter /signup ein Konto anlegen.

} + {link && ( +

+ Einladung angelegt (7 Tage gültig). Diesen Link an die eingeladene Person weitergeben: {link} +

+ )} {params.error &&

Bitte eine gültige E-Mail-Adresse und Rolle angeben.

}


- +

diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 52d475a..9f049f1 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,12 +1,20 @@ import Link from "next/link"; import { AuthForm } from "@/app/_components/auth-form"; -export default function SignupPage() { +// Invite-only: the link from the invitation carries its id (`/signup?invitation=`). +export default async function SignupPage({ searchParams }: { searchParams: Promise> }) { + const { invitation } = await searchParams; return (

Konto anlegen

-

Nur mit Einladung: Verwenden Sie die E-Mail-Adresse, an die die Einladung ging.

- + {invitation ? ( + <> +

Verwenden Sie die E-Mail-Adresse, an die die Einladung ging.

+ + + ) : ( +

Ein Konto kann nur über einen Einladungslink angelegt werden. Bitte wenden Sie sich an Ihre Administration.

+ )}

Zur Anmeldung

diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 85454a5..599468a 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -52,4 +52,18 @@ describe("loadConfig", () => { it("reads S3_FORCE_PATH_STYLE=false as false", () => { expect(loadConfig({ ...valid, S3_FORCE_PATH_STYLE: "false" }).storage.forcePathStyle).toBe(false); }); + + it("refuses the committed local auth secret in production", () => { + const local = { ...valid, BETTER_AUTH_SECRET: "local-dev-only-secret-change-me-0123456789" }; + + expect(() => loadConfig({ ...local, NODE_ENV: "production" })).toThrow(/BETTER_AUTH_SECRET/); + expect(() => loadConfig(local)).not.toThrow(); + }); + + it("reads the client-IP headers and trusted proxies for the auth rate limit as lists", () => { + const config = loadConfig({ ...valid, AUTH_IP_HEADERS: "x-real-ip, x-forwarded-for", AUTH_TRUSTED_PROXIES: "10.0.0.2" }); + + expect(config.auth.ipAddressHeaders).toEqual(["x-real-ip", "x-forwarded-for"]); + expect(config.auth.trustedProxies).toEqual(["10.0.0.2"]); + }); }); diff --git a/src/config/env.ts b/src/config/env.ts index ed751c8..8547a4b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -10,8 +10,14 @@ const schema = z.object({ S3_FORCE_PATH_STYLE: z.enum(["true", "false"]).default("true"), BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.url(), + AUTH_IP_HEADERS: z.string().default("x-forwarded-for"), + AUTH_TRUSTED_PROXIES: z.string().default(""), + NODE_ENV: z.string().default("development"), }); +const LOCAL_PLACEHOLDER_SECRETS = new Set(["local-dev-only-secret-change-me-0123456789"]); +const list = (value: string) => value.split(",").map((item) => item.trim()).filter(Boolean); + export interface AppConfig { databaseUrl: string; storage: { @@ -25,6 +31,8 @@ export interface AppConfig { auth: { secret: string; baseURL: string; + ipAddressHeaders: string[]; + trustedProxies: string[]; }; } @@ -36,6 +44,10 @@ export function loadConfig(source: Record = process. throw new Error(`Invalid or missing configuration: ${names.join(", ")}`); } const env = parsed.data; + // The committed local default must never sign sessions of a real deployment. + if (env.NODE_ENV === "production" && LOCAL_PLACEHOLDER_SECRETS.has(env.BETTER_AUTH_SECRET)) { + throw new Error("Invalid or missing configuration: BETTER_AUTH_SECRET"); + } return { databaseUrl: env.DATABASE_URL, storage: { @@ -46,6 +58,11 @@ export function loadConfig(source: Record = process. secretAccessKey: env.S3_SECRET_ACCESS_KEY, forcePathStyle: env.S3_FORCE_PATH_STYLE === "true", }, - auth: { secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL }, + auth: { + secret: env.BETTER_AUTH_SECRET, + baseURL: env.BETTER_AUTH_URL, + ipAddressHeaders: list(env.AUTH_IP_HEADERS), + trustedProxies: list(env.AUTH_TRUSTED_PROXIES), + }, }; } diff --git a/src/db/migrations/0002_auth_grants_force_rls.sql b/src/db/migrations/0002_auth_grants_force_rls.sql index af3202f..e7373f5 100644 --- a/src/db/migrations/0002_auth_grants_force_rls.sql +++ b/src/db/migrations/0002_auth_grants_force_rls.sql @@ -12,3 +12,6 @@ ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA auth GRANT SELECT, INSERT, -- 2) Forced RLS: drizzle-kit emits only ENABLE. FORCE makes the policy apply to the table owner too, -- so no role except a superuser ever reads company data without `app.company_id`. ALTER TABLE app.requests FORCE ROW LEVEL SECURITY; +--> statement-breakpoint +-- 3) One company per user in the pilot: the session hook and getActor resolve THE membership. +CREATE UNIQUE INDEX member_one_company_per_user ON auth.member (user_id); diff --git a/src/features/identity/actor.ts b/src/features/identity/actor.ts index 335e235..97332b6 100644 --- a/src/features/identity/actor.ts +++ b/src/features/identity/actor.ts @@ -1,23 +1,26 @@ -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { Database } from "@/db"; import * as schema from "@/db/schema"; import type { Auth } from "./auth"; import { isCompanyRole, type Actor } from "./authorize"; /** - * The signed-in actor of a request: user, company and company role. The company comes from the - * session's active organization and is re-checked against a live membership on every call, so a - * removed membership takes effect immediately. Never from client input (ADR-0001 D7). + * The signed-in actor of a request: user, company and company role (ADR-0001 D7). The company is + * the user's live membership (one company per user, unique index) – re-read on every call, so a + * removed membership takes effect immediately; never from client input. A session whose active + * organization disagrees with the membership fails closed. Better Auth clears the active + * organization after a refused switch; the membership still identifies the company then. */ export async function getActor(auth: Auth, db: Database, headers: Headers): Promise { const session = await auth.api.getSession({ headers }); - const companyId = session?.session.activeOrganizationId; - if (!session || !companyId) return null; + if (!session) return null; const [membership] = await db - .select({ role: schema.member.role }) + .select({ companyId: schema.member.organizationId, role: schema.member.role }) .from(schema.member) - .where(and(eq(schema.member.userId, session.user.id), eq(schema.member.organizationId, companyId))) + .where(eq(schema.member.userId, session.user.id)) .limit(1); if (!membership || !isCompanyRole(membership.role)) return null; - return { userId: session.user.id, companyId, role: membership.role }; + const active = session.session.activeOrganizationId; + if (active && active !== membership.companyId) return null; + return { userId: session.user.id, companyId: membership.companyId, role: membership.role }; } diff --git a/src/features/identity/auth.ts b/src/features/identity/auth.ts index 8479026..f5a5188 100644 --- a/src/features/identity/auth.ts +++ b/src/features/identity/auth.ts @@ -2,7 +2,7 @@ import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { betterAuth } from "better-auth"; import { APIError } from "better-auth/api"; import { admin, organization } from "better-auth/plugins"; -import { and, eq, gt, sql } from "drizzle-orm"; +import { and, asc, eq, gt, sql } from "drizzle-orm"; import type { Database } from "@/db"; import * as schema from "@/db/schema"; import { organizationAc, organizationRoles, PLATFORM_ADMIN_ROLE, platformRoles } from "./access"; @@ -11,23 +11,33 @@ import { isCompanyRole } from "./authorize"; export interface AuthSettings { secret: string; baseURL: string; + /** Client-IP source for rate limiting; must match the deployment's proxy setup. */ + ipAddressHeaders?: string[]; + trustedProxies?: string[]; /** Rate limit on /api/auth/* (built-in, database storage). Tests may tighten it. */ rateLimit?: { window: number; max: number }; } const lower = (value: string) => value.trim().toLowerCase(); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const invitationIdOf = (context: { body?: unknown } | null) => + (context?.body as { invitationId?: unknown } | undefined)?.invitationId; /** * Better Auth for RequestFlow (ADR-0001 D6): e-mail + password, invite-only, organization = company, * admin plugin without any global admin, rate limit stored in the database. */ export function createAuth(db: Database, settings: AuthSettings) { - const pendingInvitation = async (email: string) => { + // Invite-only: the sign-up must present the invitation id (a random UUID handed over as a link) + // AND the invited e-mail. An e-mail alone proves nothing – addresses are guessable and unverified. + const pendingInvitation = async (email: string, invitationId: unknown) => { + if (typeof invitationId !== "string" || !UUID.test(invitationId)) return undefined; const [row] = await db .select() .from(schema.invitation) .where( and( + eq(schema.invitation.id, invitationId), sql`lower(${schema.invitation.email}) = ${lower(email)}`, eq(schema.invitation.status, "pending"), gt(schema.invitation.expiresAt, new Date()), @@ -38,7 +48,8 @@ export function createAuth(db: Database, settings: AuthSettings) { }; const membershipOf = async (userId: string) => { - const [row] = await db.select().from(schema.member).where(eq(schema.member.userId, userId)).limit(1); + // One company per user (unique index on member.user_id); ordered for determinism anyway. + const [row] = await db.select().from(schema.member).where(eq(schema.member.userId, userId)).orderBy(asc(schema.member.createdAt)).limit(1); return row; }; @@ -47,7 +58,13 @@ export function createAuth(db: Database, settings: AuthSettings) { baseURL: settings.baseURL, basePath: "/api/auth", database: drizzleAdapter(db, { provider: "pg", schema, transaction: true }), - advanced: { database: { generateId: "uuid" } }, + advanced: { + database: { generateId: "uuid" }, + ipAddress: { + ipAddressHeaders: settings.ipAddressHeaders ?? ["x-forwarded-for"], + ...(settings.trustedProxies?.length ? { trustedProxies: settings.trustedProxies } : {}), + }, + }, emailAndPassword: { enabled: true, minPasswordLength: 12, autoSignIn: false }, session: { expiresIn: 60 * 60 * 8, updateAge: 60 * 60 }, rateLimit: { @@ -69,6 +86,18 @@ export function createAuth(db: Database, settings: AuthSettings) { disableOrganizationDeletion: true, invitationExpiresIn: 60 * 60 * 24 * 7, cancelPendingInvitationsOnReInvite: true, + // Only the pilot's two company roles – no plugin defaults (owner/member), no role lists. + organizationHooks: { + beforeCreateInvitation: async ({ invitation }) => { + if (!isCompanyRole(invitation.role)) throw new APIError("BAD_REQUEST", { message: "Unknown role." }); + }, + beforeUpdateMemberRole: async ({ newRole }) => { + if (!isCompanyRole(newRole)) throw new APIError("BAD_REQUEST", { message: "Unknown role." }); + }, + beforeAddMember: async ({ member }) => { + if (!isCompanyRole(member.role)) throw new APIError("BAD_REQUEST", { message: "Unknown role." }); + }, + }, }), // Company admins are `member.role = admin`, never a global admin-plugin role, so they cannot use // the plugin's cross-company endpoints (list/ban/impersonate users). @@ -83,16 +112,16 @@ export function createAuth(db: Database, settings: AuthSettings) { user: { create: { // Invite-only: an account is created only for an e-mail with a pending, unexpired invitation. - before: async (user) => { - const invitation = await pendingInvitation(user.email); + before: async (user, context) => { + const invitation = await pendingInvitation(user.email, invitationIdOf(context)); if (!invitation) { throw new APIError("FORBIDDEN", { message: "Registration requires an invitation." }); } return { data: { ...user, email: lower(user.email), role: "user" } }; }, // The invitation becomes the membership: company and company role come from it. - after: async (user) => { - const invitation = await pendingInvitation(user.email); + after: async (user, context) => { + const invitation = await pendingInvitation(user.email, invitationIdOf(context)); if (!invitation || !isCompanyRole(invitation.role)) return; await db.transaction(async (tx) => { await tx.insert(schema.member).values({ diff --git a/src/seed.ts b/src/seed.ts index b1a5cb0..5bfd5a6 100644 --- a/src/seed.ts +++ b/src/seed.ts @@ -18,7 +18,8 @@ async function main(): Promise { const config = loadConfig(); const database = createDatabase(config.databaseUrl, { max: 2 }); const auth = createAuth(database.db, config.auth); - const signUp = (email: string, name: string) => auth.api.signUpEmail({ body: { email, password, name } }); + const signUp = (email: string, name: string, invitationId: string) => + auth.api.signUpEmail({ body: { email, password, name, invitationId } as { email: string; password: string; name: string } }); try { for (const company of COMPANIES) { const [existing] = await database.db.select().from(schema.organization).where(eq(schema.organization.slug, company.slug)); @@ -26,15 +27,15 @@ async function main(): Promise { console.log(`skip ${company.slug}: exists`); continue; } - await bootstrapCompany(database.db, { name: company.name, slug: company.slug, adminEmail: company.admin }); - await signUp(company.admin, "Demo Admin"); + const { invitationId } = await bootstrapCompany(database.db, { name: company.name, slug: company.slug, adminEmail: company.admin }); + await signUp(company.admin, "Demo Admin", invitationId); if (company.clerk) { const login = await auth.api.signInEmail({ body: { email: company.admin, password }, returnHeaders: true }); const cookie = login.headers.getSetCookie().map((line) => line.split(";")[0]).join("; "); const admin = await getActor(auth, database.db, new Headers({ cookie })); if (!admin) throw new Error("seeded admin has no company"); - await inviteUser(database.db, admin, { email: company.clerk, role: "clerk" }); - await signUp(company.clerk, "Demo Sachbearbeitung"); + const invitation = await inviteUser(database.db, admin, { email: company.clerk, role: "clerk" }); + await signUp(company.clerk, "Demo Sachbearbeitung", invitation.invitationId); } console.log(`seeded ${company.slug}`); } diff --git a/tests/integration/helpers/stack.ts b/tests/integration/helpers/stack.ts index 854bb7f..ce8a875 100644 --- a/tests/integration/helpers/stack.ts +++ b/tests/integration/helpers/stack.ts @@ -52,8 +52,8 @@ export async function call( return { status: response.status, body: text ? JSON.parse(text) : null, cookie }; } -export async function signUp(auth: Auth, email: string) { - return call(auth, "/sign-up/email", { body: { email, password: PASSWORD, name: "Synthetic User" } }); +export async function signUp(auth: Auth, email: string, invitationId?: string) { + return call(auth, "/sign-up/email", { body: { email, password: PASSWORD, name: "Synthetic User", invitationId } }); } export async function signIn(auth: Auth, email: string, ip?: string) { @@ -63,14 +63,24 @@ export async function signIn(auth: Auth, email: string, ip?: string) { /** A company with a signed-in first admin. */ export async function companyWithAdmin(stack: Stack) { const adminEmail = syntheticEmail("admin"); - const { company } = await bootstrapCompany(stack.database.db, { + const { company, invitationId } = await bootstrapCompany(stack.database.db, { name: `Beispiel Maschinenbau ${unique("co")}`, slug: unique("beispiel"), adminEmail, }); - const signUpResult = await signUp(stack.auth, adminEmail); - if (signUpResult.status !== 200) throw new Error(`sign-up failed: ${signUpResult.status}`); + await signUp(stack.auth, adminEmail, invitationId); const login = await signIn(stack.auth, adminEmail); if (login.status !== 200) throw new Error(`sign-in failed: ${login.status}`); return { company, adminEmail, cookie: login.cookie }; } + +/** Invite a user into the actor's company and sign them in; returns the new user's cookie. */ +export async function invitedUser(stack: Stack, admin: import("@/features/identity").Actor, role: "admin" | "clerk") { + const { inviteUser } = await import("@/features/identity"); + const email = syntheticEmail(role); + const { invitationId } = await inviteUser(stack.database.db, admin, { email, role }); + await signUp(stack.auth, email, invitationId); + const login = await signIn(stack.auth, email); + if (login.status !== 200) throw new Error(`sign-in failed: ${login.status}`); + return { email, cookie: login.cookie }; +} diff --git a/tests/integration/identity.test.ts b/tests/integration/identity.test.ts index e5fbc93..572fab7 100644 --- a/tests/integration/identity.test.ts +++ b/tests/integration/identity.test.ts @@ -1,11 +1,24 @@ import { eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import * as schema from "@/db/schema"; -import { AuthorizationError, getActor, inviteUser } from "@/features/identity"; -import { call, companyWithAdmin, createStack, freshIp, signIn, signUp, syntheticEmail, type Stack } from "./helpers/stack"; +import { AuthorizationError, getActor, inviteUser, type Actor } from "@/features/identity"; +import { + call, + companyWithAdmin, + createStack, + freshIp, + invitedUser, + signIn, + signUp, + syntheticEmail, + type Stack, +} from "./helpers/stack"; describe("identity: invite-only login and companies", () => { let stack: Stack; + const actorOf = async (cookie: string) => (await getActor(stack.auth, stack.database.db, new Headers({ cookie }))) as Actor; + const userCount = async (email: string) => + (await stack.database.db.select().from(schema.user).where(eq(schema.user.email, email.toLowerCase()))).length; beforeAll(() => { stack = createStack(); @@ -25,16 +38,37 @@ describe("identity: invite-only login and companies", () => { expect((result.body as { token: unknown }).token).toBeNull(); expect(result.cookie).not.toMatch(/session_token/); - const users = await stack.database.db.select().from(schema.user).where(eq(schema.user.email, email)); - expect(users).toHaveLength(0); + expect(await userCount(email)).toBe(0); expect((await signIn(stack.auth, email)).status).toBe(401); }); + it("rejects an invited address without the invitation id, or with a wrong one (no takeover by e-mail alone)", async () => { + const { cookie } = await companyWithAdmin(stack); + const email = syntheticEmail("target"); + await inviteUser(stack.database.db, await actorOf(cookie), { email, role: "clerk" }); + + await signUp(stack.auth, email); + await signUp(stack.auth, email, crypto.randomUUID()); + await signUp(stack.auth, email, "not-a-uuid"); + + expect(await userCount(email)).toBe(0); + }); + + it("rejects the invitation id of one address for another address", async () => { + const { cookie } = await companyWithAdmin(stack); + const { invitationId } = await inviteUser(stack.database.db, await actorOf(cookie), { email: syntheticEmail("real"), role: "clerk" }); + const attacker = syntheticEmail("attacker"); + + await signUp(stack.auth, attacker, invitationId); + + expect(await userCount(attacker)).toBe(0); + }); + it("gives an invited user a session that carries their active company and role", async () => { const { company, cookie } = await companyWithAdmin(stack); const session = await call(stack.auth, "/get-session", { cookie }); - const actor = await getActor(stack.auth, stack.database.db, new Headers({ cookie })); + const actor = await actorOf(cookie); expect((session.body as { session: { activeOrganizationId: string } }).session.activeOrganizationId).toBe(company.id); expect(actor).toMatchObject({ companyId: company.id, role: "admin" }); @@ -48,36 +82,44 @@ describe("identity: invite-only login and companies", () => { it("matches the invitation e-mail case-insensitively and consumes the invitation", async () => { const { company, cookie } = await companyWithAdmin(stack); - const admin = await getActor(stack.auth, stack.database.db, new Headers({ cookie })); - const email = syntheticEmail("Clerk").replace("Clerk", "CLERK"); - const { invitationId } = await inviteUser(stack.database.db, admin!, { email: email.toLowerCase(), role: "clerk" }); + const email = syntheticEmail("Clerk").toUpperCase(); + const { invitationId } = await inviteUser(stack.database.db, await actorOf(cookie), { email: email.toLowerCase(), role: "clerk" }); - expect((await signUp(stack.auth, email)).status).toBe(200); + await signUp(stack.auth, email, invitationId); const login = await signIn(stack.auth, email.toLowerCase()); - const clerk = await getActor(stack.auth, stack.database.db, new Headers({ cookie: login.cookie })); - expect(clerk).toMatchObject({ companyId: company.id, role: "clerk" }); + expect(await actorOf(login.cookie)).toMatchObject({ companyId: company.id, role: "clerk" }); const [invitation] = await stack.database.db.select().from(schema.invitation).where(eq(schema.invitation.id, invitationId)); expect(invitation?.status).toBe("accepted"); }); it("denies inviting to clerks – server-side function and the plugin's HTTP endpoint", async () => { const { company, cookie } = await companyWithAdmin(stack); - const admin = await getActor(stack.auth, stack.database.db, new Headers({ cookie })); - const clerkEmail = syntheticEmail("clerk"); - await inviteUser(stack.database.db, admin!, { email: clerkEmail, role: "clerk" }); - await signUp(stack.auth, clerkEmail); - const clerkLogin = await signIn(stack.auth, clerkEmail); - const clerk = await getActor(stack.auth, stack.database.db, new Headers({ cookie: clerkLogin.cookie })); - - await expect(inviteUser(stack.database.db, clerk!, { email: syntheticEmail("x"), role: "admin" })).rejects.toThrow(AuthorizationError); + const clerk = await invitedUser(stack, await actorOf(cookie), "clerk"); + + await expect(inviteUser(stack.database.db, await actorOf(clerk.cookie), { email: syntheticEmail("x"), role: "admin" })).rejects.toThrow( + AuthorizationError, + ); const viaPlugin = await call(stack.auth, "/organization/invite-member", { - cookie: clerkLogin.cookie, + cookie: clerk.cookie, body: { email: syntheticEmail("y"), role: "admin", organizationId: company.id }, }); expect(viaPlugin.status).toBe(403); }); + it("accepts only the pilot roles through the plugin's HTTP endpoints", async () => { + const { company, cookie } = await companyWithAdmin(stack); + + const owner = await call(stack.auth, "/organization/invite-member", { + cookie, + body: { email: syntheticEmail("o"), role: "owner", organizationId: company.id }, + }); + + expect(owner.status).toBeGreaterThanOrEqual(400); + const pending = await stack.database.db.select().from(schema.invitation).where(sql`${schema.invitation.role} = 'owner'`); + expect(pending).toHaveLength(0); + }); + it("gives a company admin no access to the global admin plugin (no cross-company user list)", async () => { const { cookie } = await companyWithAdmin(stack); @@ -94,16 +136,54 @@ describe("identity: invite-only login and companies", () => { expect(created.status).toBe(403); }); + it("refuses switching the active company to a foreign one and listing its members", async () => { + const a = await companyWithAdmin(stack); + const b = await companyWithAdmin(stack); + + const switched = await call(stack.auth, "/organization/set-active", { cookie: a.cookie, body: { organizationId: b.company.id } }); + const members = await call(stack.auth, `/organization/list-members?organizationId=${b.company.id}`, { cookie: a.cookie }); + + expect(switched.status).toBeGreaterThanOrEqual(400); + expect(members.status).toBeGreaterThanOrEqual(400); + expect((await actorOf(a.cookie)).companyId).toBe(a.company.id); + }); + + it("refuses removing the last admin of a company", async () => { + const { company, cookie } = await companyWithAdmin(stack); + const admin = await actorOf(cookie); + + const removed = await call(stack.auth, "/organization/remove-member", { + cookie, + body: { memberIdOrEmail: admin.userId, organizationId: company.id }, + }); + + expect(removed.status).toBeGreaterThanOrEqual(400); + expect(await actorOf(cookie)).not.toBeNull(); + }); + + it("allows one company per user: a second membership is refused by the database", async () => { + const a = await companyWithAdmin(stack); + const b = await companyWithAdmin(stack); + const admin = await actorOf(a.cookie); + + const second = stack.database.db + .insert(schema.member) + .values({ organizationId: b.company.id, userId: admin.userId, role: "clerk", createdAt: new Date() }); + + await expect(second).rejects.toThrow(); + }); + it("rejects a login without membership (fail closed)", async () => { - const { cookie } = await companyWithAdmin(stack); - const admin = await getActor(stack.auth, stack.database.db, new Headers({ cookie })); - await stack.database.db.delete(schema.member).where(eq(schema.member.userId, admin!.userId)); + const { cookie, adminEmail } = await companyWithAdmin(stack); + const admin = await actorOf(cookie); + await stack.database.db.delete(schema.member).where(eq(schema.member.userId, admin.userId)); expect(await getActor(stack.auth, stack.database.db, new Headers({ cookie }))).toBeNull(); - const [user] = await stack.database.db.select().from(schema.user).where(eq(schema.user.id, admin!.userId)); - expect((await signIn(stack.auth, user!.email)).status).not.toBe(200); + expect((await signIn(stack.auth, adminEmail)).status).not.toBe(200); }); + // Proves the limiter and its database storage. The client IP comes from the configured header, + // which only a trusted reverse proxy may set in a real deployment (see operations.md). it("rate-limits repeated sign-ins over HTTP and stores the counter in the database", async () => { const limited = createStack({ rateLimit: { window: 60, max: 3 } }); const ip = freshIp(); From 02715e0e1ed3d69dcdfba8b58ae0cbfbd54e10c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:09:31 +0000 Subject: [PATCH 10/10] fix(config): explicit APP_ENV; only local may use the committed auth secret The image sets NODE_ENV=production, so the previous check blocked the local compose stack. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- .env.example | 4 ++++ compose.yaml | 1 + src/config/env.test.ts | 12 ++++++++++-- src/config/env.ts | 6 ++++-- vitest.config.ts | 1 + 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 929bdf6..953f61c 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ S3_FORCE_PATH_STYLE=true # Host port of the local S3 gateway. S3_PORT=8333 +# --- Environment ------------------------------------------------------------------------------- +# local | showcase | production. Only `local` accepts the committed local-default auth secret. +APP_ENV=local + # --- Authentication (Better Auth) --------------------------------------------------------------- # Signs sessions and cookies; at least 32 characters. Generate a real one: `openssl rand -base64 32`. BETTER_AUTH_SECRET=local-dev-only-secret-change-me-0123456789 diff --git a/compose.yaml b/compose.yaml index 8007ee4..fd11f01 100644 --- a/compose.yaml +++ b/compose.yaml @@ -56,6 +56,7 @@ services: S3_FORCE_PATH_STYLE: "true" BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-me-0123456789} BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} + APP_ENV: ${APP_ENV:-local} depends_on: postgres: condition: service_healthy diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 599468a..9cf6a92 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -10,6 +10,7 @@ const valid = { S3_SECRET_ACCESS_KEY: "s3-secret-value", BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters", BETTER_AUTH_URL: "http://localhost:3000", + APP_ENV: "local", }; describe("loadConfig", () => { @@ -53,13 +54,20 @@ describe("loadConfig", () => { expect(loadConfig({ ...valid, S3_FORCE_PATH_STYLE: "false" }).storage.forcePathStyle).toBe(false); }); - it("refuses the committed local auth secret in production", () => { + it("refuses the committed local auth secret outside APP_ENV=local", () => { const local = { ...valid, BETTER_AUTH_SECRET: "local-dev-only-secret-change-me-0123456789" }; - expect(() => loadConfig({ ...local, NODE_ENV: "production" })).toThrow(/BETTER_AUTH_SECRET/); + expect(() => loadConfig({ ...local, APP_ENV: "production" })).toThrow(/BETTER_AUTH_SECRET/); + expect(() => loadConfig({ ...local, APP_ENV: "showcase" })).toThrow(/BETTER_AUTH_SECRET/); expect(() => loadConfig(local)).not.toThrow(); }); + it("requires an explicit APP_ENV", () => { + const { APP_ENV: _env, ...withoutEnv } = valid; + + expect(() => loadConfig(withoutEnv)).toThrow(/APP_ENV/); + }); + it("reads the client-IP headers and trusted proxies for the auth rate limit as lists", () => { const config = loadConfig({ ...valid, AUTH_IP_HEADERS: "x-real-ip, x-forwarded-for", AUTH_TRUSTED_PROXIES: "10.0.0.2" }); diff --git a/src/config/env.ts b/src/config/env.ts index 8547a4b..7df3d7d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -12,7 +12,9 @@ const schema = z.object({ BETTER_AUTH_URL: z.url(), AUTH_IP_HEADERS: z.string().default("x-forwarded-for"), AUTH_TRUSTED_PROXIES: z.string().default(""), - NODE_ENV: z.string().default("development"), + // Deployment environment – set explicitly everywhere (compose, CI, .env). Only `local` may use the + // committed local-default secret. + APP_ENV: z.enum(["local", "showcase", "production"]), }); const LOCAL_PLACEHOLDER_SECRETS = new Set(["local-dev-only-secret-change-me-0123456789"]); @@ -45,7 +47,7 @@ export function loadConfig(source: Record = process. } const env = parsed.data; // The committed local default must never sign sessions of a real deployment. - if (env.NODE_ENV === "production" && LOCAL_PLACEHOLDER_SECRETS.has(env.BETTER_AUTH_SECRET)) { + if (env.APP_ENV !== "local" && LOCAL_PLACEHOLDER_SECRETS.has(env.BETTER_AUTH_SECRET)) { throw new Error("Invalid or missing configuration: BETTER_AUTH_SECRET"); } return { diff --git a/vitest.config.ts b/vitest.config.ts index 957d75b..3d1ac06 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,7 @@ const localStackDefaults: Record = { S3_FORCE_PATH_STYLE: "true", BETTER_AUTH_SECRET: "local-dev-only-secret-change-me-0123456789", BETTER_AUTH_URL: "http://localhost:3000", + APP_ENV: "local", }; const integrationEnv = Object.fromEntries( Object.entries(localStackDefaults).map(([name, value]) => [name, process.env[name] ?? value]),