feat/identity-access #1

Merged
mihir merged 4 commits from feat/identity-access into main 2026-09-09 19:30:07 +05:30
21 changed files with 755 additions and 36 deletions
Showing only changes of commit c4c85eb180 - Show all commits

View File

@ -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

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary

View File

@ -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: {

View File

@ -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",

View File

@ -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:

View File

@ -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;

View File

@ -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();

View File

@ -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")
}

View File

@ -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();
}

View File

@ -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;
}
}

View File

@ -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>;

View File

@ -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',
});
}
}
}

14
src/identity/audit.ts Normal file
View File

@ -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 },
});
}

View File

@ -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>;

View File

@ -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;
}

View File

@ -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');
}
}

View File

@ -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];

8
src/identity/tokens.ts Normal file
View File

@ -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) };
}

View File

@ -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',
);
},
);
});

50
test/password.spec.ts Normal file
View File

@ -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);
});
});

View File

@ -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"]
} }