diff --git a/README.md b/README.md index ffe99ad8..95251ad9 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ ManageHub/ ├── README.md # Project readme (this file) ``` -- **backend/** — Contains controllers, services, modules, database logic. +- **backend/** — Contains controllers, services, modules, database logic. See [`backend/src/auth/README.md`](backend/src/auth/README.md) for the authentication model (tokens, cookies, roles, websocket auth). - **frontend/** — UI components, pages, API integrations. - **contracts/** — Soroban smart contract workspace (Rust). Currently contains the `payment_escrow` scaffold. See [`contracts/README.md`](contracts/README.md) for the backend integration reference and [`contracts/RUNBOOK.md`](contracts/RUNBOOK.md) for the operator recovery guide. diff --git a/backend/package-lock.json b/backend/package-lock.json index 5a2d93b7..b1ac1a55 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -49,6 +49,7 @@ "exceljs": "^4.4.0", "express": "^5.2.1", "handlebars": "^4.7.8", + "helmet": "^8.3.0", "ioredis": "^5.9.2", "json2csv": "^6.0.0-alpha.2", "mjml": "^4.18.0", @@ -9411,6 +9412,18 @@ "he": "bin/he" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", diff --git a/backend/package.json b/backend/package.json index bb4a9cd9..336a7651 100644 --- a/backend/package.json +++ b/backend/package.json @@ -69,6 +69,7 @@ "exceljs": "^4.4.0", "express": "^5.2.1", "handlebars": "^4.7.8", + "helmet": "^8.3.0", "ioredis": "^5.9.2", "json2csv": "^6.0.0-alpha.2", "mjml": "^4.18.0", diff --git a/backend/src/auth/README.md b/backend/src/auth/README.md new file mode 100644 index 00000000..277d1bcb --- /dev/null +++ b/backend/src/auth/README.md @@ -0,0 +1,152 @@ +# Authentication model + +How ManageHub authenticates requests and sockets: what a token is, how it is +carried, how long it lives, what roles mean, and how the payments websocket +authenticates. This file documents the code as it exists today; where the +code has a known gap it is called out explicitly. + +## Tokens + +`AuthService` issues a JWT signed with `JWT_SECRET` (see `auth.module.ts`). +The payload is minimal: + +```json +{ "sub": "", "role": "user" | "admin" } +``` + +`JwtTokenVerifierService.verify` re-checks the signature and maps the payload +back to a `RequestUser` (`{ id, role }`), defaulting a missing `role` to +`USER`. + +### Lifetime + +The token is signed with **no `expiresIn`** (`AuthService.issueToken` calls +`signAsync` with no sign options), so the JWT itself carries no `exp` claim +and does not self-expire. Its practical lifetime is bounded only by the +`accessToken` cookie's `Max-Age` (7 days, see `AuthController`) — a bearer +client that stores the token elsewhere keeps it until `JWT_SECRET` is rotated. + +> Gap: bearer tokens outlive the cookie that issued them. Rotating +> `JWT_SECRET` is currently the only way to revoke outstanding bearer tokens. + +## Cookie vs bearer + +`POST /auth/login` and `POST /auth/register` return the JWT in the response +body **and** set it as an `accessToken` cookie: + +``` +httpOnly, sameSite=strict, path=/, maxAge=7 days, secure in production +``` + +`JwtAuthGuard` accepts the token from either place, checking the cookie first +and falling back to `Authorization: Bearer `: + +1. `request.cookies.accessToken` +2. `Authorization: Bearer ` + +> Gap: the guard reads `request.cookies`, which is populated by +> `cookie-parser`. That middleware is a dependency but is not currently +> registered in `main.ts`, so in practice only the bearer path works until +> `app.use(cookieParser())` is added. The cookie is still set/cleared by +> `POST /auth/logout`. + +## Account enumeration + +Login is timing-hardened (issue #1964): when the email is unknown, +`AuthService.login` still runs a bcrypt comparison against a generated +cost-12 dummy hash before returning the same `401 Invalid credentials`, so an +unknown address costs about as long as a known one with a wrong password. + +Registration deliberately keeps `409 Email is already registered` +(issue #1964). The alternative — responding identically to a brand-new +sign-up — is only truthful if `/register` never issues a token at all, i.e. +if it hands off to an email-verification / "you already have an account" +notification flow, which this codebase does not have yet. Until that flow +exists, the explicit `409` is retained and the login side is hardened. This +decision is revisited here whenever the email flow lands. + +## CSRF stance + +The auth cookie is `SameSite=Strict`, `httpOnly`, and (in production) `secure`. +`SameSite=Strict` means the browser will not attach the cookie to any +cross-site request, which is the primary CSRF mitigation; combined with +`httpOnly` a page cannot read the token either. No separate CSRF token is +issued. + +## Roles + +`UserRole` has two values: `USER` and `ADMIN`. New accounts default to `USER` +(`users.role` column default). `RolesGuard` enforces role requirements: + +- `@Roles(...)` declares the roles a handler accepts (on the method or class). +- The guard reads the requirement via `Reflector`; if none is set, the route + is open to any authenticated user. +- Matching is **any-of**: the request is allowed if the user's role appears in + the declared list (`requiredRoles.includes(request.user?.role)`). With the + current two-value enum this is equivalent to equality, but the guard is + written so adding a third role requires no change to existing handlers. + +## Socket authentication + +The payments gateway (`PaymentsGateway`, namespace `/payments`) authenticates +in the Socket.IO handshake, not with a Nest guard. On `handleConnection` it +reads the token from `client.handshake.auth.token`, verifies it with the same +`JwtTokenVerifierService`, and stores the user on `client.data.user`. A +missing or invalid token emits `connection:error` and disconnects the socket. +Each `subscribe` message re-checks access via `PaymentsService.findOne`, so a +socket can only join a room for a payment it is allowed to see. + +## Admin bootstrap + +There is no self-serve admin promotion. `role` defaults to `USER`; an admin is +created by an operator directly, e.g.: + +```sql +UPDATE users SET role = 'admin' WHERE email = 'you@example.com'; +``` + +or by seeding a user with `role = 'admin'` (see `backend/scripts/demo-data.js`). +After changing the role, the user must log in again so a fresh JWT carries the +new `role` claim (the token embeds the role at issue time). + +## Sequence diagram + +```mermaid +sequenceDiagram + participant C as Client + participant A as AuthController + participant S as AuthService + participant DB as Database + participant API as Protected API + participant G as PaymentsGateway + + Note over C,A: Register / Login + C->>A: POST /auth/login {email, password} + A->>S: login(dto) + S->>DB: find user by email + alt user exists + S->>S: bcrypt.compare(password, hash) + else user missing + S->>S: bcrypt.compare(password, dummy hash) # equalizes timing + end + S-->>A: {accessToken, user} + A-->>C: 200 + Set-Cookie accessToken (SameSite=Strict, httpOnly) + A-->>C: 200 body {accessToken, user} + + Note over C,API: Authenticated request + C->>API: GET /credits/admin/accounts
Authorization: Bearer + API->>API: JwtAuthGuard verifies token + API->>API: RolesGuard requires ADMIN (any-of) + API-->>C: 200 / 401 / 403 + + Note over C,G: Websocket + C->>G: connect /payments (handshake.auth.token = ) + G->>G: JwtTokenVerifierService.verify(token) + alt invalid + G-->>C: connection:error + disconnect + else valid + C->>G: subscribe(paymentId) + G->>G: PaymentsService.findOne(paymentId, user) + G-->>C: subscribed {paymentId, status} + end +``` diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 664802b7..4aa60e06 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -15,6 +15,31 @@ import { LoginDto } from './dto/login.dto'; import { ChangePasswordDto } from './dto/change-password.dto'; import { AuthResponseDto } from './dto/auth-response.dto'; +/** + * Cost factor every password hash in this service uses (issue #1964). Both + * the real hashes and the dummy comparison share it, so a lookup hit and a + * miss cost the same amount of work. + */ +const BCRYPT_ROUNDS = 12; + +/** + * A bcrypt hash produced with the same cost factor as real password hashes. + * + * Login uses it to burn the same amount of CPU when the email is unknown as + * when it is known, so response time stops advertising which addresses have + * accounts (issue #1964). It is generated on first use from random bytes + * rather than hardcoded; the plaintext is discarded and never compared + * against a user, so nothing secret is embedded in the source. + */ +let dummyPasswordHash: Promise | undefined; +function getDummyPasswordHash(): Promise { + dummyPasswordHash ??= hash( + Math.random().toString(36).slice(2), + BCRYPT_ROUNDS, + ); + return dummyPasswordHash; +} + @Injectable() export class AuthService { constructor( @@ -27,13 +52,20 @@ export class AuthService { const email = dto.email.trim().toLowerCase(); const existing = await this.users.findOne({ where: { email } }); if (existing) { + // Deliberate decision (issue #1964): registration still answers 409 + // for an already-registered address. Responding identically to a + // brand-new sign-up would mean never issuing a token from /register at + // all — i.e. an email-verification (or "you already have an account" + // notification) flow that this codebase does not have yet. Until that + // lands, the 409 is kept and the login side is hardened against + // timing-based enumeration. See auth/README.md. throw new ConflictException('Email is already registered'); } const user = await this.users.save( this.users.create({ email, - passwordHash: await hash(dto.password, 12), + passwordHash: await hash(dto.password, BCRYPT_ROUNDS), }), ); return this.issueToken(user); @@ -54,6 +86,12 @@ export class AuthService { const email = dto.email.trim().toLowerCase(); const user = await this.users.findOne({ where: { email } }); if (!user) { + // Burn bcrypt-equivalent work against a dummy hash before failing, so + // an unknown email costs about the same wall-clock time as a known one + // with a wrong password (issue #1964). Without this, the fast 401 on a + // missing user (no bcrypt at all) vs the slow 401 on a bad password + // (~hundreds of ms) lets a caller enumerate registered addresses. + await compare(dto.password, await getDummyPasswordHash()); throw new UnauthorizedException('Invalid credentials'); } diff --git a/backend/src/credits/credits-admin.controller.ts b/backend/src/credits/credits-admin.controller.ts index 0154ace0..bfb72dff 100644 --- a/backend/src/credits/credits-admin.controller.ts +++ b/backend/src/credits/credits-admin.controller.ts @@ -10,6 +10,7 @@ import { Query, UseGuards, Res, + BadRequestException, } from '@nestjs/common'; import { ApiBearerAuth, @@ -34,8 +35,13 @@ import { SettlementService } from './settlement.service'; import { AdminActionLogService } from '../admin-audit/admin-action-log.service'; import { AdminActionType } from '../admin-audit/admin-action-type.enum'; import { SettlementBatchStatus } from './enums/settlement-batch-status.enum'; -import { Workbook } from 'exceljs'; +import { stream as excelStream } from 'exceljs'; import { Response } from 'express'; +import { + LEDGER_ACCOUNT_EXPORT_MAX_ROWS, + LEDGER_ACCOUNT_PAGE_DEFAULT_LIMIT, + LEDGER_ACCOUNT_PAGE_MAX_LIMIT, +} from './ledger.service'; import { AbandonSettlementBatchDto, AdjustCreditsDto, @@ -76,6 +82,17 @@ import { SettlementRunResponseDto } from './dto/settlement-run-response.dto'; * reason, and nothing here can edit a posted entry — a correction is * always a new transaction, so the audit trail only ever grows. */ +function parseAccountPageLimit(limit?: string): number { + if (limit === undefined || limit === '') { + return LEDGER_ACCOUNT_PAGE_DEFAULT_LIMIT; + } + const parsed = Number(limit); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new BadRequestException('limit must be a positive integer'); + } + return Math.min(parsed, LEDGER_ACCOUNT_PAGE_MAX_LIMIT); +} + @ApiTags('credits-admin') @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @@ -94,29 +111,79 @@ export class CreditsAdminController { // ── accounts ─────────────────────────────────────────────────────────── @Get('accounts') - @ApiOperation({ summary: 'List ledger accounts' }) + @ApiOperation({ + summary: 'List one page of ledger accounts', + description: + 'Paginated (issue #1962). Returns at most `limit` accounts (default ' + + `${LEDGER_ACCOUNT_PAGE_DEFAULT_LIMIT}, max ${LEDGER_ACCOUNT_PAGE_MAX_LIMIT}) ` + + 'plus a `nextCursor`; pass that cursor back as `cursor` to fetch the ' + + 'next page. `nextCursor` is null on the last page.', + }) @ApiQuery({ name: 'currency', required: false, type: String }) - @ApiResponse({ status: 200, type: [LedgerAccountResponseDto] }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: `Page size (1-${LEDGER_ACCOUNT_PAGE_MAX_LIMIT})`, + }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + description: 'Opaque cursor from the previous page', + }) + @ApiResponse({ + status: 200, + description: 'A page of accounts plus the cursor for the next one', + }) async listAccounts( @Query('currency') currency?: string, - ): Promise { - const accounts = await this.ledger.listAccounts(currency); - return accounts.map((account) => - LedgerAccountResponseDto.fromEntity(account), + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + ): Promise<{ data: LedgerAccountResponseDto[]; nextCursor: string | null }> { + const page = await this.ledger.listAccountsPage( + currency, + parseAccountPageLimit(limit), + cursor, ); + return { + data: page.accounts.map((account) => + LedgerAccountResponseDto.fromEntity(account), + ), + nextCursor: page.nextCursor, + }; } @Get('accounts/export') - @ApiOperation({ summary: 'Export ledger accounts as xlsx' }) + @ApiOperation({ + summary: 'Export ledger accounts as xlsx', + description: + "Streamed with ExcelJS's streaming writer and walked page-by-page, so " + + 'memory stays flat regardless of table size (issue #1962). The export ' + + `stops at ${LEDGER_ACCOUNT_EXPORT_MAX_ROWS} rows.`, + }) @ApiQuery({ name: 'currency', required: false, type: String }) - @ApiProduces('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - @ApiResponse({ status: 200, description: 'xlsx workbook stream' }) + @ApiProduces( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + @ApiResponse({ + status: 200, + description: 'xlsx workbook stream', + }) async exportAccounts( @Query('currency') currency: string | undefined, - @Res({ passthrough: true }) res: Response, - ): Promise { - const accounts = await this.ledger.listAccounts(currency); - const workbook = new Workbook(); + @Res() res: Response, + ): Promise { + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="ledger-accounts.xlsx"', + ); + + const workbook = new excelStream.xlsx.WorkbookWriter({ stream: res }); const sheet = workbook.addWorksheet('ledger-accounts'); sheet.columns = [ { header: 'ID', key: 'id', width: 40 }, @@ -125,30 +192,37 @@ export class CreditsAdminController { { header: 'Currency', key: 'currency', width: 12 }, { header: 'Balance', key: 'balance', width: 14 }, { header: 'Overdraft', key: 'overdraftLimit', width: 14 }, - { header: 'External payout address', key: 'externalPayoutAddress', width: 40 }, + { + header: 'External payout address', + key: 'externalPayoutAddress', + width: 40, + }, { header: 'Frozen', key: 'frozen', width: 10 }, { header: 'Label', key: 'label', width: 24 }, ]; - sheet.addRows( - accounts.map((account) => ({ - id: account.id, - kind: account.kind, - ownerId: account.ownerId ?? '', - currency: account.currency, - balance: account.balance, - overdraftLimit: account.overdraftLimit, - externalPayoutAddress: account.externalPayoutAddress ?? '', - frozen: account.frozen, - label: account.label ?? '', - })), - ); - res.setHeader( - 'Content-Type', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + await this.ledger.iterateAccounts( + currency, + LEDGER_ACCOUNT_EXPORT_MAX_ROWS, + (account) => { + sheet + .addRow({ + id: account.id, + kind: account.kind, + ownerId: account.ownerId ?? '', + currency: account.currency, + balance: account.balance, + overdraftLimit: account.overdraftLimit, + externalPayoutAddress: account.externalPayoutAddress ?? '', + frozen: account.frozen, + label: account.label ?? '', + }) + .commit(); + }, ); - res.setHeader('Content-Disposition', 'attachment; filename="ledger-accounts.xlsx"'); - return Buffer.from(await workbook.xlsx.writeBuffer()); + + sheet.commit(); + await workbook.commit(); } @Post('accounts') diff --git a/backend/src/credits/ledger.service.ts b/backend/src/credits/ledger.service.ts index 5cb65614..d39d4529 100644 --- a/backend/src/credits/ledger.service.ts +++ b/backend/src/credits/ledger.service.ts @@ -18,6 +18,59 @@ const POSTGRES_UNIQUE_VIOLATION = '23505'; const LEDGER_TRANSACTION_REFERENCE_CONSTRAINT = 'uq_ledger_transactions_reference'; +/** Page size used by the export walk; the admin list caps its own limit. */ +export const LEDGER_ACCOUNT_PAGE_SIZE = 500; + +/** Largest page the admin list endpoint will return (issue #1962). */ +export const LEDGER_ACCOUNT_PAGE_MAX_LIMIT = 200; + +/** Page size the admin list endpoint falls back to when no limit is given. */ +export const LEDGER_ACCOUNT_PAGE_DEFAULT_LIMIT = 50; + +/** + * Hard cap on rows written by the admin xlsx export (issue #1962). Combined + * with the page-by-page walk this keeps the export's memory flat and its + * runtime bounded no matter how large the account table grows. + */ +export const LEDGER_ACCOUNT_EXPORT_MAX_ROWS = 100000; + +/** + * A page of ledger accounts plus the cursor that resumes after the last + * one. `nextCursor` is null on the final page. + */ +export interface LedgerAccountsPage { + accounts: LedgerAccount[]; + nextCursor: string | null; +} + +/** + * Cursors are opaque to clients: base64url of `|`. They + * pin the (createdAt, id) position the next page resumes from, which keeps + * pagination stable even when rows are inserted concurrently. + */ +function encodeAccountsCursor(account: LedgerAccount): string { + return Buffer.from( + `${account.createdAt.toISOString()}|${account.id}`, + 'utf8', + ).toString('base64url'); +} + +function decodeAccountsCursor(cursor: string): { + createdAt: Date; + id: string; +} { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const separator = decoded.lastIndexOf('|'); + const rawCreatedAt = separator === -1 ? '' : decoded.slice(0, separator); + const id = separator === -1 ? '' : decoded.slice(separator + 1); + const createdAt = new Date(rawCreatedAt); + + if (!id || Number.isNaN(createdAt.getTime())) { + throw new BadRequestException('cursor is not a valid pagination cursor'); + } + return { createdAt, id }; +} + /** * Raised when a debit would take a member's credit balance past its * overdraft ceiling. A ConflictException (409) rather than a 400: the @@ -218,11 +271,81 @@ export class LedgerService { } } - async listAccounts(currency?: string): Promise { - return this.accountRepository.find({ - where: currency ? { currency: currency.toUpperCase() } : {}, - order: { kind: 'ASC', createdAt: 'ASC' }, - }); + /** + * A single page of ledger accounts, ordered by (createdAt, id) so the + * cursor is stable even when two accounts share a creation timestamp. + * `nextCursor` is null once the last page is returned (issue #1962). + */ + async listAccountsPage( + currency: string | undefined, + limit: number, + cursor: string | undefined, + ): Promise { + const query = this.accountRepository + .createQueryBuilder('account') + .orderBy('account.createdAt', 'ASC') + .addOrderBy('account.id', 'ASC') + .take(limit + 1); + + if (currency) { + query.andWhere('account.currency = :currency', { + currency: currency.toUpperCase(), + }); + } + + if (cursor) { + const { createdAt, id } = decodeAccountsCursor(cursor); + query.andWhere( + '(account.createdAt, account.id) > (:cursorCreatedAt, :cursorId)', + { cursorCreatedAt: createdAt, cursorId: id }, + ); + } + + const rows = await query.getMany(); + const hasMore = rows.length > limit; + const accounts = hasMore ? rows.slice(0, limit) : rows; + const last = accounts[accounts.length - 1]; + + return { + accounts, + nextCursor: hasMore && last ? encodeAccountsCursor(last) : null, + }; + } + + /** + * Walks the whole account table one page at a time and hands each account + * to `onAccount`, so an export never materialises the table in memory + * (issue #1962). Stops early — `truncated` true — once `maxRows` have been + * visited, keeping very large exports bounded. + */ + async iterateAccounts( + currency: string | undefined, + maxRows: number, + onAccount: (account: LedgerAccount) => Promise | void, + ): Promise<{ visited: number; truncated: boolean }> { + let cursor: string | undefined; + let visited = 0; + + for (;;) { + const page = await this.listAccountsPage( + currency, + Math.min(LEDGER_ACCOUNT_PAGE_SIZE, maxRows - visited), + cursor, + ); + + for (const account of page.accounts) { + await onAccount(account); + visited += 1; + } + + if (!page.nextCursor) { + return { visited, truncated: false }; + } + if (visited >= maxRows) { + return { visited, truncated: true }; + } + cursor = page.nextCursor; + } } /** diff --git a/backend/src/main.ts b/backend/src/main.ts index 8fca6db6..e778ea25 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,9 +1,9 @@ -import { randomUUID } from 'crypto'; import { NestFactory } from '@nestjs/core'; import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestExpressApplication } from '@nestjs/platform-express'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import helmet from 'helmet'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/http-exception.filter'; import { isOriginAllowed, resolveAllowedOrigins } from './common/cors'; @@ -89,22 +89,26 @@ async function bootstrap() { // fails and only the bearer-token path works (issue #1953). app.use(cookieParser()); + /** + * Replaces the previously hand-rolled security-headers block (issue + * #1961) with helmet. Everything except the Swagger UI is served with + * helmet's full default set (including a CSP); /api/docs opts out of the + * CSP only, because Swagger UI relies on inline scripts and styles that a + * strict default-src 'self' policy would block. No other header is + * relaxed, so the docs page keeps the rest of the hardening. + */ + const defaultSecurityHeaders = helmet(); + const docsSecurityHeaders = helmet({ contentSecurityPolicy: false }); app.use((req: Request, res: Response, next: NextFunction) => { - res.setHeader('x-dns-prefetch-control', 'off'); - res.setHeader('x-frame-options', 'SAMEORIGIN'); - res.setHeader('x-content-type-options', 'nosniff'); - res.setHeader('referrer-policy', 'no-referrer'); - res.setHeader('x-download-options', 'noopen'); - res.setHeader('x-permitted-cross-domain-policies', 'none'); - if (process.env.NODE_ENV === 'production') { - res.setHeader( - 'strict-transport-security', - 'max-age=31536000; includeSubDomains', - ); + if (req.path.startsWith('/api/docs')) { + docsSecurityHeaders(req, res, next); + return; } - next(); + defaultSecurityHeaders(req, res, next); }); + app.disable('x-powered-by'); + app.useGlobalInterceptors(new HttpLoggingInterceptor()); app.useGlobalPipes(