From 21da04a2df3594c8533d4bf338b474eed077a6c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:50:55 +0000 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 fce4d6eb96932f57d4d7fcbc5732f9452f043020 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:55:05 +0000 Subject: [PATCH 09/21] chore(deps): add pg-boss 12.33.6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- package.json | 1 + pnpm-lock.yaml | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/package.json b/package.json index b449d67..61017b9 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "drizzle-orm": "0.45.3", "next": "16.3.6", "pg": "8.23.0", + "pg-boss": "12.33.6", "react": "19.3.0", "react-dom": "19.3.0", "tsx": "4.23.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f678a7..b8d5979 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: pg: specifier: 8.23.0 version: 8.23.0 + pg-boss: + specifier: 12.33.6 + version: 12.33.6 react: specifier: 19.3.0 version: 19.3.0 @@ -1654,6 +1657,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cron-parser@5.10.1: + resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2473,6 +2480,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@1.4.1: resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} @@ -2547,6 +2558,10 @@ packages: resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} engines: {node: '>=18'} + non-error@0.1.0: + resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} + engines: {node: '>=20'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2614,6 +2629,11 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pg-boss@12.33.6: + resolution: {integrity: sha512-kVGyVSFyG2xa5hn0TN1286PeTtkIVwTbIvGeVo2kme/vSkXrdcuaiXfcggID1cSHY7QG6k2CwNYRsbP7bVJeHg==} + engines: {node: '>=22.12.0'} + hasBin: true + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -2762,6 +2782,9 @@ packages: rou3@0.9.2: resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + rrule-temporal@2.2.6: + resolution: {integrity: sha512-izTnMn8pAFNLDuyRfF+H53YnWOF7tsA5+RcGrha4onVzOnXn8YXQ7tJE5StNZvAZXvxz0In3R/IQhbQJpi7EYQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2792,6 +2815,10 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-error@13.0.1: + resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} + engines: {node: '>=20'} + set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} @@ -2926,10 +2953,17 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + temporal-spec@1.0.1: + resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==} + tinybench@6.1.4: resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} engines: {node: '>=20.0.0'} @@ -2975,6 +3009,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4515,6 +4553,10 @@ snapshots: convert-source-map@2.0.0: {} + cron-parser@5.10.1: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5451,6 +5493,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + magic-string@1.4.1: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5518,6 +5562,8 @@ snapshots: node-releases@2.0.56: {} + non-error@0.1.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -5596,6 +5642,15 @@ snapshots: path-parse@1.0.7: {} + pg-boss@12.33.6: + dependencies: + cron-parser: 5.10.1 + pg: 8.23.0 + rrule-temporal: 2.2.6 + serialize-error: 13.0.1 + transitivePeerDependencies: + - pg-native + pg-cloudflare@1.4.0: optional: true @@ -5758,6 +5813,10 @@ snapshots: rou3@0.9.2: {} + rrule-temporal@2.2.6: + dependencies: + temporal-spec: 1.0.1 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5791,6 +5850,11 @@ snapshots: semver@7.8.5: {} + serialize-error@13.0.1: + dependencies: + non-error: 0.1.0 + type-fest: 5.10.0 + set-cookie-parser@3.1.2: {} set-function-length@1.2.2: @@ -5977,8 +6041,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} + tapable@2.3.3: {} + temporal-spec@1.0.1: {} + tinybench@6.1.4: {} tinyexec@1.3.0: {} @@ -6028,6 +6096,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 From 10623ec64a84e0a0612263218bcddc5e88a85ba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:55:50 +0000 Subject: [PATCH 10/21] feat(db,jobs): documents and audit_events tables, intake columns, pg-boss queues Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/db/schema/app.ts | 66 ++++++++++++++++++++++++++++++++++++- src/features/jobs/boss.ts | 65 ++++++++++++++++++++++++++++++++++++ src/features/jobs/index.ts | 6 ++-- src/features/jobs/queues.ts | 32 ++++++++++++++++++ 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 src/features/jobs/boss.ts create mode 100644 src/features/jobs/queues.ts diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts index c28c0b8..632bbf4 100644 --- a/src/db/schema/app.ts +++ b/src/db/schema/app.ts @@ -2,7 +2,7 @@ // (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 { type AnyPgColumn, bigint, boolean, check, index, jsonb, pgPolicy, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { organization } from "./auth"; export const appSchema = pgSchema("app"); @@ -33,11 +33,75 @@ export const requests = appSchema .references(() => organization.id, { onDelete: "restrict" }), status: text("status").$type().default("NEW").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + // Intake (#5): who uploaded, what arrived, duplicate fingerprint (ADR-0001 D9). + source: text("source").$type<"upload">().default("upload").notNull(), + createdBy: uuid("created_by"), + subject: text("subject"), + messageId: text("message_id"), + fingerprint: text("fingerprint"), + possibleDuplicate: boolean("possible_duplicate").default(false).notNull(), + duplicateOfId: uuid("duplicate_of_id").references((): AnyPgColumn => requests.id, { onDelete: "set null" }), }, (table) => [ index("requests_company_id_idx").on(table.companyId), + index("requests_company_message_id_idx").on(table.companyId, table.messageId), + index("requests_company_fingerprint_idx").on(table.companyId, table.fingerprint), check("requests_status_check", sql.raw(`status in (${REQUEST_STATUSES.map((s) => `'${s}'`).join(", ")})`)), tenantPolicy("requests"), ], ) .enableRLS(); + +// Originals of a request (mail or loose files). Bytes live in private object storage under +// `{companyId}/{requestId}/{documentId}`; the row keeps the reference and the SHA-256. +export const documents = appSchema + .table( + "documents", + { + id: uuid("id").default(sql`gen_random_uuid()`).primaryKey(), + companyId: uuid("company_id") + .notNull() + .references(() => organization.id, { onDelete: "restrict" }), + requestId: uuid("request_id") + .notNull() + .references(() => requests.id, { onDelete: "cascade" }), + filename: text("filename").notNull(), + contentType: text("content_type").notNull(), + kind: text("kind").$type().notNull(), + sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(), + sha256: text("sha256").notNull(), + storageKey: text("storage_key").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("documents_company_id_idx").on(table.companyId), + index("documents_request_id_idx").on(table.requestId), + check("documents_kind_check", sql.raw(`kind in (${DOCUMENT_KINDS.map((k) => `'${k}'`).join(", ")})`)), + tenantPolicy("documents"), + ], + ) + .enableRLS(); + +export const DOCUMENT_KINDS = ["eml", "msg", "pdf", "xlsx", "docx"] as const; +export type DocumentKind = (typeof DOCUMENT_KINDS)[number]; + +// Append-only business audit (ADR-0001 D10): written in the same transaction as the change; +// app_rw has INSERT and SELECT only (hand-written migration revokes UPDATE/DELETE). +export const auditEvents = appSchema + .table( + "audit_events", + { + id: uuid("id").default(sql`gen_random_uuid()`).primaryKey(), + companyId: uuid("company_id") + .notNull() + .references(() => organization.id, { onDelete: "restrict" }), + actorUserId: uuid("actor_user_id"), + action: text("action").notNull(), + entityType: text("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + data: jsonb("data").$type>().default({}).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [index("audit_events_entity_idx").on(table.companyId, table.entityType, table.entityId), tenantPolicy("audit_events")], + ) + .enableRLS(); diff --git a/src/features/jobs/boss.ts b/src/features/jobs/boss.ts new file mode 100644 index 0000000..58e7c41 --- /dev/null +++ b/src/features/jobs/boss.ts @@ -0,0 +1,65 @@ +import { sql } from "drizzle-orm"; +import { fromDrizzle, PgBoss } from "pg-boss"; +import { tenantOf, type TenantTx } from "@/features/tenancy"; +import { PGBOSS_SCHEMA, QUEUE_DEFINITIONS, QUEUES, type RequestJob } from "./queues"; + +/** + * pg-boss client for the runtime role (`app_rw`): no schema creation, no migration, no supervision – + * those belong to the deploy step (`installJobQueues`) and the worker. Polling, no LISTEN/NOTIFY + * (ADR-0001 D3/D4). + */ +export async function createJobClient(connectionString: string, options: { supervise?: boolean } = {}): Promise { + const boss = new PgBoss({ + connectionString, + schema: PGBOSS_SCHEMA, + max: 4, + migrate: false, + createSchema: false, + supervise: options.supervise ?? false, + schedule: false, + }); + boss.on("error", (error: Error) => console.error(JSON.stringify({ level: "error", module: "jobs", message: error.message }))); + await boss.start(); + return boss; +} + +/** + * Enqueues processing of a request IN the caller's tenant transaction (transactional outbox without + * an outbox table): the job exists exactly when the transaction commits. + */ +export async function enqueueRequestProcessing(boss: PgBoss, tx: TenantTx, requestId: string): Promise { + const job: RequestJob = { requestId, companyId: tenantOf(tx) }; + return boss.send(QUEUES.processRequest, job, { db: fromDrizzle(tx, sql), singletonKey: requestId }); +} + +/** Deploy step, owner role: install/upgrade the pg-boss schema and queues, grant the runtime role. */ +export async function installJobQueues(ownerConnectionString: string): Promise { + const boss = new PgBoss({ + connectionString: ownerConnectionString, + schema: PGBOSS_SCHEMA, + max: 2, + migrate: true, + supervise: false, + schedule: false, + }); + boss.on("error", () => {}); + await boss.start(); + try { + for (const { name, ...options } of QUEUE_DEFINITIONS) { + if (await boss.getQueue(name)) await boss.updateQueue(name, options); + else await boss.createQueue(name, options); + } + const db = boss.getDb(); + for (const statement of [ + `GRANT USAGE ON SCHEMA ${PGBOSS_SCHEMA} TO app_rw`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${PGBOSS_SCHEMA} TO app_rw`, + `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${PGBOSS_SCHEMA} TO app_rw`, + `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ${PGBOSS_SCHEMA} TO app_rw`, + `ALTER DEFAULT PRIVILEGES IN SCHEMA ${PGBOSS_SCHEMA} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw`, + ]) { + await db.executeSql(statement); + } + } finally { + await boss.stop({ graceful: false }); + } +} diff --git a/src/features/jobs/index.ts b/src/features/jobs/index.ts index 9655842..cddab5b 100644 --- a/src/features/jobs/index.ts +++ b/src/features/jobs/index.ts @@ -1,3 +1,3 @@ -// Public API of the `jobs` module: pg-boss, job handlers, drain(), worker entrypoint. -// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md. -export {}; +// Public API of the `jobs` module: pg-boss queues, transactional enqueue (worker + drain(): #7). +export { createJobClient, enqueueRequestProcessing, installJobQueues } from "./boss"; +export { QUEUES, PGBOSS_SCHEMA, type QueueName, type RequestJob } from "./queues"; diff --git a/src/features/jobs/queues.ts b/src/features/jobs/queues.ts new file mode 100644 index 0000000..48f5885 --- /dev/null +++ b/src/features/jobs/queues.ts @@ -0,0 +1,32 @@ +import type { Queue } from "pg-boss"; + +// Queue definitions (ADR-0001 D4). Installed by the deploy step as the owner role; the runtime role +// only sends, fetches and completes jobs. Payloads carry IDs only – never document content. +export const PGBOSS_SCHEMA = "pgboss"; + +export const QUEUES = { + processRequest: "request-process", + processRequestDead: "request-process-dead", +} as const; + +export type QueueName = (typeof QUEUES)[keyof typeof QUEUES]; + +export interface RequestJob { + requestId: string; + companyId: string; +} + +// `exclusive` + singletonKey = requestId: at most one queued-or-active job per request. +export const QUEUE_DEFINITIONS: Array = [ + { name: QUEUES.processRequestDead, policy: "standard", retentionSeconds: 60 * 60 * 24 * 14 }, + { + name: QUEUES.processRequest, + policy: "exclusive", + retryLimit: 5, + retryDelay: 30, + retryBackoff: true, + retryDelayMax: 60 * 30, + expireInSeconds: 60 * 10, + deadLetter: QUEUES.processRequestDead, + }, +]; From e0afd2972c488586b44c5241e6b44a1dab83f34d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:55:56 +0000 Subject: [PATCH 11/21] feat(db): migration 0003 intake Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/db/migrations/0003_intake.sql | 45 + src/db/migrations/meta/0003_snapshot.json | 1209 +++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + 3 files changed, 1261 insertions(+) create mode 100644 src/db/migrations/0003_intake.sql create mode 100644 src/db/migrations/meta/0003_snapshot.json diff --git a/src/db/migrations/0003_intake.sql b/src/db/migrations/0003_intake.sql new file mode 100644 index 0000000..46d180f --- /dev/null +++ b/src/db/migrations/0003_intake.sql @@ -0,0 +1,45 @@ +CREATE TABLE "app"."audit_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "actor_user_id" uuid, + "action" text NOT NULL, + "entity_type" text NOT NULL, + "entity_id" uuid NOT NULL, + "data" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "app"."audit_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "app"."documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "request_id" uuid NOT NULL, + "filename" text NOT NULL, + "content_type" text NOT NULL, + "kind" text NOT NULL, + "size_bytes" bigint NOT NULL, + "sha256" text NOT NULL, + "storage_key" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "documents_kind_check" CHECK (kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')) +); +--> statement-breakpoint +ALTER TABLE "app"."documents" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD COLUMN "source" text DEFAULT 'upload' NOT NULL;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD COLUMN "created_by" uuid;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD COLUMN "subject" text;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD COLUMN "message_id" text;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD COLUMN "fingerprint" text;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD COLUMN "possible_duplicate" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD COLUMN "duplicate_of_id" uuid;--> statement-breakpoint +ALTER TABLE "app"."audit_events" ADD CONSTRAINT "audit_events_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_request_id_requests_id_fk" FOREIGN KEY ("request_id") REFERENCES "app"."requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_events_entity_idx" ON "app"."audit_events" USING btree ("company_id","entity_type","entity_id");--> statement-breakpoint +CREATE INDEX "documents_company_id_idx" ON "app"."documents" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "documents_request_id_idx" ON "app"."documents" USING btree ("request_id");--> statement-breakpoint +ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_duplicate_of_id_requests_id_fk" FOREIGN KEY ("duplicate_of_id") REFERENCES "app"."requests"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "requests_company_message_id_idx" ON "app"."requests" USING btree ("company_id","message_id");--> statement-breakpoint +CREATE INDEX "requests_company_fingerprint_idx" ON "app"."requests" USING btree ("company_id","fingerprint");--> statement-breakpoint +CREATE POLICY "audit_events_tenant_isolation" ON "app"."audit_events" 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);--> statement-breakpoint +CREATE POLICY "documents_tenant_isolation" ON "app"."documents" 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/0003_snapshot.json b/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..678a936 --- /dev/null +++ b/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1209 @@ +{ + "id": "e4bbf32f-aa85-4b73-a166-d485a537b7b3", + "prevId": "3fa69086-6743-434f-a277-58a30576189a", + "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.audit_events": { + "name": "audit_events", + "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 + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_entity_idx": { + "name": "audit_events_entity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_company_id_organization_id_fk": { + "name": "audit_events_company_id_organization_id_fk", + "tableFrom": "audit_events", + "tableTo": "organization", + "schemaTo": "auth", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "audit_events_tenant_isolation": { + "name": "audit_events_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": {}, + "isRLSEnabled": true + }, + "app.documents": { + "name": "documents", + "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 + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_id_idx": { + "name": "documents_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_request_id_idx": { + "name": "documents_request_id_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_organization_id_fk": { + "name": "documents_company_id_organization_id_fk", + "tableFrom": "documents", + "tableTo": "organization", + "schemaTo": "auth", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "documents_request_id_requests_id_fk": { + "name": "documents_request_id_requests_id_fk", + "tableFrom": "documents", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "documents_tenant_isolation": { + "name": "documents_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": { + "documents_kind_check": { + "name": "documents_kind_check", + "value": "kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')" + } + }, + "isRLSEnabled": true + }, + "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()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upload'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "possible_duplicate": { + "name": "possible_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "duplicate_of_id": { + "name": "duplicate_of_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "requests_company_message_id_idx": { + "name": "requests_company_message_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_company_fingerprint_idx": { + "name": "requests_company_fingerprint_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "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" + }, + "requests_duplicate_of_id_requests_id_fk": { + "name": "requests_duplicate_of_id_requests_id_fk", + "tableFrom": "requests", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "duplicate_of_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 50a58ea..7a7bf19 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1790142279511, "tag": "0002_auth_grants_force_rls", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1790142953338, + "tag": "0003_intake", + "breakpoints": true } ] } \ No newline at end of file From 40d3c8f797b20bbfb8f937f0b156dacdd373bee4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:58:15 +0000 Subject: [PATCH 12/21] feat(intake): upload validation, fingerprint, atomic submit, upload and download routes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- .env.example | 5 + src/app/_server/runtime.ts | 12 + src/app/api/documents/[id]/route.ts | 31 + src/app/api/requests/route.ts | 44 + src/config/env.ts | 7 + .../0004_intake_force_rls_audit.sql | 18 + src/db/migrations/meta/0004_snapshot.json | 1209 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/features/audit/audit.ts | 26 + src/features/audit/index.ts | 3 +- src/features/documents/index.ts | 3 +- src/features/documents/repository.ts | 33 + src/features/intake/files.test.ts | 43 + src/features/intake/files.ts | 72 + src/features/intake/fingerprint.test.ts | 52 + src/features/intake/fingerprint.ts | 12 + src/features/intake/index.ts | 6 +- src/features/intake/mail-headers.ts | 54 + src/features/intake/submit.ts | 93 ++ src/features/jobs/boss.ts | 2 +- src/features/requests/index.ts | 2 +- src/features/requests/repository.ts | 47 +- src/features/storage/s3-blob-store.ts | 22 + src/setup.ts | 4 +- tests/integration/setup/global-setup.ts | 2 + 25 files changed, 1791 insertions(+), 18 deletions(-) create mode 100644 src/app/api/documents/[id]/route.ts create mode 100644 src/app/api/requests/route.ts create mode 100644 src/db/migrations/0004_intake_force_rls_audit.sql create mode 100644 src/db/migrations/meta/0004_snapshot.json create mode 100644 src/features/audit/audit.ts create mode 100644 src/features/documents/repository.ts create mode 100644 src/features/intake/files.test.ts create mode 100644 src/features/intake/files.ts create mode 100644 src/features/intake/fingerprint.test.ts create mode 100644 src/features/intake/fingerprint.ts create mode 100644 src/features/intake/mail-headers.ts create mode 100644 src/features/intake/submit.ts diff --git a/.env.example b/.env.example index 78a857f..6d154fb 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,11 @@ 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 +# --- Upload limits (intake) --------------------------------------------------------------------- +# Maximum size per file in bytes (default 20 MiB) and files per request (default 10). +UPLOAD_MAX_FILE_BYTES=20971520 +UPLOAD_MAX_FILES=10 + # --- Web --------------------------------------------------------------------------------------- # Host port of the web container. WEB_PORT=3000 diff --git a/src/app/_server/runtime.ts b/src/app/_server/runtime.ts index 0317c1a..11050ca 100644 --- a/src/app/_server/runtime.ts +++ b/src/app/_server/runtime.ts @@ -1,6 +1,7 @@ import { loadConfig, type AppConfig } from "@/config/env"; import { createDatabase, type DatabaseHandle } from "@/db"; import { createAuth, getActor, type Actor, type Auth } from "@/features/identity"; +import { createJobClient } from "@/features/jobs"; import { S3BlobStore } from "@/features/storage"; import { createTenancy, type Tenancy } from "@/features/tenancy"; @@ -31,6 +32,17 @@ export function getRuntime(): Runtime { return runtime; } +let jobClient: ReturnType | undefined; + +/** pg-boss client of the web process (send only, no supervision). */ +export function getJobClient(): ReturnType { + jobClient ??= createJobClient(getRuntime().config.databaseUrl).catch((error: unknown) => { + jobClient = undefined; + throw error; + }); + return jobClient; +} + /** The signed-in actor for the current request, or null. */ export async function currentActor(headers: Headers): Promise { const { auth, database } = getRuntime(); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts new file mode 100644 index 0000000..396d897 --- /dev/null +++ b/src/app/api/documents/[id]/route.ts @@ -0,0 +1,31 @@ +import { headers } from "next/headers"; +import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { getDocument } from "@/features/documents"; + +export const dynamic = "force-dynamic"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// GET /api/documents/:id – the only way to read an original (private bucket, no public URLs; +// ADR-0001 D5). A document of another company is indistinguishable from a missing one (404). +export async function GET(_request: Request, context: { params: Promise<{ id: string }> }): Promise { + const actor = await currentActor(await headers()); + if (!actor) return Response.json({ error: { title: "Nicht angemeldet." } }, { status: 401 }); + const { id } = await context.params; + if (!UUID.test(id)) return Response.json({ error: { title: "Nicht gefunden." } }, { status: 404 }); + + const { tenancy, storage } = getRuntime(); + const document = await tenancy.withTenant(actor.companyId, (tx) => getDocument(tx, id)); + if (!document) return Response.json({ error: { title: "Nicht gefunden." } }, { status: 404 }); + + const bytes = await storage.get(document.storageKey); + return new Response(new Blob([bytes as BlobPart]), { + headers: { + "content-type": document.contentType, + "content-length": String(bytes.byteLength), + "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(document.filename)}`, + "x-content-type-options": "nosniff", + "cache-control": "private, no-store", + }, + }); +} diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts new file mode 100644 index 0000000..fb73812 --- /dev/null +++ b/src/app/api/requests/route.ts @@ -0,0 +1,44 @@ +import { headers } from "next/headers"; +import { currentActor, getJobClient, getRuntime } from "@/app/_server/runtime"; +import { AuthorizationError } from "@/features/identity"; +import { submitUpload, UploadRejected } from "@/features/intake"; + +export const dynamic = "force-dynamic"; + +const problem = (status: number, title: string) => Response.json({ error: { title } }, { status }); + +// POST /api/requests – multipart upload of one request (field `files`, 1..n files). +// Company and user come from the session, never from the form (ADR-0001 D7). +export async function POST(request: Request): Promise { + const actor = await currentActor(await headers()); + if (!actor) return problem(401, "Nicht angemeldet."); + const { config, tenancy, storage } = getRuntime(); + + const declared = Number(request.headers.get("content-length") ?? "0"); + const bodyLimit = config.upload.maxFileBytes * config.upload.maxFiles + 1024 * 1024; + if (declared > bodyLimit) return problem(413, "Upload zu groß."); + + let form: FormData; + try { + form = await request.formData(); + } catch { + return problem(400, "Ungültiger Upload."); + } + const files = await Promise.all( + form + .getAll("files") + .filter((entry): entry is File => typeof entry !== "string") + .map(async (file) => ({ name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) })), + ); + + try { + const boss = await getJobClient(); + const result = await submitUpload({ tenancy, storage, boss, limits: config.upload }, actor, files); + return Response.json(result, { status: 201 }); + } catch (error) { + if (error instanceof UploadRejected) return problem(422, error.message); + if (error instanceof AuthorizationError) return problem(403, "Keine Berechtigung."); + console.error(JSON.stringify({ level: "error", route: "POST /api/requests", companyId: actor.companyId, message: "upload failed" })); + return problem(500, "Upload fehlgeschlagen. Bitte erneut versuchen."); + } +} diff --git a/src/config/env.ts b/src/config/env.ts index ed751c8..d471f7b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -10,6 +10,8 @@ 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(), + UPLOAD_MAX_FILE_BYTES: z.coerce.number().int().positive().default(20 * 1024 * 1024), + UPLOAD_MAX_FILES: z.coerce.number().int().positive().max(50).default(10), }); export interface AppConfig { @@ -26,6 +28,10 @@ export interface AppConfig { secret: string; baseURL: string; }; + upload: { + maxFileBytes: number; + maxFiles: number; + }; } // Errors list variable names only – values may be secrets and end up in logs. @@ -47,5 +53,6 @@ export function loadConfig(source: Record = process. forcePathStyle: env.S3_FORCE_PATH_STYLE === "true", }, auth: { secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL }, + upload: { maxFileBytes: env.UPLOAD_MAX_FILE_BYTES, maxFiles: env.UPLOAD_MAX_FILES }, }; } diff --git a/src/db/migrations/0004_intake_force_rls_audit.sql b/src/db/migrations/0004_intake_force_rls_audit.sql new file mode 100644 index 0000000..e6a198c --- /dev/null +++ b/src/db/migrations/0004_intake_force_rls_audit.sql @@ -0,0 +1,18 @@ +-- Hand-written (ADR-0001 D7, D10). +-- 1) Forced RLS on the new company-owned tables (drizzle-kit emits only ENABLE). +ALTER TABLE app.documents FORCE ROW LEVEL SECURITY; +--> statement-breakpoint +ALTER TABLE app.audit_events FORCE ROW LEVEL SECURITY; +--> statement-breakpoint +-- 2) Append-only audit: the runtime role may insert and read, never change or delete. +REVOKE UPDATE, DELETE, TRUNCATE ON app.audit_events FROM app_rw; +--> statement-breakpoint +-- 3) Same-company references. Foreign-key checks bypass RLS, so a plain FK would let a tenant attach +-- its row to another company's request if it knew the id. Composite FKs pin company_id. +ALTER TABLE app.requests ADD CONSTRAINT requests_id_company_unique UNIQUE (id, company_id); +--> statement-breakpoint +ALTER TABLE app.documents ADD CONSTRAINT documents_request_same_company_fk + FOREIGN KEY (request_id, company_id) REFERENCES app.requests (id, company_id) ON DELETE CASCADE; +--> statement-breakpoint +ALTER TABLE app.requests ADD CONSTRAINT requests_duplicate_same_company_fk + FOREIGN KEY (duplicate_of_id, company_id) REFERENCES app.requests (id, company_id); diff --git a/src/db/migrations/meta/0004_snapshot.json b/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..a4277cd --- /dev/null +++ b/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1209 @@ +{ + "id": "d257ca2a-1a2c-482f-a651-21d7d15408fa", + "prevId": "e4bbf32f-aa85-4b73-a166-d485a537b7b3", + "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.audit_events": { + "name": "audit_events", + "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 + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_entity_idx": { + "name": "audit_events_entity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "audit_events_company_id_organization_id_fk": { + "name": "audit_events_company_id_organization_id_fk", + "tableFrom": "audit_events", + "columnsFrom": [ + "company_id" + ], + "tableTo": "organization", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "audit_events_tenant_isolation": { + "name": "audit_events_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": {}, + "isRLSEnabled": true + }, + "app.documents": { + "name": "documents", + "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 + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_id_idx": { + "name": "documents_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "documents_request_id_idx": { + "name": "documents_request_id_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "documents_company_id_organization_id_fk": { + "name": "documents_company_id_organization_id_fk", + "tableFrom": "documents", + "columnsFrom": [ + "company_id" + ], + "tableTo": "organization", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + }, + "documents_request_id_requests_id_fk": { + "name": "documents_request_id_requests_id_fk", + "tableFrom": "documents", + "columnsFrom": [ + "request_id" + ], + "tableTo": "requests", + "schemaTo": "app", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "documents_tenant_isolation": { + "name": "documents_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": { + "documents_kind_check": { + "name": "documents_kind_check", + "value": "kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')" + } + }, + "isRLSEnabled": true + }, + "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()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upload'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "possible_duplicate": { + "name": "possible_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "duplicate_of_id": { + "name": "duplicate_of_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "requests_company_message_id_idx": { + "name": "requests_company_message_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "requests_company_fingerprint_idx": { + "name": "requests_company_fingerprint_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "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" + }, + "requests_duplicate_of_id_requests_id_fk": { + "name": "requests_duplicate_of_id_requests_id_fk", + "tableFrom": "requests", + "columnsFrom": [ + "duplicate_of_id" + ], + "tableTo": "requests", + "schemaTo": "app", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "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 7a7bf19..168b673 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1790142953338, "tag": "0003_intake", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1790142958057, + "tag": "0004_intake_force_rls_audit", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/features/audit/audit.ts b/src/features/audit/audit.ts new file mode 100644 index 0000000..08274d0 --- /dev/null +++ b/src/features/audit/audit.ts @@ -0,0 +1,26 @@ +import { and, asc, eq } from "drizzle-orm"; +import { auditEvents } from "@/db/schema"; +import { tenantOf, type TenantTx } from "@/features/tenancy"; + +// Business audit (ADR-0001 D10, DR6): written in the caller's transaction, so the change and its +// audit event commit or roll back together. The database allows INSERT/SELECT only. +export interface AuditEventInput { + actorUserId: string | null; + action: string; + entityType: "request" | "document" | "user"; + entityId: string; + data?: Record; +} + +export async function recordAudit(tx: TenantTx, event: AuditEventInput): Promise { + await tx.insert(auditEvents).values({ companyId: tenantOf(tx), ...event, data: event.data ?? {} }); +} + +export async function listAuditEvents(tx: TenantTx, entityType: AuditEventInput["entityType"], entityId: string) { + tenantOf(tx); + return tx + .select() + .from(auditEvents) + .where(and(eq(auditEvents.entityType, entityType), eq(auditEvents.entityId, entityId))) + .orderBy(asc(auditEvents.createdAt)); +} diff --git a/src/features/audit/index.ts b/src/features/audit/index.ts index eee41bb..6de75b1 100644 --- a/src/features/audit/index.ts +++ b/src/features/audit/index.ts @@ -1,3 +1,2 @@ // Public API of the `audit` module: append-only audit events. -// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md. -export {}; +export { recordAudit, listAuditEvents, type AuditEventInput } from "./audit"; diff --git a/src/features/documents/index.ts b/src/features/documents/index.ts index bd349e9..4f24efe 100644 --- a/src/features/documents/index.ts +++ b/src/features/documents/index.ts @@ -1,3 +1,2 @@ // Public API of the `documents` module: document records, storage references, hashes. -// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md. -export {}; +export { insertDocuments, getDocument, listDocuments, type DocumentRow, type NewDocument } from "./repository"; diff --git a/src/features/documents/repository.ts b/src/features/documents/repository.ts new file mode 100644 index 0000000..8b2073f --- /dev/null +++ b/src/features/documents/repository.ts @@ -0,0 +1,33 @@ +import { asc, eq } from "drizzle-orm"; +import { documents, type DocumentKind } from "@/db/schema"; +import { tenantOf, type TenantTx } from "@/features/tenancy"; + +export interface NewDocument { + id: string; + requestId: string; + filename: string; + contentType: string; + kind: DocumentKind; + sizeBytes: number; + sha256: string; + storageKey: string; +} + +export type DocumentRow = typeof documents.$inferSelect; + +export async function insertDocuments(tx: TenantTx, rows: NewDocument[]): Promise { + const companyId = tenantOf(tx); + if (rows.length) await tx.insert(documents).values(rows.map((row) => ({ ...row, companyId }))); +} + +/** RLS hides other companies' documents: a foreign id simply returns null. */ +export async function getDocument(tx: TenantTx, id: string): Promise { + tenantOf(tx); + const [row] = await tx.select().from(documents).where(eq(documents.id, id)); + return row ?? null; +} + +export async function listDocuments(tx: TenantTx, requestId: string): Promise { + tenantOf(tx); + return tx.select().from(documents).where(eq(documents.requestId, requestId)).orderBy(asc(documents.createdAt), asc(documents.filename)); +} diff --git a/src/features/intake/files.test.ts b/src/features/intake/files.test.ts new file mode 100644 index 0000000..68b4494 --- /dev/null +++ b/src/features/intake/files.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { classifyUpload, UploadRejected } from "./files"; + +const bytes = (text: string) => new TextEncoder().encode(text); +const PDF = bytes("%PDF-1.7\n%synthetic\n"); +const ZIP = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x06, 0x00]); +const OLE = new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0, 0]); +const EML = bytes("From: Einkauf \r\nSubject: Anfrage\r\nMessage-ID: \r\n\r\nHallo"); +const limits = { maxFileBytes: 1024 }; + +describe("classifyUpload", () => { + it.each([ + ["anfrage.pdf", PDF, "pdf"], + ["positionen.xlsx", ZIP, "xlsx"], + ["spezifikation.docx", ZIP, "docx"], + ["weitergeleitet.msg", OLE, "msg"], + ["anfrage.eml", EML, "eml"], + ["ANFRAGE.PDF", PDF, "pdf"], + ] as const)("accepts %s as %s", (name, content, kind) => { + expect(classifyUpload(name, content, limits).kind).toBe(kind); + }); + + it("rejects an unsupported extension with a clear message", () => { + expect(() => classifyUpload("makro.xlsm", ZIP, limits)).toThrow(/Dateityp nicht erlaubt/); + expect(() => classifyUpload("programm.exe", bytes("MZ"), limits)).toThrow(UploadRejected); + }); + + it("rejects content that does not match the extension (renamed files)", () => { + expect(() => classifyUpload("anfrage.pdf", ZIP, limits)).toThrow(/passt nicht zum Dateityp/); + expect(() => classifyUpload("tabelle.xlsx", PDF, limits)).toThrow(/passt nicht zum Dateityp/); + expect(() => classifyUpload("mail.eml", new Uint8Array([0x00, 0x01, 0x02]), limits)).toThrow(/passt nicht zum Dateityp/); + }); + + it("rejects files above the size limit and empty files", () => { + expect(() => classifyUpload("gross.pdf", new Uint8Array(2048).fill(0x25), limits)).toThrow(/zu groß/); + expect(() => classifyUpload("leer.pdf", new Uint8Array(0), limits)).toThrow(/leer/); + }); + + it("strips path components and control characters from the stored file name", () => { + expect(classifyUpload("../../etc/anfrage\u0000.pdf", PDF, limits).filename).toBe("anfrage.pdf"); + expect(classifyUpload("C:\\temp\\anfrage.pdf", PDF, limits).filename).toBe("anfrage.pdf"); + }); +}); diff --git a/src/features/intake/files.ts b/src/features/intake/files.ts new file mode 100644 index 0000000..25a9bbf --- /dev/null +++ b/src/features/intake/files.ts @@ -0,0 +1,72 @@ +import type { DocumentKind } from "@/db/schema"; + +// Upload validation (security rule: untrusted files). Allow-list by extension AND content signature, +// so a renamed executable or a macro workbook (.xlsm) never enters storage. Messages are user-facing. +export class UploadRejected extends Error { + constructor(message: string) { + super(message); + this.name = "UploadRejected"; + } +} + +export interface UploadLimits { + maxFileBytes: number; +} + +export interface ClassifiedFile { + kind: DocumentKind; + filename: string; + contentType: string; +} + +const CONTENT_TYPES: Record = { + eml: "message/rfc822", + msg: "application/vnd.ms-outlook", + pdf: "application/pdf", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +}; + +const startsWith = (bytes: Uint8Array, signature: number[]) => signature.every((value, index) => bytes[index] === value); +const ZIP = [0x50, 0x4b, 0x03, 0x04]; +const OLE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; +const PDF = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF- + +function looksLikeMail(bytes: Uint8Array): boolean { + const head = bytes.subarray(0, 8192); + if (head.includes(0)) return false; + const text = new TextDecoder("latin1").decode(head); + return /^[\x21-\x39\x3b-\x7e]+:/m.test(text) && /^(from|message-id|subject|date|to|received|return-path|mime-version):/im.test(text); +} + +const SIGNATURE_CHECK: Record boolean> = { + pdf: (bytes) => startsWith(bytes, PDF), + xlsx: (bytes) => startsWith(bytes, ZIP), + docx: (bytes) => startsWith(bytes, ZIP), + msg: (bytes) => startsWith(bytes, OLE), + eml: looksLikeMail, +}; + +/** Base name only, no control characters, bounded length. */ +export function safeFilename(name: string): string { + const base = name.split(/[\\/]/).pop() ?? ""; + const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(-200); + return cleaned || "datei"; +} + +export function classifyUpload(name: string, bytes: Uint8Array, limits: UploadLimits): ClassifiedFile { + const filename = safeFilename(name); + const extension = filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : ""; + if (!(extension in CONTENT_TYPES)) { + throw new UploadRejected(`Dateityp nicht erlaubt: ${filename}. Erlaubt sind .eml, .msg, .pdf, .xlsx und .docx.`); + } + const kind = extension as DocumentKind; + if (bytes.byteLength === 0) throw new UploadRejected(`Die Datei ${filename} ist leer.`); + if (bytes.byteLength > limits.maxFileBytes) { + throw new UploadRejected(`Die Datei ${filename} ist zu groß (maximal ${Math.floor(limits.maxFileBytes / (1024 * 1024)) || 1} MB).`); + } + if (!SIGNATURE_CHECK[kind](bytes)) { + throw new UploadRejected(`Der Inhalt von ${filename} passt nicht zum Dateityp .${kind}.`); + } + return { kind, filename, contentType: CONTENT_TYPES[kind] }; +} diff --git a/src/features/intake/fingerprint.test.ts b/src/features/intake/fingerprint.test.ts new file mode 100644 index 0000000..229fb44 --- /dev/null +++ b/src/features/intake/fingerprint.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { requestFingerprint, sha256Hex } from "./fingerprint"; +import { parseMailHeaders } from "./mail-headers"; + +const enc = (text: string) => new TextEncoder().encode(text); + +describe("requestFingerprint", () => { + it("is independent of the order of the files", () => { + const a = sha256Hex(enc("a")); + const b = sha256Hex(enc("b")); + + expect(requestFingerprint([a, b])).toBe(requestFingerprint([b, a])); + }); + + it("differs when one file differs", () => { + expect(requestFingerprint([sha256Hex(enc("a"))])).not.toBe(requestFingerprint([sha256Hex(enc("a2"))])); + }); + + it("treats the same file uploaded twice in one request as one", () => { + const a = sha256Hex(enc("a")); + + expect(requestFingerprint([a, a])).toBe(requestFingerprint([a])); + }); + + it("hashes raw bytes – CRLF and LF variants of a mail are different files", () => { + expect(sha256Hex(enc("x\r\ny"))).not.toBe(sha256Hex(enc("x\ny"))); + }); +}); + +describe("parseMailHeaders", () => { + it("reads Message-ID and Subject from the header block only", () => { + const mail = enc("From: a@example.com\r\nMessage-ID: \r\nSubject: Anfrage Flansche\r\n\r\nMessage-ID: "); + + expect(parseMailHeaders(mail)).toEqual({ messageId: "", subject: "Anfrage Flansche" }); + }); + + it("unfolds folded header lines and decodes RFC 2047 encoded words", () => { + const mail = enc("Subject: =?UTF-8?B?QW5mcmFnZSBEcmVoc3TDvGNr?=\r\n =?utf-8?Q?_f=C3=BCr_KW_42?=\r\nMessage-Id:\r\n \r\n\r\nbody"); + + expect(parseMailHeaders(mail)).toEqual({ messageId: "", subject: "Anfrage Drehstück für KW 42" }); + }); + + it("returns nulls when the headers are missing", () => { + expect(parseMailHeaders(enc("From: a@example.com\n\nbody"))).toEqual({ messageId: null, subject: null }); + }); + + it("caps the subject length", () => { + const long = "x".repeat(1000); + + expect(parseMailHeaders(enc(`Subject: ${long}\n\n`)).subject).toHaveLength(300); + }); +}); diff --git a/src/features/intake/fingerprint.ts b/src/features/intake/fingerprint.ts new file mode 100644 index 0000000..80e5a76 --- /dev/null +++ b/src/features/intake/fingerprint.ts @@ -0,0 +1,12 @@ +import { createHash } from "node:crypto"; + +// Exact-duplicate detection (ADR-0001 D9, DR7): SHA-256 over the raw bytes of every file, and a +// request fingerprint over the set of file hashes (order-independent, duplicates collapsed). +export function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function requestFingerprint(fileHashes: string[]): string { + const set = [...new Set(fileHashes)].sort(); + return sha256Hex(new TextEncoder().encode(set.join("\n"))); +} diff --git a/src/features/intake/index.ts b/src/features/intake/index.ts index 94dee61..ffada73 100644 --- a/src/features/intake/index.ts +++ b/src/features/intake/index.ts @@ -1,3 +1,3 @@ -// Public API of the `intake` module: upload, duplicate fingerprint, creates request + documents. -// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md. -export {}; +// Public API of the `intake` module: upload, validation, duplicate fingerprint. +export { submitUpload, type IntakeDeps, type SubmittedRequest, type UploadedFile } from "./submit"; +export { UploadRejected, type UploadLimits } from "./files"; diff --git a/src/features/intake/mail-headers.ts b/src/features/intake/mail-headers.ts new file mode 100644 index 0000000..07fd5be --- /dev/null +++ b/src/features/intake/mail-headers.ts @@ -0,0 +1,54 @@ +// Minimal RFC 5322 header reader for intake: Message-ID (duplicates) and Subject (list display). +// Full parsing of bodies and attachments is the AI service's job (docling / stdlib email). +const MAX_HEADER_BYTES = 64 * 1024; +const MAX_SUBJECT = 300; + +export interface MailHeaders { + messageId: string | null; + subject: string | null; +} + +function headerBlock(bytes: Uint8Array): string { + const text = new TextDecoder("latin1").decode(bytes.subarray(0, MAX_HEADER_BYTES)); + const end = text.search(/\r?\n\r?\n/); + return (end === -1 ? text : text.slice(0, end)).replace(/\r?\n[ \t]+/g, " "); +} + +function latin1ToUtf8(text: string): string { + return new TextDecoder("utf-8").decode(Uint8Array.from(text, (char) => char.charCodeAt(0) & 0xff)); +} + +function decodeEncodedWords(value: string): string { + const decoded = value.replace(/\?=\s+=\?/g, "?==?").replace(/=\?([^?]+)\?([bqBQ])\?([^?]*)\?=/g, (_all, charset: string, encoding: string, data: string) => { + let raw: Uint8Array; + if (encoding.toUpperCase() === "B") { + raw = Uint8Array.from(Buffer.from(data, "base64")); + } else { + const text = data.replace(/_/g, " ").replace(/=([0-9A-Fa-f]{2})/g, (_m, hex: string) => String.fromCharCode(parseInt(hex, 16))); + raw = Uint8Array.from(text, (char) => char.charCodeAt(0) & 0xff); + } + try { + return new TextDecoder(charset.toLowerCase()).decode(raw); + } catch { + return new TextDecoder("utf-8").decode(raw); + } + }); + return decoded; +} + +function header(block: string, name: string): string | null { + const match = block.match(new RegExp(`^${name}:[ \\t]*(.*)$`, "im")); + const value = match?.[1]?.trim(); + return value ? value : null; +} + +export function parseMailHeaders(bytes: Uint8Array): MailHeaders { + const block = headerBlock(bytes); + const messageId = header(block, "Message-ID"); + const rawSubject = header(block, "Subject"); + const subject = rawSubject === null ? null : decodeEncodedWords(/[^\x00-\x7f]/.test(rawSubject) ? latin1ToUtf8(rawSubject) : rawSubject); + return { + messageId: messageId ? messageId.slice(0, 998) : null, + subject: subject ? subject.slice(0, MAX_SUBJECT) : null, + }; +} diff --git a/src/features/intake/submit.ts b/src/features/intake/submit.ts new file mode 100644 index 0000000..9fa272c --- /dev/null +++ b/src/features/intake/submit.ts @@ -0,0 +1,93 @@ +import { randomUUID } from "node:crypto"; +import { recordAudit } from "@/features/audit"; +import { insertDocuments, type NewDocument } from "@/features/documents"; +import { authorize, type Actor } from "@/features/identity"; +import { enqueueRequestProcessing } from "@/features/jobs"; +import { createRequest, findDuplicate } from "@/features/requests"; +import { S3BlobStore } from "@/features/storage"; +import type { Tenancy } from "@/features/tenancy"; +import { classifyUpload, UploadRejected, type UploadLimits } from "./files"; +import { requestFingerprint, sha256Hex } from "./fingerprint"; +import { parseMailHeaders } from "./mail-headers"; + +export interface IntakeDeps { + tenancy: Tenancy; + storage: S3BlobStore; + boss: Pick; + limits: UploadLimits & { maxFiles: number }; +} + +export interface UploadedFile { + name: string; + bytes: Uint8Array; +} + +export interface SubmittedRequest { + requestId: string; + possibleDuplicate: boolean; + duplicateOfId: string | null; +} + +/** + * Upload intake (ADR-0001 D9): validate every file, store the originals privately, then create the + * request (NEW), its documents, the audit event and the processing job in ONE tenant transaction. + * If the transaction fails, nothing of it exists and the stored objects are removed again. + * Exact duplicates are flagged and linked, never discarded. + */ +export async function submitUpload(deps: IntakeDeps, actor: Actor, files: UploadedFile[]): Promise { + authorize(actor, "requests.process"); + if (files.length === 0) throw new UploadRejected("Bitte mindestens eine Datei auswählen."); + if (files.length > deps.limits.maxFiles) throw new UploadRejected(`Höchstens ${deps.limits.maxFiles} Dateien pro Anfrage.`); + + const requestId = randomUUID(); + const classified = files.map((file) => ({ file, meta: classifyUpload(file.name, file.bytes, deps.limits) })); + const documents: NewDocument[] = classified.map(({ file, meta }) => { + const id = randomUUID(); + return { + id, + requestId, + filename: meta.filename, + contentType: meta.contentType, + kind: meta.kind, + sizeBytes: file.bytes.byteLength, + sha256: sha256Hex(file.bytes), + storageKey: S3BlobStore.documentKey(actor.companyId, requestId, id), + }; + }); + const mail = classified.find(({ meta }) => meta.kind === "eml"); + const headers = mail ? parseMailHeaders(mail.file.bytes) : { messageId: null, subject: null }; + const fingerprint = requestFingerprint(documents.map((document) => document.sha256)); + + const stored: string[] = []; + try { + for (const [index, document] of documents.entries()) { + await deps.storage.put(document.storageKey, classified[index]!.file.bytes, document.contentType); + stored.push(document.storageKey); + } + return await deps.tenancy.withTenant(actor.companyId, async (tx) => { + const duplicate = await findDuplicate(tx, { messageId: headers.messageId, fingerprint }); + await createRequest(tx, { + id: requestId, + createdBy: actor.userId, + subject: headers.subject ?? documents[0]?.filename ?? null, + messageId: headers.messageId, + fingerprint, + possibleDuplicate: duplicate !== null, + duplicateOfId: duplicate?.id ?? null, + }); + await insertDocuments(tx, documents); + await recordAudit(tx, { + actorUserId: actor.userId, + action: "request.uploaded", + entityType: "request", + entityId: requestId, + data: { documents: documents.length, possibleDuplicate: duplicate !== null, duplicateOfId: duplicate?.id ?? null }, + }); + await enqueueRequestProcessing(deps.boss, tx, requestId); + return { requestId, possibleDuplicate: duplicate !== null, duplicateOfId: duplicate?.id ?? null }; + }); + } catch (error) { + await Promise.allSettled(stored.map((key) => deps.storage.delete(key))); + throw error; + } +} diff --git a/src/features/jobs/boss.ts b/src/features/jobs/boss.ts index 58e7c41..bf89e29 100644 --- a/src/features/jobs/boss.ts +++ b/src/features/jobs/boss.ts @@ -27,7 +27,7 @@ export async function createJobClient(connectionString: string, options: { super * Enqueues processing of a request IN the caller's tenant transaction (transactional outbox without * an outbox table): the job exists exactly when the transaction commits. */ -export async function enqueueRequestProcessing(boss: PgBoss, tx: TenantTx, requestId: string): Promise { +export async function enqueueRequestProcessing(boss: Pick, tx: TenantTx, requestId: string): Promise { const job: RequestJob = { requestId, companyId: tenantOf(tx) }; return boss.send(QUEUES.processRequest, job, { db: fromDrizzle(tx, sql), singletonKey: requestId }); } diff --git a/src/features/requests/index.ts b/src/features/requests/index.ts index c1bbb37..8cce744 100644 --- a/src/features/requests/index.ts +++ b/src/features/requests/index.ts @@ -1,2 +1,2 @@ // Public API of the `requests` module: request aggregate (status machine follows with #7). -export { listRequests, createRequest, type RequestRow } from "./repository"; +export { createRequest, findDuplicate, getRequest, listRequests, type NewRequest, type RequestRow } from "./repository"; diff --git a/src/features/requests/repository.ts b/src/features/requests/repository.ts index 8e7c6e0..04e00b0 100644 --- a/src/features/requests/repository.ts +++ b/src/features/requests/repository.ts @@ -1,12 +1,17 @@ -import { desc } from "drizzle-orm"; +import { and, asc, desc, eq, or, type SQL } 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; +export type RequestRow = typeof requests.$inferSelect; + +export interface NewRequest { + id?: string; + createdBy?: string | null; + subject?: string | null; + messageId?: string | null; + fingerprint?: string | null; + possibleDuplicate?: boolean; + duplicateOfId?: string | null; } // Repository of the request aggregate. Every function needs a tenant transaction; the company id is @@ -16,9 +21,35 @@ export async function listRequests(tx: TenantTx): Promise { return tx.select().from(requests).orderBy(desc(requests.createdAt)); } -export async function createRequest(tx: TenantTx): Promise { +export async function getRequest(tx: TenantTx, id: string): Promise { + tenantOf(tx); + const [row] = await tx.select().from(requests).where(eq(requests.id, id)); + return row ?? null; +} + +export async function createRequest(tx: TenantTx, input: NewRequest = {}): Promise { const companyId = tenantOf(tx); - const [row] = await tx.insert(requests).values({ companyId }).returning(); + const [row] = await tx.insert(requests).values({ ...input, companyId, status: "NEW" satisfies RequestStatus }).returning(); if (!row) throw new Error("insert returned no row"); return row; } + +/** + * The earliest request of the same company with the same Message-ID or the same file fingerprint + * (exact duplicate, ADR-0001 D9). Tenant-scoped by RLS: other companies' requests never match. + */ +export async function findDuplicate( + tx: TenantTx, + keys: { messageId: string | null; fingerprint: string }, +): Promise { + tenantOf(tx); + const conditions: SQL[] = [eq(requests.fingerprint, keys.fingerprint)]; + if (keys.messageId) conditions.push(eq(requests.messageId, keys.messageId)); + const [row] = await tx + .select() + .from(requests) + .where(and(or(...conditions))) + .orderBy(asc(requests.createdAt)) + .limit(1); + return row ?? null; +} diff --git a/src/features/storage/s3-blob-store.ts b/src/features/storage/s3-blob-store.ts index 1339683..369437a 100644 --- a/src/features/storage/s3-blob-store.ts +++ b/src/features/storage/s3-blob-store.ts @@ -1,6 +1,9 @@ import { CreateBucketCommand, + DeleteObjectCommand, + GetObjectCommand, HeadBucketCommand, + PutObjectCommand, S3Client, S3ServiceException, } from "@aws-sdk/client-s3"; @@ -52,6 +55,25 @@ export class S3BlobStore { } } + /** Object key convention (ADR-0001 D5): `{companyId}/{requestId}/{documentId}`. */ + static documentKey(companyId: string, requestId: string, documentId: string): string { + return `${companyId}/${requestId}/${documentId}`; + } + + async put(key: string, bytes: Uint8Array, contentType: string): Promise { + await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: bytes, ContentType: contentType })); + } + + async get(key: string): Promise { + const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); + if (!response.Body) throw new Error("empty object body"); + return response.Body.transformToByteArray(); + } + + async delete(key: string): Promise { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); + } + destroy(): void { this.client.destroy(); } diff --git a/src/setup.ts b/src/setup.ts index bdc0c0c..66dd97a 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -4,6 +4,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { loadConfig } from "@/config/env"; import { runMigrations } from "@/db/migrate"; +import { installJobQueues } from "@/features/jobs"; import { S3BlobStore } from "@/features/storage"; const ATTEMPTS = 30; @@ -42,13 +43,14 @@ async function main(): Promise { const config = loadConfig(); await withRetry("migrations", () => runMigrations(migrationUrl)); + await withRetry("job queues", () => installJobQueues(migrationUrl)); const store = new S3BlobStore(config.storage); try { await withRetry("bucket", () => store.ensureBucket()); } finally { store.destroy(); } - log("info", "migrations applied, bucket ready"); + log("info", "migrations applied, job queues installed, bucket ready"); } main().catch((error: unknown) => { diff --git a/tests/integration/setup/global-setup.ts b/tests/integration/setup/global-setup.ts index a190386..1e136e0 100644 --- a/tests/integration/setup/global-setup.ts +++ b/tests/integration/setup/global-setup.ts @@ -1,4 +1,5 @@ import { runMigrations } from "@/db/migrate"; +import { installJobQueues } from "@/features/jobs"; import { S3BlobStore } from "@/features/storage"; import { loadConfig } from "@/config/env"; @@ -8,6 +9,7 @@ export default async function setup(): Promise { const migrationUrl = process.env.MIGRATION_DATABASE_URL; if (!migrationUrl) throw new Error("MIGRATION_DATABASE_URL is required for integration tests"); await runMigrations(migrationUrl); + await installJobQueues(migrationUrl); const store = new S3BlobStore(loadConfig().storage); try { From 81ee674bd5b165db7b17e332ff6eeef11da71feb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:59:41 +0000 Subject: [PATCH 13/21] test(intake): atomic submit with injected failure, duplicates, append-only audit, composite FK Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/features/jobs/boss.ts | 5 +- tests/integration/intake.test.ts | 195 +++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 tests/integration/intake.test.ts diff --git a/src/features/jobs/boss.ts b/src/features/jobs/boss.ts index bf89e29..81462a8 100644 --- a/src/features/jobs/boss.ts +++ b/src/features/jobs/boss.ts @@ -45,9 +45,10 @@ export async function installJobQueues(ownerConnectionString: string): Promise {}); await boss.start(); try { - for (const { name, ...options } of QUEUE_DEFINITIONS) { + for (const { name, policy, ...options } of QUEUE_DEFINITIONS) { + // The policy is fixed at creation; retries, expiry and dead letter can be updated in place. if (await boss.getQueue(name)) await boss.updateQueue(name, options); - else await boss.createQueue(name, options); + else await boss.createQueue(name, { policy, ...options }); } const db = boss.getDb(); for (const statement of [ diff --git a/tests/integration/intake.test.ts b/tests/integration/intake.test.ts new file mode 100644 index 0000000..8dd3ae1 --- /dev/null +++ b/tests/integration/intake.test.ts @@ -0,0 +1,195 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { PgBoss } from "pg-boss"; +import { loadConfig } from "@/config/env"; +import { listAuditEvents } from "@/features/audit"; +import { listDocuments } from "@/features/documents"; +import { inviteUser, getActor, type Actor } from "@/features/identity"; +import { submitUpload, UploadRejected, type IntakeDeps } from "@/features/intake"; +import { createJobClient, QUEUES } from "@/features/jobs"; +import { getRequest } from "@/features/requests"; +import { S3BlobStore } from "@/features/storage"; +import { createTenancy } from "@/features/tenancy"; +import { companyWithAdmin, createStack, signIn, signUp, syntheticEmail, unique, type Stack } from "./helpers/stack"; + +const enc = (text: string) => new TextEncoder().encode(text); +const mail = (messageId: string) => + enc(`From: Einkauf \r\nTo: vertrieb@example.org\r\nSubject: Anfrage Flansche\r\nMessage-ID: ${messageId}\r\n\r\nBitte um Angebot.\r\n`); +const pdf = (marker: string) => enc(`%PDF-1.7\n% synthetic ${marker}\n`); + +describe("intake: upload a request and enqueue processing atomically", () => { + let stack: Stack; + let storage: S3BlobStore; + let boss: PgBoss; + let deps: IntakeDeps; + let clerkA: Actor; + let adminB: Actor; + + const jobCount = async (requestId: string) => { + const { rows } = await stack.database.pool.query( + "select count(*)::int as n from pgboss.job where name = $1 and singleton_key = $2", + [QUEUES.processRequest, requestId], + ); + return rows[0].n as number; + }; + const requestExists = async (actor: Actor, id: string) => + (await deps.tenancy.withTenant(actor.companyId, (tx) => getRequest(tx, id))) !== null; + const objectExists = (key: string) => storage.get(key).then(() => true, () => false); + + beforeAll(async () => { + stack = createStack(); + const config = loadConfig(); + storage = new S3BlobStore(config.storage); + boss = await createJobClient(config.databaseUrl); + deps = { tenancy: createTenancy(stack.database.db), storage, boss, limits: { maxFileBytes: 1024 * 1024, maxFiles: 5 } }; + + const a = await companyWithAdmin(stack); + const adminA = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: a.cookie })))!; + const clerkEmail = syntheticEmail("clerk"); + await inviteUser(stack.database.db, adminA, { email: clerkEmail, role: "clerk" }); + await signUp(stack.auth, clerkEmail); + clerkA = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: (await signIn(stack.auth, clerkEmail)).cookie })))!; + const b = await companyWithAdmin(stack); + adminB = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: b.cookie })))!; + }); + + afterAll(async () => { + await boss.stop({ graceful: false }); + storage.destroy(); + await stack.close(); + }); + + it("commits request (NEW), documents, audit event and one job together; originals are stored privately", async () => { + const result = await submitUpload(deps, clerkA, [ + { name: "anfrage.eml", bytes: mail(`<${unique("m")}@example.com>`) }, + { name: "zeichnung.pdf", bytes: pdf(unique("p")) }, + ]); + + const { request, documents, audit } = await deps.tenancy.withTenant(clerkA.companyId, async (tx) => ({ + request: await getRequest(tx, result.requestId), + documents: await listDocuments(tx, result.requestId), + audit: await listAuditEvents(tx, "request", result.requestId), + })); + expect(request).toMatchObject({ status: "NEW", companyId: clerkA.companyId, createdBy: clerkA.userId, subject: "Anfrage Flansche" }); + expect(documents.map((d) => d.kind).sort()).toEqual(["eml", "pdf"]); + for (const document of documents) { + expect(document.storageKey).toBe(`${clerkA.companyId}/${result.requestId}/${document.id}`); + expect(await objectExists(document.storageKey)).toBe(true); + expect(document.sha256).toMatch(/^[0-9a-f]{64}$/); + } + expect(audit).toHaveLength(1); + expect(audit[0]).toMatchObject({ action: "request.uploaded", actorUserId: clerkA.userId }); + expect(await jobCount(result.requestId)).toBe(1); + }); + + it("rolls back request, documents and job when a failure happens after the inserts, and removes the stored objects", async () => { + let requestId = ""; + const keys: string[] = []; + const failingBoss: Pick = { + send: (async (...args: Parameters) => { + const [, data] = args as [string, { requestId: string }]; + requestId = data.requestId; + await (boss.send as (...a: unknown[]) => Promise)(...args); // the job row is really inserted … + throw new Error("injected failure after the job insert"); // … and then the transaction fails + }) as PgBoss["send"], + }; + const put = storage.put.bind(storage); + const spyStorage = Object.assign(Object.create(storage) as S3BlobStore, { + put: async (key: string, bytes: Uint8Array, type: string) => { + keys.push(key); + return put(key, bytes, type); + }, + }); + + await expect( + submitUpload({ ...deps, boss: failingBoss, storage: spyStorage }, clerkA, [{ name: "anfrage.pdf", bytes: pdf(unique("fail")) }]), + ).rejects.toThrow(/injected failure/); + + expect(requestId).not.toBe(""); + expect(await requestExists(clerkA, requestId)).toBe(false); + expect(await jobCount(requestId)).toBe(0); + const documents = await deps.tenancy.withTenant(clerkA.companyId, (tx) => listDocuments(tx, requestId)); + expect(documents).toHaveLength(0); + expect(keys).toHaveLength(1); + expect(await objectExists(keys[0]!)).toBe(false); + }); + + it("flags a second upload with the same Message-ID as a possible duplicate of the first", async () => { + const messageId = `<${unique("dup")}@example.com>`; + const first = await submitUpload(deps, clerkA, [{ name: "a.eml", bytes: mail(messageId) }]); + + const second = await submitUpload(deps, clerkA, [ + { name: "b.eml", bytes: enc(`Subject: Re: Anfrage\r\nMessage-ID: ${messageId}\r\nFrom: x@example.com\r\n\r\nanderer Text`) }, + ]); + + expect(first.possibleDuplicate).toBe(false); + expect(second).toMatchObject({ possibleDuplicate: true, duplicateOfId: first.requestId }); + }); + + it("flags the same set of files as a possible duplicate, in any order", async () => { + const one = pdf(unique("x")); + const two = pdf(unique("y")); + const first = await submitUpload(deps, clerkA, [ + { name: "1.pdf", bytes: one }, + { name: "2.pdf", bytes: two }, + ]); + + const second = await submitUpload(deps, clerkA, [ + { name: "zwei.pdf", bytes: two }, + { name: "eins.pdf", bytes: one }, + ]); + + expect(second).toMatchObject({ possibleDuplicate: true, duplicateOfId: first.requestId }); + }); + + it("detects duplicates only within the same company", async () => { + const bytes = pdf(unique("shared")); + await submitUpload(deps, clerkA, [{ name: "a.pdf", bytes }]); + + const other = await submitUpload(deps, adminB, [{ name: "a.pdf", bytes }]); + + expect(other.possibleDuplicate).toBe(false); + }); + + it("rejects a disallowed type with a clear message and stores nothing", async () => { + await expect(submitUpload(deps, clerkA, [{ name: "makro.xlsm", bytes: new Uint8Array([0x50, 0x4b, 3, 4]) }])).rejects.toThrow( + UploadRejected, + ); + }); + + it("keeps the audit trail append-only for the runtime role", async () => { + const result = await submitUpload(deps, clerkA, [{ name: "a.pdf", bytes: pdf(unique("audit")) }]); + const client = await stack.database.pool.connect(); + try { + await client.query("begin"); + await client.query("select set_config('app.company_id', $1, true)", [clerkA.companyId]); + await expect(client.query("update app.audit_events set action = 'x' where entity_id = $1", [result.requestId])).rejects.toThrow( + /permission denied/, + ); + await client.query("rollback"); + await client.query("begin"); + await client.query("select set_config('app.company_id', $1, true)", [clerkA.companyId]); + await expect(client.query("delete from app.audit_events where entity_id = $1", [result.requestId])).rejects.toThrow(/permission denied/); + await client.query("rollback"); + } finally { + client.release(); + } + }); + + it("does not let a company attach a document to another company's request (composite FK)", async () => { + const foreign = await submitUpload(deps, adminB, [{ name: "b.pdf", bytes: pdf(unique("b")) }]); + const client = await stack.database.pool.connect(); + try { + await client.query("begin"); + await client.query("select set_config('app.company_id', $1, true)", [clerkA.companyId]); + const insert = client.query( + `insert into app.documents (company_id, request_id, filename, content_type, kind, size_bytes, sha256, storage_key) + values ($1, $2, 'x.pdf', 'application/pdf', 'pdf', 1, 'x', 'x')`, + [clerkA.companyId, foreign.requestId], + ); + await expect(insert).rejects.toThrow(/foreign key/); + await client.query("rollback"); + } finally { + client.release(); + } + }); +}); From a8a280889365f5f3e8daa0a4de42e16b5335351c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:03:20 +0000 Subject: [PATCH 14/21] 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 c75a955507f5edd0f697712c70b79ee02a90ad2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:06:07 +0000 Subject: [PATCH 15/21] =?UTF-8?q?test(intake):=20upload/download=20routes?= =?UTF-8?q?=20=E2=80=93=20tenant=20404,=20anonymous=20401,=20type=20reject?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/api/documents/[id]/route.ts | 5 +- src/app/api/requests/route.ts | 3 +- tests/integration/intake.test.ts | 12 ++-- tests/integration/upload-routes.test.ts | 73 +++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 tests/integration/upload-routes.test.ts diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 396d897..921451a 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -1,4 +1,3 @@ -import { headers } from "next/headers"; import { currentActor, getRuntime } from "@/app/_server/runtime"; import { getDocument } from "@/features/documents"; @@ -8,8 +7,8 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // GET /api/documents/:id – the only way to read an original (private bucket, no public URLs; // ADR-0001 D5). A document of another company is indistinguishable from a missing one (404). -export async function GET(_request: Request, context: { params: Promise<{ id: string }> }): Promise { - const actor = await currentActor(await headers()); +export async function GET(request: Request, context: { params: Promise<{ id: string }> }): Promise { + const actor = await currentActor(request.headers); if (!actor) return Response.json({ error: { title: "Nicht angemeldet." } }, { status: 401 }); const { id } = await context.params; if (!UUID.test(id)) return Response.json({ error: { title: "Nicht gefunden." } }, { status: 404 }); diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index fb73812..b2aa841 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -1,4 +1,3 @@ -import { headers } from "next/headers"; import { currentActor, getJobClient, getRuntime } from "@/app/_server/runtime"; import { AuthorizationError } from "@/features/identity"; import { submitUpload, UploadRejected } from "@/features/intake"; @@ -10,7 +9,7 @@ const problem = (status: number, title: string) => Response.json({ error: { titl // POST /api/requests – multipart upload of one request (field `files`, 1..n files). // Company and user come from the session, never from the form (ADR-0001 D7). export async function POST(request: Request): Promise { - const actor = await currentActor(await headers()); + const actor = await currentActor(request.headers); if (!actor) return problem(401, "Nicht angemeldet."); const { config, tenancy, storage } = getRuntime(); diff --git a/tests/integration/intake.test.ts b/tests/integration/intake.test.ts index 8dd3ae1..688f4f4 100644 --- a/tests/integration/intake.test.ts +++ b/tests/integration/intake.test.ts @@ -3,13 +3,13 @@ import type { PgBoss } from "pg-boss"; import { loadConfig } from "@/config/env"; import { listAuditEvents } from "@/features/audit"; import { listDocuments } from "@/features/documents"; -import { inviteUser, getActor, type Actor } from "@/features/identity"; +import { getActor, type Actor } from "@/features/identity"; import { submitUpload, UploadRejected, type IntakeDeps } from "@/features/intake"; import { createJobClient, QUEUES } from "@/features/jobs"; import { getRequest } from "@/features/requests"; import { S3BlobStore } from "@/features/storage"; import { createTenancy } from "@/features/tenancy"; -import { companyWithAdmin, createStack, signIn, signUp, syntheticEmail, unique, type Stack } from "./helpers/stack"; +import { companyWithAdmin, createStack, invitedUser, unique, type Stack } from "./helpers/stack"; const enc = (text: string) => new TextEncoder().encode(text); const mail = (messageId: string) => @@ -44,10 +44,8 @@ describe("intake: upload a request and enqueue processing atomically", () => { const a = await companyWithAdmin(stack); const adminA = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: a.cookie })))!; - const clerkEmail = syntheticEmail("clerk"); - await inviteUser(stack.database.db, adminA, { email: clerkEmail, role: "clerk" }); - await signUp(stack.auth, clerkEmail); - clerkA = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: (await signIn(stack.auth, clerkEmail)).cookie })))!; + const clerk = await invitedUser(stack, adminA, "clerk"); + clerkA = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: clerk.cookie })))!; const b = await companyWithAdmin(stack); adminB = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: b.cookie })))!; }); @@ -90,7 +88,7 @@ describe("intake: upload a request and enqueue processing atomically", () => { requestId = data.requestId; await (boss.send as (...a: unknown[]) => Promise)(...args); // the job row is really inserted … throw new Error("injected failure after the job insert"); // … and then the transaction fails - }) as PgBoss["send"], + }) as unknown as PgBoss["send"], }; const put = storage.put.bind(storage); const spyStorage = Object.assign(Object.create(storage) as S3BlobStore, { diff --git a/tests/integration/upload-routes.test.ts b/tests/integration/upload-routes.test.ts new file mode 100644 index 0000000..1e94b3e --- /dev/null +++ b/tests/integration/upload-routes.test.ts @@ -0,0 +1,73 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { GET as download } from "@/app/api/documents/[id]/route"; +import { POST as upload } from "@/app/api/requests/route"; +import { getJobClient, getRuntime } from "@/app/_server/runtime"; +import { listDocuments } from "@/features/documents"; +import { companyWithAdmin, createStack, unique, type Stack } from "./helpers/stack"; + +// The HTTP boundary of intake: session → actor → tenant. Uses the web process's own runtime +// (same env as `next start`), so the routes run exactly as deployed. +describe("upload and download routes", () => { + let stack: Stack; + let cookieA: string; + let cookieB: string; + let companyA: string; + + const pdf = (marker: string) => new File([`%PDF-1.7\n% synthetic ${marker}\n`], "anfrage.pdf", { type: "application/pdf" }); + const post = (cookie: string | null, files: File[]) => { + const form = new FormData(); + for (const file of files) form.append("files", file); + return upload(new Request("http://localhost:3000/api/requests", { method: "POST", body: form, headers: cookie ? { cookie } : {} })); + }; + const get = (cookie: string | null, id: string) => + download(new Request(`http://localhost:3000/api/documents/${id}`, { headers: cookie ? { cookie } : {} }), { + params: Promise.resolve({ id }), + }); + + beforeAll(async () => { + stack = createStack(); + const a = await companyWithAdmin(stack); + const b = await companyWithAdmin(stack); + cookieA = a.cookie; + cookieB = b.cookie; + companyA = a.company.id; + }); + + afterAll(async () => { + await (await getJobClient()).stop({ graceful: false }); + await getRuntime().database.pool.end(); + await stack.close(); + }); + + it("accepts an upload of a signed-in user and serves the original only to the same company", async () => { + const marker = unique("route"); + const created = await post(cookieA, [pdf(marker)]); + expect(created.status).toBe(201); + const { requestId } = (await created.json()) as { requestId: string }; + const [document] = await getRuntime().tenancy.withTenant(companyA, (tx) => listDocuments(tx, requestId)); + + const own = await get(cookieA, document!.id); + const foreign = await get(cookieB, document!.id); + const anonymous = await get(null, document!.id); + + expect(own.status).toBe(200); + expect(own.headers.get("content-disposition")).toMatch(/^attachment;/); + expect(own.headers.get("x-content-type-options")).toBe("nosniff"); + expect(await own.text()).toContain(marker); + expect(foreign.status).toBe(404); + expect(anonymous.status).toBe(401); + }); + + it("rejects an anonymous upload and a disallowed type with a clear message", async () => { + expect((await post(null, [pdf("x")])).status).toBe(401); + + const rejected = await post(cookieA, [new File(["MZ"], "tool.exe")]); + + expect(rejected.status).toBe(422); + expect(((await rejected.json()) as { error: { title: string } }).error.title).toMatch(/Dateityp nicht erlaubt/); + }); + + it("answers 404 for a malformed document id", async () => { + expect((await get(cookieA, "../../etc/passwd")).status).toBe(404); + }); +}); From bff8c683237110c1330cc72f0c3fe19e0f9287eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:07:56 +0000 Subject: [PATCH 16/21] feat(app): requests list, upload form, request detail with downloads; docs for #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- CHANGELOG.md | 3 ++ docs/technical/api.md | 20 +++++++++++++ docs/technical/architecture.md | 18 ++++++------ docs/technical/data-model.md | 35 ++++++++++++++++++++++- src/app/page.tsx | 3 ++ src/app/requests/[id]/page.tsx | 45 ++++++++++++++++++++++++++++++ src/app/requests/page.tsx | 48 ++++++++++++++++++++++++++++++++ src/app/requests/upload-form.tsx | 44 +++++++++++++++++++++++++++++ 8 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 src/app/requests/[id]/page.tsx create mode 100644 src/app/requests/page.tsx create mode 100644 src/app/requests/upload-form.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 857c24c..3e9f1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ This file records what changes **in the product** – process and session state ## [Unreleased] ### Added +- Upload of a quote request (`/requests`): .eml, .msg, .pdf, .xlsx, .docx up to a configured size; + originals stored privately, download only for the own company. Request, documents, audit entry and + the processing job are created in one step; exact duplicates are flagged and linked. - 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 diff --git a/docs/technical/api.md b/docs/technical/api.md index 2742ae7..4db2def 100644 --- a/docs/technical/api.md +++ b/docs/technical/api.md @@ -17,3 +17,23 @@ (showcase epic #19). - Checks are added additively (queue backlog and AI service follow with #28); clients must ignore unknown check names. + +## `POST /api/requests` (session required) + +Multipart form, field `files` (1–`UPLOAD_MAX_FILES` files, each ≤ `UPLOAD_MAX_FILE_BYTES`). Allowed: +`.eml .msg .pdf .xlsx .docx`, checked by extension **and** content signature. + +| Status | Body | +|---|---| +| 201 | `{"requestId": uuid, "possibleDuplicate": bool, "duplicateOfId": uuid \| null}` | +| 401 / 403 | `{"error":{"title":"…"}}` – not signed in / role | +| 413 | declared body larger than files × size limit | +| 422 | `{"error":{"title":"Dateityp nicht erlaubt: …"}}` – user-facing reason | +| 500 | generic message; details only as IDs in the log | + +Request (NEW), documents, audit event and the `request-process` job commit in one transaction. + +## `GET /api/documents/:id` (session required) + +Streams the original as `attachment` with `x-content-type-options: nosniff` and `cache-control: private, +no-store`. A document of another company answers 404 like a missing one; no session → 401. diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index be7e922..9087411 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -12,9 +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-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 +**Current state (2026-09-23): #3 skeleton, #4 identity/tenancy, #5 intake** – runnable stack, health +endpoint, invite-only login, companies, `withTenant()` with forced RLS, upload with atomic enqueue +and duplicate flags, module boundaries enforced. Tables: [data-model.md](data-model.md). Status per module below (`skeleton` = public `index.ts` only). ## Modules @@ -23,8 +23,8 @@ Every new file belongs to one of these modules – otherwise add the module here | Module | Location | Task | Exposure | Data class | Protection | Status | |---|---|---|---|---|---|---| -| `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 | +| `intake` | `src/features/intake/` | upload, duplicate fingerprint, creates request + documents | authenticated UI/route | confidential + personal | session, tenant context, size/type limits | built: upload validation (extension + signature, size), fingerprint, atomic submit | +| `documents` | `src/features/documents/` | document records, storage references, hashes | internal | confidential | tenant context | built: records, SHA-256, storage keys | | `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 | 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 | @@ -32,13 +32,13 @@ Every new file belongs to one of these modules – otherwise add the module here | `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 | 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 | +| `audit` | `src/features/audit/` | append-only audit events | internal | personal (staff) | INSERT/SELECT only | partial: `recordAudit()` (append-only enforced by grants) | +| `jobs` | `src/features/jobs/`, entrypoint `src/worker.ts` | pg-boss, job handlers, `drain()`, worker entrypoint | internal | IDs only | transactional enqueue | partial: queues + transactional enqueue; worker no-op until #7 | +| `storage` | `src/features/storage/` | `BlobStore` port + S3 adapter | internal | confidential | private bucket, access via app routes | built: S3 adapter (put/get/delete, bucket setup, 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) | `/`, `/login`, `/signup`, `/invite`, `/api/auth/*`, `/api/health` | – | calls module APIs only (dependency-cruiser) | partial: login, sign-up, invite, home | +| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client) | `/`, `/login`, `/signup`, `/invite`, `/requests`, `/api/requests`, `/api/documents/:id`, `/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 index be6da22..64b1127 100644 --- a/docs/technical/data-model.md +++ b/docs/technical/data-model.md @@ -10,6 +10,7 @@ |---|---|---|---| | `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) | +| `pgboss` | `app_owner` (deploy step installs schema + queues) | DML only | none – job queue, IDs only (exceptions register) | | `drizzle` | `app_owner` | none | migration journal | Tenant policy (all `app` tables): `company_id = nullif(current_setting('app.company_id', true), '')::uuid` @@ -26,8 +27,37 @@ query sees zero rows and every write fails. | `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 | +| `source` | text | `upload` (mailbox later) | internal | +| `created_by` | uuid | uploading user | personal (staff) | +| `subject` | text | mail subject or first file name, max 300 chars | confidential | +| `message_id` | text | `Message-ID` of an uploaded mail – duplicate key | personal | +| `fingerprint` | text | SHA-256 over the sorted file hashes – duplicate key | internal | +| `possible_duplicate` / `duplicate_of_id` | boolean / uuid | exact duplicate within the company; composite FK `(duplicate_of_id, company_id)` | internal | -Purpose: one quote request per row. Retention: open question for the customer (ADR-0001 open points). +Unique `(id, company_id)` so child tables can pin the company with composite foreign keys (FK checks +bypass RLS). Purpose: one quote request per row. Retention: open question for the customer (ADR-0001 open points). + +### `app.documents` – originals of a request (#5) + +| Column | Type | Notes | Class | +|---|---|---|---| +| `id`, `company_id`, `request_id` | uuid | composite FK `(request_id, company_id)` → `requests` | internal | +| `filename`, `content_type`, `kind` | text | kind `eml · msg · pdf · xlsx · docx` | confidential | +| `size_bytes`, `sha256` | bigint, text | SHA-256 of the raw bytes (integrity, duplicates) | internal | +| `storage_key` | text | `{companyId}/{requestId}/{documentId}` in the private bucket | internal | + +The bytes (confidential + personal) live only in object storage; served via `GET /api/documents/:id`. + +### `app.audit_events` – append-only business audit (#5, ADR-0001 D10) + +| Column | Type | Notes | Class | +|---|---|---|---| +| `company_id`, `entity_type`, `entity_id` | | what changed | internal | +| `actor_user_id` | uuid | who | personal (staff) | +| `action`, `data` | text, jsonb | e.g. `request.uploaded`; old/new values later – no document content | internal/confidential | + +`app_rw` has INSERT and SELECT only (UPDATE/DELETE/TRUNCATE revoked). Written in the same transaction +as the change. ### `auth.*` – Better Auth 1.7.5 (generated with the Better Auth CLI, timestamps with time zone) @@ -51,4 +81,7 @@ company's first admin; it can never obtain a session. 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) +app.requests 1─n app.documents (request_id, company_id) +app.requests 0─1 app.requests (duplicate_of_id, company_id) +app.* 1─n app.audit_events (entity_type, entity_id – no FK, append-only) ``` diff --git a/src/app/page.tsx b/src/app/page.tsx index bdda04b..e0ed0b6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -27,6 +27,9 @@ export default async function HomePage() {

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

