diff --git a/.env.example b/.env.example index b4852f7..4fa7d49 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,18 @@ POSTGRESDB_HOST=localhost POSTGRESDB_PORT=5432 POSTGRESDB_DATABASE=your_db_name POSTGRESDB_USER=postgres -POSTGRESDB_PASSWORD=password +# REQUIRED β€” there is no default. Set a strong value here AND before deploying. +# This dev placeholder is intentionally not a real-looking password; change it. +POSTGRESDB_PASSWORD=dev-only-change-me + +# Dev-only admin bootstrap. Off by default β€” the schema seeds NO users. +# To create a local admin, set DEV_SEED_ADMIN=true and provide a bcrypt hash: +# bash scripts/gen-admin-hash.sh 'your-strong-password' +# Then paste the output into BOOTSTRAP_ADMIN_PASSWORD_HASH below. +# NEVER enable this in production. +DEV_SEED_ADMIN=false +BOOTSTRAP_ADMIN_EMAIL= +BOOTSTRAP_ADMIN_PASSWORD_HASH= # Public JWT secrets used by the gateway. Access and refresh tokens are # signed with separate secrets so the compromise of one does not let an @@ -12,6 +23,9 @@ JWT_ACCESS_SECRET=dev-access-secret-replace-me JWT_REFRESH_SECRET=dev-refresh-secret-replace-me JWT_EXPIRES_IN=4h JWT_REFRESH_EXPIRES_IN=8h +# "remember me" refresh lifetime in days. Kept bounded (default 30) and backed +# by refresh-token rotation so revoked sessions die quickly. Avoid huge values. +JWT_REFRESH_REMEMBER_DAYS=30 # Internal JWT β€” Ed25519 asymmetric. The gateway holds the private key # and signs gateway β†’ api tokens. The api only holds the public key, so @@ -32,6 +46,13 @@ INTERNAL_JWT_PUBLIC_KEY= GATEWAY_PORT=3100 API_BASE_URL=http://api:3200 +# Auth rate limiting (anti brute-force / credential stuffing). Login is keyed by +# IP+email and only counts failed attempts. +LOGIN_RATE_WINDOW_MS=900000 +LOGIN_RATE_MAX=10 +AUTH_RATE_WINDOW_MS=900000 +AUTH_RATE_MAX=100 + # API settings NODE_PORT=3200 NODE_ENV=development @@ -58,4 +79,5 @@ DB_POOL_EVICT=1000 DB_CONNECT_TIMEOUT=60000 DB_ACQUIRE_TIMEOUT=60000 DB_TIMEOUT=60000 -HASH_SALT_ROUNDS=10 +# bcrypt cost factor. Minimum enforced in code is 12; raise as hardware allows. +HASH_SALT_ROUNDS=12 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08078c3..5d6dc8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,13 +115,16 @@ jobs: - name: πŸ“₯ Install dependencies run: npm ci - - name: πŸ”’ Run Security Audit - run: npm audit --audit-level moderate || true - continue-on-error: true - - - name: πŸ“Š Generate Audit Report + # Blocks the pipeline on high/critical advisories in PRODUCTION deps β€” + # the code that actually ships. Dev/build-tool advisories are reported but + # not gated (they churn constantly and can't be fixed without major + # toolchain bumps). Tighten with `npm audit fix` when prod issues appear. + - name: πŸ”’ Run Security Audit (production deps) + run: npm audit --omit=dev --audit-level=high + + - name: πŸ“Š Generate Full Audit Report + if: always() run: npm audit --json > audit-report.json || true - continue-on-error: true - name: πŸ“€ Upload Audit Report uses: actions/upload-artifact@v4 @@ -162,7 +165,9 @@ jobs: notify: name: πŸ“’ Notify runs-on: ubuntu-latest - needs: [lint, test, build, security, docker] + # build & docker are currently disabled (commented out below); keep this + # list in sync with the jobs that actually run or the graph is invalid. + needs: [lint, test, security] if: failure() steps: - name: πŸ“’ Notify Failure diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aba3be9..b2bb4ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,31 @@ name: 🏷️ Release +# Releases are DELIBERATE, not automatic. Running on every push to main meant a +# privileged (contents:write) job with a push-capable token ran on each merge β€” +# a supply-chain footgun: any pre-push step (npm lifecycle script, dependency) +# could abuse the persisted credential to push to main. Now a maintainer +# triggers it manually and picks the bump type. on: - push: - branches: [main] + workflow_dispatch: + inputs: + bump: + description: Semver bump type + required: true + default: patch + type: choice + options: + - patch + - minor + - major + +# Least privilege by default; the release job elevates only what it needs. +permissions: + contents: read jobs: release: name: 🏷️ Version & Release runs-on: ubuntu-latest - # Skip version bump commits pushed by this same workflow - if: "!contains(github.event.head_commit.message, '[skip ci]') && github.actor != 'github-actions[bot]'" permissions: contents: write @@ -18,56 +34,40 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + # Do NOT leave the auth token in the local git config where any later + # step / lifecycle script could reuse it. The push step injects it. + persist-credentials: false - name: πŸ“¦ Setup Node.js uses: actions/setup-node@v4 with: node-version: '22.12.0' - # ── Determine bump type from the commit/PR title ────────────────────────── - # feat!: or feat()!: β†’ MAJOR - # feat: or feat(): β†’ MINOR - # fix: or fix(): β†’ PATCH - # anything else β†’ no release - - name: πŸ” Determine bump type - id: bump - run: | - MSG=$(git log -1 --pretty=%s) - echo "Commit message: $MSG" - if echo "$MSG" | grep -qE '^feat(\(.+\))?!:'; then - echo "type=major" >> $GITHUB_OUTPUT - elif echo "$MSG" | grep -qE '^feat(\(.+\))?:'; then - echo "type=minor" >> $GITHUB_OUTPUT - elif echo "$MSG" | grep -qE '^fix(\(.+\))?:'; then - echo "type=patch" >> $GITHUB_OUTPUT - else - echo "type=none" >> $GITHUB_OUTPUT - fi - + # --ignore-scripts: never run project/dependency lifecycle scripts on the + # privileged runner before we push. - name: πŸ”’ Bump package version - if: steps.bump.outputs.type != 'none' - run: npm version ${{ steps.bump.outputs.type }} --no-git-tag-version - - - name: πŸ“Œ Read new version - if: steps.bump.outputs.type != 'none' id: version - run: echo "value=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + run: | + npm version ${{ inputs.bump }} --no-git-tag-version --ignore-scripts + echo "value=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - name: πŸ“ Commit, tag & push - if: steps.bump.outputs.type != 'none' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add package.json package-lock.json git commit -m "chore: release v${{ steps.version.outputs.value }} [skip ci]" - git tag v${{ steps.version.outputs.value }} - git push origin main --follow-tags + git tag "v${{ steps.version.outputs.value }}" + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + HEAD:main --follow-tags - name: πŸš€ Create GitHub Release - if: steps.bump.outputs.type != 'none' uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.version.outputs.value }} name: v${{ steps.version.outputs.value }} generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 24a55a3..12e97c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,24 +27,24 @@ Each package has its own `AGENTS.md` with rules specific to that layer. Read the relevant one(s) before working in that directory β€” do **not** rely on this root file for layer detail. -| Package | Guide | What lives there | -| ---------------------- | ------------------------------ | --------------------------------------------------------- | -| `apps/api` | `apps/api/AGENTS.md` | Express routes, Sequelize models, services, `HttpResponser`, JWT | -| `apps/gateway` | `apps/gateway/AGENTS.md` | Reverse proxy, public auth, EdDSA internal-token signing | -| `apps/front` | `apps/front/AGENTS.md` | Angular components, signals, routing, forms, i18n | -| `libs/rest-dto` | `libs/rest-dto/AGENTS.md` | Shared DTOs and API contracts (the single source of truth) | -| `libs/internal-auth` | `libs/internal-auth/AGENTS.md` | EdDSA sign/verify for service-to-service auth | -| `db` | `db/AGENTS.md` | SQL migration files and naming conventions | +| Package | Guide | What lives there | +| -------------------- | ------------------------------ | ---------------------------------------------------------------- | +| `apps/api` | `apps/api/AGENTS.md` | Express routes, Sequelize models, services, `HttpResponser`, JWT | +| `apps/gateway` | `apps/gateway/AGENTS.md` | Reverse proxy, public auth, EdDSA internal-token signing | +| `apps/front` | `apps/front/AGENTS.md` | Angular components, signals, routing, forms, i18n | +| `libs/rest-dto` | `libs/rest-dto/AGENTS.md` | Shared DTOs and API contracts (the single source of truth) | +| `libs/internal-auth` | `libs/internal-auth/AGENTS.md` | EdDSA sign/verify for service-to-service auth | +| `db` | `db/AGENTS.md` | SQL migration files and naming conventions | ### TypeScript path aliases (from `tsconfig.base.json`) -| Alias | Resolves to | -| ----------------- | ---------------------------- | -| `@dto` | `libs/rest-dto/src/index.ts` | -| `@internal-auth` | `libs/internal-auth/src/index.ts` | -| `@front/*` | `apps/front/src/*` | -| `@api/*` | `apps/api/src/*` | -| `@gateway/*` | `apps/gateway/src/*` | +| Alias | Resolves to | +| ---------------- | --------------------------------- | +| `@dto` | `libs/rest-dto/src/index.ts` | +| `@internal-auth` | `libs/internal-auth/src/index.ts` | +| `@front/*` | `apps/front/src/*` | +| `@api/*` | `apps/api/src/*` | +| `@gateway/*` | `apps/gateway/src/*` | --- diff --git a/README.md b/README.md index 5a1d17a..0eb1842 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,24 @@ npm run dev ### Usuario por Defecto +El esquema **no siembra ningΓΊn usuario**. Por seguridad no existen credenciales +por defecto. Para crear un administrador local en desarrollo, genera un hash y +habilita el seed (sΓ³lo dev): + +```bash +# 1. Genera el hash bcrypt de tu contraseΓ±a +bash scripts/gen-admin-hash.sh 'tu-contraseΓ±a-fuerte' + +# 2. En tu .env +DEV_SEED_ADMIN=true +BOOTSTRAP_ADMIN_EMAIL=admin@example.test +BOOTSTRAP_ADMIN_PASSWORD_HASH= + +# 3. Recrea la base de datos para que corra el seed +npm run dev:db:clean && npm run dev:db ``` -Email: test@local.com -ContraseΓ±a: 123456 -``` + +> Nunca habilites `DEV_SEED_ADMIN` en producciΓ³n. ## πŸ› οΈ Comandos de Desarrollo diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index b29e0f9..c1952a0 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -46,6 +46,15 @@ routes β†’ controllers β†’ services β†’ models β†’ (db via Sequelize) - **Never expose `password`.** `AbstractCrudService` excludes `password` from every read, and excludes `deleted`/`deletedAt` unless `where.deleted` is set. - Models import their shape from `@dto`; do not duplicate field types locally. +- **Validate every client input.** Never pass `req.body` / `req.query` / `req.params` + to a service unvalidated. Put a `validate(schema, source)` middleware + (`src/middleware/validate.middleware.ts`) in front of the controller, using a + Zod schema from `@dto` (`userCreateSchema`, `userUpdateSchema`, + `paginationQuerySchema`, `idParamSchema`, …). Input schemas are `.strict()`, so + unknown keys are rejected at the edge β€” this, plus the service-layer + `writableFields` allow-list, is the mass-assignment defense. Because `req.query` + is a read-only getter in Express 5, the parsed query is exposed on + `res.locals.query` (read it from there), while body/params are replaced in place. ## Internal auth diff --git a/apps/api/src/adapters/db/pg.connector.ts b/apps/api/src/adapters/db/pg.connector.ts index 23dbc43..556ec22 100644 --- a/apps/api/src/adapters/db/pg.connector.ts +++ b/apps/api/src/adapters/db/pg.connector.ts @@ -19,7 +19,13 @@ const dbConfig = { database: process.env.POSTGRESDB_DATABASE ?? 'your_db_name', username: process.env.POSTGRESDB_USER ?? 'postgres', password: process.env.POSTGRESDB_PASSWORD ?? 'password', - logging: process.env.NODE_PRODUCTION === 'true' ? false : console.log, + // Fail-safe: never log SQL (which includes parameter values like emails) when + // either standard NODE_ENV or the legacy NODE_PRODUCTION flag indicates prod. + logging: + process.env.NODE_ENV === 'production' || + process.env.NODE_PRODUCTION === 'true' + ? false + : console.log, pool: { max: parseInt(process.env.DB_POOL_MAX || '10'), min: parseInt(process.env.DB_POOL_MIN || '0'), diff --git a/apps/api/src/controllers/abstract-crud.controller.spec.ts b/apps/api/src/controllers/abstract-crud.controller.spec.ts new file mode 100644 index 0000000..debb6dc --- /dev/null +++ b/apps/api/src/controllers/abstract-crud.controller.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AbstractCrudController } from './abstract-crud.controller'; + +class TestController extends AbstractCrudController { + constructor(service: never) { + super(service); + } +} + +const makeRes = () => { + const res: Record = { locals: {} }; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + res.send = vi.fn().mockReturnValue(res); + return res as never; +}; + +describe('AbstractCrudController', () => { + describe('getAllPaged', () => { + it('clamps invalid pagination to sane defaults', async () => { + const service = { + getAllPaged: vi.fn().mockResolvedValue({ rows: [], count: 0 }), + }; + const ctrl = new TestController(service as never); + + await ctrl.getAllPaged( + { query: { page: 'abc', limit: '99999' } } as never, + makeRes(), + ); + + // Bad page -> 1; limit clamped to <=100 falls back to default 10 here. + expect(service.getAllPaged).toHaveBeenCalledWith(1, 10); + }); + + it('honours valid pagination', async () => { + const service = { + getAllPaged: vi.fn().mockResolvedValue({ rows: [], count: 0 }), + }; + const ctrl = new TestController(service as never); + + await ctrl.getAllPaged( + { query: { page: '2', limit: '25' } } as never, + makeRes(), + ); + + expect(service.getAllPaged).toHaveBeenCalledWith(2, 25); + }); + }); + + describe('put', () => { + it('404s and does not update a soft-deleted/absent row', async () => { + const service = { + getById: vi.fn().mockResolvedValue(null), + put: vi.fn(), + }; + const ctrl = new TestController(service as never); + const res = makeRes(); + + await ctrl.put({ params: { id: '7' }, body: {} } as never, res); + + expect(service.getById).toHaveBeenCalledWith({ id: '7', deleted: false }); + expect(service.put).not.toHaveBeenCalled(); + expect( + (res as { status: ReturnType }).status, + ).toHaveBeenCalledWith(404); + }); + + it('updates and reads back an existing row with deleted:false', async () => { + const service = { + getById: vi + .fn() + .mockResolvedValueOnce({ id: 7 }) + .mockResolvedValueOnce({ id: 7, name: 'new' }), + put: vi.fn().mockResolvedValue([1]), + }; + const ctrl = new TestController(service as never); + + await ctrl.put( + { params: { id: '7' }, body: { name: 'new' } } as never, + makeRes(), + ); + + expect(service.put).toHaveBeenCalledWith('7', { name: 'new' }); + expect(service.getById).toHaveBeenLastCalledWith({ + id: '7', + deleted: false, + }); + }); + }); +}); diff --git a/apps/api/src/controllers/abstract-crud.controller.ts b/apps/api/src/controllers/abstract-crud.controller.ts index e5c9884..b933b84 100644 --- a/apps/api/src/controllers/abstract-crud.controller.ts +++ b/apps/api/src/controllers/abstract-crud.controller.ts @@ -1,5 +1,6 @@ import HttpResponser from '@api/adapters/http/http.responser'; import { AbstractCrudService } from '@api/services/abstract-crud.service'; +import { paginationQuerySchema } from '@dto'; export abstract class AbstractCrudController { protected service: AbstractCrudService; @@ -10,7 +11,16 @@ export abstract class AbstractCrudController { getAllPaged = async (req, res) => { try { - const { page, limit } = req.query; + // Parse + clamp pagination (page>=1, 1<=limit<=100) so NaN/negative or + // huge limits can't skew the offset or pull the whole table (DoS). Bad + // input falls back to sane defaults rather than erroring. Uses the value a + // validate('query') middleware may have placed on res.locals. + const parsed = paginationQuerySchema.safeParse( + res.locals.query ?? req.query, + ); + const { page, limit } = parsed.success + ? parsed.data + : { page: 1, limit: 10 }; const data = await this.service.getAllPaged(page, limit); return HttpResponser.successJson(res, data); } catch (error) { @@ -50,11 +60,21 @@ export abstract class AbstractCrudController { put = async (req, res) => { try { + // Never read or mutate a soft-deleted row: it must behave as if gone. + const existing = await this.service.getById({ + id: req.params.id, + deleted: false, + }); + if (!existing) { + return HttpResponser.errorJson(res, { message: 'Not found' }, 404); + } await this.service.put(req.params.id, req.body); - const updated = await this.service.getById({ id: req.params.id }); + const updated = await this.service.getById({ + id: req.params.id, + deleted: false, + }); return HttpResponser.successJson(res, updated); } catch (error) { - console.log(error); return HttpResponser.errorJson(res, error); } }; diff --git a/apps/api/src/controllers/refresh-lifecycle.controller.ts b/apps/api/src/controllers/refresh-lifecycle.controller.ts index 138d209..9af1339 100644 --- a/apps/api/src/controllers/refresh-lifecycle.controller.ts +++ b/apps/api/src/controllers/refresh-lifecycle.controller.ts @@ -1,5 +1,5 @@ import HttpResponser from '@api/adapters/http/http.responser'; -import { refreshTokenFamilyService } from '@api/services'; +import { authService, refreshTokenFamilyService } from '@api/services'; import type { Request, Response } from 'express'; /** @@ -38,13 +38,29 @@ class RefreshLifecycleController { } const outcome = await refreshTokenFamilyService.rotate({ jti }); switch (outcome.status) { - case 'rotated': + case 'rotated': { + // Re-read the user from the authoritative source on every rotation so + // revoked/downgraded permissions (and soft-deleted accounts) take + // effect immediately, instead of carrying stale claims from the old + // refresh token forward (T-5). getUser already filters deleted:false. + const user = await authService.getUser(outcome.userId); + if (!user) { + await refreshTokenFamilyService.revokeFamily(outcome.familyId); + return HttpResponser.errorJson( + res, + { message: 'User no longer active' }, + 401, + ); + } return HttpResponser.successJson(res, { status: outcome.status, userId: outcome.userId, familyId: outcome.familyId, parentJti: outcome.parentJti, + email: user.email, + permissions: user.permissions, }); + } case 'reused-revoked': case 'family-revoked': return HttpResponser.errorJson( diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 22f16f0..458d69e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; import express from 'express'; +import helmet from 'helmet'; import { getConnectionStats, testConnection } from './adapters/db/pg.connector'; import { UPLOAD_DIR } from './globals'; @@ -58,6 +59,9 @@ class Main { * in the gateway and is intentionally omitted here. */ this.#app.set('trust proxy', 1); + // Defense-in-depth security headers even though the API is private (behind + // the gateway). Returns JSON only, so the document CSP is left to the front. + this.#app.use(helmet({ contentSecurityPolicy: false })); this.#app.use(express.json()); this.#app.use(dbLoggingMiddleware); this.#app.use(dbErrorMiddleware); diff --git a/apps/api/src/middleware/db-error.middleware.spec.ts b/apps/api/src/middleware/db-error.middleware.spec.ts index 39c15eb..a75ec2a 100644 --- a/apps/api/src/middleware/db-error.middleware.spec.ts +++ b/apps/api/src/middleware/db-error.middleware.spec.ts @@ -112,7 +112,10 @@ describe('Database Error Middleware', () => { ); // Assert - expect(consoleErrorSpy).toHaveBeenCalledWith('Database error:', error); + expect(consoleErrorSpy).toHaveBeenCalledWith('Database error:', { + name: 'SequelizeConnectionError', + code: undefined, + }); expect(statusMock).toHaveBeenCalledWith(503); expect(jsonMock).toHaveBeenCalledWith({ error: 'Service Unavailable', @@ -349,15 +352,12 @@ describe('Database Error Middleware', () => { // Simulate sending response mockResponse.send(testData); - // Assert + // Assert β€” metadata only: no body, no IP, no User-Agent. expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Database error on GET /api/test:', + 'Server error on GET /api/test:', { statusCode: 503, - body: testData, timestamp: '2025-10-27T12:00:00.000Z', - userAgent: 'Test User Agent', - ip: '127.0.0.1', }, ); }); @@ -421,7 +421,7 @@ describe('Database Error Middleware', () => { mockResponse.send({ error: 'Error' }); expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('Database error'), + expect.stringContaining('Server error'), expect.objectContaining({ statusCode }), ); }); diff --git a/apps/api/src/middleware/db-error.middleware.ts b/apps/api/src/middleware/db-error.middleware.ts index 39551d5..b27b48e 100644 --- a/apps/api/src/middleware/db-error.middleware.ts +++ b/apps/api/src/middleware/db-error.middleware.ts @@ -36,7 +36,12 @@ export const sequelizeErrorMiddleware = ( ) => { // Verificar si es un error de Sequelize if (error.name && error.name.startsWith('Sequelize')) { - console.error('Database error:', error); + // Log only error metadata β€” never the full error object, which can carry + // query fragments and parameter values (PII / sensitive data). + console.error('Database error:', { + name: error.name, + code: error.original?.code ?? error.parent?.code, + }); // Mapear errores comunes de Sequelize switch (error.name) { @@ -117,14 +122,12 @@ export const dbLoggingMiddleware = ( const originalSend = res.send; res.send = function (data) { - // Log errores de base de datos + // Log only request/response metadata on 5xx β€” never the response body, + // client IP or User-Agent (PII and potential leakage of error internals). if (res.statusCode >= 500 && res.statusCode < 600) { - console.error(`Database error on ${req.method} ${req.path}:`, { + console.error(`Server error on ${req.method} ${req.path}:`, { statusCode: res.statusCode, - body: data, timestamp: new Date().toISOString(), - userAgent: req.get('User-Agent'), - ip: req.ip, }); } diff --git a/apps/api/src/middleware/index.ts b/apps/api/src/middleware/index.ts index fb4fd44..e93a798 100644 --- a/apps/api/src/middleware/index.ts +++ b/apps/api/src/middleware/index.ts @@ -3,3 +3,4 @@ export { dbLoggingMiddleware, sequelizeErrorMiddleware, } from './db-error.middleware'; +export { validate } from './validate.middleware'; diff --git a/apps/api/src/middleware/validate.middleware.spec.ts b/apps/api/src/middleware/validate.middleware.spec.ts new file mode 100644 index 0000000..0ffac72 --- /dev/null +++ b/apps/api/src/middleware/validate.middleware.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { validate } from './validate.middleware'; + +const schema = z + .object({ email: z.string().email(), name: z.string().min(1) }) + .strict(); + +const makeRes = () => { + const res: Record = { locals: {} }; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + res.send = vi.fn().mockReturnValue(res); + return res as never; +}; + +describe('validate middleware', () => { + it('calls next and exposes parsed body on success', () => { + const req = { body: { email: 'a@b.com', name: 'Ann' } } as never; + const res = makeRes(); + const next = vi.fn(); + + validate(schema)(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect((req as { body: unknown }).body).toEqual({ + email: 'a@b.com', + name: 'Ann', + }); + }); + + it('rejects unknown keys (mass-assignment) with 400', () => { + const req = { + body: { email: 'a@b.com', name: 'Ann', permissions: ['ADMIN'] }, + } as never; + const res = makeRes(); + const next = vi.fn(); + + validate(schema)(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect( + (res as { status: ReturnType }).status, + ).toHaveBeenCalledWith(400); + }); + + it('rejects invalid payloads with 400 and does not call next', () => { + const req = { body: { email: 'not-an-email', name: '' } } as never; + const res = makeRes(); + const next = vi.fn(); + + validate(schema)(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect( + (res as { status: ReturnType }).status, + ).toHaveBeenCalledWith(400); + }); + + it('stores parsed query on res.locals when req.query is a getter', () => { + const querySchema = z.object({ page: z.coerce.number().int().min(1) }); + const req = {} as Record; + Object.defineProperty(req, 'query', { + get: () => ({ page: '3' }), + configurable: true, + }); + const res = makeRes(); + const next = vi.fn(); + + validate(querySchema, 'query')(req as never, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect((res as { locals: { query: unknown } }).locals.query).toEqual({ + page: 3, + }); + }); +}); diff --git a/apps/api/src/middleware/validate.middleware.ts b/apps/api/src/middleware/validate.middleware.ts new file mode 100644 index 0000000..8c6192b --- /dev/null +++ b/apps/api/src/middleware/validate.middleware.ts @@ -0,0 +1,41 @@ +import HttpResponser from '@api/adapters/http/http.responser'; +import { NextFunction, Request, Response } from 'express'; +import { ZodError, ZodType } from 'zod'; + +type Source = 'body' | 'query' | 'params'; + +const formatIssues = (error: ZodError) => + error.issues + .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`) + .join('; '); + +/** + * Validates `req[source]` against a Zod schema before the controller runs. + * On failure it short-circuits with a 400 (no stack/SQL leaked). On success it + * exposes the parsed, type-coerced, stripped value: + * - body/params are writable, so they are replaced in place. + * - query is a getter in Express 5, so the parsed value is stored on + * `res.locals.` (read it from there in the controller). + * + * Schemas are the single source of truth in `@dto`. + */ +export const validate = + (schema: ZodType, source: Source = 'body') => + (req: Request, res: Response, next: NextFunction) => { + const result = schema.safeParse(req[source]); + if (!result.success) { + return HttpResponser.errorJson( + res, + { message: `Validation failed β€” ${formatIssues(result.error)}` }, + 400, + ); + } + res.locals[source] = result.data; + try { + // body and params are writable; query is a read-only getter in Express 5. + (req as Record)[source] = result.data; + } catch { + /* query getter β€” consumers read res.locals.query instead */ + } + next(); + }; diff --git a/apps/api/src/routes/health.routes.ts b/apps/api/src/routes/health.routes.ts index 2f701bb..1da484d 100644 --- a/apps/api/src/routes/health.routes.ts +++ b/apps/api/src/routes/health.routes.ts @@ -1,55 +1,55 @@ +import { requireInternalAuth } from '@internal-auth'; import { Router } from 'express'; import { getConnectionStats, isConnected } from '../adapters/db/pg.connector'; const healthRouter = Router(); +// Detailed/db probes are gated behind a valid internal (gateway-signed) token. +// Any scope is accepted β€” these are operational endpoints, not business ones. +const requireInternal = requireInternalAuth({ + publicKey: process.env.INTERNAL_JWT_PUBLIC_KEY ?? '', +}); + +// Only the non-sensitive connection booleans/counters β€” never the DB host, +// port, name or dialect (those help an attacker target/fingerprint the DB). +const safeDbStats = () => { + const { + isConnected: connected, + poolSize, + available, + using, + waiting, + } = getConnectionStats(); + return { isConnected: connected, poolSize, available, using, waiting }; +}; + +/** Public liveness probe β€” intentionally minimal, leaks nothing. */ healthRouter.get('', (_, res) => { - res.status(200).json({ - health: 'πŸ‘Œ', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - }); + res.status(200).json({ health: 'πŸ‘Œ', timestamp: new Date().toISOString() }); }); -healthRouter.get('/db', (_, res) => { - const dbStats = getConnectionStats(); +healthRouter.get('/db', requireInternal, (_, res) => { const isDbConnected = isConnected(); - - const response = { + res.status(isDbConnected ? 200 : 503).json({ health: isDbConnected ? 'πŸ‘Œ' : '⚠️', - database: { - connected: isDbConnected, - stats: dbStats, - }, + database: { connected: isDbConnected, stats: safeDbStats() }, timestamp: new Date().toISOString(), - uptime: process.uptime(), - }; - - res.status(isDbConnected ? 200 : 503).json(response); + }); }); -healthRouter.get('/detailed', (_, res) => { - const dbStats = getConnectionStats(); +healthRouter.get('/detailed', requireInternal, (_, res) => { const isDbConnected = isConnected(); - - const response = { + res.status(isDbConnected ? 200 : 503).json({ health: isDbConnected ? 'πŸ‘Œ' : '⚠️', + // Operational metrics only β€” no nodeVersion / pid / platform fingerprinting. system: { uptime: process.uptime(), memory: process.memoryUsage(), cpu: process.cpuUsage(), - platform: process.platform, - nodeVersion: process.version, - pid: process.pid, - }, - database: { - connected: isDbConnected, - stats: dbStats, }, + database: { connected: isDbConnected, stats: safeDbStats() }, timestamp: new Date().toISOString(), - }; - - res.status(isDbConnected ? 200 : 503).json(response); + }); }); export default healthRouter; diff --git a/apps/api/src/routes/user-crud.routes.ts b/apps/api/src/routes/user-crud.routes.ts index 6c723d7..5ea26fb 100644 --- a/apps/api/src/routes/user-crud.routes.ts +++ b/apps/api/src/routes/user-crud.routes.ts @@ -1,5 +1,6 @@ import { userCrudController } from '@api/controllers'; -import { Permission } from '@dto'; +import { validate } from '@api/middleware'; +import { Permission, userCreateSchema, userUpdateSchema } from '@dto'; import { requireInternalAuth, InternalScope } from '@internal-auth'; import { Router } from 'express'; @@ -13,8 +14,18 @@ const requireAdmin = requireInternalAuth({ userCrudRouter.get('/', requireAdmin, userCrudController.getAll); userCrudRouter.get('/paged', requireAdmin, userCrudController.getAllPaged); userCrudRouter.get('/:id', requireAdmin, userCrudController.getById); -userCrudRouter.post('/', requireAdmin, userCrudController.post); -userCrudRouter.put('/:id', requireAdmin, userCrudController.put); +userCrudRouter.post( + '/', + requireAdmin, + validate(userCreateSchema), + userCrudController.post, +); +userCrudRouter.put( + '/:id', + requireAdmin, + validate(userUpdateSchema), + userCrudController.put, +); userCrudRouter.delete('/:id', requireAdmin, userCrudController.delete); export default userCrudRouter; diff --git a/apps/api/src/services/abstract-crud.service.ts b/apps/api/src/services/abstract-crud.service.ts index 13da63c..871d918 100644 --- a/apps/api/src/services/abstract-crud.service.ts +++ b/apps/api/src/services/abstract-crud.service.ts @@ -8,9 +8,18 @@ import sequelize, { ModelStatic } from 'sequelize'; export abstract class AbstractCrudService { protected model: ModelStatic; + /** + * Allow-list of attributes a client may write through post/put. When set, it + * is passed as Sequelize's `fields` option so any extra key in the payload + * (e.g. `permissions`, `deleted`, `deletedAt`, `id`) is silently ignored, + * preventing mass-assignment / over-posting privilege escalation. Leave + * undefined only for internal entities with no client-facing write surface. + */ + protected readonly writableFields?: string[]; - constructor(model: ModelStatic) { + constructor(model: ModelStatic, writableFields?: string[]) { this.model = model; + this.writableFields = writableFields; } getAllPaged = async ( @@ -51,10 +60,20 @@ export abstract class AbstractCrudService { where, }); - post = async (model) => await this.model.create(model); + post = async (model) => + await this.model.create( + model, + this.writableFields ? { fields: this.writableFields } : undefined, + ); put = async (id: number, data) => - await this.model.update({ ...data }, { where: { id } }); + await this.model.update( + { ...data }, + { + where: { id }, + ...(this.writableFields ? { fields: this.writableFields } : {}), + }, + ); delete = async (id: number) => await this.model.update( diff --git a/apps/api/src/services/auth.service.spec.ts b/apps/api/src/services/auth.service.spec.ts index 6595763..e78f905 100644 --- a/apps/api/src/services/auth.service.spec.ts +++ b/apps/api/src/services/auth.service.spec.ts @@ -18,7 +18,7 @@ vi.mock('bcrypt', () => ({ describe('AuthService (internal)', () => { beforeEach(() => { vi.clearAllMocks(); - process.env.HASH_SALT_ROUNDS = '10'; + process.env.HASH_SALT_ROUNDS = '14'; }); describe('validateCredentials', () => { @@ -35,7 +35,7 @@ describe('AuthService (internal)', () => { const result = await authService.validateCredentials('a@b.com', 'plain'); expect(User.findOne).toHaveBeenCalledWith({ - where: { email: 'a@b.com' }, + where: { email: 'a@b.com', deleted: false }, }); expect(bcrypt.compare).toHaveBeenCalledWith('plain', 'hashed'); expect(result).toEqual(mockUser); @@ -60,14 +60,39 @@ describe('AuthService (internal)', () => { }); }); + describe('getUser', () => { + it('only resolves non-deleted users (soft-delete filter)', async () => { + vi.mocked(User.findOne).mockResolvedValue(null); + + const result = await authService.getUser(1); + + expect(User.findOne).toHaveBeenCalledWith({ + where: { id: 1, deleted: false }, + }); + expect(result).toBeNull(); + }); + }); + describe('hashPassword', () => { - it('delegates to bcrypt.hash with HASH_SALT_ROUNDS env', async () => { + it('delegates to bcrypt.hash with HASH_SALT_ROUNDS as a number', async () => { vi.mocked(bcrypt.hash).mockResolvedValue('hashed' as never); const result = await authService.hashPassword('pwd'); - expect(bcrypt.hash).toHaveBeenCalledWith('pwd', '10'); + expect(bcrypt.hash).toHaveBeenCalledWith('pwd', 14); expect(result).toBe('hashed'); }); + + it('enforces a minimum cost of 12 for low/invalid values', async () => { + vi.mocked(bcrypt.hash).mockResolvedValue('hashed' as never); + + process.env.HASH_SALT_ROUNDS = '10'; + await authService.hashPassword('pwd'); + expect(bcrypt.hash).toHaveBeenLastCalledWith('pwd', 12); + + process.env.HASH_SALT_ROUNDS = 'not-a-number'; + await authService.hashPassword('pwd'); + expect(bcrypt.hash).toHaveBeenLastCalledWith('pwd', 12); + }); }); }); diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts index 89f5435..d965674 100644 --- a/apps/api/src/services/auth.service.ts +++ b/apps/api/src/services/auth.service.ts @@ -10,17 +10,29 @@ class AuthService { email: string, password: string, ): Promise => { - const user: UserModel = await User.findOne({ where: { email } }); + const user: UserModel = await User.findOne({ + where: { email, deleted: false }, + }); if (!user) throw new Error('Email or password incorrect.'); const validPassword = await this.#comparePassword(password, user.password); if (!validPassword) throw new Error('Email or password incorrect.'); return user; }; - getUser = async (id: number): Promise => await User.findByPk(id); + // findOne (not findByPk) so the soft-delete filter is actually enforced: + // a soft-deleted account must never be resolvable for auth purposes. + getUser = async (id: number): Promise => + await User.findOne({ where: { id, deleted: false } }); hashPassword = async (password: string) => { - return await hash(password, process.env.HASH_SALT_ROUNDS ?? 10); + // bcrypt expects a NUMBER of rounds; a string is treated as a pre-generated + // salt and silently changes behaviour. Parse it and enforce a sane modern + // minimum cost (12). + const MIN_ROUNDS = 12; + const parsed = parseInt(process.env.HASH_SALT_ROUNDS ?? '', 10); + const rounds = + Number.isFinite(parsed) && parsed >= MIN_ROUNDS ? parsed : MIN_ROUNDS; + return await hash(password, rounds); }; #comparePassword = async (password: string, hash: string) => { diff --git a/apps/api/src/services/user-crud.service.spec.ts b/apps/api/src/services/user-crud.service.spec.ts index d0a98f1..77e534f 100644 --- a/apps/api/src/services/user-crud.service.spec.ts +++ b/apps/api/src/services/user-crud.service.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import userCrudService from './user-crud.service'; +import userCrudService, { USER_WRITABLE_FIELDS } from './user-crud.service'; import { User } from '@api/models'; import { Permission } from '@dto'; import authService from './auth.service'; @@ -53,10 +53,13 @@ describe('UserCrudService', () => { // Assert expect(authService.hashPassword).toHaveBeenCalledWith('plainPassword123'); - expect(User.create).toHaveBeenCalledWith({ - ...userData, - password: hashedPassword, - }); + expect(User.create).toHaveBeenCalledWith( + { + ...userData, + password: hashedPassword, + }, + { fields: USER_WRITABLE_FIELDS }, + ); expect(result).toEqual(createdUser); }); @@ -76,7 +79,9 @@ describe('UserCrudService', () => { // Assert expect(authService.hashPassword).not.toHaveBeenCalled(); - expect(User.create).toHaveBeenCalledWith(userData); + expect(User.create).toHaveBeenCalledWith(userData, { + fields: USER_WRITABLE_FIELDS, + }); expect(result).toEqual(createdUser); }); }); @@ -111,7 +116,7 @@ describe('UserCrudService', () => { expect(authService.hashPassword).toHaveBeenCalledWith('newPassword123'); expect(User.update).toHaveBeenCalledWith( { ...updateData, password: hashedPassword }, - { where: { id: userId } }, + { where: { id: userId }, fields: USER_WRITABLE_FIELDS }, ); expect(result).toEqual([1]); }); @@ -156,7 +161,7 @@ describe('UserCrudService', () => { email: 'updated@example.com', permissions: [Permission.READ_SOME_ENTITY], }, - { where: { id: userId } }, + { where: { id: userId }, fields: USER_WRITABLE_FIELDS }, ); }); diff --git a/apps/api/src/services/user-crud.service.ts b/apps/api/src/services/user-crud.service.ts index bd7a231..f0c0f3d 100644 --- a/apps/api/src/services/user-crud.service.ts +++ b/apps/api/src/services/user-crud.service.ts @@ -4,16 +4,27 @@ import { Op } from 'sequelize'; import { AbstractCrudService } from './abstract-crud.service'; import authService from './auth.service'; +// Fields an admin may legitimately set on a user. Notably excludes id, deleted, +// deletedAt, createdAt and updatedAt β€” those are never client-writable, even by +// an admin, so they cannot be tampered with via over-posting. +export const USER_WRITABLE_FIELDS = [ + 'email', + 'name', + 'lastName', + 'permissions', + 'password', +]; + class UserCrudService extends AbstractCrudService { constructor() { - super(User); + super(User, USER_WRITABLE_FIELDS); } post = async (userData) => { if (userData.password) { userData.password = await authService.hashPassword(userData.password); } - return await this.model.create(userData); + return await this.model.create(userData, { fields: this.writableFields }); }; put = async (id: number, userData) => { @@ -52,7 +63,10 @@ class UserCrudService extends AbstractCrudService { delete userData.password; } - return await this.model.update({ ...userData }, { where: { id } }); + return await this.model.update( + { ...userData }, + { where: { id }, fields: this.writableFields }, + ); }; delete = async (id: number) => { diff --git a/apps/front/AGENTS.md b/apps/front/AGENTS.md index 430483b..03363b9 100644 --- a/apps/front/AGENTS.md +++ b/apps/front/AGENTS.md @@ -14,23 +14,36 @@ ## Folder layout (`src/app/`) -| Path | Contents | -| -------------- | ---------------------------------------------------------------- | -| `pages/` | Routed feature components (`home`, `login`, …) | -| `components/` | Reusable presentational components (`confirm`, `language-switcher`) | -| `services/` | App-wide services; `abstract-state.class.ts` is the state base | -| `libs/auth/` | Auth slice: `auth.provider`, guards, interceptors, services | -| `models/` | View models (`state.model.ts`) | -| `constants/` | App constants (`languages.constant.ts`) | -| `app.config.ts`, `app.routes.ts` | Root providers and routing | +| Path | Contents | +| -------------------------------- | ------------------------------------------------------------------- | +| `pages/` | Routed feature components (`home`, `login`, …) | +| `components/` | Reusable presentational components (`confirm`, `language-switcher`) | +| `services/` | App-wide services; `abstract-state.class.ts` is the state base | +| `libs/auth/` | Auth slice: `auth.provider`, guards, interceptors, services | +| `models/` | View models (`state.model.ts`) | +| `constants/` | App constants (`languages.constant.ts`) | +| `app.config.ts`, `app.routes.ts` | Root providers and routing | ## Auth (`src/app/libs/auth/`) - `guards/auth.guard.ts` β€” gate routes by authentication. - `guards/auth-permission.guard.ts` β€” gate routes by `Permission` (from `@dto`). -- `interceptors/auth.interceptor.ts` β€” attach the access token / handle refresh. +- `interceptors/auth.interceptor.ts` β€” attach the access token / handle refresh + (only to same-origin / the configured API origin β€” never third parties). +- **CSRF posture**: state-changing calls require the in-memory access token in + the `Authorization` header (non-cookie proof a cross-site page cannot forge), + and the refresh cookie is `httpOnly` + `SameSite=strict` in prod. There is no + cookie-based XSRF token, so do not add `withXsrfConfiguration`. `tokenDecoded` + is presentation-only. - `auth.provider.ts` β€” wire it all into `app.config.ts`. - Use these instead of inlining auth logic in components. +- **Protected routes MUST declare `canActivate`** in `app.routes.ts` + (`[canActivateFn]` for auth, `[canActivateWithPermission(Permission.X)]` for + permissioned routes) β€” never rely on `@if (auth.isLoggedIn())` in the template + alone. Permission guards redirect to `unauthorized` (a real route) on failure. + Guards are **UX only**; the backend is the real authorization boundary, so a + protected page must still call APIs that enforce the permission server-side. + See `pages/profile` (auth-guarded example) and `pages/unauthorized`. ## Conventions diff --git a/apps/front/Dockerfile b/apps/front/Dockerfile index 09c7e5b..1119bae 100644 --- a/apps/front/Dockerfile +++ b/apps/front/Dockerfile @@ -28,7 +28,9 @@ ENV NODE_OPTIONS="--max-old-space-size=3072" RUN npm run build:front --verbose # Etapa 2: Imagen final (Nginx para servir la aplicaciΓ³n) -FROM nginx +# Pin the image for reproducible builds (avoid the moving nginx:latest tag). +# Debian-based variant (not -alpine) so the groupadd/useradd block below works. +FROM nginx:1.27 # Definir el directorio donde se servirΓ‘n los archivos ENV APP_DIR /usr/share/nginx/html/ diff --git a/apps/front/src/app/app.routes.ts b/apps/front/src/app/app.routes.ts index 6b759fc..b96c6a3 100644 --- a/apps/front/src/app/app.routes.ts +++ b/apps/front/src/app/app.routes.ts @@ -1,4 +1,5 @@ import { Route } from '@angular/router'; +import { canActivateFn } from './libs/auth/guards/auth.guard'; export const appRoutes: Route[] = [ { @@ -9,4 +10,22 @@ export const appRoutes: Route[] = [ path: 'login', loadComponent: () => import('./pages/login/login.component'), }, + // Example PROTECTED route β€” the guard redirects anonymous users to /login. + // Use canActivateWithPermission(...) from auth-permission.guard.ts for routes + // that also require a specific permission. Guards are UX only; the backend is + // the real authorization boundary. + { + path: 'profile', + canActivate: [canActivateFn], + loadComponent: () => import('./pages/profile/profile.component'), + }, + { + path: 'unauthorized', + loadComponent: () => import('./pages/unauthorized/unauthorized.component'), + }, + // Unknown routes fall back to the landing page instead of a blank 404. + { + path: '**', + redirectTo: '', + }, ]; diff --git a/apps/front/src/app/libs/auth/interceptors/auth.interceptor.spec.ts b/apps/front/src/app/libs/auth/interceptors/auth.interceptor.spec.ts index 86bf329..dae82a6 100644 --- a/apps/front/src/app/libs/auth/interceptors/auth.interceptor.spec.ts +++ b/apps/front/src/app/libs/auth/interceptors/auth.interceptor.spec.ts @@ -86,6 +86,29 @@ describe('AuthInterceptor', () => { req.flush({}); }); + it('should NOT attach token/withCredentials to a cross-origin URL', () => { + mockAuthService.token.set('Bearer secret-token'); + + httpClient.get('https://evil.example.com/collect').subscribe(); + + const req = httpMock.expectOne('https://evil.example.com/collect'); + expect(req.request.headers.has('Authorization')).toBe(false); + expect(req.request.withCredentials).toBe(false); + + req.flush({}); + }); + + it('should not capture a token from a cross-origin response', () => { + mockAuthService.token.set('Bearer secret-token'); + + httpClient.get('https://evil.example.com/x').subscribe(); + + const req = httpMock.expectOne('https://evil.example.com/x'); + req.flush({}, { headers: { Authorization: 'Bearer attacker-token' } }); + + expect(mockAuthService.setToken).not.toHaveBeenCalled(); + }); + it('should preserve existing headers', () => { mockAuthService.token.set('Bearer test-token'); diff --git a/apps/front/src/app/libs/auth/interceptors/auth.interceptor.ts b/apps/front/src/app/libs/auth/interceptors/auth.interceptor.ts index 4444dff..0621f01 100644 --- a/apps/front/src/app/libs/auth/interceptors/auth.interceptor.ts +++ b/apps/front/src/app/libs/auth/interceptors/auth.interceptor.ts @@ -9,40 +9,69 @@ import { import { Injectable, inject } from '@angular/core'; import { catchError, tap, throwError } from 'rxjs'; +import { AUTH_CONFIGURATION } from '../auth.constants'; import { AuthService } from '../services/auth.service'; @Injectable() export class AuthInterceptor implements HttpInterceptor { #auth = inject(AuthService); + #config = inject(AUTH_CONFIGURATION, { optional: true }); + + /** + * Only same-origin requests (or the explicitly-configured API origin) may + * carry the in-memory access token and cookies. Without this guard, the day + * the app calls a third-party absolute URL (CDN, analytics, avatar service) + * the Authorization header would leak the token to that third party. + */ + #isTrustedOrigin(url: string): boolean { + try { + const appOrigin = window.location.origin; + const targetOrigin = new URL(url, appOrigin).origin; + if (targetOrigin === appOrigin) return true; + const idp = this.#config?.idpServer; + if (idp && /^https?:\/\//i.test(idp)) { + return targetOrigin === new URL(idp).origin; + } + return false; + } catch { + return false; + } + } intercept(req: HttpRequest, next: HttpHandler) { - const authToken = this.#auth.token(); + const attach = this.#isTrustedOrigin(req.url); - // Always include withCredentials to send cookies (RefreshToken) - const authReq = authToken - ? req.clone({ - headers: req.headers.set('Authorization', authToken), - withCredentials: true, - }) - : req.clone({ - withCredentials: true, - }); + let authReq = req; + if (attach) { + const authToken = this.#auth.token(); + authReq = authToken + ? req.clone({ + headers: req.headers.set('Authorization', authToken), + withCredentials: true, + }) + : req.clone({ withCredentials: true }); + } return next.handle(authReq).pipe( tap((event: HttpEvent) => { - if (event instanceof HttpResponse) { - // Capture new AccessToken from response header + // Never read an Authorization response header from an untrusted origin + // as our session token. + if (attach && event instanceof HttpResponse) { const token = event.headers.get('Authorization'); if (token) { this.#auth.setToken(token); } } }), - catchError(this.errorHandle.bind(this)), + catchError((error: HttpErrorResponse) => + this.#errorHandle(error, attach), + ), ); } - private errorHandle(error: HttpErrorResponse) { - if (error.status === 401) { + + #errorHandle(error: HttpErrorResponse, attach: boolean) { + // Only our own 401s should end the session; a third-party 401 must not. + if (attach && error.status === 401) { this.#auth.logout('/login'); } return throwError(() => error); diff --git a/apps/front/src/app/libs/auth/safe-redirect.spec.ts b/apps/front/src/app/libs/auth/safe-redirect.spec.ts new file mode 100644 index 0000000..b41ab20 --- /dev/null +++ b/apps/front/src/app/libs/auth/safe-redirect.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeRedirect } from './safe-redirect'; + +describe('sanitizeRedirect', () => { + it('accepts same-origin relative paths', () => { + expect(sanitizeRedirect('/profile')).toBe('/profile'); + expect(sanitizeRedirect('/users/1?tab=x#frag')).toBe('/users/1?tab=x#frag'); + expect(sanitizeRedirect('/')).toBe('/'); + }); + + it('rejects open-redirect payloads', () => { + for (const bad of [ + 'https://evil.com', + 'http://evil.com', + '//evil.com', + '/\\evil.com', + '/\\/evil.com', + 'javascript:alert(1)', + 'evil.com', + '\\\\evil.com', + ]) { + expect(sanitizeRedirect(bad)).toBe(''); + } + }); + + it('treats empty/nullish as no redirect', () => { + expect(sanitizeRedirect('')).toBe(''); + expect(sanitizeRedirect(null)).toBe(''); + expect(sanitizeRedirect(undefined)).toBe(''); + }); +}); diff --git a/apps/front/src/app/libs/auth/safe-redirect.ts b/apps/front/src/app/libs/auth/safe-redirect.ts new file mode 100644 index 0000000..849af75 --- /dev/null +++ b/apps/front/src/app/libs/auth/safe-redirect.ts @@ -0,0 +1,13 @@ +/** + * Returns `url` only if it is a safe same-origin relative path, otherwise ''. + * + * Blocks open-redirect payloads: absolute URLs (`https://evil.com`), scheme + * tricks (`javascript:...`), protocol-relative (`//evil.com`) and backslash + * variants (`/\evil.com`, `/\/evil.com`). Only a single leading '/' followed by + * non-backslash characters is accepted (paths, query and fragment included). + */ +export const sanitizeRedirect = (url: string | null | undefined): string => { + if (!url) return ''; + const isSameOriginPath = /^\/(?!\/)[^\\]*$/.test(url); + return isSameOriginPath ? url : ''; +}; diff --git a/apps/front/src/app/libs/auth/services/auth.service.spec.ts b/apps/front/src/app/libs/auth/services/auth.service.spec.ts index 740658e..c806cdd 100644 --- a/apps/front/src/app/libs/auth/services/auth.service.spec.ts +++ b/apps/front/src/app/libs/auth/services/auth.service.spec.ts @@ -69,6 +69,15 @@ describe('AuthService', () => { expect(service.tokenDecoded).toEqual(userData); }); + + it('returns empty claims when there is no token', () => { + expect(service.tokenDecoded).toEqual({}); + }); + + it('returns empty claims (no throw) for a malformed token', () => { + service.setToken('not-a-jwt'); + expect(service.tokenDecoded).toEqual({}); + }); }); describe('isLoggedIn$ observable', () => { diff --git a/apps/front/src/app/libs/auth/services/auth.service.ts b/apps/front/src/app/libs/auth/services/auth.service.ts index 8f55daa..8e1bccc 100644 --- a/apps/front/src/app/libs/auth/services/auth.service.ts +++ b/apps/front/src/app/libs/auth/services/auth.service.ts @@ -15,9 +15,16 @@ export class AuthService { private isInitialized = signal(false); get tokenDecoded(): UserTokenData { - return this.token - ? JSON.parse(window.atob(this.token().split('.')[1])) - : ({} as UserTokenData); + // NOTE: decoded claims are PRESENTATION-ONLY (UX). Authorization is enforced + // by the backend, never by trusting this client-side decode. + const token = this.token(); + if (!token) return {} as UserTokenData; + try { + return JSON.parse(window.atob(token.split('.')[1] ?? '')); + } catch { + // Malformed/non-JWT token β€” fail safe to empty claims. + return {} as UserTokenData; + } } #apiBase = '/login'; diff --git a/apps/front/src/app/pages/home/home.component.html b/apps/front/src/app/pages/home/home.component.html index 2eb427f..56d70a9 100644 --- a/apps/front/src/app/pages/home/home.component.html +++ b/apps/front/src/app/pages/home/home.component.html @@ -33,6 +33,14 @@

