🐛 fix(auth): use Better Auth native JWT validation with EdDSA

- Replace jsonwebtoken RS256 validation with jose EdDSA
- Add JWKS endpoint to expose Better Auth public keys
- Use createRemoteJWKSet for token validation
- Fix issuer mismatch (use env var consistently)
- Add jwks table to schema for Better Auth JWT plugin
- Install jose library for JWT verification
This commit is contained in:
Wuesteon 2025-12-01 15:18:57 +01:00
parent 2a002bf6be
commit 8dd1e4326c
10 changed files with 573 additions and 555 deletions

473
pnpm-lock.yaml generated
View file

@ -686,7 +686,7 @@ importers:
version: 17.0.7(expo@54.0.25)(react@19.1.0)
expo-router:
specifier: ~6.0.14
version: 6.0.15(hsxvh2bq7p37jxljkgxire2dbu)
version: 6.0.15(r6e3zsmutyjzazpntzawr4wuhy)
expo-secure-store:
specifier: ~15.0.7
version: 15.0.7(expo@54.0.25)
@ -789,7 +789,7 @@ importers:
version: 7.28.5
'@testing-library/react-native':
specifier: ^13.3.3
version: 13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
version: 13.3.3(jest@29.7.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
'@types/jest':
specifier: ^29.5.12
version: 29.5.14
@ -807,10 +807,10 @@ importers:
version: 10.0.0
jest:
specifier: ^29.2.1
version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
version: 29.7.0
jest-expo:
specifier: ~54.0.13
version: 54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
version: 54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.7.0)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
patch-package:
specifier: ^8.0.0
version: 8.0.1
@ -3277,7 +3277,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^10.4.9
version: 10.4.9(esbuild@0.19.12)
version: 10.4.9(esbuild@0.27.0)
'@nestjs/schematics':
specifier: ^10.2.3
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
@ -3310,7 +3310,7 @@ importers:
version: 0.5.21
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12))
version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.0))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -4103,7 +4103,7 @@ importers:
version: 0.5.21
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2)
@ -4764,7 +4764,7 @@ importers:
version: 1.57.0
jest:
specifier: ^29.0.0
version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
version: 29.7.0(@types/node@24.10.1)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
@ -4845,6 +4845,9 @@ importers:
helmet:
specifier: ^8.0.0
version: 8.1.0
jose:
specifier: ^6.1.2
version: 6.1.2
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
@ -4875,7 +4878,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^11.0.0
version: 11.0.12(@types/node@22.19.1)
version: 11.0.12(@types/node@22.19.1)(esbuild@0.19.12)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
@ -4929,10 +4932,10 @@ importers:
version: 7.1.4
ts-jest:
specifier: ^29.2.5
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2)
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -25575,7 +25578,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.18.3
optionalDependencies:
expo-router: 6.0.15(hsxvh2bq7p37jxljkgxire2dbu)
expo-router: 6.0.15(ohit2up6tuxb3x34brxduivol4)
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -27085,6 +27088,41 @@ snapshots:
jest-util: 30.2.0
slash: 3.0.0
'@jest/core@29.7.0':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.19.1
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.8
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
@ -27155,41 +27193,6 @@ snapshots:
- supports-color
- ts-node
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 22.19.1
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.8
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
'@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))':
dependencies:
'@jest/console': 30.2.0
@ -27666,32 +27669,6 @@ snapshots:
- uglify-js
- webpack-cli
'@nestjs/cli@10.4.9(esbuild@0.19.12)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
'@angular-devkit/schematics': 17.3.11(chokidar@3.6.0)
'@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0)
'@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2)
chalk: 4.1.2
chokidar: 3.6.0
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12))
glob: 10.4.5
inquirer: 8.2.6
node-emoji: 1.11.0
ora: 5.4.1
tree-kill: 1.2.2
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.7.2
webpack: 5.97.1(esbuild@0.19.12)
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- esbuild
- uglify-js
- webpack-cli
'@nestjs/cli@10.4.9(esbuild@0.27.0)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@ -27718,7 +27695,7 @@ snapshots:
- uglify-js
- webpack-cli
'@nestjs/cli@11.0.12(@types/node@22.19.1)':
'@nestjs/cli@11.0.12(@types/node@22.19.1)(esbuild@0.19.12)':
dependencies:
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.19(chokidar@4.0.3)
@ -27729,14 +27706,14 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2)
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12))
glob: 12.0.0
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.100.2
webpack: 5.100.2(esbuild@0.19.12)
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- '@types/node'
@ -32017,6 +31994,19 @@ snapshots:
redent: 3.0.0
optionalDependencies:
jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
optional: true
'@testing-library/react-native@13.3.3(jest@29.7.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
jest-matcher-utils: 30.2.0
picocolors: 1.1.1
pretty-format: 30.2.0
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
react-test-renderer: 19.1.0(react@19.1.0)
redent: 3.0.0
optionalDependencies:
jest: 29.7.0
'@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
@ -35369,6 +35359,21 @@ snapshots:
crc-32: 1.2.2
readable-stream: 4.7.0
create-jest@29.7.0:
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
create-jest@29.7.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
@ -35399,13 +35404,13 @@ snapshots:
- supports-color
- ts-node
create-jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
create-jest@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@24.10.1)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@ -38472,7 +38477,7 @@ snapshots:
- '@types/react-dom'
- supports-color
expo-router@6.0.15(hsxvh2bq7p37jxljkgxire2dbu):
expo-router@6.0.15(ohit2up6tuxb3x34brxduivol4):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@expo/schema-utils': 0.1.7
@ -38511,12 +38516,13 @@ snapshots:
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.19.12))
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
- '@types/react-dom'
- supports-color
optional: true
expo-router@6.0.15(qjp3usx4acoq47dkosl6pmu254):
dependencies:
@ -38564,6 +38570,52 @@ snapshots:
- '@types/react-dom'
- supports-color
expo-router@6.0.15(r6e3zsmutyjzazpntzawr4wuhy):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@expo/schema-utils': 0.1.7
'@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0)
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@react-navigation/native-stack': 7.8.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
client-only: 0.0.1
debug: 4.4.3
escape-string-regexp: 4.0.0
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))
expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo-server: 1.0.4
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.11
query-string: 7.1.3
react: 19.1.0
react-fast-compare: 3.2.2
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
semver: 7.6.3
server-only: 0.0.1
sf-symbols-typescript: 2.1.0
shallowequal: 1.1.0
use-latest-callback: 0.2.6(react@19.1.0)
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
optionalDependencies:
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
'@testing-library/react-native': 13.3.3(jest@29.7.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
react-dom: 19.1.0(react@19.1.0)
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
- '@types/react-dom'
- supports-color
expo-router@6.0.15(ucgv42olhsnvykdrhhfuls4dzq):
dependencies:
'@expo/metro-runtime': 6.1.2(expo@54.0.12)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@ -39654,23 +39706,6 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12)):
dependencies:
'@babel/code-frame': 7.27.1
chalk: 4.1.2
chokidar: 3.6.0
cosmiconfig: 8.3.6(typescript@5.7.2)
deepmerge: 4.3.1
fs-extra: 10.1.0
memfs: 3.5.3
minimatch: 3.1.2
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.7.3
tapable: 2.3.0
typescript: 5.7.2
webpack: 5.97.1(esbuild@0.19.12)
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.27.0)):
dependencies:
'@babel/code-frame': 7.27.1
@ -39705,6 +39740,23 @@ snapshots:
typescript: 5.7.2
webpack: 5.97.1
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)):
dependencies:
'@babel/code-frame': 7.27.1
chalk: 4.1.2
chokidar: 4.0.3
cosmiconfig: 8.3.6(typescript@5.9.3)
deepmerge: 4.3.1
fs-extra: 10.1.0
memfs: 3.5.3
minimatch: 3.1.2
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.7.3
tapable: 2.3.0
typescript: 5.9.3
webpack: 5.100.2(esbuild@0.19.12)
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)):
dependencies:
'@babel/code-frame': 7.27.1
@ -39722,23 +39774,6 @@ snapshots:
typescript: 5.9.3
webpack: 5.100.2(esbuild@0.27.0)
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2):
dependencies:
'@babel/code-frame': 7.27.1
chalk: 4.1.2
chokidar: 4.0.3
cosmiconfig: 8.3.6(typescript@5.9.3)
deepmerge: 4.3.1
fs-extra: 10.1.0
memfs: 3.5.3
minimatch: 3.1.2
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.7.3
tapable: 2.3.0
typescript: 5.9.3
webpack: 5.100.2
form-data-encoder@1.7.2: {}
form-data@2.5.5:
@ -41106,6 +41141,25 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-cli@29.7.0:
dependencies:
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
@ -41144,16 +41198,16 @@ snapshots:
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest-cli@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
create-jest: 29.7.0(@types/node@24.10.1)
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@24.10.1)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@ -41314,38 +41368,7 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.28.5)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 22.19.1
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest-config@29.7.0(@types/node@24.10.1):
dependencies:
'@babel/core': 7.28.5
'@jest/test-sequencer': 29.7.0
@ -41371,7 +41394,6 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 24.10.1
ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@ -41515,7 +41537,7 @@ snapshots:
jest-util: 30.2.0
jest-validate: 30.2.0
jest-expo@54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(webpack@5.100.2):
jest-expo@54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.7.0)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(webpack@5.100.2):
dependencies:
'@expo/config': 12.0.10
'@expo/json-file': 10.0.7
@ -41526,7 +41548,7 @@ snapshots:
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))
jest-watch-typeahead: 2.2.1(jest@29.7.0)
json5: 2.2.3
lodash: 4.17.21
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
@ -41917,11 +41939,11 @@ snapshots:
chalk: 3.0.0
prompts: 2.4.2
jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))):
jest-watch-typeahead@2.2.1(jest@29.7.0):
dependencies:
ansi-escapes: 6.2.1
chalk: 4.1.2
jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
jest: 29.7.0
jest-regex-util: 29.6.3
jest-watcher: 29.7.0
slash: 5.1.0
@ -41994,6 +42016,18 @@ snapshots:
- supports-color
- ts-node
jest@29.7.0:
dependencies:
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
@ -42006,12 +42040,12 @@ snapshots:
- supports-color
- ts-node
jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
jest@29.7.0(@types/node@24.10.1):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-cli: 29.7.0(@types/node@24.10.1)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@ -46947,6 +46981,16 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.19.12)):
dependencies:
acorn-loose: 8.5.2
neo-async: 2.6.2
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
webpack: 5.100.2(esbuild@0.19.12)
webpack-sources: 3.3.3
optional: true
react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.27.0)):
dependencies:
acorn-loose: 8.5.2
@ -48505,14 +48549,14 @@ snapshots:
ansi-escapes: 4.3.2
supports-hyperlinks: 2.3.0
terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)):
terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
serialize-javascript: 6.0.2
terser: 5.44.1
webpack: 5.97.1(esbuild@0.19.12)
webpack: 5.100.2(esbuild@0.19.12)
optionalDependencies:
esbuild: 0.19.12
@ -48757,6 +48801,27 @@ snapshots:
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.28.5)
ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
semver: 7.7.3
type-fest: 4.41.0
typescript: 5.9.3
yargs-parser: 21.1.1
optionalDependencies:
'@babel/core': 7.28.5
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
babel-jest: 30.2.0(@babel/core@7.28.5)
esbuild: 0.19.12
jest-util: 30.2.0
ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
@ -48778,25 +48843,15 @@ snapshots:
esbuild: 0.27.0
jest-util: 30.2.0
ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3):
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
chalk: 4.1.2
enhanced-resolve: 5.18.3
micromatch: 4.0.8
semver: 7.7.3
type-fest: 4.41.0
source-map: 0.7.6
typescript: 5.9.3
yargs-parser: 21.1.1
optionalDependencies:
'@babel/core': 7.28.5
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
babel-jest: 30.2.0(@babel/core@7.28.5)
jest-util: 30.2.0
webpack: 5.100.2(esbuild@0.19.12)
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)):
dependencies:
@ -48818,7 +48873,7 @@ snapshots:
typescript: 5.9.3
webpack: 5.100.2
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)):
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.0)):
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.18.3
@ -48826,7 +48881,7 @@ snapshots:
semver: 7.7.3
source-map: 0.7.6
typescript: 5.9.3
webpack: 5.97.1(esbuild@0.19.12)
webpack: 5.97.1(esbuild@0.27.0)
ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3):
dependencies:
@ -49999,6 +50054,38 @@ snapshots:
- esbuild
- uglify-js
webpack@5.100.2(esbuild@0.19.12):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-phases: 1.0.4(acorn@8.15.0)
browserslist: 4.28.0
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.3
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.0
terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12))
watchpack: 2.4.4
webpack-sources: 3.3.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
webpack@5.100.2(esbuild@0.27.0):
dependencies:
'@types/eslint-scope': 3.7.7
@ -50061,36 +50148,6 @@ snapshots:
- esbuild
- uglify-js
webpack@5.97.1(esbuild@0.19.12):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
browserslist: 4.28.0
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.3
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.3.0
terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12))
watchpack: 2.4.4
webpack-sources: 3.3.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
webpack@5.97.1(esbuild@0.27.0):
dependencies:
'@types/eslint-scope': 3.7.7