+

+ Anfragen +

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

Mitarbeitende einladen diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx new file mode 100644 index 0000000..d28e4fc --- /dev/null +++ b/src/app/requests/[id]/page.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { listDocuments } from "@/features/documents"; +import { getRequest } from "@/features/requests"; + +export const dynamic = "force-dynamic"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export default async function RequestPage({ params }: { params: Promise<{ id: string }> }) { + const actor = await currentActor(await headers()); + if (!actor) redirect("/login"); + const { id } = await params; + if (!UUID.test(id)) notFound(); + const data = await getRuntime().tenancy.withTenant(actor.companyId, async (tx) => { + const request = await getRequest(tx, id); + return request ? { request, documents: await listDocuments(tx, id) } : null; + }); + if (!data) notFound(); + const { request, documents } = data; + return ( +

+

+ ← Anfragen +

+

{request.subject ?? "(ohne Betreff)"}

+

Status: {request.status}

+ {request.possibleDuplicate && request.duplicateOfId && ( +

+ Mögliches Duplikat von dieser Anfrage. +

+ )} +

Dokumente

+
    + {documents.map((document) => ( +
  • + {document.filename} ({Math.ceil(document.sizeBytes / 1024)} KB) +
  • + ))} +
+
+ ); +} diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx new file mode 100644 index 0000000..e36ed59 --- /dev/null +++ b/src/app/requests/page.tsx @@ -0,0 +1,48 @@ +import Link from "next/link"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { listRequests } from "@/features/requests"; +import { UploadForm } from "./upload-form"; + +export const dynamic = "force-dynamic"; + +const dateFormat = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short", timeZone: "Europe/Berlin" }); + +export default async function RequestsPage() { + const actor = await currentActor(await headers()); + if (!actor) redirect("/login"); + const requests = await getRuntime().tenancy.withTenant(actor.companyId, (tx) => listRequests(tx)); + return ( +
+