+
diff --git a/apps/front/src/app/pages/profile/profile.component.ts b/apps/front/src/app/pages/profile/profile.component.ts new file mode 100644 index 0000000..f83750f --- /dev/null +++ b/apps/front/src/app/pages/profile/profile.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { AuthService } from '@front/app/libs/auth/services/auth.service'; +import { TranslocoModule } from '@jsverse/transloco'; + +/** + * Example PROTECTED route. It is guarded by `canActivateFn` in app.routes.ts, + * so an unauthenticated visit is redirected to /login. The decoded token data + * shown here is presentation-only β€” real authorization is always enforced by + * the backend, never by this guard. + */ +@Component({ + selector: 'app-profile', + standalone: true, + imports: [TranslocoModule, RouterLink], + templateUrl: './profile.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class ProfileComponent { + readonly auth = inject(AuthService); + + get permissions(): string[] { + return this.auth.tokenDecoded.permissions ?? []; + } +} diff --git a/apps/front/src/app/pages/unauthorized/unauthorized.component.html b/apps/front/src/app/pages/unauthorized/unauthorized.component.html new file mode 100644 index 0000000..b3e7ae9 --- /dev/null +++ b/apps/front/src/app/pages/unauthorized/unauthorized.component.html @@ -0,0 +1,25 @@ +
+
+
+
+
+ +
+

+ {{ 'unauthorized.title' | transloco }} +

+

+ {{ 'unauthorized.description' | transloco }} +

+ + {{ 'unauthorized.back' | transloco }} + +
+
+
+
diff --git a/apps/front/src/app/pages/unauthorized/unauthorized.component.ts b/apps/front/src/app/pages/unauthorized/unauthorized.component.ts new file mode 100644 index 0000000..69100a2 --- /dev/null +++ b/apps/front/src/app/pages/unauthorized/unauthorized.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslocoModule } from '@jsverse/transloco'; + +@Component({ + selector: 'app-unauthorized', + standalone: true, + imports: [TranslocoModule, RouterLink], + templateUrl: './unauthorized.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class UnauthorizedComponent {} diff --git a/apps/front/src/assets/i18n/ca.json b/apps/front/src/assets/i18n/ca.json index c2f0752..5d5e1e3 100644 --- a/apps/front/src/assets/i18n/ca.json +++ b/apps/front/src/assets/i18n/ca.json @@ -24,6 +24,16 @@ "invalid": "Usuari o contrasenya incorrectes" } }, + "unauthorized": { + "title": "AccΓ©s denegat", + "description": "No tens permisos per veure aquesta pΓ gina.", + "back": "Tornar a l'inici" + }, + "profile": { + "title": "El teu perfil", + "email": "Correu electrΓ²nic", + "permissions": "Permisos" + }, "common": { "loading": "Carregant...", "save": "Guardar", diff --git a/apps/front/src/assets/i18n/en.json b/apps/front/src/assets/i18n/en.json index c67a42b..bca4f1a 100644 --- a/apps/front/src/assets/i18n/en.json +++ b/apps/front/src/assets/i18n/en.json @@ -24,6 +24,16 @@ "invalid": "Invalid username or password" } }, + "unauthorized": { + "title": "Access denied", + "description": "You don't have permission to view this page.", + "back": "Back to home" + }, + "profile": { + "title": "Your profile", + "email": "Email", + "permissions": "Permissions" + }, "common": { "loading": "Loading...", "save": "Save", diff --git a/apps/front/src/assets/i18n/es.json b/apps/front/src/assets/i18n/es.json index a4ee958..df976a0 100644 --- a/apps/front/src/assets/i18n/es.json +++ b/apps/front/src/assets/i18n/es.json @@ -24,6 +24,16 @@ "invalid": "Usuario o contraseΓ±a incorrectos" } }, + "unauthorized": { + "title": "Acceso denegado", + "description": "No tienes permisos para ver esta pΓ‘gina.", + "back": "Volver al inicio" + }, + "profile": { + "title": "Tu perfil", + "email": "Correo electrΓ³nico", + "permissions": "Permisos" + }, "common": { "loading": "Cargando...", "save": "Guardar", diff --git a/apps/gateway/AGENTS.md b/apps/gateway/AGENTS.md index bf89628..db78355 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -43,15 +43,24 @@ browser ──/api/*──▢ nginx ──proxy_pass──▢ gateway - CORS, `cookie-parser`, and `trust proxy` are configured in `src/main.ts`; allowed origins come from `CORS_ORIGIN` (comma-separated). - Responses use the gateway's own `HttpResponser` (`src/adapters/http/`). +- **On refresh rotation, trust the claims the API returns, never the old token.** + `/internal/refresh/rotate` re-reads the user and returns its current + `email`/`permissions`; the gateway must put those on `res.locals.user` so a + revoked/downgraded (or soft-deleted) account loses access on the next rotation + rather than carrying stale claims for the whole refresh lifetime. +- **`remember` is a strict boolean** (`=== true`) and the "remember me" refresh + lifetime is bounded by `JWT_REFRESH_REMEMBER_DAYS` (default 30) β€” never a + year-long token. The JWT expiry and cookie `maxAge` share that single source. ## Env vars -| Var | Purpose | -| ------------------------- | -------------------------------------------------- | -| `GATEWAY_PORT` | Listen port (default 3100) | -| `API_BASE_URL` | Upstream API base (default `http://api:3200`) | -| `INTERNAL_JWT_PRIVATE_KEY`| EdDSA private key for signing internal tokens (PEM)| -| `CORS_ORIGIN` | Comma-separated allowed origins | +| Var | Purpose | +| --------------------------- | --------------------------------------------------- | +| `GATEWAY_PORT` | Listen port (default 3100) | +| `API_BASE_URL` | Upstream API base (default `http://api:3200`) | +| `INTERNAL_JWT_PRIVATE_KEY` | EdDSA private key for signing internal tokens (PEM) | +| `CORS_ORIGIN` | Comma-separated allowed origins | +| `JWT_REFRESH_REMEMBER_DAYS` | "remember me" refresh lifetime in days (default 30) | ## Testing diff --git a/apps/gateway/src/clients/api.client.ts b/apps/gateway/src/clients/api.client.ts index c4feb54..97d98bf 100644 --- a/apps/gateway/src/clients/api.client.ts +++ b/apps/gateway/src/clients/api.client.ts @@ -17,6 +17,10 @@ interface RotateRefreshResponse { userId: number; familyId: string; parentJti: string; + // Authoritative, freshly-read claims so rotation cannot carry stale + // permissions forward (T-5). + email: string; + permissions: Permission[]; } const baseUrl = () => diff --git a/apps/gateway/src/controllers/auth.controller.ts b/apps/gateway/src/controllers/auth.controller.ts index 08f87dd..042a18a 100644 --- a/apps/gateway/src/controllers/auth.controller.ts +++ b/apps/gateway/src/controllers/auth.controller.ts @@ -12,7 +12,10 @@ class AuthController { login = async (req: Request, res: Response) => { const requestId = randomUUID(); try { - const { email, password, remember } = req.body ?? {}; + const { email, password } = req.body ?? {}; + // Strict boolean: any truthy non-true value (e.g. {} or "yes") must not + // silently opt the client into the long-lived "remember me" token (T-5). + const remember = req.body?.remember === true; if (!email || !password) { return HttpResponser.errorJson( res, @@ -37,9 +40,14 @@ class AuthController { { issueRefreshCookie: true, requestId }, ); return HttpResponser.successEmpty(res); - } catch (err) { - const status = (err as { statusCode?: number })?.statusCode ?? 401; - return HttpResponser.errorJson(res, err as Error, status); + } catch { + // Always a generic 401 β€” never forward the upstream message, which could + // reveal whether an account exists (user enumeration). + return HttpResponser.errorJson( + res, + { message: 'Invalid email or password' }, + 401, + ); } }; diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index 19ec0d5..3a11763 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -2,6 +2,7 @@ import cookieParser from 'cookie-parser'; import cors from 'cors'; import 'dotenv/config'; import express from 'express'; +import helmet from 'helmet'; import api from './routes'; @@ -31,8 +32,22 @@ class Main { #config() { this.#app.set('trust proxy', 1); + // Security headers (HSTS, X-Content-Type-Options, X-Frame-Options: DENY, + // etc.). The document-level CSP is owned by the front's nginx; the gateway + // only returns JSON, so a document CSP here would be noise. + this.#app.use(helmet({ contentSecurityPolicy: false })); + + // Fail closed: with credentials:true, never fall back to reflecting any + // origin (that would enable account takeover). An empty CORS_ORIGIN means + // "no cross-origin allowed", and we log it loudly as a misconfiguration. + const allowedOrigins = parseOrigins(process.env.CORS_ORIGIN); + if (allowedOrigins.length === 0) { + console.error( + '⚠️ CORS_ORIGIN is empty β€” all cross-origin requests will be rejected. Set it in your env.', + ); + } const corsOptions: cors.CorsOptions = { - origin: parseOrigins(process.env.CORS_ORIGIN) ?? true, + origin: allowedOrigins, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], @@ -40,7 +55,17 @@ class Main { }; this.#app.use(cors(corsOptions)); this.#app.use(cookieParser()); - this.#app.use(express.json()); + + // Parse JSON only where a body is actually consumed (the auth endpoints), + // with an explicit small size limit. Proxied API traffic is streamed, never + // buffered/parsed here. + const jsonParser = express.json({ limit: '100kb' }); + this.#app.use((req, res, next) => { + if (req.path.startsWith('/api/v1/auth')) { + return jsonParser(req, res, next); + } + return next(); + }); } #setRoutes() { diff --git a/apps/gateway/src/middleware/auth.middleware.spec.ts b/apps/gateway/src/middleware/auth.middleware.spec.ts index 5414565..a737097 100644 --- a/apps/gateway/src/middleware/auth.middleware.spec.ts +++ b/apps/gateway/src/middleware/auth.middleware.spec.ts @@ -142,6 +142,10 @@ describe('hasPermission', () => { userId: 7, familyId: FAMILY, parentJti: 'old-jti', + // api re-reads the user: the gateway must trust THESE claims, not the + // ones embedded in the old refresh token (T-5). + email: 'fresh@b.com', + permissions: [Permission.READ_SOME_ENTITY], }, }, { status: 201, body: { recorded: true } }, @@ -158,6 +162,11 @@ describe('hasPermission', () => { expect(next).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(2); + expect(res.locals.user).toEqual({ + id: 7, + email: 'fresh@b.com', + permissions: [Permission.READ_SOME_ENTITY], + }); expect(setHeader).toHaveBeenCalledWith('Authorization', expect.any(String)); expect(cookie).toHaveBeenCalledWith( 'refreshToken', diff --git a/apps/gateway/src/middleware/auth.middleware.ts b/apps/gateway/src/middleware/auth.middleware.ts index 45f05c1..69b734c 100644 --- a/apps/gateway/src/middleware/auth.middleware.ts +++ b/apps/gateway/src/middleware/auth.middleware.ts @@ -1,6 +1,7 @@ import HttpResponser from '@gateway/adapters/http/http.responser'; import { ApiClient } from '@gateway/clients/api.client'; import { tokenService } from '@gateway/services'; +import { rememberRefreshMaxAgeMs } from '@gateway/services/token.service'; import { Permission } from '@dto'; import { randomUUID } from 'crypto'; import type { CookieOptions, NextFunction, Request, Response } from 'express'; @@ -59,6 +60,7 @@ const issueRefreshAndRecord = async ( email: options.user.email, permissions: options.user.permissions, remember: options.user.remember, + familyId: options.familyId, jti, }); await ApiClient.recordRefresh( @@ -71,7 +73,7 @@ const issueRefreshAndRecord = async ( options.requestId, ); const maxAge = options.user.remember - ? 365 * 24 * 60 * 60 * 1000 + ? rememberRefreshMaxAgeMs() : 8 * 60 * 60 * 1000; res.cookie(REFRESH_COOKIE, refreshToken, buildCookieOptions(maxAge)); }; @@ -120,7 +122,11 @@ export const revokeCurrentRefreshFamily = async ( if (!refreshToken) return; try { const decoded = tokenService.verifyRefreshToken(refreshToken); - if (decoded.jti) { + // Revoke the whole family (all rotations of this session), not just the + // single presented jti, so logout actually ends the session everywhere. + if (decoded.familyId) { + await ApiClient.revokeRefresh({ familyId: decoded.familyId }, requestId); + } else if (decoded.jti) { await ApiClient.revokeRefresh({ jti: decoded.jti }, requestId); } } catch { @@ -205,10 +211,12 @@ const refreshAndContinue = async ( const requestId = randomUUID(); try { const rotation = await ApiClient.rotateRefresh(decoded.jti, requestId); + // Use the permissions/email the api just re-read from the user record, NOT + // the (possibly stale) claims embedded in the old refresh token (T-5). const user: UserContext = { - id: decoded.id, - email: decoded.email, - permissions: decoded.permissions ?? [], + id: rotation.userId, + email: rotation.email ?? decoded.email, + permissions: rotation.permissions ?? [], }; if ( diff --git a/apps/gateway/src/middleware/index.ts b/apps/gateway/src/middleware/index.ts index 331f314..275a70f 100644 --- a/apps/gateway/src/middleware/index.ts +++ b/apps/gateway/src/middleware/index.ts @@ -1 +1,2 @@ export * from './auth.middleware'; +export * from './rate-limit.middleware'; diff --git a/apps/gateway/src/middleware/rate-limit.middleware.spec.ts b/apps/gateway/src/middleware/rate-limit.middleware.spec.ts new file mode 100644 index 0000000..a865777 --- /dev/null +++ b/apps/gateway/src/middleware/rate-limit.middleware.spec.ts @@ -0,0 +1,45 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { loginRateLimiter } from './rate-limit.middleware'; + +const buildApp = () => { + const app = express(); + app.set('trust proxy', 1); + app.use(express.json()); + // Always "fail" so skipSuccessfulRequests does not exempt the attempts. + app.post('/login', loginRateLimiter, (_req, res) => { + res.status(401).json({ error: 'bad creds' }); + }); + return app; +}; + +describe('loginRateLimiter', () => { + it('429s after too many failed attempts for the same ip+email', async () => { + const app = buildApp(); + const agent = request(app); + const body = { email: 'victim@example.com', password: 'x' }; + + let last = 401; + for (let i = 0; i < 12; i++) { + const res = await agent.post('/login').send(body); + last = res.status; + } + expect(last).toBe(429); + }); + + it('keys by email: a different account is not throttled by another’s failures', async () => { + const app = buildApp(); + const agent = request(app); + + for (let i = 0; i < 12; i++) { + await agent + .post('/login') + .send({ email: 'a@example.com', password: 'x' }); + } + const other = await agent + .post('/login') + .send({ email: 'b@example.com', password: 'x' }); + expect(other.status).toBe(401); + }); +}); diff --git a/apps/gateway/src/middleware/rate-limit.middleware.ts b/apps/gateway/src/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..813970a --- /dev/null +++ b/apps/gateway/src/middleware/rate-limit.middleware.ts @@ -0,0 +1,53 @@ +import HttpResponser from '@gateway/adapters/http/http.responser'; +import type { Request, Response } from 'express'; +import rateLimit from 'express-rate-limit'; + +const num = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const tooManyRequests = (_req: Request, res: Response) => + HttpResponser.errorJson( + res, + { message: 'Too many attempts, please try again later.' }, + 429, + ); + +/** + * Anti brute-force / credential-stuffing limiter for the login endpoint. + * Keyed by IP + submitted email so one attacker cannot lock every account by + * cycling addresses, nor exhaust one account from many IPs unnoticed. Only + * failed attempts count (skipSuccessfulRequests) so normal logins are never + * throttled. `trust proxy: 1` is set in main.ts, so req.ip is the real client. + */ +export const loginRateLimiter = rateLimit({ + windowMs: num(process.env.LOGIN_RATE_WINDOW_MS, 15 * 60 * 1000), + limit: num(process.env.LOGIN_RATE_MAX, 10), + standardHeaders: 'draft-7', + legacyHeaders: false, + skipSuccessfulRequests: true, + // IP comes from req.ip (trust proxy: 1 β†’ real client behind Nginx). + validate: { keyGeneratorIpFallback: false }, + keyGenerator: (req) => { + const email = + typeof req.body?.email === 'string' + ? req.body.email.trim().toLowerCase() + : 'unknown'; + return `${req.ip ?? 'unknown-ip'}:${email}`; + }, + handler: tooManyRequests, +}); + +/** + * Coarser per-IP limiter for the rest of the auth surface (logout, refresh + * rotation that flows through the proxy) to blunt automated abuse without + * impacting interactive use. + */ +export const authRateLimiter = rateLimit({ + windowMs: num(process.env.AUTH_RATE_WINDOW_MS, 15 * 60 * 1000), + limit: num(process.env.AUTH_RATE_MAX, 100), + standardHeaders: 'draft-7', + legacyHeaders: false, + handler: tooManyRequests, +}); diff --git a/apps/gateway/src/routes/auth.routes.ts b/apps/gateway/src/routes/auth.routes.ts index 37f1653..a68bb44 100644 --- a/apps/gateway/src/routes/auth.routes.ts +++ b/apps/gateway/src/routes/auth.routes.ts @@ -1,9 +1,13 @@ import authController from '@gateway/controllers/auth.controller'; +import { authRateLimiter, loginRateLimiter } from '@gateway/middleware'; import { Router } from 'express'; const authRouter = Router(); -authRouter.post('/login', authController.login); +// Coarse per-IP cap on the whole auth surface, plus a strict per-IP+email cap +// on login (counts only failed attempts) to resist credential stuffing. +authRouter.use(authRateLimiter); +authRouter.post('/login', loginRateLimiter, authController.login); authRouter.post('/logout', authController.logout); export default authRouter; diff --git a/apps/gateway/src/routes/proxy.routes.ts b/apps/gateway/src/routes/proxy.routes.ts index 1cc85e6..826454a 100644 --- a/apps/gateway/src/routes/proxy.routes.ts +++ b/apps/gateway/src/routes/proxy.routes.ts @@ -35,8 +35,12 @@ export const buildProxyRouter = (): Router => { if (!user) { return res.status(401).json({ error: 'Unauthorized' }); } - const requestId = - (req.header(INTERNAL_REQUEST_ID_HEADER) as string) ?? randomUUID(); + // Never trust client-supplied internal headers: strip them so a forged + // x-internal-auth / x-request-id can never reach the api or poison logs. + delete req.headers[INTERNAL_AUTH_HEADER]; + delete req.headers[INTERNAL_REQUEST_ID_HEADER]; + // The correlation id is always minted server-side. + const requestId = randomUUID(); try { const internalToken = await signUserContext( { @@ -66,6 +70,10 @@ export const buildProxyRouter = (): Router => { internalToken?: string; internalRequestId?: string; }; + // Defense in depth: drop any inbound internal headers that survived, + // then set only the gateway-minted values. + proxyReq.removeHeader(INTERNAL_AUTH_HEADER); + proxyReq.removeHeader(INTERNAL_REQUEST_ID_HEADER); if (carrier.internalToken) { proxyReq.setHeader(INTERNAL_AUTH_HEADER, carrier.internalToken); } diff --git a/apps/gateway/src/services/token.service.spec.ts b/apps/gateway/src/services/token.service.spec.ts index 15f681c..68e5e5a 100644 --- a/apps/gateway/src/services/token.service.spec.ts +++ b/apps/gateway/src/services/token.service.spec.ts @@ -57,7 +57,7 @@ describe('TokenService', () => { expect(decoded.jti).toBeDefined(); }); - it('extends expiry when remember=true', async () => { + it('extends expiry to the bounded remember window when remember=true', async () => { const token = await tokenService.generateRefreshToken({ id: 1, email: 'a@b.com', @@ -65,9 +65,12 @@ describe('TokenService', () => { remember: true, }); const decoded = tokenService.verifyRefreshToken(token); - expect((decoded.exp ?? 0) - (decoded.iat ?? 0)).toBeGreaterThan( - 30 * 24 * 60 * 60, - ); + const lifetime = (decoded.exp ?? 0) - (decoded.iat ?? 0); + // Default 30d window β€” much longer than the 8h non-remember token, but no + // longer a year-long token (T-5). + expect(lifetime).toBeGreaterThan(8 * 60 * 60); + expect(lifetime).toBeLessThanOrEqual(31 * 24 * 60 * 60); + expect(lifetime).toBeGreaterThanOrEqual(29 * 24 * 60 * 60); }); it('rejects a refresh token verified with the access secret', async () => { @@ -80,13 +83,35 @@ describe('TokenService', () => { }); }); + describe('issuer / audience binding', () => { + it('rejects an access token with a wrong audience', async () => { + const jwt = await import('jsonwebtoken'); + const forged = jwt.default.sign( + { id: 1, email: 'a@b.com', permissions: [], typ: 'access' }, + process.env.JWT_ACCESS_SECRET as string, + { + algorithm: 'HS256', + expiresIn: '1h', + issuer: 'gateway', + audience: 'someone-else', + }, + ); + expect(() => tokenService.verifyAccessToken(forged)).toThrow(); + }); + }); + describe('typ enforcement', () => { it('refuses an access token shape signed with the access secret but typ=refresh', async () => { const jwt = await import('jsonwebtoken'); const forged = jwt.default.sign( { id: 1, email: 'a@b.com', permissions: [], typ: 'refresh' }, process.env.JWT_ACCESS_SECRET as string, - { algorithm: 'HS256', expiresIn: '1h' }, + { + algorithm: 'HS256', + expiresIn: '1h', + issuer: 'gateway', + audience: 'web', + }, ); expect(() => tokenService.verifyAccessToken(forged)).toThrow( /Expected access token/, diff --git a/apps/gateway/src/services/token.service.ts b/apps/gateway/src/services/token.service.ts index 7f6f270..fb5286e 100644 --- a/apps/gateway/src/services/token.service.ts +++ b/apps/gateway/src/services/token.service.ts @@ -4,6 +4,27 @@ import jwt, { JwtPayload } from 'jsonwebtoken'; export type ClientTokenType = 'access' | 'refresh'; +const DEFAULT_REMEMBER_DAYS = 30; + +// Bind public tokens to an issuer/audience and verify them, so a token minted +// for a different context cannot be replayed here. Small clock tolerance avoids +// flakiness from minor drift. +const TOKEN_ISSUER = process.env.JWT_ISSUER ?? 'gateway'; +const TOKEN_AUDIENCE = process.env.JWT_AUDIENCE ?? 'web'; +const CLOCK_TOLERANCE_SECONDS = 5; + +/** + * Lifetime (in days) of a "remember me" refresh token / cookie. Single source + * of truth shared by the JWT expiry and the cookie maxAge so they never drift. + */ +export const rememberRefreshDays = (): number => { + const parsed = Number(process.env.JWT_REFRESH_REMEMBER_DAYS); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_REMEMBER_DAYS; +}; + +export const rememberRefreshMaxAgeMs = (): number => + rememberRefreshDays() * 24 * 60 * 60 * 1000; + export interface AccessTokenPayload extends JwtPayload { id: number; email: string; @@ -17,6 +38,7 @@ export interface RefreshTokenPayload extends JwtPayload { email: string; permissions: Permission[]; remember?: boolean; + familyId?: string; typ: 'refresh'; jti: string; } @@ -33,6 +55,7 @@ export interface RefreshTokenInput { email: string; permissions: Permission[]; remember?: boolean; + familyId?: string; jti?: string; } @@ -64,6 +87,8 @@ class TokenService { }; return jwt.sign(payload, this.#accessSecret(), { algorithm: 'HS256', + issuer: TOKEN_ISSUER, + audience: TOKEN_AUDIENCE, expiresIn: (process.env.JWT_EXPIRES_IN as jwt.SignOptions['expiresIn']) ?? '4h', }); @@ -78,14 +103,22 @@ class TokenService { email: input.email, permissions: input.permissions, remember: input.remember, + familyId: input.familyId, typ: 'refresh', jti: input.jti ?? randomUUID(), }; + // "remember me" leans on refresh rotation rather than a year-long token: a + // bounded window (default 30d) limits how long stale/revoked sessions live. + const rememberDays = rememberRefreshDays(); const expiresIn = ( - input.remember ? '365d' : (process.env.JWT_REFRESH_EXPIRES_IN ?? '8h') + input.remember + ? `${rememberDays}d` + : (process.env.JWT_REFRESH_EXPIRES_IN ?? '8h') ) as jwt.SignOptions['expiresIn']; return jwt.sign(payload, this.#refreshSecret(), { algorithm: 'HS256', + issuer: TOKEN_ISSUER, + audience: TOKEN_AUDIENCE, expiresIn, }); }; @@ -93,6 +126,9 @@ class TokenService { verifyAccessToken = (token: string): AccessTokenPayload => { const decoded = jwt.verify(token, this.#accessSecret(), { algorithms: ['HS256'], + issuer: TOKEN_ISSUER, + audience: TOKEN_AUDIENCE, + clockTolerance: CLOCK_TOLERANCE_SECONDS, }) as AccessTokenPayload; if (decoded.typ !== 'access') { throw new Error(`Expected access token, got ${decoded.typ ?? 'unknown'}`); @@ -103,6 +139,9 @@ class TokenService { verifyRefreshToken = (token: string): RefreshTokenPayload => { const decoded = jwt.verify(token, this.#refreshSecret(), { algorithms: ['HS256'], + issuer: TOKEN_ISSUER, + audience: TOKEN_AUDIENCE, + clockTolerance: CLOCK_TOLERANCE_SECONDS, }) as RefreshTokenPayload; if (decoded.typ !== 'refresh') { throw new Error( diff --git a/compose.yaml b/compose.yaml index ac45cb3..6b16ed5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -37,7 +37,7 @@ services: - POSTGRESDB_PORT=${POSTGRESDB_PORT:-5432} - POSTGRESDB_HOST=postgresdb - INTERNAL_JWT_PUBLIC_KEY=${INTERNAL_JWT_PUBLIC_KEY} - - HASH_SALT_ROUNDS=${HASH_SALT_ROUNDS:-10} + - HASH_SALT_ROUNDS=${HASH_SALT_ROUNDS:-12} - NODE_UPLOAD_FILES=${NODE_UPLOAD_FILES:-uploads} networks: - internal-network @@ -61,6 +61,7 @@ services: - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET} - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-4h} - JWT_REFRESH_EXPIRES_IN=${JWT_REFRESH_EXPIRES_IN:-8h} + - JWT_REFRESH_REMEMBER_DAYS=${JWT_REFRESH_REMEMBER_DAYS:-30} - INTERNAL_JWT_PRIVATE_KEY=${INTERNAL_JWT_PRIVATE_KEY} - CORS_ORIGIN=${CORS_ORIGIN} - SERVICE_FQDN_GATEWAY=${SERVICE_FQDN_GATEWAY} diff --git a/db/10.user.sql b/db/10.user.sql index de8ee06..9d58cdb 100644 --- a/db/10.user.sql +++ b/db/10.user.sql @@ -35,9 +35,8 @@ ALTER SEQUENCE public.user_id_seq OWNED BY public.user.id; ALTER TABLE ONLY public.user ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); - -INSERT INTO public.user (id, email, name, lastname, password, permissions) VALUES (1, 'test@local.com', 'Test', 'T', '$2b$10$MFlYds40Bv5jd3BAOvuuk.XrgzeniY84r572RGfbr5/d7O8f5bouy', ARRAY['ADMIN']::permission_type[]); - - - -SELECT pg_catalog.setval('public.user_id_seq', 2); +-- NOTE: this schema file intentionally seeds NO users. A bootstrap admin is +-- created only by the gated, dev-only script `db/zz-dev-seed.sh`, which runs +-- after this file and does nothing unless DEV_SEED_ADMIN=true is set with an +-- explicit BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD_HASH. Never ship a +-- known credential as part of the schema. diff --git a/db/AGENTS.md b/db/AGENTS.md index 1e9b4c4..733324b 100644 --- a/db/AGENTS.md +++ b/db/AGENTS.md @@ -36,8 +36,14 @@ next free unit after its base file. - Enums are real PG types (`CREATE TYPE … AS ENUM (...)`) and must match the enum in `libs/rest-dto` (e.g. `permission_type` ↔ `Permission`). - Add indexes for foreign keys and frequent lookup/filter columns. -- Seed/bootstrap rows go at the end of the file and must reset the sequence - (`SELECT pg_catalog.setval(...)`). +- **Never seed credentials or known passwords in schema files.** Schema `.sql` + files define structure only. Bootstrap data that includes secrets (e.g. an + admin user) must live in a **gated, dev-only** entrypoint script + (`db/zz-dev-seed.sh`) that is fail-safe OFF (does nothing unless + `DEV_SEED_ADMIN=true` with an explicit, externally-provided bcrypt hash). + Production compose never sets those vars, so it never seeds. +- Non-secret seed/bootstrap rows may go at the end of a schema file and must + reset the sequence (`SELECT pg_catalog.setval(...)`). ## Workflow when adding an entity diff --git a/db/zz-dev-seed.sh b/db/zz-dev-seed.sh new file mode 100755 index 0000000..b0190ca --- /dev/null +++ b/db/zz-dev-seed.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# --------------------------------------------------------------------------- +# Gated, DEV-ONLY admin bootstrap. +# +# This file lives in the Postgres init pipeline (mounted at +# /docker-entrypoint-initdb.d) but is FAIL-SAFE OFF: it seeds nothing unless +# the operator explicitly opts in. Production (compose.yaml) never sets these +# variables, so the admin is never created there. +# +# To enable in dev, set in your .env: +# DEV_SEED_ADMIN=true +# BOOTSTRAP_ADMIN_EMAIL=admin@example.test +# BOOTSTRAP_ADMIN_PASSWORD_HASH= # generate, never hardcode +# +# Generate the hash with: +# bash scripts/gen-admin-hash.sh 'your-strong-password' +# --------------------------------------------------------------------------- +set -eu + +if [ "${DEV_SEED_ADMIN:-false}" != "true" ]; then + echo "[dev-seed] DEV_SEED_ADMIN is not 'true' β€” skipping admin seed." + exit 0 +fi + +if [ -z "${BOOTSTRAP_ADMIN_EMAIL:-}" ] || [ -z "${BOOTSTRAP_ADMIN_PASSWORD_HASH:-}" ]; then + echo "[dev-seed] DEV_SEED_ADMIN=true but BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD_HASH are unset β€” skipping (no insecure default)." >&2 + exit 0 +fi + +echo "[dev-seed] seeding bootstrap ADMIN ${BOOTSTRAP_ADMIN_EMAIL}" +psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + -v admin_email="$BOOTSTRAP_ADMIN_EMAIL" \ + -v admin_hash="$BOOTSTRAP_ADMIN_PASSWORD_HASH" <<'EOSQL' +INSERT INTO public.user (email, name, lastname, password, permissions) +VALUES (:'admin_email', 'Admin', 'User', :'admin_hash', ARRAY['ADMIN']::permission_type[]) +ON CONFLICT (email) DO NOTHING; +SELECT pg_catalog.setval('public.user_id_seq', COALESCE((SELECT MAX(id) FROM public.user), 1), true); +EOSQL +echo "[dev-seed] done." diff --git a/docker-compose.db.yml b/docker-compose.db.yml index e6c9fc2..1ab6d59 100644 --- a/docker-compose.db.yml +++ b/docker-compose.db.yml @@ -8,9 +8,16 @@ services: environment: POSTGRES_DB: ${POSTGRESDB_DATABASE:-your_db_name} POSTGRES_USER: ${POSTGRESDB_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRESDB_PASSWORD:-password} + # No insecure default: you MUST set POSTGRESDB_PASSWORD in your .env. + POSTGRES_PASSWORD: ${POSTGRESDB_PASSWORD:?POSTGRESDB_PASSWORD is required β€” set it in your .env (copy .env.example to .env)} + # Dev-only admin bootstrap (consumed by db/zz-dev-seed.sh). Off by default. + DEV_SEED_ADMIN: ${DEV_SEED_ADMIN:-false} + BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} + BOOTSTRAP_ADMIN_PASSWORD_HASH: ${BOOTSTRAP_ADMIN_PASSWORD_HASH:-} ports: - - "5432:5432" + # Bind to loopback only: the dev DB must not be reachable from other hosts + # on the network. Override POSTGRESDB_PORT if 5432 is taken locally. + - "127.0.0.1:${POSTGRESDB_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./db:/docker-entrypoint-initdb.d diff --git a/docs/README_eng.md b/docs/README_eng.md index 3c30dd7..d8f750c 100644 --- a/docs/README_eng.md +++ b/docs/README_eng.md @@ -117,10 +117,24 @@ npm run dev ### Default Credentials +The schema **seeds no users**. For security there are no default credentials. +To create a local admin in development, generate a hash and enable the +(dev-only) seed: + +```bash +# 1. Generate the bcrypt hash of your password +bash scripts/gen-admin-hash.sh 'your-strong-password' + +# 2. In your .env +DEV_SEED_ADMIN=true +BOOTSTRAP_ADMIN_EMAIL=admin@example.test +BOOTSTRAP_ADMIN_PASSWORD_HASH= + +# 3. Recreate the database so the seed runs +npm run dev:db:clean && npm run dev:db ``` -Email: test@local.com -Password: 123456 -``` + +> Never enable `DEV_SEED_ADMIN` in production. ## πŸ› οΈ Development Commands @@ -294,9 +308,9 @@ Always runs **last**, after all implementation agents have finished. ### Skills -| Skill | Invocation | Description | -| ------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------- | -| `angular-developer` | `/angular-developer` | Loads the official Angular guidelines before writing code. Invoked automatically by the frontend agent. | +| Skill | Invocation | Description | +| ------------------- | -------------------- | ------------------------------------------------------------------------------------------------------- | +| `angular-developer` | `/angular-developer` | Loads the official Angular guidelines before writing code. Invoked automatically by the frontend agent. | > **Task tracking**: this starter is not tied to any task manager. Use whatever your team already uses (Jira, Linear, GitHub Issues, etc.) or none; the orchestration does not require one. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a6f7c97..a8840d0 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -49,10 +49,17 @@ para otros microservicios futuros β€” sΓ³lo el gateway puede hacerlo. Cada login emite dos JWT distintos: -| Token | Secreto | TTL | Reside en | -| ------- | -------------------- | ----------------------------------------------- | ---------------------- | -| Access | `JWT_ACCESS_SECRET` | `JWT_EXPIRES_IN` (4h) | Header `Authorization` | -| Refresh | `JWT_REFRESH_SECRET` | `JWT_REFRESH_EXPIRES_IN` (8h, 365d si remember) | Cookie HttpOnly Secure | +| Token | Secreto | TTL | Reside en | +| ------- | -------------------- | --------------------------------------------------------------------------------------- | ---------------------- | +| Access | `JWT_ACCESS_SECRET` | `JWT_EXPIRES_IN` (4h) | Header `Authorization` | +| Refresh | `JWT_REFRESH_SECRET` | `JWT_REFRESH_EXPIRES_IN` (8h; `JWT_REFRESH_REMEMBER_DAYS`, 30 por defecto, si remember) | Cookie HttpOnly Secure | + +Los tokens del cliente ademΓ‘s llevan `iss`/`aud` (`gateway`/`web`) que el +gateway verifica, de modo que un token emitido para otro contexto no se puede +reutilizar aquΓ­. En cada **rotaciΓ³n** el gateway re-lee los permisos del usuario +desde el API (fuente autoritativa) en lugar de copiarlos del refresh viejo, asΓ­ +que una cuenta degradada/revocada o borrada pierde acceso en la siguiente +rotaciΓ³n, no al cabo de toda la vida del refresh. Cada token lleva: @@ -71,8 +78,34 @@ La tabla `public.refresh_token_family` registra cada refresh JWT emitido: - Si el mismo `jti` se presenta **dos veces** (alguien interceptΓ³ la cookie y la usΓ³ despuΓ©s de la rotaciΓ³n), el API revoca la **familia completa** y devuelve 401. El gateway limpia la cookie del cliente. -- En `logout`, el gateway revoca la familia activa para invalidar todo - el linaje. +- En `logout`, el gateway revoca la **familia completa** (no sΓ³lo el `jti` + presentado): el `familyId` viaja dentro del refresh JWT, de modo que cerrar + sesiΓ³n termina el linaje entero. + +## Token interno: suposiciΓ³n de red y replay + +El token interno (`X-Internal-Auth`, EdDSA) que el gateway firma para llamar al +API es **de un solo request y de vida muy corta** (TTL 60s, ver +`internal-auth.constants.ts`). Lleva un `requestId` de correlaciΓ³n, pero el +verificador **no** impone unicidad (no hay store de `jti`/nonce). En +consecuencia: + +> **SuposiciΓ³n de seguridad explΓ­cita.** Dentro de su ventana de TTL (60s, mΓ‘s +> una tolerancia de reloj de 5s), un `X-Internal-Auth` capturado podrΓ­a +> **reusarse** contra el API. Esto estΓ‘ mitigado por el diseΓ±o de red: el token +> **nunca sale de `internal-network`** (red `internal: true`, sin entrada desde +> Internet) y el gateway es la ΓΊnica puerta pΓΊblica. Explotarlo requiere estar +> ya **dentro** de la red interna, o un SSRF/leak separado. + +El `requestId` lo **genera siempre el gateway en servidor** (`randomUUID()`); el +API deriva el `requestId` del token verificado, nunca de una cabecera entrante +del cliente. El gateway, ademΓ‘s, hace _strip_ de cualquier `x-internal-auth` / +`x-request-id` entrante antes de proxiar. + +**Si el borde interno llegara a ser cruzado por servicios menos confiables** +(p. ej. una malla de servicios multirust), endurecer aΓ±adiendo una cachΓ© de +`jti` de corta vida en `verifyInternalAuth` para rechazar replays: incluir un +`jti` ΓΊnico en el token interno y registrar los vistos durante su TTL. ## Pasos manuales antes de levantar @@ -106,6 +139,7 @@ ningΓΊn archivo a disco. > **Formato β€” el origen de los fallos de arranque mΓ‘s comunes.** La clave > **debe** ir en una sola lΓ­nea entre comillas dobles con `\n` literales: +> > - PEM multilΓ­nea **sin comillas** β†’ dotenv lo trunca en el primer salto y > jose falla con `Invalid keyData / Failed to read private key`. > - `\n` escapado de mΓ‘s (`\\n`) β†’ `jose` lanza `InvalidCharacterError` en diff --git a/libs/internal-auth/src/lib/internal-auth.signer.spec.ts b/libs/internal-auth/src/lib/internal-auth.signer.spec.ts index 5a9f491..1981aba 100644 --- a/libs/internal-auth/src/lib/internal-auth.signer.spec.ts +++ b/libs/internal-auth/src/lib/internal-auth.signer.spec.ts @@ -64,13 +64,26 @@ describe('signUserContext / verifyInternalAuth', () => { ).rejects.toThrow(); }); - it('rejects expired tokens', async () => { + it('rejects expired tokens (beyond the 5s clock tolerance)', async () => { const token = await signUserContext( { userId: 1, permissions: [] }, { privateKey, ttlSeconds: 1 }, ); - await new Promise((resolve) => setTimeout(resolve, 1100)); + // Must exceed exp + the 5s clockTolerance to be rejected. + await new Promise((resolve) => setTimeout(resolve, 6500)); await expect(verifyInternalAuth(token, { publicKey })).rejects.toThrow(); + }, 10000); + + it('tolerates small clock drift (within 5s of expiry)', async () => { + const token = await signUserContext( + { userId: 1, permissions: [] }, + { privateKey, ttlSeconds: 1 }, + ); + // Expired by ~1.1s β€” inside the 5s tolerance, so still accepted. + await new Promise((resolve) => setTimeout(resolve, 1100)); + await expect( + verifyInternalAuth(token, { publicKey }), + ).resolves.toMatchObject({ sub: 1 }); }); it('rejects mismatched audience', async () => { diff --git a/libs/internal-auth/src/lib/internal-auth.signer.ts b/libs/internal-auth/src/lib/internal-auth.signer.ts index 9d4967b..c3402d1 100644 --- a/libs/internal-auth/src/lib/internal-auth.signer.ts +++ b/libs/internal-auth/src/lib/internal-auth.signer.ts @@ -113,6 +113,9 @@ export const verifyInternalAuth = async ( issuer: options.issuer ?? INTERNAL_AUTH_ISSUER, audience: options.audience ?? INTERNAL_AUTH_AUDIENCE_API, algorithms: [ALG], + // Small skew tolerance so minor clock drift between containers does not + // cause intermittent 401s on the 60s-TTL internal token. + clockTolerance: '5s', }); const subRaw = payload.sub ?? ''; diff --git a/libs/rest-dto/AGENTS.md b/libs/rest-dto/AGENTS.md index 3b27f29..a35541c 100644 --- a/libs/rest-dto/AGENTS.md +++ b/libs/rest-dto/AGENTS.md @@ -17,6 +17,12 @@ file wins and the duplicate is a bug. - `src/index.ts` β€” public barrel; everything consumers use is re-exported here. - `src/lib/rest-dto.ts` β€” entity DTOs (e.g. `UserDTO`), the `Permission` enum, and permission option metadata (`PERMISSION_OPTIONS`). +- `src/lib/validation.ts` β€” **runtime validation contracts** (Zod schemas: + `userCreateSchema`, `userUpdateSchema`, `paginationQuerySchema`, + `idParamSchema`). These are the single source of truth for request validation; + the API's `validate()` middleware parses every request through them. Input + schemas are `.strict()` (reject unknown keys). Add a schema here when you add an + entity β€” never inline validation in the apps. - `src/lib/common-types.ts` β€” shared primitives/helpers. ## Conventions diff --git a/libs/rest-dto/src/index.ts b/libs/rest-dto/src/index.ts index 6847190..624052d 100644 --- a/libs/rest-dto/src/index.ts +++ b/libs/rest-dto/src/index.ts @@ -1 +1,2 @@ export * from './lib/rest-dto'; +export * from './lib/validation'; diff --git a/libs/rest-dto/src/lib/validation.ts b/libs/rest-dto/src/lib/validation.ts new file mode 100644 index 0000000..7f58fcd --- /dev/null +++ b/libs/rest-dto/src/lib/validation.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { Permission } from './rest-dto'; + +/** + * Runtime validation contracts for the API, kept here in `@dto` so the schema + * is a single source of truth shared by the backend (request validation) and, + * if needed, the frontend (form validation). The API never trusts req.body / + * query / params directly β€” it parses them through these schemas first. + * + * Input schemas are `.strict()`: unknown keys are rejected, which closes + * mass-assignment / over-posting at the edge (defense-in-depth on top of the + * service-layer field allow-list). + */ + +const passwordSchema = z + .string() + .min(8, 'Password must be at least 8 characters') + .max(128); + +export const userCreateSchema = z + .object({ + email: z.string().trim().toLowerCase().email().max(150), + name: z.string().trim().min(1).max(150), + lastName: z.string().trim().max(150).optional(), + permissions: z.array(z.nativeEnum(Permission)).nonempty().optional(), + password: passwordSchema, + }) + .strict(); + +export const userUpdateSchema = z + .object({ + email: z.string().trim().toLowerCase().email().max(150).optional(), + name: z.string().trim().min(1).max(150).optional(), + lastName: z.string().trim().max(150).optional(), + permissions: z.array(z.nativeEnum(Permission)).nonempty().optional(), + // Empty string => "leave password unchanged" (handled by the service). + password: z.union([passwordSchema, z.literal('')]).optional(), + }) + .strict(); + +/** Reusable query/param schemas for paged CRUD endpoints. */ +export const paginationQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(10), +}); + +export const idParamSchema = z.object({ + id: z.coerce.number().int().positive(), +}); + +export type UserCreateInput = z.infer; +export type UserUpdateInput = z.infer; +export type PaginationQuery = z.infer; diff --git a/nginx/default.conf b/nginx/default.conf index 48e8a19..a81f3ba 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -6,6 +6,19 @@ server { #charset koi8-r; #access_log /var/log/nginx/host.access.log main; + # ── Security headers ───────────────────────────────────────────────────── + # Applied at the server level. NOTE: nginx does NOT inherit add_header into + # a location that defines its own add_header, so any such location below + # re-declares these. The access token is JS-visible by design, so a strong + # CSP is the key mitigation against XSS-driven exfiltration. + # 'unsafe-inline' is required for Angular component styles (style-src only). + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # HSTS β€” cookies are Secure in prod and Nginx is the only public door. + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + # Proxy to the gateway (same-origin for cookies). The gateway authenticates # the request and forwards it to internal microservices. location /api/ { @@ -35,6 +48,11 @@ server { # forces the browser to ask if /assets/files have changed location /assets { add_header Cache-Control "no-cache"; + # Re-declare security headers (this location overrides server-level ones). + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; root /usr/share/nginx/html; } @@ -48,8 +66,12 @@ server { location /index.html { # force donwload index.html add_header Cache-Control "no-store, no-cache"; - # prevent be embebed - add_header Content-Security-Policy "frame-ancestors 'self'"; + # Full security header set (this location overrides server-level ones). + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; root /usr/share/nginx/html; } diff --git a/package-lock.json b/package-lock.json index 9bdcb29..e7878b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^7.5.1", + "helmet": "^8.2.0", "http-proxy-middleware": "^3.0.5", "jose": "^5.10.0", "jsonwebtoken": "^9.0.3", @@ -41,6 +43,7 @@ "sequelize": "^6.37.8", "tslib": "^2.8.1", "uuid": "^14.0.0", + "zod": "^3.25.76", "zone.js": "^0.16.1" }, "devDependencies": { @@ -76,6 +79,7 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", "@types/node": "^22.13.0", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.59.0", "@typescript-eslint/parser": "^8.59.0", "@vitest/coverage-v8": "^4.0.8", @@ -90,6 +94,7 @@ "nx": "22.7.0", "prettier": "^3.8.3", "react-refresh": "^0.10.0", + "supertest": "^7.2.2", "ts-node": "10.9.1", "typescript": "^5.8.2", "vitest": "^4.0.8", @@ -401,17 +406,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-21.2.8.tgz", - "integrity": "sha512-NvTsu4+aDxj/mObw/tlFH7iyiDlFA7uVmk5jdicaV7TeY8QbMA0ona1mlSqehhyE0dgrROeYV6rXeJ4BcX7waw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-21.2.14.tgz", + "integrity": "sha512-EgKDd5pYctnsj3yYAZt3vivodH+r61X0ivWbZljcwT7OO4CEDyTR7Vtu4TUSr+tlplz5x2PYsWNr1nqyF0hufw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.8", - "@angular-devkit/build-webpack": "0.2102.8", - "@angular-devkit/core": "21.2.8", - "@angular/build": "21.2.8", + "@angular-devkit/architect": "0.2102.14", + "@angular-devkit/build-webpack": "0.2102.14", + "@angular-devkit/core": "21.2.14", + "@angular/build": "21.2.14", "@babel/core": "7.29.0", "@babel/generator": "7.29.1", "@babel/helper-annotate-as-pure": "7.27.3", @@ -422,7 +427,7 @@ "@babel/preset-env": "7.29.2", "@babel/runtime": "7.29.2", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "21.2.8", + "@ngtools/webpack": "21.2.14", "ansi-colors": "4.1.3", "autoprefixer": "10.4.27", "babel-loader": "10.0.0", @@ -443,7 +448,7 @@ "ora": "9.3.0", "picomatch": "4.0.4", "piscina": "5.1.4", - "postcss": "8.5.6", + "postcss": "8.5.12", "postcss-loader": "8.2.0", "resolve-url-loader": "5.0.0", "rxjs": "7.8.2", @@ -477,7 +482,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.8", + "@angular/ssr": "^21.2.14", "@web/test-runner": "^0.20.0", "browser-sync": "^3.0.2", "jest": "^30.2.0", @@ -533,6 +538,71 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/architect": { + "version": "0.2102.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.14.tgz", + "integrity": "sha512-0+vjVsCkMyJdVjz5XkPW+Bdf/9TI8V2voomx/+o0o+oOaqqiEhptQWFnaIlLr7HasjB0LxXK5P9L0oQ61vxj8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.14", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/core": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.14.tgz", + "integrity": "sha512-RSOWXB9bFc2nwRWMxbIT0RbSNFUrwfBo4N5MNxbyQ69Ndc0gVm3h+3ArHv0qotH4d+pJYbm5ttXu8YqR2kc0CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/core/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/@angular-devkit/build-angular/node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1177,13 +1247,13 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.2102.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2102.8.tgz", - "integrity": "sha512-XBSfx302hMcnF7ABxObIInusYr8S0R9vqORKe48/bKhI5J15gyGXrlRvjXEPCY3s3w3ysLKcw5POBo5zEmmuGQ==", + "version": "0.2102.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2102.14.tgz", + "integrity": "sha512-UjZzypzYLYaWMVplu9CYpx0gxKYu9+V3GiOrtlshInuGMZe9uQy7wRgiFUUKClRSMf8CCT1jfCof9gFwqYCRuQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.8", + "@angular-devkit/architect": "0.2102.14", "rxjs": "7.8.2" }, "engines": { @@ -1196,6 +1266,53 @@ "webpack-dev-server": "^5.0.2" } }, + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/architect": { + "version": "0.2102.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.14.tgz", + "integrity": "sha512-0+vjVsCkMyJdVjz5XkPW+Bdf/9TI8V2voomx/+o0o+oOaqqiEhptQWFnaIlLr7HasjB0LxXK5P9L0oQ61vxj8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.14", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/core": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.14.tgz", + "integrity": "sha512-RSOWXB9bFc2nwRWMxbIT0RbSNFUrwfBo4N5MNxbyQ69Ndc0gVm3h+3ArHv0qotH4d+pJYbm5ttXu8YqR2kc0CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular-devkit/core": { "version": "21.2.8", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.8.tgz", @@ -1333,14 +1450,14 @@ } }, "node_modules/@angular/build": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.8.tgz", - "integrity": "sha512-t0PHT7ONDMLwcjC9GaClNF+gsUKN78ofBikw4huiu6np5Rwmxp8KKCrdoRx20lOiibSolXgjZ2Ny0xxjNdNdQA==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.14.tgz", + "integrity": "sha512-l8JB326iIwum2WmbopUUFdiuYsbHchix6MH8o6F6FA7LJr8QLTvipwwbw+Jx31/RE50WkGmzsZ1fBDw/cMbmUw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.8", + "@angular-devkit/architect": "0.2102.14", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -1383,7 +1500,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.8", + "@angular/ssr": "^21.2.14", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -1432,6 +1549,53 @@ } } }, + "node_modules/@angular/build/node_modules/@angular-devkit/architect": { + "version": "0.2102.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.14.tgz", + "integrity": "sha512-0+vjVsCkMyJdVjz5XkPW+Bdf/9TI8V2voomx/+o0o+oOaqqiEhptQWFnaIlLr7HasjB0LxXK5P9L0oQ61vxj8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.14", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/build/node_modules/@angular-devkit/core": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.14.tgz", + "integrity": "sha512-RSOWXB9bFc2nwRWMxbIT0RbSNFUrwfBo4N5MNxbyQ69Ndc0gVm3h+3ArHv0qotH4d+pJYbm5ttXu8YqR2kc0CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular/build/node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1968,6 +2132,16 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@angular/common": { "version": "21.2.10", "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.10.tgz", @@ -2220,12 +2394,12 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -2441,9 +2615,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2464,27 +2638,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2507,9 +2681,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -2580,18 +2754,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2635,12 +2809,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -3474,16 +3648,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -4220,45 +4394,61 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/traverse/node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -6830,6 +7020,25 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/@modelcontextprotocol/sdk/node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -6841,13 +7050,13 @@ } }, "node_modules/@module-federation/bridge-react-webpack-plugin": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.3.3.tgz", - "integrity": "sha512-W2jQ3Wuqree9Dq3UAx8jGbYtvHuuYgzrd2j9FP8Bt6NaynaNU1yYG86MBnAhZJPTltex0CguudR1dgFpYdvLUg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.5.1.tgz", + "integrity": "sha512-bMRKo1ac6dYUhvPWH6ElFRfR+yWjbCse2mwu9fEzjemeaAqqse0K7XBIrW+4HcU7k/A6fu4Dk8o137ol6ZFXvw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.3.3", + "@module-federation/sdk": "2.5.1", "@types/semver": "7.5.8", "semver": "7.6.3" } @@ -6866,14 +7075,14 @@ } }, "node_modules/@module-federation/cli": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.3.3.tgz", - "integrity": "sha512-g3f3aEruv07zK4VcUlAllswrp2ncA/jF0P0yoEWNRa9K7N+xNCfqcdzw2aVWOJ30qNMurhLWuyzYqfDIx0LpfQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.5.1.tgz", + "integrity": "sha512-Y5dQrcPoph91KAzg6XXQ40faqFQ4iPwq3JuvFbsawyKwhQ/+aHkaH7cfclNbNr9pHGiiozkdxb22zK3oJRI3rw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.3.3", - "@module-federation/sdk": "2.3.3", + "@module-federation/dts-plugin": "2.5.1", + "@module-federation/sdk": "2.5.1", "commander": "11.1.0", "jiti": "2.4.2" }, @@ -6884,46 +7093,23 @@ "node": ">=16.0.0" } }, - "node_modules/@module-federation/data-prefetch": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/data-prefetch/-/data-prefetch-2.3.3.tgz", - "integrity": "sha512-ZM1QtyjbWYnhUizHFhwYjHGXlkZek3vzTpL35d5FkAhVrOU0u0Qv6zpZjdcCm0FJznqVsUQx1w0vagUyGWQf0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/runtime": "2.3.3", - "@module-federation/sdk": "2.3.3" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/@module-federation/dts-plugin": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.3.3.tgz", - "integrity": "sha512-VNtURt+hvieNKCBleAqHKffLAU4clKmuuqLQIbvDkFbGe4bo7hUaq5DruTnJBWWDOizZx0OQrdQYPijCnBK6UQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.5.1.tgz", + "integrity": "sha512-XylIL02As+VXk4DzFb8qYQs1YYoY0MZOpIqhf06LMkZBjPdOE0wJ1GOBvhFP9kM/cfuK7QV//mNdrWlZgvEkRA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.3", - "@module-federation/managers": "2.3.3", - "@module-federation/sdk": "2.3.3", - "@module-federation/third-party-dts-extractor": "2.3.3", + "@module-federation/error-codes": "2.5.1", + "@module-federation/managers": "2.5.1", + "@module-federation/sdk": "2.5.1", + "@module-federation/third-party-dts-extractor": "2.5.1", "adm-zip": "0.5.10", "ansi-colors": "4.1.3", "isomorphic-ws": "5.0.0", "node-schedule": "2.1.1", "undici": "7.24.7", - "ws": "8.18.0" + "ws": "8.21.0" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0", @@ -6946,24 +7132,23 @@ } }, "node_modules/@module-federation/enhanced": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.3.3.tgz", - "integrity": "sha512-BJSs56lqO9NI9aC+hVhg2CU/UwG1TphVl1b7WBx6Jv6DYUyVQbgXeQpgqYVsxYVRKYOl7eDZmjXl2eA/n1IP/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.3.3", - "@module-federation/cli": "2.3.3", - "@module-federation/data-prefetch": "2.3.3", - "@module-federation/dts-plugin": "2.3.3", - "@module-federation/error-codes": "2.3.3", - "@module-federation/inject-external-runtime-core-plugin": "2.3.3", - "@module-federation/managers": "2.3.3", - "@module-federation/manifest": "2.3.3", - "@module-federation/rspack": "2.3.3", - "@module-federation/runtime-tools": "2.3.3", - "@module-federation/sdk": "2.3.3", - "@module-federation/webpack-bundler-runtime": "2.3.3", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.5.1.tgz", + "integrity": "sha512-uddrS58NxOW2lsLfXLHenzfX37QMuZ0g03nG9SVhqfo3tMdP9877SLaMKRpoOvy7YQRfrXVAGF//9U9QtEWr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/bridge-react-webpack-plugin": "2.5.1", + "@module-federation/cli": "2.5.1", + "@module-federation/dts-plugin": "2.5.1", + "@module-federation/error-codes": "2.5.1", + "@module-federation/inject-external-runtime-core-plugin": "2.5.1", + "@module-federation/managers": "2.5.1", + "@module-federation/manifest": "2.5.1", + "@module-federation/rspack": "2.5.1", + "@module-federation/runtime-tools": "2.5.1", + "@module-federation/sdk": "2.5.1", + "@module-federation/webpack-bundler-runtime": "2.5.1", "schema-utils": "4.3.0", "tapable": "2.3.0", "upath": "2.0.1" @@ -6989,56 +7174,56 @@ } }, "node_modules/@module-federation/error-codes": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.3.3.tgz", - "integrity": "sha512-UVtKBoKnRDcHgByIDvPRZSxQqjqbNH7NvJm1KHLoce33+EDiIdZYs0HvvUQv43RgESpB9s7HjrqFlq3bEcAgfQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.5.1.tgz", + "integrity": "sha512-3KIR8XbEW0Y+Fn8IAnxzDWMvXQWiS40Z1TE/Fft9aTeXP9dDAM7AiVhjTh5yF2csAwHSt/1LJVZbiCmS13mE8A==", "dev": true, "license": "MIT" }, "node_modules/@module-federation/inject-external-runtime-core-plugin": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.3.3.tgz", - "integrity": "sha512-ImSft6hOkMdnpZX8O+RydwkYENxhAwT92n1OAT3Xf01DXMrEpSO0PqBlPGgontxuiaV9dM2/xWSLGuIWaOtupA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.5.1.tgz", + "integrity": "sha512-cCFCQ0j0xObXwVwBq/ZiU+MMdJrE2OVwdPUR7l6NHKKwKAOmkagM+AE5wNEkzVht6m+Dh6x6/5UTqqa/vbDrjQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@module-federation/runtime-tools": "2.3.3" + "@module-federation/runtime-tools": "2.5.1" } }, "node_modules/@module-federation/managers": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.3.3.tgz", - "integrity": "sha512-sYL0t2guakJ+nDSQANH54uz5q1YxaNCn5C3lr+7BoRD49dX7Z6k7094yqOPEy8trzqdIoQVFpgewVA6IC/FeyQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.5.1.tgz", + "integrity": "sha512-AWbkH/U76FJDbdwBOodQ1An40WOdrDGAgqUMc8RD62+Ec/ocPfda0NhFcJbPxlUIvSFPTvBv6DwTnygmNC7GPA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.3.3", + "@module-federation/sdk": "2.5.1", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/manifest": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.3.3.tgz", - "integrity": "sha512-mAEXuo5sGt8FUDzftU8f0ci0PbsZIDcLRYX9AkXwbXg0JRyVEvWyiBrEKF+zZuy7YM7eRdyp6JjLJPDzufhj5w==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.5.1.tgz", + "integrity": "sha512-z44Fita44rpLufoEW71tWw+eWGMVLVQzT2OjrzOHH8JLPOX3czT0INPwVbRp9v8puzWiU6Kz5W6PqelqutpJKA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.3.3", - "@module-federation/managers": "2.3.3", - "@module-federation/sdk": "2.3.3", + "@module-federation/dts-plugin": "2.5.1", + "@module-federation/managers": "2.5.1", + "@module-federation/sdk": "2.5.1", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/node": { - "version": "2.7.41", - "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.41.tgz", - "integrity": "sha512-ZaNrfX+7ua8UvnRa6qgrDtViU9Oz3oBGpFprldU8ek0NB2QWNACu9RMU7fHdTD/FKlzGVLi/LgdKnNKkmJD2TA==", + "version": "2.7.44", + "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.44.tgz", + "integrity": "sha512-XlwBWbSX2RMQM7dKQifUsMncDtvjeMFGH5LW6yG2cM/nqrM9uvN0QaG3AJIbYXKpOqH94x/XfP7jBV5JYyWpzQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/enhanced": "2.3.3", - "@module-federation/runtime": "2.3.3", - "@module-federation/sdk": "2.3.3", + "@module-federation/enhanced": "2.5.1", + "@module-federation/runtime": "2.5.1", + "@module-federation/sdk": "2.5.1", "encoding": "0.1.13", "node-fetch": "2.7.0", "tapable": "2.3.0" @@ -7099,19 +7284,19 @@ } }, "node_modules/@module-federation/rspack": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.3.3.tgz", - "integrity": "sha512-4s3G+wXZ6J3rKe0EeZnGLQUM7y+qpiI5NM3U6ylZuxD8q7mAwQVHThbH6ruDYUNDVEOc6N0j/+/LdfGRw+e5xw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.5.1.tgz", + "integrity": "sha512-9Qu4Q7P4VHUhMQLHKA95x3L85WKPtk2Lz0VPpUprIwlKhKgZbuy5ou2PjlWiaPIffWo1JsVfoBi+P9dtpOeEsw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.3.3", - "@module-federation/dts-plugin": "2.3.3", - "@module-federation/inject-external-runtime-core-plugin": "2.3.3", - "@module-federation/managers": "2.3.3", - "@module-federation/manifest": "2.3.3", - "@module-federation/runtime-tools": "2.3.3", - "@module-federation/sdk": "2.3.3" + "@module-federation/bridge-react-webpack-plugin": "2.5.1", + "@module-federation/dts-plugin": "2.5.1", + "@module-federation/inject-external-runtime-core-plugin": "2.5.1", + "@module-federation/managers": "2.5.1", + "@module-federation/manifest": "2.5.1", + "@module-federation/runtime-tools": "2.5.1", + "@module-federation/sdk": "2.5.1" }, "peerDependencies": { "@rspack/core": "^0.7.0 || ^1.0.0 || ^2.0.0-0", @@ -7128,47 +7313,47 @@ } }, "node_modules/@module-federation/runtime": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.3.3.tgz", - "integrity": "sha512-JYJ3qv9V85DtBtT/ppDuJNwBTUrYqqZDYcyiTzwY5+44dC5QPvgJ//F+BOhAhZ02WkZV0b4jsKTyLOC3vXKGqQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.5.1.tgz", + "integrity": "sha512-Tf33FIpnQMn8FjIUAQMtSTYQgGibfh5vEvJihFO3q/hG9LiWwLMErZvOz/+wcPsE81gzHjYPxQgMKGSP3BuG8g==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.3", - "@module-federation/runtime-core": "2.3.3", - "@module-federation/sdk": "2.3.3" + "@module-federation/error-codes": "2.5.1", + "@module-federation/runtime-core": "2.5.1", + "@module-federation/sdk": "2.5.1" } }, "node_modules/@module-federation/runtime-core": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.3.3.tgz", - "integrity": "sha512-B07LDH9KxhBO3GbULGW64mQFVQBtrEd3PoaCBm7XR1IbU8rMQUJQjDNVZgXYcyhRPBVP+3KWZuiaKFRiNb6PQw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.5.1.tgz", + "integrity": "sha512-UMuMsWHXeMrm8Isl8YD6/s1jmTVau3SQhp9RO4Ln+eD2lrjM4hQSwOX2xPtfT1C1I4/E6hgyZQV1K1Q/3Zpr0Q==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.3", - "@module-federation/sdk": "2.3.3" + "@module-federation/error-codes": "2.5.1", + "@module-federation/sdk": "2.5.1" } }, "node_modules/@module-federation/runtime-tools": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.3.3.tgz", - "integrity": "sha512-XODzyLbBYcy4wnYBXKIBqaHPVfBx1HshGdjZmSctDDnx9/VYgdx9DShb6UI+WuQBKJgPzTcx4xbvbCM4SdMilQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.5.1.tgz", + "integrity": "sha512-pYUNvaQQBEwP66TLrjmmfkDIrTmPnX0kK86HgClkWLQKkX/oCgnqDxEgNbjeCc75dwUvZP6fW2d0pZ5++XILTw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/runtime": "2.3.3", - "@module-federation/webpack-bundler-runtime": "2.3.3" + "@module-federation/runtime": "2.5.1", + "@module-federation/webpack-bundler-runtime": "2.5.1" } }, "node_modules/@module-federation/sdk": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.3.3.tgz", - "integrity": "sha512-mwCS+LQdqiSc6fM5iz/S60ibaFNSH6kNqlZkCRIuS4yjdZ+jgnihz+6xp1QzppvfFgKLhEHBiXOmcYOdk3Ckew==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.5.1.tgz", + "integrity": "sha512-FDhCx81ZCxX1oT/fyt/bW+gpPt287GR156E/Thv1yhb9XyNHGNkqe8zqJOipOMfb07E22OMzSzOulCBvAOgn3g==", "dev": true, "license": "MIT", "peerDependencies": { - "node-fetch": "^3.3.2" + "node-fetch": "^2.7.0 || ^3.3.2" }, "peerDependenciesMeta": { "node-fetch": { @@ -7177,9 +7362,9 @@ } }, "node_modules/@module-federation/third-party-dts-extractor": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.3.3.tgz", - "integrity": "sha512-rR94TjC1QVQLQPTazI0waLc76hI8dnv6aHTl+PUEIY9s5hXp8TA85XS0QJQqIf2KTjlPgZbWAwyFjOAJluTjaw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.5.1.tgz", + "integrity": "sha512-/jD/o2IivDgg6jGUgdb9NZtlJWeoz1uKcblGL2BxYVzzlKT+1YS7Y2idTl1tqp4hNdFnDlPpQv6WLdKiLoOPNw==", "dev": true, "license": "MIT", "dependencies": { @@ -7188,21 +7373,21 @@ } }, "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.3.3.tgz", - "integrity": "sha512-W+P6ZF9J3gwnQuoF07YV0OiR1D6sI/uErUu4+c3QXxka3orANUHujkddNSsDxL1obAGoJa7Da99crZKf7u2j/w==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.5.1.tgz", + "integrity": "sha512-0pUsP9aaWIUcfWUXqax/iSwozngORwf4RK0R1qTOYYC13qx+p4p1Ck28Rz6Tzj/6zpzJgcMQXR7nW4sL+ztaww==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.3", - "@module-federation/runtime": "2.3.3", - "@module-federation/sdk": "2.3.3" + "@module-federation/error-codes": "2.5.1", + "@module-federation/runtime": "2.5.1", + "@module-federation/sdk": "2.5.1" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -7214,9 +7399,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -7228,9 +7413,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -7242,9 +7427,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -7256,9 +7441,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -7270,9 +7455,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -7870,9 +8055,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.2.8.tgz", - "integrity": "sha512-e3A9NBuco6QdIqi4jKB8ateTGDEB2Nlby9Y5zG/uvbK0ggV6GPcEkqmVNX0LqyIQYv2Br9WmPLnJnegvl5lqEA==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.2.14.tgz", + "integrity": "sha512-HXt4pYLlWCJphO6TZoTsi2Z9Lyq/PqYpiKlqUmNoo/oIiSuXtoT/8+84Z/SfBdzeZpiVrAFz+/QGTVFoD8RSGg==", "dev": true, "license": "MIT", "engines": { @@ -7886,6 +8071,19 @@ "webpack": "^5.54.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -9935,6 +10133,16 @@ "win32" ] }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -12438,9 +12646,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -12670,6 +12878,13 @@ "@types/express": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -12831,6 +13046,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -12958,6 +13180,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -14328,6 +14574,13 @@ "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -14423,16 +14676,42 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -14889,9 +15168,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -15573,6 +15852,16 @@ "dev": true, "license": "ISC" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -15792,6 +16081,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", @@ -16164,35 +16460,6 @@ "postcss": "^8.5.10" } }, - "node_modules/css-minimizer-webpack-plugin/node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-colormin": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.9.tgz", @@ -16974,6 +17241,17 @@ "node": ">= 4.0.0" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -17975,14 +18253,10 @@ } }, "node_modules/express-rate-limit": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", - "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", - "dev": true, + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, "engines": { "node": ">= 16" }, @@ -18053,10 +18327,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -18628,6 +18909,24 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -19056,28 +19355,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/happy-dom/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/harmony-reflect": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", @@ -19143,6 +19420,18 @@ "he": "bin/he" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -19157,9 +19446,9 @@ } }, "node_modules/hono": { - "version": "4.12.15", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", - "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "dev": true, "license": "MIT", "engines": { @@ -19667,9 +19956,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -20749,28 +21038,6 @@ "node": ">=18" } }, - "node_modules/jsdom/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -21811,6 +22078,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -22136,9 +22413,9 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", "dev": true, "license": "MIT", "optional": true, @@ -22147,9 +22424,9 @@ } }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -22161,12 +22438,12 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/multer": { @@ -24193,9 +24470,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -24690,9 +24967,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -27066,6 +27343,65 @@ "webpack": "^5.0.0" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -29817,9 +30153,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -30106,10 +30442,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 3643bb2..bc7edf8 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.6", "@types/node": "^22.13.0", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.59.0", "@typescript-eslint/parser": "^8.59.0", "@vitest/coverage-v8": "^4.0.8", @@ -100,6 +101,7 @@ "nx": "22.7.0", "prettier": "^3.8.3", "react-refresh": "^0.10.0", + "supertest": "^7.2.2", "ts-node": "10.9.1", "typescript": "^5.8.2", "vitest": "^4.0.8", @@ -126,6 +128,8 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^7.5.1", + "helmet": "^8.2.0", "http-proxy-middleware": "^3.0.5", "jose": "^5.10.0", "jsonwebtoken": "^9.0.3", @@ -139,6 +143,7 @@ "sequelize": "^6.37.8", "tslib": "^2.8.1", "uuid": "^14.0.0", + "zod": "^3.25.76", "zone.js": "^0.16.1" }, "overrides": { diff --git a/scripts/gen-admin-hash.sh b/scripts/gen-admin-hash.sh new file mode 100755 index 0000000..877b7ce --- /dev/null +++ b/scripts/gen-admin-hash.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Generates a bcrypt hash for the dev admin seed +# (BOOTSTRAP_ADMIN_PASSWORD_HASH, consumed by db/zz-dev-seed.sh). +# +# Usage: +# bash scripts/gen-admin-hash.sh 'your-strong-password' +# +# Cost defaults to HASH_SALT_ROUNDS (or 12), matching the API hashing config. +# --------------------------------------------------------------------------- +set -euo pipefail + +PASSWORD="${1:?Usage: gen-admin-hash.sh }" +ROUNDS="${HASH_SALT_ROUNDS:-12}" + +node -e "const b=require('bcrypt');console.log(b.hashSync(process.argv[1], parseInt(process.argv[2],10)))" "$PASSWORD" "$ROUNDS"