feat(identity): add account schema, security primitives and migrations
This commit is contained in:
parent
340485914d
commit
c4c85eb180
10
.env.example
10
.env.example
|
|
@ -2,3 +2,13 @@ NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DATABASE_URL=postgresql://mani:mani_local@localhost:5432/mani_candles
|
DATABASE_URL=postgresql://mani:mani_local@localhost:5432/mani_candles
|
||||||
CORS_ORIGINS=http://localhost:3001
|
CORS_ORIGINS=http://localhost:3001
|
||||||
|
|
||||||
|
SESSION_TTL_MINUTES=480
|
||||||
|
RECOVERY_TTL_MINUTES=15
|
||||||
|
# Optional recovery email: configure all five values together.
|
||||||
|
# SMTP_HOST=smtp.example.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=replace_me
|
||||||
|
# SMTP_PASSWORD=replace_me
|
||||||
|
# SMTP_FROM=support@example.com
|
||||||
|
# RECOVERY_URL=https://shop.example.com/reset
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
|
|
@ -7,6 +7,7 @@ module.exports = {
|
||||||
'src/**/*.ts',
|
'src/**/*.ts',
|
||||||
'!src/generated/**',
|
'!src/generated/**',
|
||||||
'!src/main.ts',
|
'!src/main.ts',
|
||||||
|
'!src/cli/**',
|
||||||
'!src/**/*.module.ts',
|
'!src/**/*.module.ts',
|
||||||
],
|
],
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
|
|
|
||||||
14
package.json
14
package.json
|
|
@ -10,8 +10,8 @@
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"dev": "tsc -p tsconfig.build.json --watch",
|
"dev": "tsc -p tsconfig.build.json --watch",
|
||||||
"test": "jest --runInBand",
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand",
|
||||||
"test:coverage": "jest --runInBand --coverage",
|
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --coverage",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
@ -21,7 +21,9 @@
|
||||||
"db:deploy": "prisma migrate deploy",
|
"db:deploy": "prisma migrate deploy",
|
||||||
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
|
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
|
||||||
"start:watch": "node --watch dist/main.js",
|
"start:watch": "node --watch dist/main.js",
|
||||||
"db:status": "prisma migrate status"
|
"db:status": "prisma migrate status",
|
||||||
|
"bootstrap:owner": "node dist/cli/bootstrap-owner.js",
|
||||||
|
"db:test": "node scripts/test-migrations.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.1.0",
|
"@nestjs/common": "^11.1.0",
|
||||||
|
|
@ -31,17 +33,23 @@
|
||||||
"@prisma/client": "^7.0.0",
|
"@prisma/client": "^7.0.0",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^17.0.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
"nodemailer": "^10.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@electric-sql/pglite": "^0.5.8",
|
||||||
|
"@electric-sql/pglite-socket": "^0.2.11",
|
||||||
"@nestjs/testing": "^11.1.0",
|
"@nestjs/testing": "^11.1.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"prisma": "^7.0.0",
|
"prisma": "^7.0.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|
|
||||||
123
pnpm-lock.yaml
123
pnpm-lock.yaml
|
|
@ -29,6 +29,9 @@ importers:
|
||||||
helmet:
|
helmet:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.3.0
|
version: 8.3.0
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^10.0.1
|
||||||
|
version: 10.0.1
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
|
|
@ -39,6 +42,12 @@ importers:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.5.4
|
version: 4.5.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@electric-sql/pglite':
|
||||||
|
specifier: ^0.5.8
|
||||||
|
version: 0.5.8
|
||||||
|
'@electric-sql/pglite-socket':
|
||||||
|
specifier: ^0.2.11
|
||||||
|
version: 0.2.11(@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite@0.5.8)
|
||||||
'@nestjs/testing':
|
'@nestjs/testing':
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)
|
version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)
|
||||||
|
|
@ -51,12 +60,21 @@ importers:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.0
|
specifier: ^24.0.0
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.23.1
|
||||||
|
version: 8.23.1
|
||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
jest:
|
jest:
|
||||||
specifier: ^30.0.0
|
specifier: ^30.0.0
|
||||||
version: 30.5.1(@types/node@24.13.3)(supports-color@8.1.1)
|
version: 30.5.1(@types/node@24.13.3)(supports-color@8.1.1)
|
||||||
|
pg:
|
||||||
|
specifier: ^8.23.0
|
||||||
|
version: 8.23.0
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.9.6
|
version: 3.9.6
|
||||||
|
|
@ -243,12 +261,60 @@ packages:
|
||||||
'@borewit/text-codec@0.2.2':
|
'@borewit/text-codec@0.2.2':
|
||||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||||
|
|
||||||
|
'@electric-sql/pglite-age@0.0.9':
|
||||||
|
resolution: {integrity: sha512-IdNy5P5nxwKVeSPLjDLXkp/eYffH5roympM7fTG+g9ehzBrDnQ9TT2tfCJ2vRVcOSPQBp4oURzfRE19cs/x8ZA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pg_hashids@0.0.9':
|
||||||
|
resolution: {integrity: sha512-NffsZH+FaRLp0WzsYG35BW/wOHLsTs325zjgp+PnKlWeGyW523Mbw4EZ1Awm02C3QWBU9p8Y2Fk02Al5NpRGUg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pg_ivm@0.0.9':
|
||||||
|
resolution: {integrity: sha512-mbTcDLQyzeOG4rX6LLHhWDPJeQUv2xHvayKtf4oE7xujWEGWEDUEJP1+U0pXXELtIoWiUzzPBWjzKY1mRisqkQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pg_textsearch@0.0.10':
|
||||||
|
resolution: {integrity: sha512-tsL1rTYU4tzIC44htL7/0nS6C2XC5Srz0tfxU1LJelCqNIBkpFI9Esw1JbuVCaQKUHGDbPgAz8JYavNxwbuRVw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pg_uuidv7@0.0.9':
|
||||||
|
resolution: {integrity: sha512-n/JbzoQMF9jFKFxGLRIzUfC1O4V2vTE0Pt3uoAO6/EP7dX5m1h4zBdezQAcT2JFostD0YWUdzHj9iYvcbUGM1A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pgtap@0.0.9':
|
||||||
|
resolution: {integrity: sha512-VIyclfOawUN0IJROKiCnrVxsZQEG7tixgiC8c8nxp9TlBhI79MZqotoqlqvdPzGHmjWZlcgJ7tC8InOcy+2A/A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pgvector@0.0.9':
|
||||||
|
resolution: {integrity: sha512-ue4iBW651gDQwBwn97Ekv1lYGPvXa1ymHbRbTCSL0Ib286PRDD1VDOwzwEoekZuO/wctMbTzKzlwdgDwYrqZ8A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
'@electric-sql/pglite-socket@0.1.3':
|
'@electric-sql/pglite-socket@0.1.3':
|
||||||
resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==}
|
resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@electric-sql/pglite': 0.4.3
|
'@electric-sql/pglite': 0.4.3
|
||||||
|
|
||||||
|
'@electric-sql/pglite-socket@0.2.11':
|
||||||
|
resolution: {integrity: sha512-DKnJBf7+5zFCXXGZAGUvGoWKetPPvaQ7fLSGVqP5onzgBhV9vvTcHigireMROMglgovvhoXHaMOKqt69/vts4A==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
'@electric-sql/pglite-age': 0.0.9
|
||||||
|
'@electric-sql/pglite-pg_hashids': 0.0.9
|
||||||
|
'@electric-sql/pglite-pg_ivm': 0.0.9
|
||||||
|
'@electric-sql/pglite-pg_textsearch': 0.0.10
|
||||||
|
'@electric-sql/pglite-pg_uuidv7': 0.0.9
|
||||||
|
'@electric-sql/pglite-pgtap': 0.0.9
|
||||||
|
'@electric-sql/pglite-pgvector': 0.0.9
|
||||||
|
|
||||||
'@electric-sql/pglite-tools@0.3.3':
|
'@electric-sql/pglite-tools@0.3.3':
|
||||||
resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==}
|
resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -257,6 +323,9 @@ packages:
|
||||||
'@electric-sql/pglite@0.4.3':
|
'@electric-sql/pglite@0.4.3':
|
||||||
resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==}
|
resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==}
|
||||||
|
|
||||||
|
'@electric-sql/pglite@0.5.8':
|
||||||
|
resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==}
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||||
|
|
||||||
|
|
@ -936,6 +1005,9 @@ packages:
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.1':
|
||||||
|
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||||
|
|
||||||
|
|
@ -2138,6 +2210,10 @@ packages:
|
||||||
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
|
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
nodemailer@10.0.1:
|
||||||
|
resolution: {integrity: sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
normalize-path@3.0.0:
|
normalize-path@3.0.0:
|
||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2936,16 +3012,57 @@ snapshots:
|
||||||
|
|
||||||
'@borewit/text-codec@0.2.2': {}
|
'@borewit/text-codec@0.2.2': {}
|
||||||
|
|
||||||
|
'@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8)':
|
||||||
|
dependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8)':
|
||||||
|
dependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8)':
|
||||||
|
dependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8)':
|
||||||
|
dependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8)':
|
||||||
|
dependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8)':
|
||||||
|
dependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
|
'@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8)':
|
||||||
|
dependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
|
||||||
'@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)':
|
'@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electric-sql/pglite': 0.4.3
|
'@electric-sql/pglite': 0.4.3
|
||||||
|
|
||||||
|
'@electric-sql/pglite-socket@0.2.11(@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite@0.5.8)':
|
||||||
|
dependencies:
|
||||||
|
'@electric-sql/pglite': 0.5.8
|
||||||
|
'@electric-sql/pglite-age': 0.0.9(@electric-sql/pglite@0.5.8)
|
||||||
|
'@electric-sql/pglite-pg_hashids': 0.0.9(@electric-sql/pglite@0.5.8)
|
||||||
|
'@electric-sql/pglite-pg_ivm': 0.0.9(@electric-sql/pglite@0.5.8)
|
||||||
|
'@electric-sql/pglite-pg_textsearch': 0.0.10(@electric-sql/pglite@0.5.8)
|
||||||
|
'@electric-sql/pglite-pg_uuidv7': 0.0.9(@electric-sql/pglite@0.5.8)
|
||||||
|
'@electric-sql/pglite-pgtap': 0.0.9(@electric-sql/pglite@0.5.8)
|
||||||
|
'@electric-sql/pglite-pgvector': 0.0.9(@electric-sql/pglite@0.5.8)
|
||||||
|
|
||||||
'@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)':
|
'@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electric-sql/pglite': 0.4.3
|
'@electric-sql/pglite': 0.4.3
|
||||||
|
|
||||||
'@electric-sql/pglite@0.4.3': {}
|
'@electric-sql/pglite@0.4.3': {}
|
||||||
|
|
||||||
|
'@electric-sql/pglite@0.5.8': {}
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.1
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
|
|
@ -3666,6 +3783,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.13.3
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
|
|
@ -5041,6 +5162,8 @@ snapshots:
|
||||||
|
|
||||||
node-releases@2.0.54: {}
|
node-releases@2.0.54: {}
|
||||||
|
|
||||||
|
nodemailer@10.0.1: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
npm-run-path@4.0.1:
|
npm-run-path@4.0.1:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserStatus" AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organization_id" UUID NOT NULL,
|
||||||
|
"email" VARCHAR(254) NOT NULL,
|
||||||
|
"name" VARCHAR(160) NOT NULL,
|
||||||
|
"password_hash" VARCHAR(256) NOT NULL,
|
||||||
|
"status" "UserStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"is_owner" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "roles" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organization_id" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(80) NOT NULL,
|
||||||
|
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"permissions" TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_roles" (
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"role_id" UUID NOT NULL,
|
||||||
|
"organization_id" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("user_id","role_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"token_hash" CHAR(64) NOT NULL,
|
||||||
|
"expires_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "recovery_tokens" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"token_hash" CHAR(64) NOT NULL,
|
||||||
|
"expires_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "recovery_tokens_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit_events" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organization_id" UUID NOT NULL,
|
||||||
|
"actor_id" UUID,
|
||||||
|
"action" VARCHAR(80) NOT NULL,
|
||||||
|
"target_id" UUID,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "rate_limits" (
|
||||||
|
"key" CHAR(64) NOT NULL,
|
||||||
|
"hits" INTEGER NOT NULL,
|
||||||
|
"expires_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "users_organization_id_created_at_id_idx" ON "users"("organization_id", "created_at", "id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_organization_id_email_key" ON "users"("organization_id", "email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_id_organization_id_key" ON "users"("id", "organization_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "roles_organization_id_name_key" ON "roles"("organization_id", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "roles_id_organization_id_key" ON "roles"("id", "organization_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "user_roles_role_id_organization_id_idx" ON "user_roles"("role_id", "organization_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "recovery_tokens_token_hash_key" ON "recovery_tokens"("token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "recovery_tokens_user_id_idx" ON "recovery_tokens"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "recovery_tokens_expires_at_idx" ON "recovery_tokens"("expires_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_events_organization_id_created_at_id_idx" ON "audit_events"("organization_id", "created_at", "id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "rate_limits_expires_at_idx" ON "rate_limits"("expires_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "roles" ADD CONSTRAINT "roles_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_organization_id_fkey" FOREIGN KEY ("role_id", "organization_id") REFERENCES "roles"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "recovery_tokens" ADD CONSTRAINT "recovery_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- Database-level invariants complement HTTP validation.
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_email_normalized CHECK (email = lower(email));
|
||||||
|
CREATE UNIQUE INDEX users_single_owner ON users (is_owner) WHERE is_owner = true;
|
||||||
|
|
||||||
|
-- Application-facing audit history is append-only. A dedicated DBA retention
|
||||||
|
-- procedure is required if records ever need to be archived or removed.
|
||||||
|
CREATE FUNCTION reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'Audit events are append-only';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
CREATE TRIGGER audit_events_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON audit_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_audit_mutation();
|
||||||
|
|
@ -3,17 +3,101 @@ generator client {
|
||||||
output = "../src/generated/prisma"
|
output = "../src/generated/prisma"
|
||||||
moduleFormat = "cjs"
|
moduleFormat = "cjs"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
enum UserStatus {
|
||||||
// The owning business. Supplier/customer organizations belong to later modules.
|
PENDING
|
||||||
|
ACTIVE
|
||||||
|
SUSPENDED
|
||||||
|
}
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String @db.VarChar(160)
|
name String @db.VarChar(160)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
users User[]
|
||||||
|
roles Role[]
|
||||||
|
auditEvents AuditEvent[]
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organizationId String @map("organization_id") @db.Uuid
|
||||||
|
email String @db.VarChar(254)
|
||||||
|
name String @db.VarChar(160)
|
||||||
|
passwordHash String @map("password_hash") @db.VarChar(256)
|
||||||
|
status UserStatus @default(PENDING)
|
||||||
|
isOwner Boolean @default(false) @map("is_owner")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
||||||
|
roles UserRole[]
|
||||||
|
sessions Session[]
|
||||||
|
recoveryTokens RecoveryToken[]
|
||||||
|
@@unique([organizationId, email])
|
||||||
|
@@unique([id, organizationId])
|
||||||
|
@@index([organizationId, createdAt, id])
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
model Role {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organizationId String @map("organization_id") @db.Uuid
|
||||||
|
name String @db.VarChar(80)
|
||||||
|
isSystem Boolean @default(false) @map("is_system")
|
||||||
|
permissions String[]
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
||||||
|
users UserRole[]
|
||||||
|
@@unique([organizationId, name])
|
||||||
|
@@unique([id, organizationId])
|
||||||
|
@@map("roles")
|
||||||
|
}
|
||||||
|
model UserRole {
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
roleId String @map("role_id") @db.Uuid
|
||||||
|
organizationId String @map("organization_id") @db.Uuid
|
||||||
|
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Cascade)
|
||||||
|
role Role @relation(fields: [roleId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||||
|
@@id([userId, roleId])
|
||||||
|
@@index([roleId, organizationId])
|
||||||
|
@@map("user_roles")
|
||||||
|
}
|
||||||
|
model Session {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
tokenHash String @unique @map("token_hash") @db.Char(64)
|
||||||
|
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("sessions")
|
||||||
|
}
|
||||||
|
model RecoveryToken {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
tokenHash String @unique @map("token_hash") @db.Char(64)
|
||||||
|
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("recovery_tokens")
|
||||||
|
}
|
||||||
|
model AuditEvent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organizationId String @map("organization_id") @db.Uuid
|
||||||
|
actorId String? @map("actor_id") @db.Uuid
|
||||||
|
action String @db.VarChar(80)
|
||||||
|
targetId String? @map("target_id") @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
||||||
|
@@index([organizationId, createdAt, id])
|
||||||
|
@@map("audit_events")
|
||||||
|
}
|
||||||
|
model RateLimit {
|
||||||
|
key String @id @db.Char(64)
|
||||||
|
hits Int
|
||||||
|
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("rate_limits")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { PGlite } from '@electric-sql/pglite';
|
||||||
|
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const db = await PGlite.create();
|
||||||
|
const server = new PGLiteSocketServer({ db, port: 0, host: '127.0.0.1' });
|
||||||
|
await server.start();
|
||||||
|
try {
|
||||||
|
const status = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, ['scripts/verify-migrations.mjs'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: `postgresql://postgres:postgres@${server.getServerConn()}/postgres`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code) => resolve(code ?? 1));
|
||||||
|
});
|
||||||
|
process.exitCode = status;
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { BadRequestException, PipeTransform } from '@nestjs/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export class SchemaPipe<T> implements PipeTransform<unknown, T> {
|
||||||
|
constructor(private readonly schema: z.ZodType<T>) {}
|
||||||
|
transform(value: unknown): T {
|
||||||
|
const result = this.schema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'Invalid request',
|
||||||
|
fields: [
|
||||||
|
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import { identityEnvironmentShape, validateSmtp } from './identity-environment';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z
|
||||||
|
.object({
|
||||||
|
...identityEnvironmentShape,
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(['development', 'test', 'production'])
|
.enum(['development', 'test', 'production'])
|
||||||
.default('development'),
|
.default('development'),
|
||||||
|
|
@ -26,7 +29,8 @@ const schema = z.object({
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
});
|
})
|
||||||
|
.superRefine(validateSmtp);
|
||||||
|
|
||||||
export type Environment = z.infer<typeof schema>;
|
export type Environment = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const identityEnvironmentShape = {
|
||||||
|
SESSION_TTL_MINUTES: z.coerce.number().int().min(5).max(10080).default(480),
|
||||||
|
RECOVERY_TTL_MINUTES: z.coerce.number().int().min(5).max(60).default(15),
|
||||||
|
SMTP_HOST: z.string().min(1).optional(),
|
||||||
|
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
|
||||||
|
SMTP_USER: z.string().min(1).optional(),
|
||||||
|
SMTP_PASSWORD: z.string().min(1).optional(),
|
||||||
|
SMTP_FROM: z.email().optional(),
|
||||||
|
RECOVERY_URL: z
|
||||||
|
.url()
|
||||||
|
.refine((value) => {
|
||||||
|
if (!URL.canParse(value)) return false;
|
||||||
|
const url = new URL(value);
|
||||||
|
return (
|
||||||
|
url.protocol === 'https:' &&
|
||||||
|
!url.username &&
|
||||||
|
!url.password &&
|
||||||
|
!url.hash &&
|
||||||
|
!url.search
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
};
|
||||||
|
export function validateSmtp(
|
||||||
|
input: {
|
||||||
|
SMTP_HOST?: string;
|
||||||
|
SMTP_USER?: string;
|
||||||
|
SMTP_PASSWORD?: string;
|
||||||
|
SMTP_FROM?: string;
|
||||||
|
RECOVERY_URL?: string;
|
||||||
|
},
|
||||||
|
context: z.RefinementCtx,
|
||||||
|
) {
|
||||||
|
const fields = [
|
||||||
|
'SMTP_HOST',
|
||||||
|
'SMTP_USER',
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
'SMTP_FROM',
|
||||||
|
'RECOVERY_URL',
|
||||||
|
] as const;
|
||||||
|
if (fields.some((field) => input[field] !== undefined)) {
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!input[field])
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: [field],
|
||||||
|
message: 'Required for recovery email',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Prisma } from '../generated/prisma/client';
|
||||||
|
|
||||||
|
export function recordAudit(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
organizationId: string,
|
||||||
|
actorId: string | null,
|
||||||
|
action: string,
|
||||||
|
targetId: string | null = null,
|
||||||
|
) {
|
||||||
|
// Deliberately accept no arbitrary payload: passwords and tokens cannot enter audit data.
|
||||||
|
return tx.auditEvent.create({
|
||||||
|
data: { organizationId, actorId, action, targetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { PERMISSIONS } from './permissions';
|
||||||
|
|
||||||
|
export const email = z
|
||||||
|
.email()
|
||||||
|
.max(254)
|
||||||
|
.transform((value) => value.toLowerCase());
|
||||||
|
export const password = z.string().min(15).max(128);
|
||||||
|
const name = z.string().trim().min(1).max(160);
|
||||||
|
export const loginSchema = z
|
||||||
|
.object({
|
||||||
|
organizationId: z.uuid(),
|
||||||
|
email,
|
||||||
|
password: z.string().min(1).max(128),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const recoveryRequestSchema = loginSchema.omit({ password: true });
|
||||||
|
export const recoveryResetSchema = z
|
||||||
|
.object({
|
||||||
|
token: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const createUserSchema = z.object({ email, name, password }).strict();
|
||||||
|
export const statusSchema = z
|
||||||
|
.object({ status: z.enum(['ACTIVE', 'SUSPENDED']) })
|
||||||
|
.strict();
|
||||||
|
export const roleSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(80),
|
||||||
|
permissions: z
|
||||||
|
.array(z.enum(PERMISSIONS))
|
||||||
|
.max(PERMISSIONS.length)
|
||||||
|
.refine((values) => new Set(values).size === values.length),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const assignmentsSchema = z
|
||||||
|
.object({
|
||||||
|
roleIds: z
|
||||||
|
.array(z.uuid())
|
||||||
|
.max(20)
|
||||||
|
.refine((ids) => new Set(ids).size === ids.length),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const pageSchema = z
|
||||||
|
.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||||
|
offset: z.coerce.number().int().min(0).max(10000).default(0),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
export type RecoveryInput = z.infer<typeof recoveryRequestSchema>;
|
||||||
|
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||||
|
export type RoleInput = z.infer<typeof roleSchema>;
|
||||||
|
export type PageInput = z.infer<typeof pageSchema>;
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { Request } from 'express';
|
||||||
|
export interface Principal {
|
||||||
|
userId: string;
|
||||||
|
organizationId: string;
|
||||||
|
sessionId: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
principal: Principal;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
const COST = 32768;
|
||||||
|
function derive(password: string, salt: string): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
scrypt(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
64,
|
||||||
|
{ N: COST, r: 8, p: 3, maxmem: 64 * 1024 * 1024 },
|
||||||
|
(error, key) => (error ? reject(error) : resolve(key)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@Injectable()
|
||||||
|
export class PasswordService {
|
||||||
|
async hash(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const key = await derive(password, salt);
|
||||||
|
return `scrypt-v1$${salt}$${key.toString('hex')}`;
|
||||||
|
}
|
||||||
|
async verify(password: string, encoded: string): Promise<boolean> {
|
||||||
|
const [version, salt, hash, extra] = encoded.split('$');
|
||||||
|
if (
|
||||||
|
version !== 'scrypt-v1' ||
|
||||||
|
!/^[a-f0-9]{32}$/.test(salt ?? '') ||
|
||||||
|
!/^[a-f0-9]{128}$/.test(hash ?? '') ||
|
||||||
|
extra !== undefined
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return timingSafeEqual(
|
||||||
|
await derive(password, salt),
|
||||||
|
Buffer.from(hash, 'hex'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Equal-cost work for an unknown account; no dummy credentials are usable.
|
||||||
|
async dummyVerify(password: string): Promise<void> {
|
||||||
|
await derive(password, '00000000000000000000000000000000');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export const PERMISSIONS = [
|
||||||
|
'users.read',
|
||||||
|
'users.create',
|
||||||
|
'users.approve',
|
||||||
|
'users.roles.assign',
|
||||||
|
'roles.read',
|
||||||
|
'roles.manage',
|
||||||
|
'audit.read',
|
||||||
|
] as const;
|
||||||
|
export type Permission = (typeof PERMISSIONS)[number];
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
export function issueToken(): { token: string; tokenHash: string } {
|
||||||
|
const token = randomBytes(32).toString('base64url');
|
||||||
|
return { token, tokenHash: hashToken(token) };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { parseEnvironment } from '../src/config/environment';
|
||||||
|
|
||||||
|
describe('identity environment', () => {
|
||||||
|
const base = { DATABASE_URL: 'postgresql://localhost/mani' };
|
||||||
|
const smtp = {
|
||||||
|
SMTP_HOST: 'smtp.example.com',
|
||||||
|
SMTP_USER: 'user',
|
||||||
|
SMTP_PASSWORD: 'secret',
|
||||||
|
SMTP_FROM: 'support@example.com',
|
||||||
|
RECOVERY_URL: 'https://shop.example.com/reset',
|
||||||
|
};
|
||||||
|
it('validates complete SMTP and lifetime configuration', () => {
|
||||||
|
expect(
|
||||||
|
parseEnvironment({ ...base, ...smtp, SESSION_TTL_MINUTES: '60' }),
|
||||||
|
).toMatchObject({ SESSION_TTL_MINUTES: 60, SMTP_PORT: 587 });
|
||||||
|
});
|
||||||
|
it('rejects partially configured SMTP without exposing secrets', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseEnvironment({ ...base, SMTP_PASSWORD: 'sensitive' }),
|
||||||
|
).toThrow('SMTP_HOST');
|
||||||
|
try {
|
||||||
|
parseEnvironment({ ...base, SMTP_PASSWORD: 'sensitive' });
|
||||||
|
} catch (error) {
|
||||||
|
expect(String(error)).not.toContain('sensitive');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
'http://shop.example.com/reset',
|
||||||
|
'https://shop.example.com/reset?token=x',
|
||||||
|
'https://user:pass@shop.example.com/reset',
|
||||||
|
'https://shop.example.com/reset#token',
|
||||||
|
'not-url',
|
||||||
|
])('rejects unsafe recovery URLs %s', (url) => {
|
||||||
|
expect(() =>
|
||||||
|
parseEnvironment({ ...base, ...smtp, RECOVERY_URL: url }),
|
||||||
|
).toThrow('RECOVERY_URL');
|
||||||
|
});
|
||||||
|
it.each([{ SESSION_TTL_MINUTES: '0' }, { RECOVERY_TTL_MINUTES: '61' }])(
|
||||||
|
'bounds expiry settings %j',
|
||||||
|
(values) => {
|
||||||
|
expect(() => parseEnvironment({ ...base, ...values })).toThrow(
|
||||||
|
'Invalid environment',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { PasswordService } from '../src/identity/password.service';
|
||||||
|
import { issueToken, hashToken } from '../src/identity/tokens';
|
||||||
|
import { createUserSchema, roleSchema } from '../src/identity/identity.schemas';
|
||||||
|
|
||||||
|
describe('passwords and token primitives', () => {
|
||||||
|
const passwords = new PasswordService();
|
||||||
|
it('salts equal passwords independently and verifies them', async () => {
|
||||||
|
const first = await passwords.hash('a secure long passphrase');
|
||||||
|
const second = await passwords.hash('a secure long passphrase');
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
expect(await passwords.verify('a secure long passphrase', first)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(await passwords.verify('wrong', first)).toBe(false);
|
||||||
|
await passwords.dummyVerify('anything');
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'bcrypt$bad',
|
||||||
|
'scrypt-v1$x$x',
|
||||||
|
'scrypt-v1$' + 'a'.repeat(32) + '$' + 'a'.repeat(128) + '$extra',
|
||||||
|
])('fails closed for malformed stored hash %s', async (value) => {
|
||||||
|
expect(await passwords.verify('password', value)).toBe(false);
|
||||||
|
});
|
||||||
|
it('issues random URL-safe tokens and one-way digests', () => {
|
||||||
|
const first = issueToken();
|
||||||
|
expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
|
expect(first.tokenHash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(hashToken(first.token)).toBe(first.tokenHash);
|
||||||
|
expect(issueToken().token).not.toBe(first.token);
|
||||||
|
});
|
||||||
|
it('validates long passphrases, known permissions and duplicate permissions', () => {
|
||||||
|
expect(
|
||||||
|
createUserSchema.safeParse({
|
||||||
|
email: 'a@example.com',
|
||||||
|
name: 'A',
|
||||||
|
password: 'short',
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
roleSchema.safeParse({ name: 'Role', permissions: ['anything'] }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
roleSchema.safeParse({
|
||||||
|
name: 'Role',
|
||||||
|
permissions: ['users.read', 'users.read'],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"isolatedModules": true
|
"isolatedModules": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "test/**/*.ts", "prisma.config.ts"]
|
"include": ["src/**/*.ts", "test/**/*.ts", "prisma.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue