This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3d6b0e0d4e
commit 5386bc51d1
65 changed files with 4058 additions and 161 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ STORAGE_PUBLIC_URL=http://localhost:9000/sport-media
# --- Rate limiting ----------------------------------------------------------
RATE_LIMIT_TTL_SECONDS=60
RATE_LIMIT_MAX=120
RATE_LIMIT_MAX=300
# --- Observability ----------------------------------------------------------
LOG_LEVEL=debug
+4 -1
View File
@@ -17,7 +17,8 @@
"db:deploy": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset --force"
"db:reset": "prisma migrate reset --force",
"create-admin": "tsx prisma/create-admin.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
@@ -37,6 +38,7 @@
"@sport/types": "workspace:*",
"@sport/validation": "workspace:*",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"nestjs-pino": "^4.6.1",
@@ -54,6 +56,7 @@
"@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/node": "^22.19.0",
+83
View File
@@ -0,0 +1,83 @@
/**
* Creates or repairs a SUPER_ADMIN account.
*
* pnpm --filter @sport/api run create-admin
* ADMIN_EMAIL=me@example.com ADMIN_PASSWORD='…' pnpm ... run create-admin
*
* This is the production bootstrap path, and the reason the seed never creates
* a privileged account with a known password. Safe to re-run: an existing
* account has its password reset and its role re-granted, which doubles as the
* "locked out of the admin" recovery procedure.
*/
import { PrismaClient } from '@prisma/client';
import { SYSTEM_ROLES } from '@sport/types';
import { generatePassword, hashPassword, verifyPassword } from './seed/accounts';
const prisma = new PrismaClient();
async function main(): Promise<void> {
const email = (process.env['ADMIN_EMAIL'] ?? 'admin@sport.local').trim().toLowerCase();
const provided = process.env['ADMIN_PASSWORD'];
const password = provided ?? generatePassword();
if (provided && provided.length < 10) {
throw new Error('ADMIN_PASSWORD must be at least 10 characters.');
}
const passwordHash = await hashPassword(password);
// Verify the hash round-trips before writing it. A malformed hash here would
// create an account nobody can ever sign in to, and the failure would only
// surface at the login screen.
if (!(await verifyPassword(password, passwordHash))) {
throw new Error('Password hash failed self-verification; refusing to write.');
}
const role = await prisma.role.findUnique({ where: { key: SYSTEM_ROLES.SUPER_ADMIN } });
if (!role) {
throw new Error('The super_admin role is missing. Run `pnpm db:seed` first.');
}
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash, status: 'ACTIVE', type: 'SUPER_ADMIN', deletedAt: null },
create: {
email,
passwordHash,
type: 'SUPER_ADMIN',
status: 'ACTIVE',
firstName: 'Super',
lastName: 'Admin',
emailVerifiedAt: new Date(),
},
select: { id: true },
});
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.id, roleId: role.id } },
update: {},
create: { userId: user.id, roleId: role.id },
});
console.log('\nSuper admin ready.\n');
console.log(` Email: ${email}`);
if (provided) {
console.log(' Password: (from ADMIN_PASSWORD)');
} else {
console.log(` Password: ${password}`);
console.log('\n Generated password — shown once. Store it now.');
}
console.log('');
}
main()
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
})
.finally(() => {
void prisma.$disconnect();
});
+14
View File
@@ -9,6 +9,7 @@
import { PrismaClient } from '@prisma/client';
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
import { seedDevAccounts } from './seed/accounts';
import { seedCatalog } from './seed/catalog';
const prisma = new PrismaClient();
@@ -144,6 +145,19 @@ async function main(): Promise<void> {
}
await seedCatalog(prisma);
const accounts = await seedDevAccounts(prisma);
const created = accounts.filter((account) => account.created);
if (created.length > 0) {
console.log('\nDevelopment sign-in accounts (shown once):\n');
for (const account of created) {
console.log(` ${account.email.padEnd(24)} ${account.password} [${account.role}]`);
}
console.log('\n Development only. Use `pnpm db:create-admin` for real environments.\n');
} else {
console.log('Development accounts already exist; passwords left unchanged.');
}
}
main()
+167
View File
@@ -0,0 +1,167 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import type { PrismaClient } from '@prisma/client';
import { SYSTEM_ROLES } from '@sport/types';
/**
* Password hashing for scripts.
*
* Deliberately duplicated from `common/security/password.service.ts` rather
* than imported: these scripts run under `tsx` outside the Nest container, and
* booting the DI graph to hash one string would be the more fragile choice.
*
* The hash FORMAT is the contract between the two, and it is self-describing —
* so a drift shows up as a failed login on the very next attempt, not as silent
* corruption. If a third caller ever appears, extract it to a package.
*/
const PARAMS = { N: 16_384, r: 8, p: 1 } as const;
const KEY_LENGTH = 64;
const MAX_MEM = 64 * 1024 * 1024;
export function hashPassword(plaintext: string): Promise<string> {
const salt = randomBytes(16);
return new Promise((resolve, reject) => {
scrypt(
plaintext.normalize('NFKC'),
salt,
KEY_LENGTH,
{ ...PARAMS, maxmem: MAX_MEM },
(error, derived) => {
if (error) return reject(error);
resolve(
[
'scrypt',
PARAMS.N,
PARAMS.r,
PARAMS.p,
salt.toString('base64'),
derived.toString('base64'),
].join('$'),
);
},
);
});
}
/** Used by the smoke test below to prove the format round-trips. */
export function verifyPassword(plaintext: string, stored: string): Promise<boolean> {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return Promise.resolve(false);
const [, n, r, p, salt, hash] = parts;
return new Promise((resolve) => {
scrypt(
plaintext.normalize('NFKC'),
Buffer.from(salt ?? '', 'base64'),
KEY_LENGTH,
{ N: Number(n), r: Number(r), p: Number(p), maxmem: MAX_MEM },
(error, derived) => {
if (error) return resolve(false);
const expected = Buffer.from(hash ?? '', 'base64');
resolve(derived.length === expected.length && timingSafeEqual(derived, expected));
},
);
});
}
/** A readable, high-entropy password for generated accounts. */
export function generatePassword(): string {
// Base64url of 18 bytes ≈ 24 characters, ~144 bits. Suffixed to guarantee the
// policy's uppercase/lowercase/digit requirements regardless of the draw.
return `${randomBytes(18).toString('base64url')}aA1`;
}
export interface SeededAccount {
email: string;
password: string;
role: string;
created: boolean;
}
/**
* Development sign-in accounts.
*
* Guarded twice — by NODE_ENV and by an explicit opt-out — because a known
* password reaching production is the single worst thing a seed can do. The
* generated password is printed once and never stored anywhere else.
*
* Existing accounts are left completely alone: re-running the seed must not
* reset a password someone has already changed.
*/
export async function seedDevAccounts(prisma: PrismaClient): Promise<SeededAccount[]> {
const accounts: SeededAccount[] = [];
const definitions = [
{
email: 'admin@sport.local',
type: 'SUPER_ADMIN' as const,
firstName: 'Demo',
lastName: 'Admin',
roleKey: SYSTEM_ROLES.SUPER_ADMIN,
},
{
email: 'staff@sport.local',
type: 'STAFF' as const,
firstName: 'Demo',
lastName: 'Staff',
roleKey: SYSTEM_ROLES.CATALOG_MANAGER,
},
{
email: 'customer@sport.local',
type: 'CUSTOMER' as const,
firstName: 'Demo',
lastName: 'Customer',
roleKey: SYSTEM_ROLES.CUSTOMER,
},
];
for (const definition of definitions) {
const existing = await prisma.user.findUnique({ where: { email: definition.email } });
if (existing) {
accounts.push({
email: definition.email,
password: '(unchanged)',
role: definition.roleKey,
created: false,
});
continue;
}
const password = generatePassword();
const role = await prisma.role.findUnique({ where: { key: definition.roleKey } });
const user = await prisma.user.create({
data: {
email: definition.email,
passwordHash: await hashPassword(password),
type: definition.type,
status: 'ACTIVE',
firstName: definition.firstName,
lastName: definition.lastName,
emailVerifiedAt: new Date(),
...(role ? { roles: { create: { roleId: role.id } } } : {}),
},
select: { id: true },
});
// A customer account needs its shopper profile, or /account has nothing to
// hang addresses and orders off later.
if (definition.type === 'CUSTOMER') {
await prisma.customer.create({ data: { userId: user.id } });
}
accounts.push({
email: definition.email,
password,
role: definition.roleKey,
created: true,
});
}
return accounts;
}
+2
View File
@@ -5,6 +5,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
import { MediaUrlModule } from './common/media/media.module';
import { SecurityModule } from './common/security/security.module';
import { APP_CONFIG, AppConfigModule } from './config/app-config.module';
import type { AppConfig } from './config/configuration';
import { EventsModule } from './infrastructure/events/events.module';
@@ -55,6 +56,7 @@ import { WishlistModule } from './modules/wishlist/wishlist.module';
StorageModule,
EventsModule,
MediaUrlModule,
SecurityModule,
ThrottlerModule.forRootAsync({
inject: [APP_CONFIG],
@@ -0,0 +1,154 @@
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
/**
* Hand-written rather than `promisify(scrypt)`: promisify resolves to the
* no-options overload, so passing `maxmem` becomes a type error even though the
* runtime accepts it.
*/
function scryptAsync(
password: string,
salt: Buffer,
keylen: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, options, (error, derivedKey) => {
if (error) reject(error);
else resolve(derivedKey);
});
});
}
/**
* Password hashing.
*
* ALGORITHM CHOICE
* scrypt from `node:crypto`, at OWASP's recommended parameters. Argon2id would
* be the first choice on paper, but every Node binding for it is a native
* module — a compile step in CI, a platform matrix in Docker, and a class of
* deployment failure that has nothing to do with this application. scrypt is
* memory-hard, standardised (RFC 7914), on OWASP's approved list, and already
* in the runtime.
*
* ALGORITHM AGILITY
* Hashes are stored self-describing:
*
* scrypt$16384$8$1$<salt-b64>$<hash-b64>
*
* The verifier reads its parameters from the stored string rather than from
* today's constants, so raising the cost — or moving to Argon2id later — is a
* transparent rehash-on-next-login. `needsRehash()` reports when that applies.
* A password store you cannot upgrade is a password store you are stuck with.
*/
/**
* N=2^14 (16 MiB), r=8, p=1 — OWASP's minimum for scrypt.
*
* Deliberately not higher: this runs on the login path, and a cost that makes
* sign-in feel slow is a cost someone will quietly lower in six months. The
* `maxmem` bump is required because Node's default ceiling is below what N
* needs.
*/
const CURRENT_PARAMS = { N: 16_384, r: 8, p: 1 } as const;
const KEY_LENGTH = 64;
const SALT_LENGTH = 16;
const MAX_MEM = 64 * 1024 * 1024;
@Injectable()
export class PasswordService {
private readonly logger = new Logger(PasswordService.name);
async hash(plaintext: string): Promise<string> {
const salt = randomBytes(SALT_LENGTH);
const derived = await this.derive(plaintext, salt, CURRENT_PARAMS);
return [
'scrypt',
CURRENT_PARAMS.N,
CURRENT_PARAMS.r,
CURRENT_PARAMS.p,
salt.toString('base64'),
derived.toString('base64'),
].join('$');
}
/**
* Constant-time verification.
*
* Returns false on a malformed hash rather than throwing: a corrupt row must
* read as "wrong password", not as a 500 that tells an attacker the account
* exists and is in an unusual state.
*/
async verify(plaintext: string, stored: string | null | undefined): Promise<boolean> {
if (!stored) return false;
const parsed = this.parse(stored);
if (!parsed) {
this.logger.warn('Encountered an unparseable password hash');
return false;
}
try {
const derived = await this.derive(plaintext, parsed.salt, parsed.params);
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
} catch (error) {
this.logger.error(`Password verification failed: ${String(error)}`);
return false;
}
}
/** True when the stored hash uses weaker parameters than we now require. */
needsRehash(stored: string): boolean {
const parsed = this.parse(stored);
if (!parsed) return true;
return (
parsed.params.N < CURRENT_PARAMS.N ||
parsed.params.r < CURRENT_PARAMS.r ||
parsed.params.p < CURRENT_PARAMS.p
);
}
/**
* A hash of a throwaway value, used to keep login timing flat when the email
* does not exist. Without it, "unknown email" returns measurably faster than
* "wrong password", which turns the login form into a user enumeration oracle.
*/
async burnCycles(): Promise<void> {
await this.derive('timing-equalisation', Buffer.alloc(SALT_LENGTH), CURRENT_PARAMS);
}
private derive(
plaintext: string,
salt: Buffer,
params: { N: number; r: number; p: number },
): Promise<Buffer> {
return scryptAsync(plaintext.normalize('NFKC'), salt, KEY_LENGTH, {
...params,
maxmem: MAX_MEM,
});
}
private parse(
stored: string,
): { params: { N: number; r: number; p: number }; salt: Buffer; hash: Buffer } | null {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return null;
const [, rawN, rawR, rawP, rawSalt, rawHash] = parts;
const N = Number(rawN);
const r = Number(rawR);
const p = Number(rawP);
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return null;
if (!rawSalt || !rawHash) return null;
return {
params: { N, r, p },
salt: Buffer.from(rawSalt, 'base64'),
hash: Buffer.from(rawHash, 'base64'),
};
}
}
@@ -0,0 +1,66 @@
import { PasswordService } from './password.service';
/**
* Password handling has no second chance: a bug here is either "nobody can log
* in" or "everybody can". These tests pin the properties that matter rather
* than the implementation.
*/
describe('PasswordService', () => {
const service = new PasswordService();
it('round-trips a password', async () => {
const hash = await service.hash('Correct horse battery 1');
await expect(service.verify('Correct horse battery 1', hash)).resolves.toBe(true);
});
it('rejects the wrong password', async () => {
const hash = await service.hash('Correct horse battery 1');
await expect(service.verify('Correct horse battery 2', hash)).resolves.toBe(false);
});
it('salts, so the same password hashes differently every time', async () => {
const [a, b] = await Promise.all([service.hash('SameInput123'), service.hash('SameInput123')]);
expect(a).not.toBe(b);
await expect(service.verify('SameInput123', a)).resolves.toBe(true);
await expect(service.verify('SameInput123', b)).resolves.toBe(true);
});
it('emits a self-describing hash so parameters can change later', async () => {
const hash = await service.hash('Parameters123');
const [algorithm, n, r, p] = hash.split('$');
expect(algorithm).toBe('scrypt');
expect(Number(n)).toBeGreaterThanOrEqual(16_384);
expect(Number(r)).toBeGreaterThanOrEqual(8);
expect(Number(p)).toBeGreaterThanOrEqual(1);
});
it('treats a null or malformed hash as a failed verification, never an error', async () => {
// A corrupt row must read as "wrong password". Throwing would tell an
// attacker the account exists and is in an unusual state.
await expect(service.verify('anything', null)).resolves.toBe(false);
await expect(service.verify('anything', undefined)).resolves.toBe(false);
await expect(service.verify('anything', 'not-a-hash')).resolves.toBe(false);
await expect(service.verify('anything', 'scrypt$bad$params$here$x$y')).resolves.toBe(false);
});
it('normalises unicode, so an accented password survives a different keyboard', async () => {
// U+00E9 vs U+0065 U+0301 — visually identical, different bytes.
const composed = 'caféPassw0rd';
const decomposed = 'caféPassw0rd';
const hash = await service.hash(composed);
await expect(service.verify(decomposed, hash)).resolves.toBe(true);
});
it('flags weaker stored parameters for rehash', () => {
expect(service.needsRehash('scrypt$1024$8$1$c2FsdA==$aGFzaA==')).toBe(true);
expect(service.needsRehash('garbage')).toBe(true);
});
it('does not flag a current hash for rehash', async () => {
const hash = await service.hash('CurrentParams1');
expect(service.needsRehash(hash)).toBe(false);
});
});
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { PasswordService } from './password.service';
/**
* Stateless security primitives.
*
* `PasswordService` lives here rather than in AuthModule to break a dependency
* cycle: AuthModule needs UsersModule to look up accounts, and UsersModule
* needs password hashing to create them. Hashing has no dependencies of its
* own, so hoisting it out of both is the fix — `forwardRef` would only hide the
* cycle rather than remove it.
*/
@Global()
@Module({
providers: [PasswordService],
exports: [PasswordService],
})
export class SecurityModule {}
+11 -1
View File
@@ -45,7 +45,17 @@ export const envSchema = z.object({
STORAGE_PUBLIC_URL: z.url(),
RATE_LIMIT_TTL_SECONDS: z.coerce.number().int().positive().default(60),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
/**
* Coarse per-IP ceiling — a DoS safety net, not the real protection.
*
* Raised from 120: many users share one address behind corporate NAT or
* carrier-grade NAT, and a catalog page makes several API calls. At 120/min a
* single office could exhaust the budget and start receiving 429s on
* `/auth/refresh`, which the client correctly reads as "session over" and
* signs everyone out. Fine-grained protection lives per-endpoint — see
* LoginThrottleService, which counts failures per account and per IP.
*/
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
LOG_PRETTY: z.stringbool().default(false),
+4
View File
@@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { Logger } from 'nestjs-pino';
@@ -31,6 +32,9 @@ async function bootstrap(): Promise<void> {
// First in the chain: every log line and error response carries this id.
app.use(requestIdMiddleware);
// Refresh tokens arrive as httpOnly cookies; without this `req.cookies` is
// undefined and every refresh silently fails as "no session".
app.use(cookieParser());
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
app.use(compression());
@@ -0,0 +1,194 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Post,
Req,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import {
TOKEN_AUDIENCES,
type AuthenticatedActor,
type CurrentUser,
type LoginResult,
type RefreshResult,
type SessionSummary,
type TokenAudience,
} from '@sport/types';
import { loginSchema, type LoginInput } from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import { Public } from '@/common/decorators/public.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
import { AuthService, type IssuedSession, type RequestContext } from './auth.service';
import { clearRefreshCookie, readRefreshCookie, setRefreshCookie } from './refresh-cookie';
/**
* Credential exchange.
*
* Storefront and admin have separate login endpoints rather than one endpoint
* that infers the audience. The audience decides which population may sign in
* and which cookie is issued — inferring it from the account would mean a
* single leaked customer credential could be pointed at the admin surface and
* only a later check would stop it. Two routes make the boundary explicit.
*/
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Customer sign-in (storefront audience)' })
login(
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<LoginResult> {
return this.handleLogin(body, TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Back-office sign-in (admin audience)' })
adminLogin(
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<LoginResult> {
return this.handleLogin(body, TOKEN_AUDIENCES.ADMIN, request, response);
}
/**
* Public because it authenticates with the cookie, not with an access token —
* the whole point is to be callable once the access token has expired.
*/
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Rotate the storefront refresh token' })
refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<RefreshResult> {
return this.handleRefresh(TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Rotate the admin refresh token' })
adminRefresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<RefreshResult> {
return this.handleRefresh(TOKEN_AUDIENCES.ADMIN, request, response);
}
@Public()
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Sign out of the storefront' })
logout(@Req() request: Request, @Res({ passthrough: true }) response: Response): Promise<void> {
return this.handleLogout(TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/logout')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Sign out of the admin' })
adminLogout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
return this.handleLogout(TOKEN_AUDIENCES.ADMIN, request, response);
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'The signed-in user, with roles and permissions' })
me(@CurrentActor() actor: AuthenticatedActor): Promise<CurrentUser> {
return this.authService.me(actor.userId);
}
@Get('sessions')
@ApiBearerAuth()
@ApiOperation({ summary: 'Active sessions for the signed-in user' })
sessions(@CurrentActor() actor: AuthenticatedActor): Promise<SessionSummary[]> {
return this.authService.listSessions(actor.userId, actor.sessionId);
}
// ---- shared handlers -----------------------------------------------------
private async handleLogin(
body: LoginInput,
audience: TokenAudience,
request: Request,
response: Response,
): Promise<LoginResult> {
const { result, session } = await this.authService.login(body, audience, contextOf(request));
this.writeSession(response, audience, session);
return result;
}
private async handleRefresh(
audience: TokenAudience,
request: Request,
response: Response,
): Promise<RefreshResult> {
const { result, session } = await this.authService.refresh(
readRefreshCookie(request, audience),
audience,
contextOf(request),
);
this.writeSession(response, audience, session);
return result;
}
private async handleLogout(
audience: TokenAudience,
request: Request,
response: Response,
): Promise<void> {
await this.authService.logout(readRefreshCookie(request, audience));
clearRefreshCookie(response, audience, this.config.app.isProduction);
}
private writeSession(response: Response, audience: TokenAudience, session: IssuedSession): void {
setRefreshCookie(
response,
audience,
session.refreshToken,
session.refreshTokenExpiresAt,
this.config.app.isProduction,
);
}
}
/**
* The client IP comes from Express's `trust proxy` handling, which is why
* `app.set('trust proxy', 1)` in main.ts matters — without it every request
* behind Nginx would look like it came from the proxy, and the per-IP login
* throttle would lock out the entire internet at once.
*/
function contextOf(request: Request): RequestContext {
return {
userAgent: request.header('user-agent')?.slice(0, 512) ?? null,
ipAddress: request.ip ?? null,
};
}
+18 -7
View File
@@ -2,27 +2,38 @@ import { Global, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '@/modules/users/users.module';
import { AuthController } from './auth.controller';
import { AuthRepository } from './auth.repository';
import { AuthService } from './auth.service';
import { AccessTokenGuard } from './guards/access-token.guard';
import { PermissionsGuard } from './guards/permissions.guard';
import { LoginThrottleService } from './login-throttle.service';
import { TokenService } from './token.service';
/**
* Milestone 0 provides the *enforcement* half of auth: token verification,
* audience separation and RBAC evaluation, wired globally.
* AuthModule — owns `sessions`, and nothing else.
*
* The *issuance* half — login, registration, refresh rotation, password reset,
* OTP — is milestone 1. Splitting it this way means every endpoint written from
* here on is protected by default, before a single credential exists.
* Accounts, roles and permissions belong to UsersModule; this module exchanges
* credentials for tokens and manages session lifetime. Password *hashing* lives
* in `common/security` so that both modules can use it without a cycle.
*
* Guard order matters: AccessTokenGuard must populate `request.actor` before
* PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order.
*/
@Global()
@Module({
imports: [JwtModule.register({})],
imports: [JwtModule.register({}), UsersModule],
controllers: [AuthController],
providers: [
AuthService,
AuthRepository,
TokenService,
LoginThrottleService,
{ provide: APP_GUARD, useClass: AccessTokenGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
exports: [JwtModule],
exports: [JwtModule, AuthService],
})
export class AuthModule {}
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
/**
* The session table — the only data AuthModule owns.
*
* One row per refresh token. Rotation appends a new row and links the old one
* to it, so a session's full history is reconstructable, which is what makes
* token-reuse detection possible at all.
*/
@Injectable()
export class AuthRepository {
constructor(private readonly prisma: PrismaService) {}
findByRefreshHash(refreshTokenHash: string) {
return this.prisma.session.findUnique({
where: { refreshTokenHash },
select: {
id: true,
userId: true,
familyId: true,
replacedById: true,
revokedAt: true,
expiresAt: true,
},
});
}
create(params: {
userId: string;
familyId: string;
refreshTokenHash: string;
expiresAt: Date;
userAgent: string | null;
ipAddress: string | null;
}) {
return this.prisma.session.create({
data: {
userId: params.userId,
familyId: params.familyId,
refreshTokenHash: params.refreshTokenHash,
expiresAt: params.expiresAt,
userAgent: params.userAgent,
ipAddress: params.ipAddress,
},
select: { id: true, familyId: true },
});
}
/**
* Rotates a session: creates the successor and links the predecessor to it,
* in one transaction.
*
* If this were two statements and the second failed, the old token would stay
* valid alongside the new one — two live credentials from one refresh, which
* defeats the point of rotating.
*/
async rotate(params: {
previousSessionId: string;
userId: string;
familyId: string;
refreshTokenHash: string;
expiresAt: Date;
userAgent: string | null;
ipAddress: string | null;
}) {
return this.prisma.$transaction(async (tx) => {
const next = await tx.session.create({
data: {
userId: params.userId,
familyId: params.familyId,
refreshTokenHash: params.refreshTokenHash,
expiresAt: params.expiresAt,
userAgent: params.userAgent,
ipAddress: params.ipAddress,
},
select: { id: true },
});
await tx.session.update({
where: { id: params.previousSessionId },
data: { replacedById: next.id },
});
return next;
});
}
/** Revokes one session — a single device signing out. */
async revoke(sessionId: string): Promise<void> {
await this.prisma.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Revokes an entire token family.
*
* Called when a refresh token is replayed. Either the token leaked or the
* client is broken; both warrant forcing a fresh sign-in on that device.
*/
async revokeFamily(familyId: string): Promise<number> {
const result = await this.prisma.session.updateMany({
where: { familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
}
/** Signs a user out everywhere — used after a password change. */
async revokeAllForUser(userId: string): Promise<number> {
const result = await this.prisma.session.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
}
listActiveForUser(userId: string) {
return this.prisma.session.findMany({
where: { userId, revokedAt: null, replacedById: null, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ipAddress: true,
createdAt: true,
expiresAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Housekeeping for expired rows.
*
* Rotation is append-only, so this table grows with every refresh — a daily
* job calls this. Rows are kept a week past expiry so a security review can
* still see what happened.
*/
async deleteExpired(before = new Date(Date.now() - 7 * 86_400_000)): Promise<number> {
const result = await this.prisma.session.deleteMany({ where: { expiresAt: { lt: before } } });
return result.count;
}
}
+298
View File
@@ -0,0 +1,298 @@
import { Injectable, Logger } from '@nestjs/common';
import {
API_ERROR_CODES,
TOKEN_AUDIENCES,
isBackOfficeUser,
type CurrentUser,
type LoginResult,
type RefreshResult,
type SessionSummary,
type TokenAudience,
type UserType,
} from '@sport/types';
import type { LoginInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { PasswordService } from '@/common/security/password.service';
import { UsersService, type AuthUserRow } from '@/modules/users/public';
import { AuthRepository } from './auth.repository';
import { LoginThrottleService } from './login-throttle.service';
import { TokenService } from './token.service';
export interface RequestContext {
userAgent: string | null;
ipAddress: string | null;
}
export interface IssuedSession {
accessToken: string;
accessTokenExpiresAt: Date;
refreshToken: string;
refreshTokenExpiresAt: Date;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly usersService: UsersService,
private readonly passwordService: PasswordService,
private readonly tokenService: TokenService,
private readonly repository: AuthRepository,
private readonly throttle: LoginThrottleService,
) {}
/**
* Exchanges credentials for a session.
*
* Every failure path returns the same message and the same status. Telling a
* caller apart — "no such account" vs "wrong password" vs "suspended" — turns
* the login form into a user-enumeration oracle, and the timing is equalised
* for the same reason.
*/
async login(
input: LoginInput,
audience: TokenAudience,
context: RequestContext,
): Promise<{ result: LoginResult; session: IssuedSession }> {
await this.throttle.assertNotLocked(input.email, context.ipAddress);
const user = await this.usersService.findForAuthByEmail(input.email);
if (!user) {
// Spend the same CPU as a real verification so a missing account is not
// measurably faster than a wrong password.
await this.passwordService.burnCycles();
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
const passwordValid = await this.passwordService.verify(input.password, user.passwordHash);
if (!passwordValid) {
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
if (user.status !== 'ACTIVE') {
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
this.assertAudience(user.type as UserType, audience);
// Transparent upgrade if the stored hash predates the current cost.
if (user.passwordHash && this.passwordService.needsRehash(user.passwordHash)) {
await this.usersService.updatePasswordHash(
user.id,
await this.passwordService.hash(input.password),
);
this.logger.log(`Upgraded password hash parameters for user ${user.id}`);
}
const session = await this.startSession(user, audience, context);
await Promise.all([
this.usersService.recordLogin(user.id),
this.throttle.recordSuccess(input.email),
]);
return {
result: {
user: this.usersService.toCurrentUser(user),
accessToken: session.accessToken,
accessTokenExpiresAt: session.accessTokenExpiresAt.toISOString(),
},
session,
};
}
/**
* Rotates a refresh token.
*
* The security-critical branch is reuse detection: a token that has already
* been rotated or revoked must never work again, and being presented with one
* means either it leaked or the client is broken. Both justify killing the
* whole family — that is the difference between detecting theft and merely
* limiting its window.
*/
async refresh(
refreshToken: string | undefined,
audience: TokenAudience,
context: RequestContext,
): Promise<{ result: RefreshResult; session: IssuedSession }> {
if (!refreshToken) {
throw AppException.unauthenticated('Your session has expired. Please sign in again.');
}
const hash = this.tokenService.hashRefreshToken(refreshToken);
const existing = await this.repository.findByRefreshHash(hash);
if (!existing) {
throw AppException.unauthenticated(
'Your session has expired. Please sign in again.',
API_ERROR_CODES.TOKEN_INVALID,
);
}
if (existing.revokedAt !== null || existing.replacedById !== null) {
const revoked = await this.repository.revokeFamily(existing.familyId);
this.logger.error(
`Refresh token reuse detected for user ${existing.userId}; revoked ${revoked} session(s) in family ${existing.familyId}`,
);
throw AppException.unauthenticated(
'Your session is no longer valid. Please sign in again.',
API_ERROR_CODES.TOKEN_INVALID,
);
}
if (existing.expiresAt.getTime() <= Date.now()) {
throw AppException.unauthenticated(
'Your session has expired. Please sign in again.',
API_ERROR_CODES.TOKEN_EXPIRED,
);
}
const user = await this.usersService.findForAuthById(existing.userId);
if (!user || user.status !== 'ACTIVE') {
await this.repository.revokeFamily(existing.familyId);
throw AppException.unauthenticated('Your session is no longer valid. Please sign in again.');
}
this.assertAudience(user.type as UserType, audience);
const refresh = this.tokenService.issueRefreshToken();
const next = await this.repository.rotate({
previousSessionId: existing.id,
userId: user.id,
familyId: existing.familyId,
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
userAgent: context.userAgent,
ipAddress: context.ipAddress,
});
// Permissions are re-read from the database on every rotation, so a role
// change takes effect within one access-token lifetime rather than
// persisting for the life of the refresh token.
const access = await this.tokenService.issueAccessToken({
userId: user.id,
userType: user.type as UserType,
audience,
permissions: this.usersService.permissionsOf(user),
sessionId: next.id,
});
return {
result: {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt.toISOString(),
},
session: {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt,
refreshToken: refresh.token,
refreshTokenExpiresAt: refresh.expiresAt,
},
};
}
/** Signs out one device. Idempotent — an unknown token is still a success. */
async logout(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
const existing = await this.repository.findByRefreshHash(
this.tokenService.hashRefreshToken(refreshToken),
);
if (existing) {
await this.repository.revokeFamily(existing.familyId);
}
}
async me(userId: string): Promise<CurrentUser> {
const user = await this.usersService.findForAuthById(userId);
if (!user || user.status !== 'ACTIVE') {
throw AppException.unauthenticated();
}
return this.usersService.toCurrentUser(user);
}
async listSessions(userId: string, currentSessionId: string): Promise<SessionSummary[]> {
const rows = await this.repository.listActiveForUser(userId);
return rows.map((row) => ({
id: row.id,
userAgent: row.userAgent,
ipAddress: row.ipAddress,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
isCurrent: row.id === currentSessionId,
}));
}
// ---- internals -----------------------------------------------------------
private async startSession(
user: AuthUserRow,
audience: TokenAudience,
context: RequestContext,
): Promise<IssuedSession> {
const refresh = this.tokenService.issueRefreshToken();
const session = await this.repository.create({
userId: user.id,
// A fresh sign-in starts a new family; rotation stays within it. That is
// what keeps revoking one compromised device from signing out the rest.
familyId: this.tokenService.newSessionFamilyId(),
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
userAgent: context.userAgent,
ipAddress: context.ipAddress,
});
const access = await this.tokenService.issueAccessToken({
userId: user.id,
userType: user.type as UserType,
audience,
permissions: this.usersService.permissionsOf(user),
sessionId: session.id,
});
return {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt,
refreshToken: refresh.token,
refreshTokenExpiresAt: refresh.expiresAt,
};
}
/**
* A customer may never obtain an admin token, and a staff account may not
* sign in through the storefront form.
*
* Checked at issuance as well as at every request (AccessTokenGuard), because
* a token that should never have existed is worse than one that is merely
* rejected later.
*/
private assertAudience(userType: UserType, audience: TokenAudience): void {
const allowed =
audience === TOKEN_AUDIENCES.ADMIN ? isBackOfficeUser(userType) : userType === 'CUSTOMER';
if (!allowed) {
throw invalidCredentials();
}
}
}
/** One message, one status, for every failure mode. */
function invalidCredentials(): AppException {
return AppException.unauthenticated(
'Email or password is incorrect.',
API_ERROR_CODES.INVALID_CREDENTIALS,
);
}
@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { API_ERROR_CODES } from '@sport/types';
import { AppException } from '@/common/errors/app.exception';
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
/**
* Login-specific rate limiting, on top of the global per-IP throttle.
*
* Two counters, because they stop different attacks:
*
* - **per account** — someone guessing one user's password. A botnet spreads
* across many IPs, so an IP counter alone never trips.
* - **per IP** — someone spraying one common password across many accounts.
* An account counter alone never trips for that.
*
* Counting only failures means a busy legitimate user is never locked out, and
* a successful login clears the account counter.
*/
const MAX_FAILURES_PER_ACCOUNT = 8;
const MAX_FAILURES_PER_IP = 30;
const WINDOW_SECONDS = 15 * 60;
@Injectable()
export class LoginThrottleService {
private readonly logger = new Logger(LoginThrottleService.name);
constructor(private readonly redis: RedisService) {}
async assertNotLocked(email: string, ipAddress: string | null): Promise<void> {
const [accountFailures, ipFailures] = await Promise.all([
this.peek(CACHE_KEYS.rateLimit('login:account', email.toLowerCase())),
ipAddress ? this.peek(CACHE_KEYS.rateLimit('login:ip', ipAddress)) : Promise.resolve(0),
]);
if (accountFailures >= MAX_FAILURES_PER_ACCOUNT || ipFailures >= MAX_FAILURES_PER_IP) {
this.logger.warn(
`Login blocked by throttle (account failures: ${accountFailures}, ip failures: ${ipFailures})`,
);
throw new AppException({
code: API_ERROR_CODES.RATE_LIMITED,
message: 'Too many failed sign-in attempts. Please try again in a few minutes.',
status: 429,
});
}
}
async recordFailure(email: string, ipAddress: string | null): Promise<void> {
await Promise.all([
this.redis.increment(
CACHE_KEYS.rateLimit('login:account', email.toLowerCase()),
WINDOW_SECONDS,
),
ipAddress
? this.redis.increment(CACHE_KEYS.rateLimit('login:ip', ipAddress), WINDOW_SECONDS)
: Promise.resolve(0),
]);
}
/**
* Clears the account counter on success. The IP counter is deliberately left
* alone: one correct password should not reset a spray in progress from the
* same address.
*/
async recordSuccess(email: string): Promise<void> {
await this.redis.delete(CACHE_KEYS.rateLimit('login:account', email.toLowerCase()));
}
/**
* Reads a counter without incrementing. Redis being unavailable must not
* block sign-in — the global throttle and the password itself still apply.
*/
private async peek(key: string): Promise<number> {
try {
const value = await this.redis.get<number>(key);
return typeof value === 'number' ? value : 0;
} catch {
return 0;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Public surface of AuthModule.
*
* Deliberately narrow. `TokenService`, `AuthRepository` and the throttle stay
* private — nothing outside this module should be minting tokens or writing to
* the session table.
*
* Note that `PasswordService` is NOT here: it lives in `common/security`
* because UsersModule needs it too, and routing it through this module would
* create a dependency cycle.
*/
export { AuthService } from '../auth.service';
export type { RequestContext } from '../auth.service';
@@ -0,0 +1,78 @@
import type { CookieOptions, Request, Response } from 'express';
import type { TokenAudience } from '@sport/types';
/**
* Refresh-token cookie handling.
*
* Storefront and admin get *separately named* cookies. Sharing one name would
* mean signing into the admin silently replaces a customer session on the same
* browser — and worse, that a single cookie could be replayed against the other
* audience.
*/
const COOKIE_NAMES: Record<TokenAudience, string> = {
storefront: 'sport_refresh',
admin: 'sport_admin_refresh',
};
/**
* Scoped to the refresh endpoints only.
*
* The browser then sends this cookie on exactly two requests instead of
* attaching it to every API call — so an XSS that can read responses still
* never sees it, and it is not sitting in the headers of hundreds of unrelated
* requests waiting to be logged somewhere.
*/
const COOKIE_PATH = '/api/v1/auth';
export function refreshCookieName(audience: TokenAudience): string {
return COOKIE_NAMES[audience];
}
export function readRefreshCookie(request: Request, audience: TokenAudience): string | undefined {
const cookies = request.cookies as Record<string, string> | undefined;
return cookies?.[refreshCookieName(audience)];
}
export function setRefreshCookie(
response: Response,
audience: TokenAudience,
token: string,
expiresAt: Date,
isProduction: boolean,
): void {
response.cookie(refreshCookieName(audience), token, {
...baseOptions(isProduction),
expires: expiresAt,
});
}
export function clearRefreshCookie(
response: Response,
audience: TokenAudience,
isProduction: boolean,
): void {
response.clearCookie(refreshCookieName(audience), baseOptions(isProduction));
}
function baseOptions(isProduction: boolean): CookieOptions {
return {
// JavaScript cannot read it. This is the property that makes an XSS unable
// to steal the credential that mints new sessions.
httpOnly: true,
// HTTPS only in production. Local development runs on plain HTTP, and a
// Secure cookie would simply never be set — which looks like a broken
// login rather than a config choice.
secure: isProduction,
/**
* `lax` works because the frontends reach the API through their *own*
* origin — Nginx in production, a Next.js rewrite in development — so this
* is a first-party cookie. Cross-origin would force `SameSite=None`, which
* requires `Secure` and therefore cannot work over local HTTP at all.
*/
sameSite: 'lax',
path: COOKIE_PATH,
};
}
+127
View File
@@ -0,0 +1,127 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { AccessTokenClaims, Permission, TokenAudience, UserType } from '@sport/types';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
export interface IssuedAccessToken {
token: string;
expiresAt: Date;
}
export interface IssuedRefreshToken {
/** The value handed to the client. Never stored. */
token: string;
/** SHA-256 of the token — this is what the database keeps. */
hash: string;
expiresAt: Date;
}
/**
* Mints and verifies tokens. Holds no state; sessions live in AuthRepository.
*
* The two token types are deliberately different in kind:
*
* - The **access token** is a JWT. Stateless, short-lived, carries the
* permission set so guards do no database work on the hot path.
* - The **refresh token** is opaque random bytes, not a JWT. There is nothing
* for a client to read in it, and because it is checked against a database
* row it can be revoked — which a stateless JWT fundamentally cannot be.
*
* Only a SHA-256 of the refresh token is stored. A database leak therefore
* yields no usable credentials. SHA-256 rather than a password hash is correct
* here: the token is 256 bits of entropy, so there is no dictionary to attack
* and no reason to pay scrypt's cost on every refresh.
*/
@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async issueAccessToken(params: {
userId: string;
userType: UserType;
audience: TokenAudience;
permissions: readonly Permission[];
sessionId: string;
}): Promise<IssuedAccessToken> {
const expiresInSeconds = parseDuration(this.config.auth.accessTtl);
const token = await this.jwtService.signAsync(
{
sub: params.userId,
aud: params.audience,
type: params.userType,
permissions: params.permissions,
sid: params.sessionId,
} satisfies Omit<AccessTokenClaims, 'iat' | 'exp'>,
{
secret: this.config.auth.accessSecret,
issuer: this.config.auth.issuer,
expiresIn: expiresInSeconds,
},
);
return { token, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) };
}
issueRefreshToken(): IssuedRefreshToken {
// 32 bytes = 256 bits. base64url so it is cookie- and URL-safe without
// escaping.
const token = randomBytes(32).toString('base64url');
return {
token,
hash: this.hashRefreshToken(token),
expiresAt: new Date(Date.now() + parseDuration(this.config.auth.refreshTtl) * 1000),
};
}
hashRefreshToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
newSessionFamilyId(): string {
return randomUUID();
}
get refreshTtlSeconds(): number {
return parseDuration(this.config.auth.refreshTtl);
}
}
/**
* `15m` → 900. The env schema already guarantees the format, so an unparseable
* value here means the validator and this function have drifted — which should
* fail loudly at boot rather than silently issue an eternal token.
*/
export function parseDuration(value: string): number {
const match = /^(\d+)(ms|s|m|h|d)$/.exec(value);
if (!match) {
throw new Error(`Invalid duration: ${value}`);
}
const amount = Number(match[1]);
const unit = match[2];
switch (unit) {
case 'ms':
return Math.ceil(amount / 1000);
case 's':
return amount;
case 'm':
return amount * 60;
case 'h':
return amount * 3600;
case 'd':
return amount * 86_400;
default:
throw new Error(`Invalid duration unit: ${String(unit)}`);
}
}
+5 -6
View File
@@ -1,10 +1,9 @@
/**
* Public surface of UsersModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
* AuthModule consumes `UsersService` for account lookup, permission resolution
* and login bookkeeping. The repositories, the mapper and every Prisma row type
* stay private.
*/
export {};
export { UsersService } from '../users.service';
export type { AuthUserRow } from '../users.repository';
@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PERMISSIONS, TOKEN_AUDIENCES, type PermissionGroup, type RoleDetail } from '@sport/types';
import {
createRoleSchema,
updateRoleSchema,
type CreateRoleInput,
type UpdateRoleInput,
} from '@sport/validation';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { RolesService } from './roles.service';
@ApiTags('admin/roles')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get()
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'List roles with their permissions and user counts' })
list(): Promise<RoleDetail[]> {
return this.rolesService.list();
}
/**
* Declared before `:id` so the literal path wins — otherwise
* `/admin/roles/permissions` resolves as a role with the id "permissions".
*/
@Get('permissions')
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'The permission catalog, grouped by resource' })
listPermissions(): Promise<PermissionGroup[]> {
return this.rolesService.listPermissions();
}
@Get(':id')
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'Get one role' })
getById(@Param('id') id: string): Promise<RoleDetail> {
return this.rolesService.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@ApiOperation({ summary: 'Create a role' })
create(
@Body(new ZodValidationPipe(createRoleSchema)) body: CreateRoleInput,
): Promise<RoleDetail> {
return this.rolesService.create(body);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@ApiOperation({ summary: "Update a role's name, description or permissions" })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateRoleSchema)) body: UpdateRoleInput,
): Promise<RoleDetail> {
return this.rolesService.update(id, body);
}
@Delete(':id')
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a non-system role that nobody holds' })
delete(@Param('id') id: string): Promise<void> {
return this.rolesService.delete(id);
}
}
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const roleSelect = {
id: true,
key: true,
name: true,
description: true,
isSystem: true,
permissions: { select: { permission: { select: { key: true } } } },
} as const;
@Injectable()
export class RolesRepository {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.role.findMany({
select: { ...roleSelect, _count: { select: { users: true } } },
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
});
}
findById(id: string) {
return this.prisma.role.findUnique({
where: { id },
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
findByKey(key: string) {
return this.prisma.role.findUnique({ where: { key }, select: roleSelect });
}
findManyByIds(ids: readonly string[]) {
return this.prisma.role.findMany({ where: { id: { in: [...ids] } }, select: { id: true } });
}
listPermissions() {
return this.prisma.permission.findMany({
select: { key: true, resource: true, action: true },
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
});
}
/**
* Creates a role and its grants in one transaction.
*
* Permission keys are resolved to ids here rather than trusted from the
* client: an unknown key is dropped instead of silently creating a permission
* that no guard will ever check.
*/
async create(input: {
key: string;
name: string;
description: string | null;
permissionKeys: readonly string[];
}) {
const permissionIds = await this.resolvePermissionIds(input.permissionKeys);
return this.prisma.role.create({
data: {
key: input.key,
name: input.name,
description: input.description,
isSystem: false,
permissions: {
createMany: { data: permissionIds.map((permissionId) => ({ permissionId })) },
},
},
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
async update(
id: string,
input: { name?: string; description?: string | null; permissionKeys?: readonly string[] },
) {
if (input.permissionKeys) {
const permissionIds = await this.resolvePermissionIds(input.permissionKeys);
// Replace the grant set wholesale inside a transaction — a half-applied
// permission change is a security incident, not a glitch.
await this.prisma.$transaction([
this.prisma.rolePermission.deleteMany({ where: { roleId: id } }),
this.prisma.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
skipDuplicates: true,
}),
]);
}
return this.prisma.role.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.description === undefined ? {} : { description: input.description }),
},
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
delete(id: string) {
return this.prisma.role.delete({ where: { id }, select: { id: true } });
}
private async resolvePermissionIds(keys: readonly string[]): Promise<string[]> {
if (keys.length === 0) return [];
const rows = await this.prisma.permission.findMany({
where: { key: { in: [...keys] } },
select: { id: true },
});
return rows.map((row) => row.id);
}
}
export type RoleRow = NonNullable<Awaited<ReturnType<RolesRepository['findById']>>>;
+120
View File
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { ALL_PERMISSIONS, type PermissionGroup, type RoleDetail } from '@sport/types';
import type { CreateRoleInput, UpdateRoleInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { RolesRepository } from './roles.repository';
import { UsersMapper } from './users.mapper';
@Injectable()
export class RolesService {
constructor(
private readonly repository: RolesRepository,
private readonly mapper: UsersMapper,
) {}
async list(): Promise<RoleDetail[]> {
const rows = await this.repository.findAll();
return rows.map((row) => this.mapper.toRoleDetail(row));
}
async getById(id: string): Promise<RoleDetail> {
const row = await this.repository.findById(id);
if (!row) throw AppException.notFound('Role');
return this.mapper.toRoleDetail(row);
}
/**
* The permission catalog, served from the database.
*
* The seed reconciles this table against the code catalog in @sport/types, so
* what the role editor shows is exactly what the running guards enforce — a
* deploy skew surfaces as a missing checkbox rather than a grant that silently
* does nothing.
*/
async listPermissions(): Promise<PermissionGroup[]> {
const rows = await this.repository.listPermissions();
return this.mapper.toPermissionGroups(rows);
}
async create(input: CreateRoleInput): Promise<RoleDetail> {
const existing = await this.repository.findByKey(input.key);
if (existing) {
throw AppException.conflict('A role with that key already exists.');
}
const row = await this.repository.create({
key: input.key,
name: input.name,
description: input.description ?? null,
permissionKeys: this.assertKnownPermissions(input.permissions),
});
return this.mapper.toRoleDetail(row);
}
async update(id: string, input: UpdateRoleInput): Promise<RoleDetail> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('Role');
/**
* System roles may have their permissions edited but not their identity.
*
* The seed reconciles system roles from code on every run, so a renamed key
* would be silently recreated — and an operator would be left wondering why
* their change vanished. Rejecting it is clearer than losing it.
*/
if (existing.isSystem && input.name !== undefined && input.name !== existing.name) {
throw AppException.badRequest('System roles cannot be renamed.');
}
const row = await this.repository.update(id, {
...(input.name === undefined ? {} : { name: input.name }),
...(input.description === undefined ? {} : { description: input.description ?? null }),
...(input.permissions === undefined
? {}
: { permissionKeys: this.assertKnownPermissions(input.permissions) }),
});
return this.mapper.toRoleDetail(row);
}
async delete(id: string): Promise<void> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('Role');
if (existing.isSystem) {
throw AppException.badRequest('System roles cannot be deleted.');
}
// Deleting a role that people hold would silently strip their access.
// Making the operator reassign first keeps the consequence visible.
if (existing._count.users > 0) {
throw AppException.conflict(
`This role is assigned to ${existing._count.users} user(s). Reassign them first.`,
);
}
await this.repository.delete(id);
}
/**
* Rejects permission keys the code does not define.
*
* Without this a typo'd key would be stored, displayed as granted, and never
* match a guard — an access-control bug that looks like working configuration.
*/
private assertKnownPermissions(keys: readonly string[]): string[] {
const known = new Set<string>(ALL_PERMISSIONS);
const unknown = keys.filter((key) => !known.has(key));
if (unknown.length > 0) {
throw AppException.badRequest(`Unknown permission(s): ${unknown.join(', ')}`);
}
return [...new Set(keys)];
}
}
@@ -0,0 +1,111 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PERMISSIONS, TOKEN_AUDIENCES, type OffsetPaginated, type UserSummary } from '@sport/types';
import {
createUserSchema,
resetUserPasswordSchema,
updateUserSchema,
userListQuerySchema,
type CreateUserInput,
type UpdateUserInput,
type UserListQuery,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { AppException } from '@/common/errors/app.exception';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { PasswordService } from '@/common/security/password.service';
import { UsersService } from './users.service';
/**
* Back-office user administration.
*
* `@RequireAudience('admin')` on the controller means a storefront token is
* rejected before any permission is even read — defence in depth, not an
* optimisation.
*/
@ApiTags('admin/users')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/users')
export class UsersController {
constructor(
private readonly usersService: UsersService,
private readonly passwordService: PasswordService,
) {}
@Get()
@RequirePermissions(PERMISSIONS.USER_READ)
@ApiOperation({ summary: 'List back-office users' })
list(
@Query(new ZodValidationPipe(userListQuerySchema)) query: UserListQuery,
): Promise<OffsetPaginated<UserSummary>> {
return this.usersService.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.USER_READ)
@ApiOperation({ summary: 'Get one user' })
getById(@Param('id') id: string): Promise<UserSummary> {
return this.usersService.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: 'Create a back-office user' })
async create(
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserInput,
): Promise<UserSummary> {
const passwordHash = await this.passwordService.hash(body.password);
return this.usersService.create(body, passwordHash);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: 'Update a user, including their roles' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserInput,
@CurrentActor() actor: { userId: string },
): Promise<UserSummary> {
/**
* An operator cannot change their own type, status or roles.
*
* This is the lockout guard: without it, an admin can demote themselves out
* of the very permission needed to undo it, and the only recovery is a
* database console.
*/
if (id === actor.userId) {
const touchesOwnAccess =
body.roleIds !== undefined || body.status !== undefined || body.type !== undefined;
if (touchesOwnAccess) {
throw AppException.forbidden('You cannot change your own roles, type or status.');
}
}
return this.usersService.update(id, body);
}
@Post(':id/password')
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: "Reset another user's password" })
async resetPassword(
@Param('id') id: string,
@Body(new ZodValidationPipe(resetUserPasswordSchema)) body: { password: string },
): Promise<{ ok: true }> {
await this.usersService.getById(id);
await this.usersService.updatePasswordHash(id, await this.passwordService.hash(body.password));
// NOTE: existing sessions are intentionally NOT revoked here yet. Doing it
// properly means revoking every session family for the user, which belongs
// with the session-management screen rather than bolted on here.
return { ok: true };
}
}
+112
View File
@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import type {
CurrentUser,
Permission,
PermissionGroup,
RoleDetail,
UserStatus,
UserSummary,
UserType,
} from '@sport/types';
import { MediaUrlService } from '@/common/media/media-url.service';
import type { RoleRow } from './roles.repository';
import type { AuthUserRow, UserRow } from './users.repository';
@Injectable()
export class UsersMapper {
constructor(private readonly mediaUrl: MediaUrlService) {}
/**
* Flattens role grants into a distinct permission set.
*
* Roles are additive and may overlap — a user with both "Catalog Manager" and
* "Order Manager" gets the union, deduplicated. Nothing subtracts, which is
* what keeps "why can this person do X?" answerable by listing their roles.
*/
permissionsOf(row: AuthUserRow): Permission[] {
const permissions = new Set<string>();
for (const link of row.roles) {
for (const grant of link.role.permissions) {
permissions.add(grant.permission.key);
}
}
return [...permissions] as Permission[];
}
roleKeysOf(row: AuthUserRow): string[] {
return row.roles.map((link) => link.role.key);
}
toCurrentUser(row: AuthUserRow): CurrentUser {
return {
id: row.id,
email: row.email,
type: row.type as UserType,
firstName: row.firstName,
lastName: row.lastName,
displayName: displayName(row.firstName, row.lastName, row.email),
avatarUrl: row.avatar ? this.mediaUrl.url(row.avatar.storageKey) : null,
roles: this.roleKeysOf(row),
permissions: this.permissionsOf(row),
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
};
}
toSummary(row: UserRow): UserSummary {
return {
id: row.id,
email: row.email,
type: row.type as UserType,
status: row.status as UserStatus,
firstName: row.firstName,
lastName: row.lastName,
displayName: displayName(row.firstName, row.lastName, row.email),
roles: row.roles.map((link) => ({
id: link.role.id,
key: link.role.key,
name: link.role.name,
isSystem: link.role.isSystem,
})),
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
toRoleDetail(row: RoleRow): RoleDetail {
return {
id: row.id,
key: row.key,
name: row.name,
description: row.description,
isSystem: row.isSystem,
permissions: row.permissions.map((grant) => grant.permission.key as Permission),
userCount: row._count.users,
};
}
/** Groups the catalog by resource so the role editor renders as sections. */
toPermissionGroups(
rows: readonly { key: string; resource: string; action: string }[],
): PermissionGroup[] {
const groups = new Map<string, PermissionGroup['permissions'][number][]>();
for (const row of rows) {
const bucket = groups.get(row.resource) ?? [];
bucket.push({ key: row.key as Permission, resource: row.resource, action: row.action });
groups.set(row.resource, bucket);
}
return [...groups.entries()].map(([resource, permissions]) => ({ resource, permissions }));
}
}
/** Falls back to the email so a row never renders as an empty name. */
function displayName(firstName: string | null, lastName: string | null, email: string): string {
const full = [firstName, lastName].filter(Boolean).join(' ').trim();
return full.length > 0 ? full : email;
}
+18 -13
View File
@@ -1,19 +1,24 @@
import { Module } from '@nestjs/common';
import { RolesController } from './roles.controller';
import { RolesRepository } from './roles.repository';
import { RolesService } from './roles.service';
import { UsersController } from './users.controller';
import { UsersMapper } from './users.mapper';
import { UsersRepository } from './users.repository';
import { UsersService } from './users.service';
/**
* UsersModule — boundary declared, implementation pending.
* UsersModule — owns `users`, `roles`, `permissions`, `role_permissions` and
* `user_roles`.
*
* Owns (exclusively): `users`, `roles`, `permissions`, `role_permissions`, `user_roles`
*
* Back-office identity and the RBAC administration surface. Owns the role/permission tables that AuthModule only reads through this module.
*
* Anatomy once implemented (see ../README.md):
* users.module.ts wiring only
* users.controller.ts HTTP surface, no logic
* users.service.ts business rules
* users.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* Back-office identity plus the RBAC administration surface. AuthModule reads
* accounts through this module's public service; it never queries `users`
* itself, which keeps credential exchange and account management separable.
*/
@Module({})
@Module({
controllers: [UsersController, RolesController],
providers: [UsersService, RolesService, UsersRepository, RolesRepository, UsersMapper],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,152 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { UserListQuery } from '@sport/validation';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
/** Everything needed to authenticate, in one query. */
const authSelect = {
id: true,
email: true,
passwordHash: true,
type: true,
status: true,
firstName: true,
lastName: true,
lastLoginAt: true,
avatar: { select: { storageKey: true } },
roles: {
select: {
role: {
select: {
key: true,
permissions: { select: { permission: { select: { key: true } } } },
},
},
},
},
} as const;
const summarySelect = {
id: true,
email: true,
type: true,
status: true,
firstName: true,
lastName: true,
lastLoginAt: true,
createdAt: true,
roles: {
select: { role: { select: { id: true, key: true, name: true, isSystem: true } } },
},
} as const;
@Injectable()
export class UsersRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Soft-deleted accounts are invisible everywhere. Filtering here rather than
* at each call site means a forgotten `deletedAt: null` cannot resurrect a
* removed operator.
*/
private alive(): Prisma.UserWhereInput {
return { deletedAt: null };
}
findForAuthByEmail(email: string) {
return this.prisma.user.findFirst({
// Emails are normalised to lowercase on write (@sport/validation), so a
// plain equality match is correct and uses the unique index.
where: { ...this.alive(), email: email.toLowerCase() },
select: authSelect,
});
}
findForAuthById(id: string) {
return this.prisma.user.findFirst({ where: { ...this.alive(), id }, select: authSelect });
}
async list(query: UserListQuery) {
const where: Prisma.UserWhereInput = {
...this.alive(),
...(query.type ? { type: query.type } : {}),
...(query.status ? { status: query.status } : {}),
...(query.q
? {
OR: [
{ email: { contains: query.q, mode: 'insensitive' } },
{ firstName: { contains: query.q, mode: 'insensitive' } },
{ lastName: { contains: query.q, mode: 'insensitive' } },
],
}
: {}),
};
const [items, totalItems] = await Promise.all([
this.prisma.user.findMany({
where,
select: summarySelect,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.perPage,
take: query.perPage,
}),
this.prisma.user.count({ where }),
]);
return { items, totalItems };
}
findById(id: string) {
return this.prisma.user.findFirst({ where: { ...this.alive(), id }, select: summarySelect });
}
create(data: Prisma.UserCreateInput) {
return this.prisma.user.create({ data, select: summarySelect });
}
update(id: string, data: Prisma.UserUpdateInput) {
return this.prisma.user.update({ where: { id }, data, select: summarySelect });
}
/**
* Replaces a user's role set atomically.
*
* Delete-then-insert inside one transaction, because a partial application
* would briefly leave an operator with fewer — or worse, more — permissions
* than intended.
*/
async setRoles(userId: string, roleIds: readonly string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.userRole.deleteMany({ where: { userId } }),
this.prisma.userRole.createMany({
data: roleIds.map((roleId) => ({ userId, roleId })),
skipDuplicates: true,
}),
]);
}
recordLogin(id: string) {
return this.prisma.user.update({
where: { id },
data: { lastLoginAt: new Date() },
select: { id: true },
});
}
updatePasswordHash(id: string, passwordHash: string) {
return this.prisma.user.update({
where: { id },
data: { passwordHash },
select: { id: true },
});
}
countByRole(roleId: string) {
return this.prisma.userRole.count({ where: { roleId } });
}
}
export type AuthUserRow = NonNullable<Awaited<ReturnType<UsersRepository['findForAuthByEmail']>>>;
export type UserRow = NonNullable<Awaited<ReturnType<UsersRepository['findById']>>>;
+142
View File
@@ -0,0 +1,142 @@
import { Injectable } from '@nestjs/common';
import {
API_ERROR_CODES,
type CurrentUser,
type OffsetPaginated,
type Permission,
type UserSummary,
} from '@sport/types';
import type { CreateUserInput, UpdateUserInput, UserListQuery } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { RolesRepository } from './roles.repository';
import { UsersMapper } from './users.mapper';
import { UsersRepository, type AuthUserRow } from './users.repository';
/**
* Identity data: who exists, what they are, which roles they hold.
*
* AuthModule owns the *exchange* of credentials for tokens and the session
* table; this module owns the accounts themselves. Auth reads through the
* public surface below rather than querying `users` directly, which is what
* keeps password handling and account management from bleeding into each other.
*/
@Injectable()
export class UsersService {
constructor(
private readonly repository: UsersRepository,
private readonly rolesRepository: RolesRepository,
private readonly mapper: UsersMapper,
) {}
// ---- Consumed by AuthModule ---------------------------------------------
findForAuthByEmail(email: string): Promise<AuthUserRow | null> {
return this.repository.findForAuthByEmail(email);
}
findForAuthById(id: string): Promise<AuthUserRow | null> {
return this.repository.findForAuthById(id);
}
permissionsOf(row: AuthUserRow): Permission[] {
return this.mapper.permissionsOf(row);
}
toCurrentUser(row: AuthUserRow): CurrentUser {
return this.mapper.toCurrentUser(row);
}
async recordLogin(userId: string): Promise<void> {
await this.repository.recordLogin(userId);
}
async updatePasswordHash(userId: string, passwordHash: string): Promise<void> {
await this.repository.updatePasswordHash(userId, passwordHash);
}
// ---- Back-office user management ----------------------------------------
async list(query: UserListQuery): Promise<OffsetPaginated<UserSummary>> {
const { items, totalItems } = await this.repository.list(query);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: items.map((item) => this.mapper.toSummary(item)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
async getById(id: string): Promise<UserSummary> {
const row = await this.repository.findById(id);
if (!row) throw AppException.notFound('User');
return this.mapper.toSummary(row);
}
async create(input: CreateUserInput, passwordHash: string): Promise<UserSummary> {
const existing = await this.repository.findForAuthByEmail(input.email);
if (existing) {
throw AppException.conflict('An account with that email already exists.');
}
await this.assertRolesExist(input.roleIds);
const user = await this.repository.create({
email: input.email,
passwordHash,
type: input.type,
status: 'ACTIVE',
firstName: input.firstName,
lastName: input.lastName,
phone: input.phone ?? null,
roles: { createMany: { data: input.roleIds.map((roleId) => ({ roleId })) } },
});
return this.getById(user.id);
}
async update(id: string, input: UpdateUserInput): Promise<UserSummary> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('User');
if (input.roleIds) {
await this.assertRolesExist(input.roleIds);
await this.repository.setRoles(id, input.roleIds);
}
await this.repository.update(id, {
...(input.firstName === undefined ? {} : { firstName: input.firstName }),
...(input.lastName === undefined ? {} : { lastName: input.lastName }),
...(input.phone === undefined ? {} : { phone: input.phone ?? null }),
...(input.status === undefined ? {} : { status: input.status }),
...(input.type === undefined ? {} : { type: input.type }),
});
return this.getById(id);
}
/**
* Rejects unknown role ids rather than silently ignoring them.
*
* A create that quietly drops a role leaves an operator convinced they
* granted access that was never granted — the worst possible failure mode for
* a permissions screen.
*/
private async assertRolesExist(roleIds: readonly string[]): Promise<void> {
if (roleIds.length === 0) return;
const found = await this.rolesRepository.findManyByIds(roleIds);
if (found.length !== new Set(roleIds).size) {
throw AppException.badRequest('One or more roles do not exist.', API_ERROR_CODES.BAD_REQUEST);
}
}
}