Anfragen

+ + {requests.length === 0 ? ( +

Noch keine Anfragen.

+ ) : ( +
+ + + + + + + + + + {requests.map((request) => ( + + + + + + + ))} + +
EingangBetreffStatusHinweis
{dateFormat.format(request.createdAt)} + {request.subject ?? "(ohne Betreff)"} + {request.status}{request.possibleDuplicate ? "Mögliches Duplikat" : ""}
+ )} + + ); +} diff --git a/src/app/requests/upload-form.tsx b/src/app/requests/upload-form.tsx new file mode 100644 index 0000000..3fe7e1e --- /dev/null +++ b/src/app/requests/upload-form.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; + +// Posts the selected files to POST /api/requests; errors from the server are shown verbatim +// (they are written for users, e.g. "Dateityp nicht erlaubt"). +export function UploadForm() { + const router = useRouter(); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const formElement = event.currentTarget; + setBusy(true); + setMessage(null); + const response = await fetch("/api/requests", { method: "POST", body: new FormData(formElement) }); + setBusy(false); + if (response.ok) { + const result = (await response.json()) as { possibleDuplicate: boolean }; + setMessage(result.possibleDuplicate ? "Anfrage angelegt – möglicherweise ein Duplikat, bitte prüfen." : "Anfrage angelegt."); + formElement.reset(); + router.refresh(); + return; + } + const body = (await response.json().catch(() => null)) as { error?: { title?: string } } | null; + setMessage(body?.error?.title ?? "Upload fehlgeschlagen."); + } + + return ( + +

