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