View file

@ -5,8 +5,9 @@ export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore',
},
schemaFilter: ['auth', 'credits', 'public'],
verbose: true,
strict: true,
});

View file

@ -33,6 +33,7 @@
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"helmet": "^8.0.0",
"jose": "^6.1.2",
"jsonwebtoken": "^9.0.2",
"nanoid": "^5.0.9",
"postgres": "^3.4.5",

View file

@ -126,6 +126,17 @@ export class AuthController {
return this.betterAuthService.validateToken(body.token);
}
/**
* Get JWKS (JSON Web Key Set)
*
* Returns public keys for JWT verification.
* This is a passthrough to Better Auth's JWKS.
*/
@Get('jwks')
async getJwks() {
return this.betterAuthService.getJwks();
}
// =========================================================================
// B2B Registration
// =========================================================================

View file

@ -4,11 +4,14 @@
* This file configures Better Auth with:
* - Email/password authentication
* - Organization plugin for B2B (multi-tenant)
* - JWT plugin with custom claims (credit_balance, customer_type, organization)
* - JWT plugin with minimal claims
* - Drizzle adapter for PostgreSQL
*
* ARCHITECTURE DECISION (2024-12):
* We use MINIMAL JWT claims. Organization and credit data should be fetched
* via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md
*
* @see https://www.better-auth.com/docs
* @see BETTER_AUTH_FINAL_PLAN.md
*/
import { betterAuth } from 'better-auth';
@ -16,16 +19,32 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { jwt } from 'better-auth/plugins/jwt';
import { organization } from 'better-auth/plugins/organization';
import { getDb } from '../db/connection';
import { eq, and } from 'drizzle-orm';
import { balances } from '../db/schema/credits.schema';
import { organizations, members } from '../db/schema/organizations.schema';
import {
organizations,
members,
invitations,
} from '../db/schema/organizations.schema';
import {
users,
sessions,
accounts,
verificationTokens,
jwks,
} from '../db/schema/auth.schema';
import type { JWTPayloadContext } from './types/better-auth.types';
/**
* JWT Custom Payload Interface
*
* Defines the structure of custom claims included in JWT tokens.
* These claims are added to the standard JWT payload (sub, iat, exp, etc.)
* MINIMAL claims only. Organization context and credits are available via:
* - GET /organization/get-active-member - org membership & role
* - GET /api/v1/credits/balance - credit balance
*
* Why minimal claims?
* 1. Credit balance changes frequently - JWT would be stale
* 2. Organization context available via Better Auth org plugin APIs
* 3. Smaller tokens = better performance
* 4. Follows Better Auth's session-based design
*/
export interface JWTCustomPayload {
/** User ID (standard JWT claim) */
@ -37,145 +56,13 @@ export interface JWTCustomPayload {
/** User role (user, admin, service) */
role: string;
/** Customer type: B2C (individual) or B2B (organization member) */
customer_type: 'b2c' | 'b2b';
/** Organization context (null for B2C users) */
organization: {
id: string;
name: string;
role: 'owner' | 'admin' | 'member';
} | null;
/** User's credit balance (personal for B2C, allocated for B2B) */
credit_balance: number;
/** Application ID (memoro, chat, picture, etc.) */
app_id?: string;
/** Device ID (for mobile apps) */
device_id?: string;
}
/**
* Helper function to get personal credit balance (B2C users)
*
* @param userId - User ID
* @param databaseUrl - Database connection URL
* @returns Credit balance or 0 if not found
*/
async function getPersonalCreditBalance(userId: string, databaseUrl: string): Promise<number> {
try {
const db = getDb(databaseUrl);
const [balance] = await db
.select({ balance: balances.balance })
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
return balance?.balance ?? 0;
} catch (error) {
console.error('Error fetching personal credit balance:', error);
return 0;
}
}
/**
* Helper function to get employee credit balance (B2B users)
*
* For B2B employees, this returns their allocated credit balance.
* The balance is stored in the same balances table but tracked separately per employee.
*
* @param userId - Employee user ID
* @param organizationId - Organization ID
* @param databaseUrl - Database connection URL
* @returns Allocated credit balance or 0 if not found
*/
async function getEmployeeCreditBalance(
userId: string,
organizationId: string,
databaseUrl: string
): Promise<number> {
try {
const db = getDb(databaseUrl);
// Get employee's personal balance (which represents their allocated credits from the org)
const [balance] = await db
.select({ balance: balances.balance })
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
return balance?.balance ?? 0;
} catch (error) {
console.error('Error fetching employee credit balance:', error);
return 0;
}
}
/**
* Helper function to get organization membership data
*
* Queries the organization and member tables to get:
* - Organization name
* - User's role in the organization
*
* @param userId - User ID
* @param organizationId - Organization ID
* @param databaseUrl - Database connection URL
* @returns Organization data with name and role, or null if not found
*/
async function getOrganizationMembership(
userId: string,
organizationId: string,
databaseUrl: string
): Promise<{ name: string; role: 'owner' | 'admin' | 'member' } | null> {
try {
const db = getDb(databaseUrl);
// Query member table to get user's role in the organization
const [memberRecord] = await db
.select({
role: members.role,
})
.from(members)
.where(and(eq(members.userId, userId), eq(members.organizationId, organizationId)))
.limit(1);
if (!memberRecord) {
return null;
}
// Query organization table to get organization name
const [orgRecord] = await db
.select({
name: organizations.name,
})
.from(organizations)
.where(eq(organizations.id, organizationId))
.limit(1);
if (!orgRecord) {
return null;
}
return {
name: orgRecord.name,
role: memberRecord.role as 'owner' | 'admin' | 'member',
};
} catch (error) {
console.error('Error fetching organization membership:', error);
return null;
}
/** Session ID for reference */
sid: string;
}
/**
* Create Better Auth instance
*
* This function initializes Better Auth with the database connection URL.
* It must be called with the database URL from the configuration.
*
* @param databaseUrl - PostgreSQL connection URL
* @returns Better Auth instance
*/
@ -187,16 +74,19 @@ export function createBetterAuth(databaseUrl: string) {
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
// Auth tables
user: 'auth.users',
session: 'auth.sessions',
account: 'auth.accounts',
verification: 'auth.verification_tokens',
// Auth tables (actual Drizzle table objects)
user: users,
session: sessions,
account: accounts,
verification: verificationTokens,
// Organization tables (Better Auth creates these schemas)
organization: 'auth.organizations',
member: 'auth.members',
invitation: 'auth.invitations',
// Organization tables
organization: organizations,
member: members,
invitation: invitations,
// JWT plugin table
jwks: jwks,
},
}),
@ -226,7 +116,12 @@ export function createBetterAuth(databaseUrl: string) {
* - Create/update/delete organizations
* - Invite/add/remove members
* - Role-based access control
* - Email-based invitations
* - Active organization tracking (session.activeOrganizationId)
*
* Client apps use these endpoints for org context:
* - GET /organization/get-active-member
* - GET /organization/get-active-member-role
* - POST /organization/set-active
*/
organization({
// Allow users to create their own organizations
@ -242,22 +137,10 @@ export function createBetterAuth(databaseUrl: string) {
organization: organization.name,
invitationId: data.id,
});
// Example email template:
// Subject: Join ${organization.name} on Mana Universe
// Body: You've been invited to join ${organization.name}
// Click here to accept: ${baseURL}/invite/${data.id}
},
// Custom roles and permissions
organizationRole: {
/**
* Owner Role
* - Full organization control
* - Can delete organization
* - Can manage all members
* - Can allocate credits to employees
*/
owner: {
permissions: [
'organization:update',
@ -265,17 +148,10 @@ export function createBetterAuth(databaseUrl: string) {
'members:invite',
'members:remove',
'members:update_role',
'credits:allocate', // Custom permission
'credits:view_all', // Custom permission
'credits:allocate',
'credits:view_all',
],
},
/**
* Admin Role
* - Can update organization settings
* - Can invite and remove members
* - Can view all credit usage
*/
admin: {
permissions: [
'organization:update',
@ -284,12 +160,6 @@ export function createBetterAuth(databaseUrl: string) {
'credits:view_all',
],
},
/**
* Member Role
* - Basic organization access
* - Can only view their own credits
*/
member: {
permissions: ['credits:view_own'],
},
@ -299,99 +169,35 @@ export function createBetterAuth(databaseUrl: string) {
/**
* JWT Plugin
*
* Generates JWT tokens with custom claims for:
* - Credit balance
* - Customer type (B2C vs B2B)
* - Organization context
* - App/device metadata
* Generates JWT tokens with MINIMAL claims.
*
* DO NOT add complex claims like:
* - credit_balance (stale after 15min, fetch via API instead)
* - organization details (use Better Auth org plugin APIs)
* - customer_type (derive from activeOrganizationId presence)
*
* Apps should call APIs for dynamic data:
* - Credits: GET /api/v1/credits/balance
* - Org info: GET /organization/get-active-member
*/
jwt({
jwt: {
issuer: 'mana-core',
issuer: process.env.JWT_ISSUER || 'manacore',
audience: process.env.JWT_AUDIENCE || 'manacore',
expirationTime: '15m', // 15 minutes for access tokens
expirationTime: '15m',
/**
* Define custom JWT payload
* Define minimal JWT payload
*
* This function is called when generating a JWT token.
* It enriches the standard JWT claims with custom data.
*
* @param context - JWT payload context with user and session
* @returns Custom JWT payload
* Only includes static user info that doesn't change frequently.
*/
async definePayload({ user, session }: JWTPayloadContext) {
// Get user's active organization (from session metadata or first membership)
const activeOrgId = session.activeOrganizationId;
let organizationData: JWTCustomPayload['organization'] = null;
let creditBalance = 0;
let customerType: 'b2c' | 'b2b' = 'b2c';
if (activeOrgId) {
// B2B user - get organization membership from database
try {
// Query actual organization and membership data
const membership = await getOrganizationMembership(
user.id,
activeOrgId,
databaseUrl
);
if (membership) {
// Get employee's allocated credit balance
creditBalance = await getEmployeeCreditBalance(
user.id,
activeOrgId,
databaseUrl
);
organizationData = {
id: activeOrgId,
name: membership.name,
role: membership.role,
};
customerType = 'b2b';
} else {
// User is not a member of this organization, fall back to B2C
console.warn(
`User ${user.id} is not a member of organization ${activeOrgId}`
);
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
}
} catch (error) {
console.error('Error fetching organization data:', error);
// Fall back to B2C on error
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
}
} else {
// B2C user - get personal credit balance
creditBalance = await getPersonalCreditBalance(user.id, databaseUrl);
}
// Build custom JWT payload
const payload: Partial<JWTCustomPayload> = {
// Standard claims
definePayload({ user, session }: JWTPayloadContext) {
return {
sub: user.id,
email: user.email,
role: user.role || 'user',
// Customer type
customer_type: customerType,
// Organization (null for B2C)
organization: organizationData,
// Credits
credit_balance: creditBalance,
// App metadata (from session)
app_id: (session.metadata?.appId as string) || undefined,
device_id: (session.metadata?.deviceId as string) || undefined,
role: (user as { role?: string }).role || 'user',
sid: session.id,
};
return payload;
},
},
}),

View file

@ -62,6 +62,7 @@ import type {
BetterAuthSession,
} from '../types/better-auth.types';
import * as jwt from 'jsonwebtoken';
import { jwtVerify, createRemoteJWKSet } from 'jose';
// Re-export DTOs and result types for external use
export type {
@ -418,6 +419,73 @@ export class BetterAuthService {
const { user } = result;
// Get session token (used as refresh token)
const session = hasSession(result) ? result.session : null;
const sessionToken = session?.token || (hasToken(result) ? result.token : '');
// Generate JWT access token using Better Auth's JWT plugin
let accessToken = '';
try {
const api = this.auth.api as any;
// Use Better Auth's signJWT with the jwks table
const jwtResult = await api.signJWT({
body: {
payload: {
sub: user.id,
email: user.email,
role: (user as BetterAuthUser).role || 'user',
sid: session?.id || '',
},
},
});
accessToken = jwtResult?.token || '';
// Fallback to manual JWT if Better Auth fails
if (!accessToken) {
throw new Error('Better Auth signJWT returned empty token');
}
} catch (jwtError) {
console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError);
// Fallback: Generate JWT manually using jsonwebtoken
const privateKey = this.configService.get<string>('jwt.privateKey');
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
console.log('[signIn] Private key exists:', !!privateKey);
console.log('[signIn] Private key length:', privateKey?.length);
console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30));
console.log('[signIn] Issuer:', issuer);
console.log('[signIn] Audience:', audience);
if (privateKey) {
const payload = {
sub: user.id,
email: user.email,
role: (user as BetterAuthUser).role || 'user',
sid: session?.id || '',
};
accessToken = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '15m',
issuer,
audience,
});
console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50));
// Decode to verify
const decoded = jwt.decode(accessToken, { complete: true });
console.log('[signIn] Generated JWT header:', decoded?.header);
console.log('[signIn] Generated JWT payload:', decoded?.payload);
} else {
console.error('[signIn] No JWT private key configured');
accessToken = sessionToken;
}
}
return {
user: {
id: user.id,
@ -425,7 +493,9 @@ export class BetterAuthService {
name: user.name,
role: (user as BetterAuthUser).role,
},
token: hasToken(result) ? result.token : '',
accessToken,
refreshToken: sessionToken,
expiresIn: 15 * 60, // 15 minutes in seconds
};
} catch (error: unknown) {
if (error instanceof Error) {
@ -617,7 +687,7 @@ export class BetterAuthService {
}
// Check if refresh token is expired
if (new Date() > session.refreshTokenExpiresAt) {
if (!session.refreshTokenExpiresAt || new Date() > session.refreshTokenExpiresAt) {
throw new UnauthorizedException('Refresh token expired');
}
@ -715,26 +785,44 @@ export class BetterAuthService {
*/
async validateToken(token: string): Promise<ValidateTokenResult> {
try {
const publicKey = this.configService.get<string>('jwt.publicKey');
if (!publicKey) {
throw new Error('JWT public key not configured');
}
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
const audience = this.configService.get<string>('jwt.audience');
const issuer = this.configService.get<string>('jwt.issuer');
// Decode to check the algorithm
const decoded = jwt.decode(token, { complete: true });
console.log('[validateToken] Decoded header:', decoded?.header);
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
// Use our JWKS endpoint (NestJS prefix: /api/v1)
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
console.log('[validateToken] Using JWKS from:', jwksUrl.toString());
// Create JWKS fetcher
const JWKS = createRemoteJWKSet(jwksUrl);
// Get issuer/audience from config (Better Auth uses BASE_URL by default)
const issuer = this.configService.get<string>('jwt.issuer') || baseUrl;
const audience = this.configService.get<string>('jwt.audience') || baseUrl;
console.log('[validateToken] Issuer:', issuer);
console.log('[validateToken] Audience:', audience);
// Verify using jose library with Better Auth's JWKS
const { payload } = await jwtVerify(token, JWKS, {
issuer,
}) as TokenPayload;
audience,
});
console.log('[validateToken] Verification SUCCESS');
console.log('[validateToken] Payload:', payload);
return {
valid: true,
payload,
payload: payload as unknown as TokenPayload,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[validateToken] Verification FAILED:', errorMessage);
return {
valid: false,
error: errorMessage,
@ -742,6 +830,46 @@ export class BetterAuthService {
}
}
/**
* Get JWKS (JSON Web Key Set)
*
* Returns public keys for JWT verification.
* Proxies to Better Auth's internal JWKS.
*
* @returns JWKS with public keys
*/
async getJwks(): Promise<{ keys: unknown[] }> {
try {
// Better Auth exposes JWKS via auth.api
const api = this.auth.api as any;
// Try to get JWKS from Better Auth
if (api.getJwks) {
const result = await api.getJwks();
return result;
}
// Fallback: read from jwks table directly
const db = getDb(this.databaseUrl);
const { jwks } = await import('../../db/schema/auth.schema');
const keys = await db.select().from(jwks);
// Convert to JWKS format (EdDSA public keys)
return {
keys: keys.map((key) => {
try {
return JSON.parse(key.publicKey);
} catch {
return { kid: key.id, publicKey: key.publicKey };
}
}),
};
} catch (error) {
console.error('[getJwks] Error:', error);
return { keys: [] };
}
}
// =========================================================================
// Private Helper Methods
// =========================================================================

View file

@ -441,9 +441,9 @@ export interface SignInResult {
name: string | null;
role?: string;
};
token: string;
refreshToken?: string;
expiresIn?: number;
accessToken: string;
refreshToken: string;
expiresIn: number;
}
/**

View file

@ -7,8 +7,9 @@ export default () => ({
},
jwt: {
publicKey: process.env.JWT_PUBLIC_KEY || '',
privateKey: process.env.JWT_PRIVATE_KEY || '',
// Convert \n string literals to actual newlines for PEM format
publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
issuer: process.env.JWT_ISSUER || 'manacore',

View file

@ -1,78 +1,83 @@
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum, index } from 'drizzle-orm/pg-core';
export const authSchema = pgSchema('auth');
// Enum for user roles
export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']);
// Users table
// Users table (Better Auth schema)
export const users = authSchema.table('users', {
id: uuid('id').primaryKey().defaultRandom(),
id: text('id').primaryKey(), // Better Auth generates nanoid
name: text('name').notNull(),
email: text('email').unique().notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
role: userRoleEnum('role').default('user').notNull(),
image: text('image'), // Better Auth uses 'image' not 'avatarUrl'
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
// Custom fields (not required by Better Auth)
role: userRoleEnum('role').default('user').notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
// Sessions table
// Sessions table (Better Auth schema)
export const sessions = authSchema.table('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
id: text('id').primaryKey(), // Better Auth generates nanoid
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
token: text('token').unique().notNull(),
refreshToken: text('refresh_token').unique().notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
// Custom fields (not required by Better Auth)
refreshToken: text('refresh_token').unique(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
deviceId: text('device_id'),
deviceName: text('device_name'),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
});
// Accounts table (for OAuth providers)
// Accounts table (for OAuth providers and credentials - Better Auth schema)
export const accounts = authSchema.table('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
id: text('id').primaryKey(), // Better Auth generates nanoid
accountId: text('account_id').notNull(), // Better Auth field
providerId: text('provider_id').notNull(), // Better Auth field (was 'provider')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
providerAccountId: text('provider_account_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
tokenType: text('token_type'),
scope: text('scope'),
idToken: text('id_token'),
metadata: jsonb('metadata'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
scope: text('scope'),
password: text('password'), // Better Auth stores hashed password here for credential provider
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Verification tokens (for email verification, password reset)
export const verificationTokens = authSchema.table('verification_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
token: text('token').unique().notNull(),
type: text('type').notNull(), // 'email_verification', 'password_reset'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
});
// Verification table (Better Auth schema - for email verification, password reset)
export const verificationTokens = authSchema.table(
'verification',
{
id: text('id').primaryKey(), // Better Auth generates nanoid
identifier: text('identifier').notNull(), // Better Auth uses identifier (e.g., email)
value: text('value').notNull(), // Better Auth uses value (the token)
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
identifierIdx: index('verification_identifier_idx').on(table.identifier),
})
);
// Password table (separate for security)
export const passwords = authSchema.table('passwords', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
hashedPassword: text('hashed_password').notNull(),
@ -82,23 +87,31 @@ export const passwords = authSchema.table('passwords', {
// Two-factor authentication
export const twoFactorAuth = authSchema.table('two_factor_auth', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
secret: text('secret').notNull(),
enabled: boolean('enabled').default(false).notNull(),
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
backupCodes: jsonb('backup_codes'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
enabledAt: timestamp('enabled_at', { withTimezone: true }),
});
// Security events log
export const securityEvents = authSchema.table('security_events', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
id: uuid('id').primaryKey().defaultRandom(), // Our table, can keep UUID
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
// JWKS table (Better Auth JWT plugin - stores signing keys)
export const jwks = authSchema.table('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View file

@ -34,7 +34,7 @@ export const transactionStatusEnum = pgEnum('transaction_status', [
// Credit balances (one per user)
export const balances = creditsSchema.table('balances', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
@ -43,7 +43,7 @@ export const balances = creditsSchema.table('balances', {
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
totalEarned: integer('total_earned').default(0).notNull(),
totalSpent: integer('total_spent').default(0).notNull(),
version: integer('version').default(0).notNull(), // For optimistic locking
version: integer('version').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
@ -53,7 +53,7 @@ export const transactions = creditsSchema.table(
'transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
type: transactionTypeEnum('type').notNull(),
@ -61,10 +61,10 @@ export const transactions = creditsSchema.table(
amount: integer('amount').notNull(),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
appId: text('app_id').notNull(),
description: text('description').notNull(),
organizationId: text('organization_id').references(() => organizations.id), // NULL for B2C, set for B2B
metadata: jsonb('metadata'), // Additional context
organizationId: text('organization_id').references(() => organizations.id),
metadata: jsonb('metadata'),
idempotencyKey: text('idempotency_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
@ -83,8 +83,8 @@ export const packages = creditsSchema.table('packages', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
credits: integer('credits').notNull(), // Number of credits
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
credits: integer('credits').notNull(),
priceEuroCents: integer('price_euro_cents').notNull(),
stripePriceId: text('stripe_price_id').unique(),
active: boolean('active').default(true).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
@ -98,7 +98,7 @@ export const purchases = creditsSchema.table(
'purchases',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
packageId: uuid('package_id').references(() => packages.id),
@ -124,7 +124,7 @@ export const usageStats = creditsSchema.table(
'usage_stats',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
appId: text('app_id').notNull(),
@ -143,12 +143,12 @@ export const organizationBalances = creditsSchema.table('organization_balances',
organizationId: text('organization_id')
.primaryKey()
.references(() => organizations.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(), // Total purchased credits
allocatedCredits: integer('allocated_credits').default(0).notNull(), // Sum of credits allocated to employees
availableCredits: integer('available_credits').default(0).notNull(), // balance - allocated_credits
totalPurchased: integer('total_purchased').default(0).notNull(), // Total credits ever purchased
totalAllocated: integer('total_allocated').default(0).notNull(), // Total ever allocated
version: integer('version').default(0).notNull(), // For optimistic locking
balance: integer('balance').default(0).notNull(),
allocatedCredits: integer('allocated_credits').default(0).notNull(),
availableCredits: integer('available_credits').default(0).notNull(),
totalPurchased: integer('total_purchased').default(0).notNull(),
totalAllocated: integer('total_allocated').default(0).notNull(),
version: integer('version').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
@ -161,17 +161,17 @@ export const creditAllocations = creditsSchema.table(
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
employeeId: uuid('employee_id')
employeeId: text('employee_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
amount: integer('amount').notNull(), // Amount allocated (can be positive or negative)
allocatedBy: uuid('allocated_by')
amount: integer('amount').notNull(),
allocatedBy: text('allocated_by')
.references(() => users.id)
.notNull(), // Owner or admin who made the allocation
reason: text('reason'), // Optional reason for allocation
balanceBefore: integer('balance_before').notNull(), // Employee balance before
balanceAfter: integer('balance_after').notNull(), // Employee balance after
metadata: jsonb('metadata'), // Additional context
.notNull(),
reason: text('reason'),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({