+ +
+ +

+ + {message &&

{message}

} + + ); +} From 02715e0e1ed3d69dcdfba8b58ae0cbfbd54e10c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:09:31 +0000 Subject: [PATCH 17/21] 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]), From 66ab13c629bb6eb8c1a20f771b810766ff9d36d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:17:07 +0000 Subject: [PATCH 18/21] =?UTF-8?q?wip(intake):=20review=20fixes=20=E2=80=93?= =?UTF-8?q?=20pg-boss=20into=20src/db,=20body=20bounds,=20OOXML=20check,?= =?UTF-8?q?=20lock,=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- .dependency-cruiser.cjs | 7 ++ .env.example | 2 + src/app/_server/runtime.ts | 8 +- src/app/api/documents/[id]/route.ts | 6 +- src/app/api/requests/route.ts | 12 ++- src/config/env.ts | 4 +- src/db/job-queue.ts | 85 +++++++++++++++++++ .../0004_intake_force_rls_audit.sql | 10 --- src/db/schema/app.ts | 20 +++-- src/features/intake/files.test.ts | 17 ++-- src/features/intake/files.ts | 12 ++- src/features/intake/ooxml.test.ts | 41 +++++++++ src/features/intake/ooxml.ts | 47 ++++++++++ src/features/intake/submit.ts | 16 +++- src/features/intake/zip-fixture.ts | 53 ++++++++++++ src/features/jobs/boss.ts | 67 ++------------- src/features/jobs/index.ts | 7 +- src/features/jobs/queues.ts | 2 - src/features/requests/index.ts | 2 +- src/features/requests/repository.ts | 11 ++- src/features/storage/s3-blob-store.ts | 7 ++ src/setup.ts | 5 +- tests/integration/intake.test.ts | 5 +- tests/integration/setup/global-setup.ts | 5 +- 24 files changed, 341 insertions(+), 110 deletions(-) create mode 100644 src/db/job-queue.ts create mode 100644 src/features/intake/ooxml.test.ts create mode 100644 src/features/intake/ooxml.ts create mode 100644 src/features/intake/zip-fixture.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 669f3da..eb83fae 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -31,6 +31,13 @@ module.exports = { from: { path: "^src/features/" }, to: { path: "^src/db/(index|client|migrate)\\.ts$", dependencyTypesNot: ["type-only"] }, }, + { + name: "pg-boss-client-only-in-db", + severity: "error", + comment: "pg-boss opens its own pool: construct it only in src/db (job-queue.ts); features use injected JobSender types.", + from: { path: "^src/", pathNot: "^src/db/" }, + to: { path: "(^|/)node_modules/pg-boss(/|$)", dependencyTypesNot: ["type-only"] }, + }, { name: "not-to-unresolvable", severity: "error", diff --git a/.env.example b/.env.example index 2ddcaa9..a134d7a 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,8 @@ SEED_PASSWORD=demo-password-local-only # Maximum size per file in bytes (default 20 MiB) and files per request (default 10). UPLOAD_MAX_FILE_BYTES=20971520 UPLOAD_MAX_FILES=10 +# Cap of one whole upload request (all files + form overhead); requests without Content-Length are refused. +UPLOAD_MAX_REQUEST_BYTES=41943040 # --- Web --------------------------------------------------------------------------------------- # Host port of the web container. diff --git a/src/app/_server/runtime.ts b/src/app/_server/runtime.ts index 11050ca..1c23e6f 100644 --- a/src/app/_server/runtime.ts +++ b/src/app/_server/runtime.ts @@ -1,7 +1,7 @@ import { loadConfig, type AppConfig } from "@/config/env"; import { createDatabase, type DatabaseHandle } from "@/db"; +import { createJobQueue } from "@/db/job-queue"; import { createAuth, getActor, type Actor, type Auth } from "@/features/identity"; -import { createJobClient } from "@/features/jobs"; import { S3BlobStore } from "@/features/storage"; import { createTenancy, type Tenancy } from "@/features/tenancy"; @@ -32,11 +32,11 @@ export function getRuntime(): Runtime { return runtime; } -let jobClient: ReturnType | undefined; +let jobClient: ReturnType | undefined; /** pg-boss client of the web process (send only, no supervision). */ -export function getJobClient(): ReturnType { - jobClient ??= createJobClient(getRuntime().config.databaseUrl).catch((error: unknown) => { +export function getJobClient(): ReturnType { + jobClient ??= createJobQueue(getRuntime().config.databaseUrl).catch((error: unknown) => { jobClient = undefined; throw error; }); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 921451a..16ebeea 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -17,11 +17,11 @@ export async function GET(request: Request, context: { params: Promise<{ id: str const document = await tenancy.withTenant(actor.companyId, (tx) => getDocument(tx, id)); if (!document) return Response.json({ error: { title: "Nicht gefunden." } }, { status: 404 }); - const bytes = await storage.get(document.storageKey); - return new Response(new Blob([bytes as BlobPart]), { + const { body, length } = await storage.stream(document.storageKey); + return new Response(body, { headers: { "content-type": document.contentType, - "content-length": String(bytes.byteLength), + ...(length === undefined ? {} : { "content-length": String(length) }), "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(document.filename)}`, "x-content-type-options": "nosniff", "cache-control": "private, no-store", diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index b2aa841..2bbee90 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -13,9 +13,11 @@ export async function POST(request: Request): Promise { if (!actor) return problem(401, "Nicht angemeldet."); const { config, tenancy, storage } = getRuntime(); - const declared = Number(request.headers.get("content-length") ?? "0"); - const bodyLimit = config.upload.maxFileBytes * config.upload.maxFiles + 1024 * 1024; - if (declared > bodyLimit) return problem(413, "Upload zu groß."); + // The body is buffered by formData(): bound it BEFORE reading. Node enforces the declared length, + // so a request without a numeric Content-Length (chunked) is refused. + const declared = request.headers.get("content-length"); + if (!declared || !/^\d+$/.test(declared)) return problem(411, "Upload ohne Längenangabe wird nicht angenommen."); + if (Number(declared) > config.upload.maxRequestBytes) return problem(413, "Upload zu groß."); let form: FormData; try { @@ -37,7 +39,9 @@ export async function POST(request: Request): Promise { } catch (error) { if (error instanceof UploadRejected) return problem(422, error.message); if (error instanceof AuthorizationError) return problem(403, "Keine Berechtigung."); - console.error(JSON.stringify({ level: "error", route: "POST /api/requests", companyId: actor.companyId, message: "upload failed" })); + const kind = error instanceof Error ? error.name : "unknown"; + const code = (error as { code?: unknown } | null)?.code; + console.error(JSON.stringify({ level: "error", route: "POST /api/requests", companyId: actor.companyId, userId: actor.userId, error: kind, code: typeof code === "string" ? code : undefined })); return problem(500, "Upload fehlgeschlagen. Bitte erneut versuchen."); } } diff --git a/src/config/env.ts b/src/config/env.ts index c19b8a2..855ceda 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -17,6 +17,7 @@ const schema = z.object({ APP_ENV: z.enum(["local", "showcase", "production"]), UPLOAD_MAX_FILE_BYTES: z.coerce.number().int().positive().default(20 * 1024 * 1024), UPLOAD_MAX_FILES: z.coerce.number().int().positive().max(50).default(10), + UPLOAD_MAX_REQUEST_BYTES: z.coerce.number().int().positive().default(40 * 1024 * 1024), }); const LOCAL_PLACEHOLDER_SECRETS = new Set(["local-dev-only-secret-change-me-0123456789"]); @@ -41,6 +42,7 @@ export interface AppConfig { upload: { maxFileBytes: number; maxFiles: number; + maxRequestBytes: number; }; } @@ -72,6 +74,6 @@ export function loadConfig(source: Record = process. ipAddressHeaders: list(env.AUTH_IP_HEADERS), trustedProxies: list(env.AUTH_TRUSTED_PROXIES), }, - upload: { maxFileBytes: env.UPLOAD_MAX_FILE_BYTES, maxFiles: env.UPLOAD_MAX_FILES }, + upload: { maxFileBytes: env.UPLOAD_MAX_FILE_BYTES, maxFiles: env.UPLOAD_MAX_FILES, maxRequestBytes: env.UPLOAD_MAX_REQUEST_BYTES }, }; } diff --git a/src/db/job-queue.ts b/src/db/job-queue.ts new file mode 100644 index 0000000..874b20e --- /dev/null +++ b/src/db/job-queue.ts @@ -0,0 +1,85 @@ +import { sql } from "drizzle-orm"; +import { fromDrizzle, PgBoss, type Queue, type SendOptions } from "pg-boss"; +import type { Database } from "./client"; + +// pg-boss (ADR-0001 D4) opens its own pool, so it lives here with the other database clients +// (AGENTS.md: no raw DB client outside src/db and tenancy). Feature modules get the instance +// injected and only send/fetch/complete. +export const PGBOSS_SCHEMA = "pgboss"; + +type Transaction = Parameters[0]>[0]; +export type JobSender = Pick; + +/** Runtime client (`app_rw`): no schema creation or migration; polling, no LISTEN/NOTIFY. */ +export async function createJobQueue(connectionString: string, options: { supervise?: boolean } = {}): Promise { + const boss = new PgBoss({ + connectionString, + schema: PGBOSS_SCHEMA, + max: 4, + migrate: false, + createSchema: false, + supervise: options.supervise ?? false, + schedule: false, + }); + boss.on("error", (error: Error) => console.error(JSON.stringify({ level: "error", module: "jobs", message: error.message }))); + await boss.start(); + return boss; +} + +/** + * Enqueues IN the caller's transaction (transactional outbox without an outbox table): the job + * exists exactly when the transaction commits. A job refused by the queue policy is an error. + */ +export async function sendInTransaction( + queue: JobSender, + tx: Transaction, + name: string, + data: object, + options: Omit = {}, +): Promise { + const id = await queue.send(name, data, { ...options, db: fromDrizzle(tx, sql) }); + if (!id) throw new Error(`job refused by queue policy: ${name}`); + return id; +} + +/** + * Deploy step, owner role: install/upgrade the pg-boss schema and queues, then grant the runtime role + * the least it needs – job rows read/write, queue and version read, queue statistics maintenance. + */ +export async function installJobQueues(ownerConnectionString: string, definitions: Queue[]): Promise { + const boss = new PgBoss({ + connectionString: ownerConnectionString, + schema: PGBOSS_SCHEMA, + max: 2, + migrate: true, + supervise: false, + schedule: false, + }); + boss.on("error", () => {}); + await boss.start(); + try { + for (const { name, policy, ...options } of definitions) { + // The policy is fixed at creation; retries, expiry and dead letter can be updated in place. + if (await boss.getQueue(name)) await boss.updateQueue(name, options); + else await boss.createQueue(name, { policy, ...options }); + } + const db = boss.getDb(); + const s = PGBOSS_SCHEMA; + for (const statement of [ + `REVOKE ALL ON ALL TABLES IN SCHEMA ${s} FROM app_rw`, + `GRANT USAGE ON SCHEMA ${s} TO app_rw`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ${s}.job, ${s}.job_common, ${s}.job_dependency, ${s}.warning, ${s}.bam TO app_rw`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ${s}.queue_stats TO app_rw`, + `GRANT SELECT, UPDATE ON ${s}.queue TO app_rw`, + `GRANT SELECT ON ${s}.version, ${s}.schedule, ${s}.subscription TO app_rw`, + `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO app_rw`, + `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ${s} TO app_rw`, + // Partitions of job/queue_stats created later by the owner inherit the table privileges. + `ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw`, + ]) { + await db.executeSql(statement); + } + } finally { + await boss.stop({ graceful: false }); + } +} diff --git a/src/db/migrations/0004_intake_force_rls_audit.sql b/src/db/migrations/0004_intake_force_rls_audit.sql index e6a198c..ef6fcfb 100644 --- a/src/db/migrations/0004_intake_force_rls_audit.sql +++ b/src/db/migrations/0004_intake_force_rls_audit.sql @@ -6,13 +6,3 @@ ALTER TABLE app.audit_events FORCE ROW LEVEL SECURITY; --> statement-breakpoint -- 2) Append-only audit: the runtime role may insert and read, never change or delete. REVOKE UPDATE, DELETE, TRUNCATE ON app.audit_events FROM app_rw; ---> statement-breakpoint --- 3) Same-company references. Foreign-key checks bypass RLS, so a plain FK would let a tenant attach --- its row to another company's request if it knew the id. Composite FKs pin company_id. -ALTER TABLE app.requests ADD CONSTRAINT requests_id_company_unique UNIQUE (id, company_id); ---> statement-breakpoint -ALTER TABLE app.documents ADD CONSTRAINT documents_request_same_company_fk - FOREIGN KEY (request_id, company_id) REFERENCES app.requests (id, company_id) ON DELETE CASCADE; ---> statement-breakpoint -ALTER TABLE app.requests ADD CONSTRAINT requests_duplicate_same_company_fk - FOREIGN KEY (duplicate_of_id, company_id) REFERENCES app.requests (id, company_id); diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts index 632bbf4..76a053d 100644 --- a/src/db/schema/app.ts +++ b/src/db/schema/app.ts @@ -2,7 +2,7 @@ // (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 { type AnyPgColumn, bigint, boolean, check, index, jsonb, pgPolicy, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { bigint, boolean, check, foreignKey, index, jsonb, pgPolicy, pgSchema, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; import { organization } from "./auth"; export const appSchema = pgSchema("app"); @@ -40,12 +40,19 @@ export const requests = appSchema messageId: text("message_id"), fingerprint: text("fingerprint"), possibleDuplicate: boolean("possible_duplicate").default(false).notNull(), - duplicateOfId: uuid("duplicate_of_id").references((): AnyPgColumn => requests.id, { onDelete: "set null" }), + duplicateOfId: uuid("duplicate_of_id"), }, (table) => [ index("requests_company_id_idx").on(table.companyId), index("requests_company_message_id_idx").on(table.companyId, table.messageId), index("requests_company_fingerprint_idx").on(table.companyId, table.fingerprint), + // FK checks bypass RLS: child rows pin company_id through composite keys. + unique("requests_id_company_unique").on(table.id, table.companyId), + foreignKey({ + name: "requests_duplicate_same_company_fk", + columns: [table.duplicateOfId, table.companyId], + foreignColumns: [table.id, table.companyId], + }), check("requests_status_check", sql.raw(`status in (${REQUEST_STATUSES.map((s) => `'${s}'`).join(", ")})`)), tenantPolicy("requests"), ], @@ -62,9 +69,7 @@ export const documents = appSchema companyId: uuid("company_id") .notNull() .references(() => organization.id, { onDelete: "restrict" }), - requestId: uuid("request_id") - .notNull() - .references(() => requests.id, { onDelete: "cascade" }), + requestId: uuid("request_id").notNull(), filename: text("filename").notNull(), contentType: text("content_type").notNull(), kind: text("kind").$type().notNull(), @@ -76,6 +81,11 @@ export const documents = appSchema (table) => [ index("documents_company_id_idx").on(table.companyId), index("documents_request_id_idx").on(table.requestId), + foreignKey({ + name: "documents_request_same_company_fk", + columns: [table.requestId, table.companyId], + foreignColumns: [requests.id, requests.companyId], + }).onDelete("cascade"), check("documents_kind_check", sql.raw(`kind in (${DOCUMENT_KINDS.map((k) => `'${k}'`).join(", ")})`)), tenantPolicy("documents"), ], diff --git a/src/features/intake/files.test.ts b/src/features/intake/files.test.ts index 68b4494..c08049d 100644 --- a/src/features/intake/files.test.ts +++ b/src/features/intake/files.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { classifyUpload, UploadRejected } from "./files"; +import { makeZip, MINIMAL_DOCX, MINIMAL_XLSX } from "./zip-fixture"; const bytes = (text: string) => new TextEncoder().encode(text); const PDF = bytes("%PDF-1.7\n%synthetic\n"); -const ZIP = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x06, 0x00]); +const XLSX = makeZip(MINIMAL_XLSX); +const DOCX = makeZip(MINIMAL_DOCX); const OLE = new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0, 0]); const EML = bytes("From: Einkauf \r\nSubject: Anfrage\r\nMessage-ID: \r\n\r\nHallo"); const limits = { maxFileBytes: 1024 }; @@ -11,8 +13,8 @@ const limits = { maxFileBytes: 1024 }; describe("classifyUpload", () => { it.each([ ["anfrage.pdf", PDF, "pdf"], - ["positionen.xlsx", ZIP, "xlsx"], - ["spezifikation.docx", ZIP, "docx"], + ["positionen.xlsx", XLSX, "xlsx"], + ["spezifikation.docx", DOCX, "docx"], ["weitergeleitet.msg", OLE, "msg"], ["anfrage.eml", EML, "eml"], ["ANFRAGE.PDF", PDF, "pdf"], @@ -21,16 +23,21 @@ describe("classifyUpload", () => { }); it("rejects an unsupported extension with a clear message", () => { - expect(() => classifyUpload("makro.xlsm", ZIP, limits)).toThrow(/Dateityp nicht erlaubt/); + expect(() => classifyUpload("makro.xlsm", XLSX, limits)).toThrow(/Dateityp nicht erlaubt/); expect(() => classifyUpload("programm.exe", bytes("MZ"), limits)).toThrow(UploadRejected); }); it("rejects content that does not match the extension (renamed files)", () => { - expect(() => classifyUpload("anfrage.pdf", ZIP, limits)).toThrow(/passt nicht zum Dateityp/); + expect(() => classifyUpload("anfrage.pdf", XLSX, limits)).toThrow(/passt nicht zum Dateityp/); expect(() => classifyUpload("tabelle.xlsx", PDF, limits)).toThrow(/passt nicht zum Dateityp/); expect(() => classifyUpload("mail.eml", new Uint8Array([0x00, 0x01, 0x02]), limits)).toThrow(/passt nicht zum Dateityp/); }); + it("rejects a macro workbook renamed to .xlsx and a workbook renamed to .docx", () => { + expect(() => classifyUpload("umbenannt.xlsx", makeZip([...MINIMAL_XLSX, ["xl/vbaProject.bin", 10]]), limits)).toThrow(/Makros/); + expect(() => classifyUpload("tabelle.docx", XLSX, limits)).toThrow(/passt nicht zum Dateityp/); + }); + it("rejects files above the size limit and empty files", () => { expect(() => classifyUpload("gross.pdf", new Uint8Array(2048).fill(0x25), limits)).toThrow(/zu groß/); expect(() => classifyUpload("leer.pdf", new Uint8Array(0), limits)).toThrow(/leer/); diff --git a/src/features/intake/files.ts b/src/features/intake/files.ts index 25a9bbf..a6213ea 100644 --- a/src/features/intake/files.ts +++ b/src/features/intake/files.ts @@ -1,7 +1,9 @@ import type { DocumentKind } from "@/db/schema"; +import { inspectOoxml } from "./ooxml"; -// Upload validation (security rule: untrusted files). Allow-list by extension AND content signature, -// so a renamed executable or a macro workbook (.xlsm) never enters storage. Messages are user-facing. +// Upload validation (security rule: untrusted files). Allow-list by extension AND content: signature +// for all types, OOXML package structure for .xlsx/.docx (no macros, no foreign ZIPs, no zip bombs). +// Known limit: .msg is checked by its OLE signature only (registered risk). Messages are user-facing. export class UploadRejected extends Error { constructor(message: string) { super(message); @@ -68,5 +70,11 @@ export function classifyUpload(name: string, bytes: Uint8Array, limits: UploadLi if (!SIGNATURE_CHECK[kind](bytes)) { throw new UploadRejected(`Der Inhalt von ${filename} passt nicht zum Dateityp .${kind}.`); } + if (kind === "xlsx" || kind === "docx") { + const check = inspectOoxml(bytes, kind); + if (!check.ok && check.reason === "macros") throw new UploadRejected(`Die Datei ${filename} enthält Makros – nicht erlaubt.`); + if (!check.ok && check.reason === "too-large") throw new UploadRejected(`Die Datei ${filename} ist entpackt zu groß.`); + if (!check.ok) throw new UploadRejected(`Der Inhalt von ${filename} passt nicht zum Dateityp .${kind}.`); + } return { kind, filename, contentType: CONTENT_TYPES[kind] }; } diff --git a/src/features/intake/ooxml.test.ts b/src/features/intake/ooxml.test.ts new file mode 100644 index 0000000..7ba61d9 --- /dev/null +++ b/src/features/intake/ooxml.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { inspectOoxml } from "./ooxml"; +import { makeZip } from "./zip-fixture"; + +const XLSX: Array<[string, number]> = [ + ["[Content_Types].xml", 1000], + ["xl/workbook.xml", 800], + ["xl/worksheets/sheet1.xml", 5000], +]; +const DOCX: Array<[string, number]> = [ + ["[Content_Types].xml", 1000], + ["word/document.xml", 9000], +]; + +describe("inspectOoxml", () => { + it("accepts a well-formed workbook and document", () => { + expect(inspectOoxml(makeZip(XLSX), "xlsx")).toEqual({ ok: true }); + expect(inspectOoxml(makeZip(DOCX), "docx")).toEqual({ ok: true }); + }); + + it("rejects a macro-enabled package renamed to .xlsx / .docx", () => { + expect(inspectOoxml(makeZip([...XLSX, ["xl/vbaProject.bin", 4000]]), "xlsx")).toMatchObject({ ok: false, reason: "macros" }); + expect(inspectOoxml(makeZip([...DOCX, ["word/vbaProject.bin", 4000]]), "docx")).toMatchObject({ ok: false, reason: "macros" }); + expect(inspectOoxml(makeZip([...XLSX, ["xl/macrosheets/sheet1.xml", 10]]), "xlsx")).toMatchObject({ ok: false, reason: "macros" }); + }); + + it("rejects a ZIP that is not the claimed OOXML type (e.g. a .jar or a docx renamed to .xlsx)", () => { + expect(inspectOoxml(makeZip([["META-INF/MANIFEST.MF", 10], ["a.class", 10]]), "xlsx")).toMatchObject({ ok: false, reason: "structure" }); + expect(inspectOoxml(makeZip(DOCX), "xlsx")).toMatchObject({ ok: false, reason: "structure" }); + }); + + it("rejects zip bombs by declared uncompressed size and by entry count", () => { + expect(inspectOoxml(makeZip([...XLSX, ["xl/media/huge.bin", 0xfffffff0]]), "xlsx")).toMatchObject({ ok: false, reason: "too-large" }); + const many: Array<[string, number]> = Array.from({ length: 3000 }, (_, i) => [`xl/x${i}.xml`, 1]); + expect(inspectOoxml(makeZip([...XLSX, ...many]), "xlsx")).toMatchObject({ ok: false, reason: "too-large" }); + }); + + it("rejects truncated or garbage archives", () => { + expect(inspectOoxml(new Uint8Array([0x50, 0x4b, 3, 4, 0, 0]), "xlsx")).toMatchObject({ ok: false, reason: "structure" }); + }); +}); diff --git a/src/features/intake/ooxml.ts b/src/features/intake/ooxml.ts new file mode 100644 index 0000000..5f5b721 --- /dev/null +++ b/src/features/intake/ooxml.ts @@ -0,0 +1,47 @@ +// Structure check of Office Open XML uploads (.xlsx/.docx) from the ZIP central directory only – +// nothing is decompressed. Rejects macro packages, foreign ZIPs (.jar, renamed types) and zip bombs +// by declared size/entry count, before the file reaches storage or the AI service. +export type OoxmlCheck = { ok: true } | { ok: false; reason: "structure" | "macros" | "too-large" }; + +const MAX_ENTRIES = 2000; +const MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024; +const MAIN_PART = { xlsx: "xl/workbook.xml", docx: "word/document.xml" } as const; +const MACRO_PARTS = /(^|\/)vbaProject\.bin$|^xl\/macrosheets\/|(^|\/)activeX\//i; + +export function inspectOoxml(bytes: Uint8Array, kind: "xlsx" | "docx"): OoxmlCheck { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + // End of central directory: last 22 bytes plus up to 64 KiB comment. + let eocd = -1; + for (let at = bytes.length - 22; at >= Math.max(0, bytes.length - 22 - 0xffff); at--) { + if (view.getUint32(at, true) === 0x06054b50) { + eocd = at; + break; + } + } + if (eocd < 0) return { ok: false, reason: "structure" }; + const entries = view.getUint16(eocd + 10, true); + const cdSize = view.getUint32(eocd + 12, true); + const cdOffset = view.getUint32(eocd + 16, true); + if (entries === 0xffff || cdOffset === 0xffffffff) return { ok: false, reason: "structure" }; // ZIP64: not expected here + if (entries > MAX_ENTRIES) return { ok: false, reason: "too-large" }; + if (cdOffset + cdSize > eocd) return { ok: false, reason: "structure" }; + + const decoder = new TextDecoder("utf-8", { fatal: false }); + const names = new Set(); + let total = 0; + let at = cdOffset; + for (let index = 0; index < entries; index++) { + if (at + 46 > eocd || view.getUint32(at, true) !== 0x02014b50) return { ok: false, reason: "structure" }; + total += view.getUint32(at + 24, true); + const nameLength = view.getUint16(at + 28, true); + const extraLength = view.getUint16(at + 30, true); + const commentLength = view.getUint16(at + 32, true); + if (at + 46 + nameLength > eocd) return { ok: false, reason: "structure" }; + names.add(decoder.decode(bytes.subarray(at + 46, at + 46 + nameLength))); + at += 46 + nameLength + extraLength + commentLength; + } + if (total > MAX_UNCOMPRESSED_BYTES) return { ok: false, reason: "too-large" }; + for (const name of names) if (MACRO_PARTS.test(name)) return { ok: false, reason: "macros" }; + if (!names.has("[Content_Types].xml") || !names.has(MAIN_PART[kind])) return { ok: false, reason: "structure" }; + return { ok: true }; +} diff --git a/src/features/intake/submit.ts b/src/features/intake/submit.ts index 9fa272c..769d9f1 100644 --- a/src/features/intake/submit.ts +++ b/src/features/intake/submit.ts @@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto"; import { recordAudit } from "@/features/audit"; import { insertDocuments, type NewDocument } from "@/features/documents"; import { authorize, type Actor } from "@/features/identity"; -import { enqueueRequestProcessing } from "@/features/jobs"; -import { createRequest, findDuplicate } from "@/features/requests"; +import { enqueueRequestProcessing, type JobSender } from "@/features/jobs"; +import { createRequest, findDuplicate, lockDuplicateDetection } from "@/features/requests"; import { S3BlobStore } from "@/features/storage"; import type { Tenancy } from "@/features/tenancy"; import { classifyUpload, UploadRejected, type UploadLimits } from "./files"; @@ -13,7 +13,7 @@ import { parseMailHeaders } from "./mail-headers"; export interface IntakeDeps { tenancy: Tenancy; storage: S3BlobStore; - boss: Pick; + boss: JobSender; limits: UploadLimits & { maxFiles: number }; } @@ -65,6 +65,7 @@ export async function submitUpload(deps: IntakeDeps, actor: Actor, files: Upload stored.push(document.storageKey); } return await deps.tenancy.withTenant(actor.companyId, async (tx) => { + await lockDuplicateDetection(tx); const duplicate = await findDuplicate(tx, { messageId: headers.messageId, fingerprint }); await createRequest(tx, { id: requestId, @@ -87,7 +88,14 @@ export async function submitUpload(deps: IntakeDeps, actor: Actor, files: Upload return { requestId, possibleDuplicate: duplicate !== null, duplicateOfId: duplicate?.id ?? null }; }); } catch (error) { - await Promise.allSettled(stored.map((key) => deps.storage.delete(key))); + // Nothing references these objects: remove them. A failed delete leaves an orphan without any + // row pointing to it – logged by key (IDs only) for a later cleanup sweep. + const results = await Promise.allSettled(stored.map((key) => deps.storage.delete(key))); + results.forEach((result, index) => { + if (result.status === "rejected") { + console.error(JSON.stringify({ level: "error", module: "intake", message: "orphaned object", key: stored[index] })); + } + }); throw error; } } diff --git a/src/features/intake/zip-fixture.ts b/src/features/intake/zip-fixture.ts new file mode 100644 index 0000000..144443b --- /dev/null +++ b/src/features/intake/zip-fixture.ts @@ -0,0 +1,53 @@ +// Test fixture helper (used by *.test.ts only): builds synthetic ZIP archives. +// Minimal ZIP writer (stored entries, CRC not checked by the inspector) – synthetic fixtures only. +export function makeZip(entries: Array<[string, number]>): Uint8Array { + const enc = new TextEncoder(); + const locals: Uint8Array[] = []; + const centrals: Uint8Array[] = []; + let offset = 0; + for (const [name, size] of entries) { + const nameBytes = enc.encode(name); + const local = new Uint8Array(30 + nameBytes.length); + const lv = new DataView(local.buffer); + lv.setUint32(0, 0x04034b50, true); + lv.setUint32(18, 0, true); + lv.setUint32(22, size, true); + lv.setUint16(26, nameBytes.length, true); + local.set(nameBytes, 30); + const central = new Uint8Array(46 + nameBytes.length); + const cv = new DataView(central.buffer); + cv.setUint32(0, 0x02014b50, true); + cv.setUint32(20, 0, true); + cv.setUint32(24, size, true); + cv.setUint16(28, nameBytes.length, true); + cv.setUint32(42, offset, true); + central.set(nameBytes, 46); + locals.push(local); + centrals.push(central); + offset += local.length; + } + const cdSize = centrals.reduce((sum, c) => sum + c.length, 0); + const eocd = new Uint8Array(22); + const ev = new DataView(eocd.buffer); + ev.setUint32(0, 0x06054b50, true); + ev.setUint16(8, entries.length, true); + ev.setUint16(10, entries.length, true); + ev.setUint32(12, cdSize, true); + ev.setUint32(16, offset, true); + const out = new Uint8Array(offset + cdSize + 22); + let at = 0; + for (const part of [...locals, ...centrals, eocd]) { + out.set(part, at); + at += part.length; + } + return out; +} + +export const MINIMAL_XLSX: Array<[string, number]> = [ + ["[Content_Types].xml", 1000], + ["xl/workbook.xml", 800], +]; +export const MINIMAL_DOCX: Array<[string, number]> = [ + ["[Content_Types].xml", 1000], + ["word/document.xml", 900], +]; diff --git a/src/features/jobs/boss.ts b/src/features/jobs/boss.ts index 81462a8..ccb42fb 100644 --- a/src/features/jobs/boss.ts +++ b/src/features/jobs/boss.ts @@ -1,66 +1,15 @@ -import { sql } from "drizzle-orm"; -import { fromDrizzle, PgBoss } from "pg-boss"; +import { sendInTransaction, type JobSender } from "@/db/job-queue"; import { tenantOf, type TenantTx } from "@/features/tenancy"; -import { PGBOSS_SCHEMA, QUEUE_DEFINITIONS, QUEUES, type RequestJob } from "./queues"; +import { QUEUES, type RequestJob } from "./queues"; -/** - * pg-boss client for the runtime role (`app_rw`): no schema creation, no migration, no supervision – - * those belong to the deploy step (`installJobQueues`) and the worker. Polling, no LISTEN/NOTIFY - * (ADR-0001 D3/D4). - */ -export async function createJobClient(connectionString: string, options: { supervise?: boolean } = {}): Promise { - const boss = new PgBoss({ - connectionString, - schema: PGBOSS_SCHEMA, - max: 4, - migrate: false, - createSchema: false, - supervise: options.supervise ?? false, - schedule: false, - }); - boss.on("error", (error: Error) => console.error(JSON.stringify({ level: "error", module: "jobs", message: error.message }))); - await boss.start(); - return boss; -} +export type { JobSender }; /** - * Enqueues processing of a request IN the caller's tenant transaction (transactional outbox without - * an outbox table): the job exists exactly when the transaction commits. + * Enqueues processing of a request IN the caller's tenant transaction: the job exists exactly when + * the request row commits. Payload: IDs only. `singletonKey = requestId` + queue policy `exclusive` + * → at most one queued-or-active job per request (ADR-0001 D4). */ -export async function enqueueRequestProcessing(boss: Pick, tx: TenantTx, requestId: string): Promise { +export async function enqueueRequestProcessing(queue: JobSender, tx: TenantTx, requestId: string): Promise { const job: RequestJob = { requestId, companyId: tenantOf(tx) }; - return boss.send(QUEUES.processRequest, job, { db: fromDrizzle(tx, sql), singletonKey: requestId }); -} - -/** Deploy step, owner role: install/upgrade the pg-boss schema and queues, grant the runtime role. */ -export async function installJobQueues(ownerConnectionString: string): Promise { - const boss = new PgBoss({ - connectionString: ownerConnectionString, - schema: PGBOSS_SCHEMA, - max: 2, - migrate: true, - supervise: false, - schedule: false, - }); - boss.on("error", () => {}); - await boss.start(); - try { - for (const { name, policy, ...options } of QUEUE_DEFINITIONS) { - // The policy is fixed at creation; retries, expiry and dead letter can be updated in place. - if (await boss.getQueue(name)) await boss.updateQueue(name, options); - else await boss.createQueue(name, { policy, ...options }); - } - const db = boss.getDb(); - for (const statement of [ - `GRANT USAGE ON SCHEMA ${PGBOSS_SCHEMA} TO app_rw`, - `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${PGBOSS_SCHEMA} TO app_rw`, - `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${PGBOSS_SCHEMA} TO app_rw`, - `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ${PGBOSS_SCHEMA} TO app_rw`, - `ALTER DEFAULT PRIVILEGES IN SCHEMA ${PGBOSS_SCHEMA} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw`, - ]) { - await db.executeSql(statement); - } - } finally { - await boss.stop({ graceful: false }); - } + return sendInTransaction(queue, tx, QUEUES.processRequest, job, { singletonKey: requestId }); } diff --git a/src/features/jobs/index.ts b/src/features/jobs/index.ts index cddab5b..3db3dce 100644 --- a/src/features/jobs/index.ts +++ b/src/features/jobs/index.ts @@ -1,3 +1,4 @@ -// Public API of the `jobs` module: pg-boss queues, transactional enqueue (worker + drain(): #7). -export { createJobClient, enqueueRequestProcessing, installJobQueues } from "./boss"; -export { QUEUES, PGBOSS_SCHEMA, type QueueName, type RequestJob } from "./queues"; +// Public API of the `jobs` module: queue definitions, transactional enqueue (worker + drain(): #7). +// The pg-boss client itself is created in src/db (it opens a pool) and injected. +export { enqueueRequestProcessing, type JobSender } from "./boss"; +export { QUEUE_DEFINITIONS, QUEUES, type QueueName, type RequestJob } from "./queues"; diff --git a/src/features/jobs/queues.ts b/src/features/jobs/queues.ts index 48f5885..b92aad4 100644 --- a/src/features/jobs/queues.ts +++ b/src/features/jobs/queues.ts @@ -2,8 +2,6 @@ import type { Queue } from "pg-boss"; // Queue definitions (ADR-0001 D4). Installed by the deploy step as the owner role; the runtime role // only sends, fetches and completes jobs. Payloads carry IDs only – never document content. -export const PGBOSS_SCHEMA = "pgboss"; - export const QUEUES = { processRequest: "request-process", processRequestDead: "request-process-dead", diff --git a/src/features/requests/index.ts b/src/features/requests/index.ts index 8cce744..84fa3ff 100644 --- a/src/features/requests/index.ts +++ b/src/features/requests/index.ts @@ -1,2 +1,2 @@ // Public API of the `requests` module: request aggregate (status machine follows with #7). -export { createRequest, findDuplicate, getRequest, listRequests, type NewRequest, type RequestRow } from "./repository"; +export { createRequest, findDuplicate, lockDuplicateDetection, getRequest, listRequests, type NewRequest, type RequestRow } from "./repository"; diff --git a/src/features/requests/repository.ts b/src/features/requests/repository.ts index 04e00b0..171bced 100644 --- a/src/features/requests/repository.ts +++ b/src/features/requests/repository.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, or, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, or, sql, type SQL } from "drizzle-orm"; import { requests, type RequestStatus } from "@/db/schema"; import { tenantOf, type TenantTx } from "@/features/tenancy"; @@ -34,6 +34,15 @@ export async function createRequest(tx: TenantTx, input: NewRequest = {}): Promi return row; } +/** + * Serialises duplicate detection per company for the rest of the transaction, so two identical + * uploads at the same moment cannot both miss each other. + */ +export async function lockDuplicateDetection(tx: TenantTx): Promise { + const companyId = tenantOf(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`intake-duplicates:${companyId}`}, 0))`); +} + /** * The earliest request of the same company with the same Message-ID or the same file fingerprint * (exact duplicate, ADR-0001 D9). Tenant-scoped by RLS: other companies' requests never match. diff --git a/src/features/storage/s3-blob-store.ts b/src/features/storage/s3-blob-store.ts index 369437a..33b5700 100644 --- a/src/features/storage/s3-blob-store.ts +++ b/src/features/storage/s3-blob-store.ts @@ -70,6 +70,13 @@ export class S3BlobStore { return response.Body.transformToByteArray(); } + /** Streams an object (downloads never buffer the whole file). */ + async stream(key: string): Promise<{ body: ReadableStream; length: number | undefined }> { + const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); + if (!response.Body) throw new Error("empty object body"); + return { body: response.Body.transformToWebStream() as ReadableStream, length: response.ContentLength }; + } + async delete(key: string): Promise { await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); } diff --git a/src/setup.ts b/src/setup.ts index 66dd97a..03d2542 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -4,7 +4,8 @@ import { setTimeout as sleep } from "node:timers/promises"; import { loadConfig } from "@/config/env"; import { runMigrations } from "@/db/migrate"; -import { installJobQueues } from "@/features/jobs"; +import { installJobQueues } from "@/db/job-queue"; +import { QUEUE_DEFINITIONS } from "@/features/jobs"; import { S3BlobStore } from "@/features/storage"; const ATTEMPTS = 30; @@ -43,7 +44,7 @@ async function main(): Promise { const config = loadConfig(); await withRetry("migrations", () => runMigrations(migrationUrl)); - await withRetry("job queues", () => installJobQueues(migrationUrl)); + await withRetry("job queues", () => installJobQueues(migrationUrl, QUEUE_DEFINITIONS)); const store = new S3BlobStore(config.storage); try { await withRetry("bucket", () => store.ensureBucket()); diff --git a/tests/integration/intake.test.ts b/tests/integration/intake.test.ts index 688f4f4..fd1f709 100644 --- a/tests/integration/intake.test.ts +++ b/tests/integration/intake.test.ts @@ -5,7 +5,8 @@ import { listAuditEvents } from "@/features/audit"; import { listDocuments } from "@/features/documents"; import { getActor, type Actor } from "@/features/identity"; import { submitUpload, UploadRejected, type IntakeDeps } from "@/features/intake"; -import { createJobClient, QUEUES } from "@/features/jobs"; +import { createJobQueue } from "@/db/job-queue"; +import { QUEUES } from "@/features/jobs"; import { getRequest } from "@/features/requests"; import { S3BlobStore } from "@/features/storage"; import { createTenancy } from "@/features/tenancy"; @@ -39,7 +40,7 @@ describe("intake: upload a request and enqueue processing atomically", () => { stack = createStack(); const config = loadConfig(); storage = new S3BlobStore(config.storage); - boss = await createJobClient(config.databaseUrl); + boss = await createJobQueue(config.databaseUrl); deps = { tenancy: createTenancy(stack.database.db), storage, boss, limits: { maxFileBytes: 1024 * 1024, maxFiles: 5 } }; const a = await companyWithAdmin(stack); diff --git a/tests/integration/setup/global-setup.ts b/tests/integration/setup/global-setup.ts index 1e136e0..f265a6d 100644 --- a/tests/integration/setup/global-setup.ts +++ b/tests/integration/setup/global-setup.ts @@ -1,5 +1,6 @@ import { runMigrations } from "@/db/migrate"; -import { installJobQueues } from "@/features/jobs"; +import { installJobQueues } from "@/db/job-queue"; +import { QUEUE_DEFINITIONS } from "@/features/jobs"; import { S3BlobStore } from "@/features/storage"; import { loadConfig } from "@/config/env"; @@ -9,7 +10,7 @@ export default async function setup(): Promise { const migrationUrl = process.env.MIGRATION_DATABASE_URL; if (!migrationUrl) throw new Error("MIGRATION_DATABASE_URL is required for integration tests"); await runMigrations(migrationUrl); - await installJobQueues(migrationUrl); + await installJobQueues(migrationUrl, QUEUE_DEFINITIONS); const store = new S3BlobStore(loadConfig().storage); try { From 2a0b891e23ff94402271c16a066bb492437de1ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:19:03 +0000 Subject: [PATCH 19/21] fix(intake): address the fresh security review of PR #32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pg-boss client and queue install move to src/db (job-queue.ts); least-privilege grants; depcruise rule - upload: Content-Length required (411), request cap (413), OOXML structure check (macros, foreign ZIPs, zip bombs) - duplicate detection serialised per company (advisory lock); orphaned objects logged; streaming download - composite same-company FKs declared in the Drizzle schema (migration 0005, fresh-DB tested) - tests: job row proven inside the rolled-back transaction, audit rolled back, 411/413/too many files - test helper: random IPv6 per call – no rate-limit bleed across test files - docs: api.md limits, exceptions register (upload rate limit, .msg check) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- docs/technical/api.md | 11 +- docs/technical/architecture.md | 2 + .../0005_intake_same_company_fks.sql | 8 + src/db/migrations/meta/0005_snapshot.json | 1222 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + tests/integration/helpers/stack.ts | 10 +- tests/integration/intake.test.ts | 8 +- tests/integration/upload-routes.test.ts | 20 +- 8 files changed, 1279 insertions(+), 9 deletions(-) create mode 100644 src/db/migrations/0005_intake_same_company_fks.sql create mode 100644 src/db/migrations/meta/0005_snapshot.json diff --git a/docs/technical/api.md b/docs/technical/api.md index 4db2def..86bd935 100644 --- a/docs/technical/api.md +++ b/docs/technical/api.md @@ -20,18 +20,23 @@ ## `POST /api/requests` (session required) -Multipart form, field `files` (1–`UPLOAD_MAX_FILES` files, each ≤ `UPLOAD_MAX_FILE_BYTES`). Allowed: -`.eml .msg .pdf .xlsx .docx`, checked by extension **and** content signature. +Multipart form, field `files` (1–`UPLOAD_MAX_FILES` files, each ≤ `UPLOAD_MAX_FILE_BYTES`, whole request +≤ `UPLOAD_MAX_REQUEST_BYTES`, `Content-Length` required). Allowed: `.eml .msg .pdf .xlsx .docx`, checked +by extension **and** content: signature for all; for `.xlsx/.docx` also the package structure (no +macros, no foreign ZIPs, declared size/entry limits). `.msg` is checked by its OLE signature only. | Status | Body | |---|---| | 201 | `{"requestId": uuid, "possibleDuplicate": bool, "duplicateOfId": uuid \| null}` | | 401 / 403 | `{"error":{"title":"…"}}` – not signed in / role | -| 413 | declared body larger than files × size limit | +| 411 | no numeric `Content-Length` (the body is bounded before it is read) | +| 413 | declared body larger than `UPLOAD_MAX_REQUEST_BYTES` | | 422 | `{"error":{"title":"Dateityp nicht erlaubt: …"}}` – user-facing reason | | 500 | generic message; details only as IDs in the log | Request (NEW), documents, audit event and the `request-process` job commit in one transaction. +Duplicates: same `Message-ID` (read from `.eml` only – `.msg` Message-ID extraction is a follow-up) +or the same set of file hashes within the company; checks are serialised per company. ## `GET /api/documents/:id` (session required) diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 9087411..49b2913 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -51,6 +51,8 @@ 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) | +| Upload endpoint without a per-user rate limit | Authenticated staff only; body bounded by `Content-Length` + `UPLOAD_MAX_REQUEST_BYTES` before reading | Fluory | before any public deployment (#19) | +| `.msg` uploads checked by OLE signature only | Structure check of Outlook messages needs a CFB parser; files are served only as attachments with `nosniff` and parsed later by the stateless AI service | Fluory | with #23 (MSG parsing) | | 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/src/db/migrations/0005_intake_same_company_fks.sql b/src/db/migrations/0005_intake_same_company_fks.sql new file mode 100644 index 0000000..dd278bf --- /dev/null +++ b/src/db/migrations/0005_intake_same_company_fks.sql @@ -0,0 +1,8 @@ +-- Generated by drizzle-kit; edited: the UNIQUE (id, company_id) must exist before the FKs that use it. +ALTER TABLE "app"."documents" DROP CONSTRAINT "documents_request_id_requests_id_fk"; +--> statement-breakpoint +ALTER TABLE "app"."requests" DROP CONSTRAINT "requests_duplicate_of_id_requests_id_fk"; +--> statement-breakpoint +ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_id_company_unique" UNIQUE("id","company_id");--> statement-breakpoint +ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_request_same_company_fk" FOREIGN KEY ("request_id","company_id") REFERENCES "app"."requests"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_duplicate_same_company_fk" FOREIGN KEY ("duplicate_of_id","company_id") REFERENCES "app"."requests"("id","company_id") ON DELETE no action ON UPDATE no action; diff --git a/src/db/migrations/meta/0005_snapshot.json b/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..b460bd5 --- /dev/null +++ b/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1222 @@ +{ + "id": "6dd7be4b-8830-4b08-8aeb-333d5afc540b", + "prevId": "d257ca2a-1a2c-482f-a651-21d7d15408fa", + "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.audit_events": { + "name": "audit_events", + "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 + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_entity_idx": { + "name": "audit_events_entity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_company_id_organization_id_fk": { + "name": "audit_events_company_id_organization_id_fk", + "tableFrom": "audit_events", + "tableTo": "organization", + "schemaTo": "auth", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "audit_events_tenant_isolation": { + "name": "audit_events_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": {}, + "isRLSEnabled": true + }, + "app.documents": { + "name": "documents", + "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 + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_id_idx": { + "name": "documents_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_request_id_idx": { + "name": "documents_request_id_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_organization_id_fk": { + "name": "documents_company_id_organization_id_fk", + "tableFrom": "documents", + "tableTo": "organization", + "schemaTo": "auth", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "documents_request_same_company_fk": { + "name": "documents_request_same_company_fk", + "tableFrom": "documents", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "documents_tenant_isolation": { + "name": "documents_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": { + "documents_kind_check": { + "name": "documents_kind_check", + "value": "kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')" + } + }, + "isRLSEnabled": true + }, + "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()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upload'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "possible_duplicate": { + "name": "possible_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "duplicate_of_id": { + "name": "duplicate_of_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "requests_company_message_id_idx": { + "name": "requests_company_message_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_company_fingerprint_idx": { + "name": "requests_company_fingerprint_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "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" + }, + "requests_duplicate_same_company_fk": { + "name": "requests_duplicate_same_company_fk", + "tableFrom": "requests", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "duplicate_of_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requests_id_company_unique": { + "name": "requests_id_company_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "company_id" + ] + } + }, + "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 168b673..4d58b3c 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1790142958057, "tag": "0004_intake_force_rls_audit", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1790144232796, + "tag": "0005_intake_same_company_fks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/tests/integration/helpers/stack.ts b/tests/integration/helpers/stack.ts index ce8a875..b6b8f0f 100644 --- a/tests/integration/helpers/stack.ts +++ b/tests/integration/helpers/stack.ts @@ -22,9 +22,13 @@ 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)}`; +/** + * A fresh client IP per call, so the per-IP rate limit of one test never bleeds into another – + * across test files too. Random /64 prefixes in the IPv6 documentation range (Better Auth collapses + * IPv6 to /64 before keying). + */ +const group = () => Math.floor(Math.random() * 0x10000).toString(16); +export const freshIp = () => `2001:db8:${group()}:${group()}::1`; export async function call( auth: Auth, diff --git a/tests/integration/intake.test.ts b/tests/integration/intake.test.ts index fd1f709..d35df35 100644 --- a/tests/integration/intake.test.ts +++ b/tests/integration/intake.test.ts @@ -82,12 +82,15 @@ describe("intake: upload a request and enqueue processing atomically", () => { it("rolls back request, documents and job when a failure happens after the inserts, and removes the stored objects", async () => { let requestId = ""; + let jobsInsideTransaction = -1; const keys: string[] = []; const failingBoss: Pick = { send: (async (...args: Parameters) => { - const [, data] = args as [string, { requestId: string }]; + const [, data, options] = args as [string, { requestId: string }, { db: { executeSql(t: string, v: unknown[]): Promise<{ rows: Array<{ n: number }> }> } }]; requestId = data.requestId; await (boss.send as (...a: unknown[]) => Promise)(...args); // the job row is really inserted … + const seen = await options.db.executeSql("select count(*)::int as n from pgboss.job where singleton_key = $1", [requestId]); + jobsInsideTransaction = seen.rows[0]!.n; // … visible inside the transaction … throw new Error("injected failure after the job insert"); // … and then the transaction fails }) as unknown as PgBoss["send"], }; @@ -104,7 +107,10 @@ describe("intake: upload a request and enqueue processing atomically", () => { ).rejects.toThrow(/injected failure/); expect(requestId).not.toBe(""); + expect(jobsInsideTransaction).toBe(1); expect(await requestExists(clerkA, requestId)).toBe(false); + const audit = await deps.tenancy.withTenant(clerkA.companyId, (tx) => listAuditEvents(tx, "request", requestId)); + expect(audit).toHaveLength(0); expect(await jobCount(requestId)).toBe(0); const documents = await deps.tenancy.withTenant(clerkA.companyId, (tx) => listDocuments(tx, requestId)); expect(documents).toHaveLength(0); diff --git a/tests/integration/upload-routes.test.ts b/tests/integration/upload-routes.test.ts index 1e94b3e..cbb3da1 100644 --- a/tests/integration/upload-routes.test.ts +++ b/tests/integration/upload-routes.test.ts @@ -14,10 +14,17 @@ describe("upload and download routes", () => { let companyA: string; const pdf = (marker: string) => new File([`%PDF-1.7\n% synthetic ${marker}\n`], "anfrage.pdf", { type: "application/pdf" }); - const post = (cookie: string | null, files: File[]) => { + // Serialise like a browser does, including Content-Length (the route refuses uploads without it). + const post = async (cookie: string | null, files: File[], override: { contentLength?: string | null } = {}) => { const form = new FormData(); for (const file of files) form.append("files", file); - return upload(new Request("http://localhost:3000/api/requests", { method: "POST", body: form, headers: cookie ? { cookie } : {} })); + const encoded = new Response(form); + const body = new Uint8Array(await encoded.arrayBuffer()); + const headers = new Headers({ "content-type": encoded.headers.get("content-type")! }); + const length = override.contentLength === undefined ? String(body.byteLength) : override.contentLength; + if (length !== null) headers.set("content-length", length); + if (cookie) headers.set("cookie", cookie); + return upload(new Request("http://localhost:3000/api/requests", { method: "POST", body, headers })); }; const get = (cookie: string | null, id: string) => download(new Request(`http://localhost:3000/api/documents/${id}`, { headers: cookie ? { cookie } : {} }), { @@ -67,6 +74,15 @@ describe("upload and download routes", () => { expect(((await rejected.json()) as { error: { title: string } }).error.title).toMatch(/Dateityp nicht erlaubt/); }); + it("refuses uploads without Content-Length (411), above the request cap (413) and with too many files (422)", async () => { + expect((await post(cookieA, [pdf("a")], { contentLength: null })).status).toBe(411); + expect((await post(cookieA, [pdf("b")], { contentLength: String(1024 * 1024 * 1024) })).status).toBe(413); + + const tooMany = await post(cookieA, Array.from({ length: 11 }, (_, i) => pdf(`many-${i}`))); + + expect(tooMany.status).toBe(422); + }); + it("answers 404 for a malformed document id", async () => { expect((await get(cookieA, "../../etc/passwd")).status).toBe(404); }); From a162aa9592890502e34f05a9557ede6c6c5bb31a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:20:47 +0000 Subject: [PATCH 20/21] test: rate-limit assertion matches Better Auth's expanded IPv6 key Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- tests/integration/helpers/stack.ts | 4 +++- tests/integration/identity.test.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/integration/helpers/stack.ts b/tests/integration/helpers/stack.ts index b6b8f0f..d668cd2 100644 --- a/tests/integration/helpers/stack.ts +++ b/tests/integration/helpers/stack.ts @@ -27,8 +27,10 @@ export const PASSWORD = "synthetic-password-123"; * across test files too. Random /64 prefixes in the IPv6 documentation range (Better Auth collapses * IPv6 to /64 before keying). */ -const group = () => Math.floor(Math.random() * 0x10000).toString(16); +const group = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0"); export const freshIp = () => `2001:db8:${group()}:${group()}::1`; +/** How Better Auth keys an IPv6 client in `auth.rate_limit` (expanded /64 prefix). */ +export const rateLimitKeyPrefix = (ip: string) => `2001:0db8:${ip.split(":")[2]}:${ip.split(":")[3]}:`; export async function call( auth: Auth, diff --git a/tests/integration/identity.test.ts b/tests/integration/identity.test.ts index 572fab7..6984ef1 100644 --- a/tests/integration/identity.test.ts +++ b/tests/integration/identity.test.ts @@ -8,6 +8,7 @@ import { createStack, freshIp, invitedUser, + rateLimitKeyPrefix, signIn, signUp, syntheticEmail, @@ -193,7 +194,7 @@ describe("identity: invite-only login and companies", () => { 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}%`}`); + const rows = await limited.database.db.select().from(schema.rateLimit).where(sql`${schema.rateLimit.key} like ${`${rateLimitKeyPrefix(ip)}%`}`); expect(rows.length).toBeGreaterThan(0); } finally { await limited.close(); From d805a9a982c0649f38b7febf9f8b7167da91c95c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:25:19 +0000 Subject: [PATCH 21/21] refactor(db): pg-boss pool factory in its own file, covered by the no-db-connection rule Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- .dependency-cruiser.cjs | 2 +- src/app/_server/runtime.ts | 2 +- src/db/job-queue-client.ts | 64 ++++++++++++++++++ src/db/job-queue.ts | 66 +------------------ src/setup.ts | 2 +- tests/architecture/dependency-rules.test.ts | 7 ++ .../depcruise/src/db/job-queue-client.ts | 3 + .../depcruise/src/features/eta/index.ts | 4 ++ tests/integration/intake.test.ts | 2 +- tests/integration/setup/global-setup.ts | 2 +- 10 files changed, 86 insertions(+), 68 deletions(-) create mode 100644 src/db/job-queue-client.ts create mode 100644 tests/fixtures/depcruise/src/db/job-queue-client.ts create mode 100644 tests/fixtures/depcruise/src/features/eta/index.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index eb83fae..cd7fe68 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -29,7 +29,7 @@ module.exports = { severity: "error", comment: "Feature modules never open connections or run migrations: createDatabase()/runMigrations() belong to the composition roots (src/app/_server, src/*.ts entrypoints). Types and table definitions (src/db/schema) are fine.", from: { path: "^src/features/" }, - to: { path: "^src/db/(index|client|migrate)\\.ts$", dependencyTypesNot: ["type-only"] }, + to: { path: "^src/db/(index|client|migrate|job-queue-client)\\.ts$", dependencyTypesNot: ["type-only"] }, }, { name: "pg-boss-client-only-in-db", diff --git a/src/app/_server/runtime.ts b/src/app/_server/runtime.ts index 1c23e6f..a66ad60 100644 --- a/src/app/_server/runtime.ts +++ b/src/app/_server/runtime.ts @@ -1,6 +1,6 @@ import { loadConfig, type AppConfig } from "@/config/env"; import { createDatabase, type DatabaseHandle } from "@/db"; -import { createJobQueue } from "@/db/job-queue"; +import { createJobQueue } from "@/db/job-queue-client"; import { createAuth, getActor, type Actor, type Auth } from "@/features/identity"; import { S3BlobStore } from "@/features/storage"; import { createTenancy, type Tenancy } from "@/features/tenancy"; diff --git a/src/db/job-queue-client.ts b/src/db/job-queue-client.ts new file mode 100644 index 0000000..d0de6b0 --- /dev/null +++ b/src/db/job-queue-client.ts @@ -0,0 +1,64 @@ +import { PgBoss, type Queue } from "pg-boss"; + +// pg-boss opens its own pool, so its construction lives in src/db with the other database clients +// (AGENTS.md: no raw DB client outside src/db and tenancy) and is called only by composition roots +// (web runtime, worker, deploy step, tests). Feature modules get the instance injected. +export const PGBOSS_SCHEMA = "pgboss"; + +/** Runtime client (`app_rw`): no schema creation or migration; polling, no LISTEN/NOTIFY. */ +export async function createJobQueue(connectionString: string, options: { supervise?: boolean } = {}): Promise { + const boss = new PgBoss({ + connectionString, + schema: PGBOSS_SCHEMA, + max: 4, + migrate: false, + createSchema: false, + supervise: options.supervise ?? false, + schedule: false, + }); + boss.on("error", (error: Error) => console.error(JSON.stringify({ level: "error", module: "jobs", message: error.message }))); + await boss.start(); + return boss; +} + +/** + * Deploy step, owner role: install/upgrade the pg-boss schema and queues, then grant the runtime role + * the least it needs – job rows read/write, queue and version read, queue statistics maintenance. + */ +export async function installJobQueues(ownerConnectionString: string, definitions: Queue[]): Promise { + const boss = new PgBoss({ + connectionString: ownerConnectionString, + schema: PGBOSS_SCHEMA, + max: 2, + migrate: true, + supervise: false, + schedule: false, + }); + boss.on("error", () => {}); + await boss.start(); + try { + for (const { name, policy, ...options } of definitions) { + // The policy is fixed at creation; retries, expiry and dead letter can be updated in place. + if (await boss.getQueue(name)) await boss.updateQueue(name, options); + else await boss.createQueue(name, { policy, ...options }); + } + const db = boss.getDb(); + const s = PGBOSS_SCHEMA; + for (const statement of [ + `REVOKE ALL ON ALL TABLES IN SCHEMA ${s} FROM app_rw`, + `GRANT USAGE ON SCHEMA ${s} TO app_rw`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ${s}.job, ${s}.job_common, ${s}.job_dependency, ${s}.warning, ${s}.bam TO app_rw`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ${s}.queue_stats TO app_rw`, + `GRANT SELECT, UPDATE ON ${s}.queue TO app_rw`, + `GRANT SELECT ON ${s}.version, ${s}.schedule, ${s}.subscription TO app_rw`, + `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO app_rw`, + `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ${s} TO app_rw`, + // Partitions of job/queue_stats created later by the owner inherit the table privileges. + `ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw`, + ]) { + await db.executeSql(statement); + } + } finally { + await boss.stop({ graceful: false }); + } +} diff --git a/src/db/job-queue.ts b/src/db/job-queue.ts index 874b20e..04a6f7d 100644 --- a/src/db/job-queue.ts +++ b/src/db/job-queue.ts @@ -1,31 +1,13 @@ import { sql } from "drizzle-orm"; -import { fromDrizzle, PgBoss, type Queue, type SendOptions } from "pg-boss"; +import { fromDrizzle, type PgBoss, type SendOptions } from "pg-boss"; import type { Database } from "./client"; -// pg-boss (ADR-0001 D4) opens its own pool, so it lives here with the other database clients -// (AGENTS.md: no raw DB client outside src/db and tenancy). Feature modules get the instance -// injected and only send/fetch/complete. -export const PGBOSS_SCHEMA = "pgboss"; +// Transactional enqueue helper (ADR-0001 D4). Opening pg-boss pools lives in ./job-queue-client.ts +// (composition roots only – dependency-cruiser rule `no-db-connection-in-features`). type Transaction = Parameters[0]>[0]; export type JobSender = Pick; -/** Runtime client (`app_rw`): no schema creation or migration; polling, no LISTEN/NOTIFY. */ -export async function createJobQueue(connectionString: string, options: { supervise?: boolean } = {}): Promise { - const boss = new PgBoss({ - connectionString, - schema: PGBOSS_SCHEMA, - max: 4, - migrate: false, - createSchema: false, - supervise: options.supervise ?? false, - schedule: false, - }); - boss.on("error", (error: Error) => console.error(JSON.stringify({ level: "error", module: "jobs", message: error.message }))); - await boss.start(); - return boss; -} - /** * Enqueues IN the caller's transaction (transactional outbox without an outbox table): the job * exists exactly when the transaction commits. A job refused by the queue policy is an error. @@ -41,45 +23,3 @@ export async function sendInTransaction( if (!id) throw new Error(`job refused by queue policy: ${name}`); return id; } - -/** - * Deploy step, owner role: install/upgrade the pg-boss schema and queues, then grant the runtime role - * the least it needs – job rows read/write, queue and version read, queue statistics maintenance. - */ -export async function installJobQueues(ownerConnectionString: string, definitions: Queue[]): Promise { - const boss = new PgBoss({ - connectionString: ownerConnectionString, - schema: PGBOSS_SCHEMA, - max: 2, - migrate: true, - supervise: false, - schedule: false, - }); - boss.on("error", () => {}); - await boss.start(); - try { - for (const { name, policy, ...options } of definitions) { - // The policy is fixed at creation; retries, expiry and dead letter can be updated in place. - if (await boss.getQueue(name)) await boss.updateQueue(name, options); - else await boss.createQueue(name, { policy, ...options }); - } - const db = boss.getDb(); - const s = PGBOSS_SCHEMA; - for (const statement of [ - `REVOKE ALL ON ALL TABLES IN SCHEMA ${s} FROM app_rw`, - `GRANT USAGE ON SCHEMA ${s} TO app_rw`, - `GRANT SELECT, INSERT, UPDATE, DELETE ON ${s}.job, ${s}.job_common, ${s}.job_dependency, ${s}.warning, ${s}.bam TO app_rw`, - `GRANT SELECT, INSERT, UPDATE, DELETE ON ${s}.queue_stats TO app_rw`, - `GRANT SELECT, UPDATE ON ${s}.queue TO app_rw`, - `GRANT SELECT ON ${s}.version, ${s}.schedule, ${s}.subscription TO app_rw`, - `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO app_rw`, - `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ${s} TO app_rw`, - // Partitions of job/queue_stats created later by the owner inherit the table privileges. - `ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw`, - ]) { - await db.executeSql(statement); - } - } finally { - await boss.stop({ graceful: false }); - } -} diff --git a/src/setup.ts b/src/setup.ts index 03d2542..00e9618 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -4,7 +4,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { loadConfig } from "@/config/env"; import { runMigrations } from "@/db/migrate"; -import { installJobQueues } from "@/db/job-queue"; +import { installJobQueues } from "@/db/job-queue-client"; import { QUEUE_DEFINITIONS } from "@/features/jobs"; import { S3BlobStore } from "@/features/storage"; diff --git a/tests/architecture/dependency-rules.test.ts b/tests/architecture/dependency-rules.test.ts index fff9700..cb0a494 100644 --- a/tests/architecture/dependency-rules.test.ts +++ b/tests/architecture/dependency-rules.test.ts @@ -46,6 +46,13 @@ describe("module boundary rules", () => { expect(output).toContain("no-db-connection-in-features"); }); + it("rejects a feature module opening its own pg-boss pool", () => { + const { status, output } = cruise("src/features/eta"); + + expect(status).not.toBe(0); + expect(output).toContain("no-db-connection-in-features"); + }); + it("accepts an import through the other module's index.ts", () => { const { status, output } = cruise("src/features/gamma"); diff --git a/tests/fixtures/depcruise/src/db/job-queue-client.ts b/tests/fixtures/depcruise/src/db/job-queue-client.ts new file mode 100644 index 0000000..9ad28c4 --- /dev/null +++ b/tests/fixtures/depcruise/src/db/job-queue-client.ts @@ -0,0 +1,3 @@ +export function createJobQueue() { + return {}; +} diff --git a/tests/fixtures/depcruise/src/features/eta/index.ts b/tests/fixtures/depcruise/src/features/eta/index.ts new file mode 100644 index 0000000..f73b531 --- /dev/null +++ b/tests/fixtures/depcruise/src/features/eta/index.ts @@ -0,0 +1,4 @@ +// Fixture: a feature opening its own pg-boss pool (forbidden). +import { createJobQueue } from "../../db/job-queue-client"; + +export const queue = createJobQueue(); diff --git a/tests/integration/intake.test.ts b/tests/integration/intake.test.ts index d35df35..cd0b204 100644 --- a/tests/integration/intake.test.ts +++ b/tests/integration/intake.test.ts @@ -5,7 +5,7 @@ import { listAuditEvents } from "@/features/audit"; import { listDocuments } from "@/features/documents"; import { getActor, type Actor } from "@/features/identity"; import { submitUpload, UploadRejected, type IntakeDeps } from "@/features/intake"; -import { createJobQueue } from "@/db/job-queue"; +import { createJobQueue } from "@/db/job-queue-client"; import { QUEUES } from "@/features/jobs"; import { getRequest } from "@/features/requests"; import { S3BlobStore } from "@/features/storage"; diff --git a/tests/integration/setup/global-setup.ts b/tests/integration/setup/global-setup.ts index f265a6d..6322551 100644 --- a/tests/integration/setup/global-setup.ts +++ b/tests/integration/setup/global-setup.ts @@ -1,5 +1,5 @@ import { runMigrations } from "@/db/migrate"; -import { installJobQueues } from "@/db/job-queue"; +import { installJobQueues } from "@/db/job-queue-client"; import { QUEUE_DEFINITIONS } from "@/features/jobs"; import { S3BlobStore } from "@/features/storage"; import { loadConfig } from "@/config/env";