From b92a1e2566a9758cf0cd59cd3d2a4af7217c6c15 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:04:15 +0200
Subject: [PATCH 01/24] fix(security): remove seeded ADMIN with known password;
gate dev seed behind flag
[CRITICAL] T-1: the schema seeded test@local.com with a public bcrypt('123456')
ADMIN via docker-entrypoint-initdb.d, and the password was documented in the
READMEs. POSTGRESDB_PASSWORD also silently defaulted to 'password'.
- Drop the hardcoded ADMIN INSERT/setval from db/10.user.sql (schema seeds no users).
- Add fail-safe-OFF dev seed db/zz-dev-seed.sh: no-op unless DEV_SEED_ADMIN=true
with an explicit BOOTSTRAP_ADMIN_EMAIL + externally-generated bcrypt hash.
- Add scripts/gen-admin-hash.sh helper to produce the hash (cost 12).
- Make POSTGRESDB_PASSWORD mandatory in docker-compose.db.yml (no :-password default).
- Rotate documented credentials in README.md / docs/README_eng.md to seed instructions.
- Document the no-secrets-in-schema rule in db/AGENTS.md.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 13 ++++++++++++-
README.md | 20 ++++++++++++++++---
db/10.user.sql | 11 +++++------
db/AGENTS.md | 10 ++++++++--
db/zz-dev-seed.sh | 41 +++++++++++++++++++++++++++++++++++++++
docker-compose.db.yml | 7 ++++++-
docs/README_eng.md | 20 ++++++++++++++++---
scripts/gen-admin-hash.sh | 16 +++++++++++++++
8 files changed, 122 insertions(+), 16 deletions(-)
create mode 100755 db/zz-dev-seed.sh
create mode 100755 scripts/gen-admin-hash.sh
diff --git a/.env.example b/.env.example
index b4852f7..95bf97c 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
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/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..cab0bff 100644
--- a/docker-compose.db.yml
+++ b/docker-compose.db.yml
@@ -8,7 +8,12 @@ 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"
volumes:
diff --git a/docs/README_eng.md b/docs/README_eng.md
index 3c30dd7..07c8feb 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
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"
From e0765c421d9921d39860f316e045c8892f3cd66d Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:05:32 +0200
Subject: [PATCH 02/24] fix(security): reject soft-deleted users at
authentication
[ALTO] T-2: validateCredentials/getUser queried User without deleted:false, so a
soft-deleted account (offboarded admin, compromised user) could still log in and
receive its original permissions.
- validateCredentials: where { email, deleted: false }.
- getUser: switch findByPk -> findOne with { id, deleted: false } so the filter
is actually enforced (findByPk ignores extra where on the PK).
- Update/extend auth.service.spec.ts.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/api/src/services/auth.service.spec.ts | 15 ++++++++++++++-
apps/api/src/services/auth.service.ts | 9 +++++++--
2 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/apps/api/src/services/auth.service.spec.ts b/apps/api/src/services/auth.service.spec.ts
index 6595763..e09b560 100644
--- a/apps/api/src/services/auth.service.spec.ts
+++ b/apps/api/src/services/auth.service.spec.ts
@@ -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,6 +60,19 @@ 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 () => {
vi.mocked(bcrypt.hash).mockResolvedValue('hashed' as never);
diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts
index 89f5435..930339b 100644
--- a/apps/api/src/services/auth.service.ts
+++ b/apps/api/src/services/auth.service.ts
@@ -10,14 +10,19 @@ 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);
From a788a0e0cf66bbb0d48d858f5e3d2c0cb8795274 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:07:39 +0200
Subject: [PATCH 03/24] fix(security): allow-list writable fields to stop
mass-assignment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[ALTO] T-3: AbstractCrudService passed req.body verbatim to model.create/update,
letting a caller over-post permissions, deleted, deletedAt or id (privilege
escalation / tampering). This is the base CRUD for every future entity, so the
pattern would propagate.
- AbstractCrudService: optional writableFields allow-list, applied as Sequelize's
{ fields } option on post/put so unlisted keys are dropped.
- UserCrudService: declare USER_WRITABLE_FIELDS (email,name,lastName,permissions,
password) — excludes id/deleted/deletedAt/audit columns even for admins.
- Update specs to assert the fields allow-list is enforced.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../api/src/services/abstract-crud.service.ts | 25 ++++++++++++++++---
.../src/services/user-crud.service.spec.ts | 21 ++++++++++------
apps/api/src/services/user-crud.service.ts | 20 ++++++++++++---
3 files changed, 52 insertions(+), 14 deletions(-)
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/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) => {
From efaafbb0eeaf2107c79dda814de6c9ee97938b92 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:10:34 +0200
Subject: [PATCH 04/24] feat(security): add Zod input-validation layer at the
API edge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[ALTO] T-4: the API had no input validation — req.body/query/params flowed
untyped into Sequelize, enabling mass-assignment, NaN pagination and
inconsistent errors. This establishes the validation pattern for every entity.
- libs/rest-dto: add Zod schemas (userCreateSchema, userUpdateSchema,
paginationQuerySchema, idParamSchema) as the single source of truth; input
schemas are .strict() so unknown keys are rejected.
- apps/api: add validate(schema, source) middleware (Express 5-safe: body/params
replaced in place, query exposed on res.locals.query); wire body validation
onto user POST/PUT.
- Tests for the middleware; document the layer in api & rest-dto AGENTS.md.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/api/AGENTS.md | 9 +
apps/api/src/middleware/index.ts | 1 +
.../middleware/validate.middleware.spec.ts | 77 ++++
.../api/src/middleware/validate.middleware.ts | 41 +++
apps/api/src/routes/user-crud.routes.ts | 17 +-
libs/rest-dto/AGENTS.md | 6 +
libs/rest-dto/src/index.ts | 1 +
libs/rest-dto/src/lib/validation.ts | 53 +++
package-lock.json | 347 ++++--------------
package.json | 1 +
10 files changed, 274 insertions(+), 279 deletions(-)
create mode 100644 apps/api/src/middleware/validate.middleware.spec.ts
create mode 100644 apps/api/src/middleware/validate.middleware.ts
create mode 100644 libs/rest-dto/src/lib/validation.ts
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/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/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/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/package-lock.json b/package-lock.json
index 9bdcb29..7f9b66e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,6 +41,7 @@
"sequelize": "^6.37.8",
"tslib": "^2.8.1",
"uuid": "^14.0.0",
+ "zod": "^3.25.76",
"zone.js": "^0.16.1"
},
"devDependencies": {
@@ -1968,6 +1969,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",
@@ -2000,6 +2011,7 @@
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.10.tgz",
"integrity": "sha512-FDcnj3ogRmnTca4m2GbKP2khFOCtoVvWDZyfw2ZCPAf+zsQlKTyscKvx4GpTFo+KHrYXpawUpDIWHORFpuqFEA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "7.29.0",
@@ -2086,6 +2098,7 @@
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-21.2.10.tgz",
"integrity": "sha512-pQNL1LwI/6e8SbwiPxGJkXhV5AZxODhS/UJMEeYTx2fRvm4Ob3aMxWTl3ixhFZeYAx9bcU3QpfEJyGr7YDO24w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "7.29.0",
@@ -2237,6 +2250,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2246,6 +2260,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
@@ -2276,12 +2291,14 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2291,6 +2308,7 @@
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
@@ -2320,6 +2338,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
@@ -2336,6 +2355,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2444,6 +2464,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2467,6 +2488,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
@@ -2480,6 +2502,7 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
@@ -2583,6 +2606,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2601,6 +2625,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2625,6 +2650,7 @@
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
@@ -2638,6 +2664,7 @@
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
@@ -4223,6 +4250,7 @@
"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==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
@@ -4237,6 +4265,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
@@ -4255,6 +4284,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -6100,6 +6130,7 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -6110,6 +6141,7 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -6120,6 +6152,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -6140,12 +6173,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -11207,277 +11242,6 @@
"win32"
]
},
- "node_modules/@rspack/binding": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz",
- "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "optionalDependencies": {
- "@rspack/binding-darwin-arm64": "1.7.11",
- "@rspack/binding-darwin-x64": "1.7.11",
- "@rspack/binding-linux-arm64-gnu": "1.7.11",
- "@rspack/binding-linux-arm64-musl": "1.7.11",
- "@rspack/binding-linux-x64-gnu": "1.7.11",
- "@rspack/binding-linux-x64-musl": "1.7.11",
- "@rspack/binding-wasm32-wasi": "1.7.11",
- "@rspack/binding-win32-arm64-msvc": "1.7.11",
- "@rspack/binding-win32-ia32-msvc": "1.7.11",
- "@rspack/binding-win32-x64-msvc": "1.7.11"
- }
- },
- "node_modules/@rspack/binding-darwin-arm64": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz",
- "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true
- },
- "node_modules/@rspack/binding-darwin-x64": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz",
- "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true
- },
- "node_modules/@rspack/binding-linux-arm64-gnu": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz",
- "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rspack/binding-linux-arm64-musl": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz",
- "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rspack/binding-linux-x64-gnu": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz",
- "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rspack/binding-linux-x64-musl": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz",
- "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true
- },
- "node_modules/@rspack/binding-wasm32-wasi": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz",
- "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==",
- "cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@napi-rs/wasm-runtime": "1.0.7"
- }
- },
- "node_modules/@rspack/binding-win32-arm64-msvc": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz",
- "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true
- },
- "node_modules/@rspack/binding-win32-ia32-msvc": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz",
- "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true
- },
- "node_modules/@rspack/binding-win32-x64-msvc": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz",
- "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true
- },
- "node_modules/@rspack/core": {
- "version": "1.7.11",
- "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz",
- "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@module-federation/runtime-tools": "0.22.0",
- "@rspack/binding": "1.7.11",
- "@rspack/lite-tapable": "1.1.0"
- },
- "engines": {
- "node": ">=18.12.0"
- },
- "peerDependencies": {
- "@swc/helpers": ">=0.5.1"
- },
- "peerDependenciesMeta": {
- "@swc/helpers": {
- "optional": true
- }
- }
- },
- "node_modules/@rspack/core/node_modules/@module-federation/error-codes": {
- "version": "0.22.0",
- "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz",
- "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
- "node_modules/@rspack/core/node_modules/@module-federation/runtime": {
- "version": "0.22.0",
- "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz",
- "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@module-federation/error-codes": "0.22.0",
- "@module-federation/runtime-core": "0.22.0",
- "@module-federation/sdk": "0.22.0"
- }
- },
- "node_modules/@rspack/core/node_modules/@module-federation/runtime-core": {
- "version": "0.22.0",
- "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz",
- "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@module-federation/error-codes": "0.22.0",
- "@module-federation/sdk": "0.22.0"
- }
- },
- "node_modules/@rspack/core/node_modules/@module-federation/runtime-tools": {
- "version": "0.22.0",
- "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz",
- "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@module-federation/runtime": "0.22.0",
- "@module-federation/webpack-bundler-runtime": "0.22.0"
- }
- },
- "node_modules/@rspack/core/node_modules/@module-federation/sdk": {
- "version": "0.22.0",
- "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz",
- "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
- "node_modules/@rspack/core/node_modules/@module-federation/webpack-bundler-runtime": {
- "version": "0.22.0",
- "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz",
- "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@module-federation/runtime": "0.22.0",
- "@module-federation/sdk": "0.22.0"
- }
- },
"node_modules/@rspack/dev-server": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-1.2.1.tgz",
@@ -12570,6 +12334,7 @@
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.20.7",
@@ -12583,6 +12348,7 @@
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
@@ -12592,6 +12358,7 @@
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.1.0",
@@ -12602,6 +12369,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
@@ -14713,6 +14481,7 @@
"version": "2.10.23",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz",
"integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==",
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -14917,6 +14686,7 @@
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
"funding": [
{
"type": "opencollective",
@@ -15173,6 +14943,7 @@
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
+ "dev": true,
"funding": [
{
"type": "opencollective",
@@ -15236,6 +15007,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
@@ -15762,6 +15534,7 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+ "dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -17180,6 +16953,7 @@
"version": "1.5.344",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
+ "dev": true,
"license": "ISC"
},
"node_modules/emittery": {
@@ -18117,6 +17891,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -18735,6 +18510,7 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -18753,6 +18529,7 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -20775,6 +20552,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -20825,6 +20603,7 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -21640,6 +21419,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -22482,6 +22262,7 @@
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "dev": true,
"license": "MIT"
},
"node_modules/node-schedule": {
@@ -24027,6 +23808,7 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -24794,6 +24576,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -24820,6 +24603,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "dev": true,
"license": "Apache-2.0"
},
"node_modules/regenerate": {
@@ -27536,6 +27320,7 @@
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -28029,7 +27814,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -28185,6 +27970,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
"funding": [
{
"type": "opencollective",
@@ -29910,6 +29696,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
"license": "ISC"
},
"node_modules/yaml": {
@@ -29932,6 +29719,7 @@
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
@@ -29958,6 +29746,7 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -29970,6 +29759,7 @@
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -29982,6 +29772,7 @@
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
@@ -29996,12 +29787,14 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
@@ -30019,6 +29812,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
@@ -30034,6 +29828,7 @@
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
@@ -30051,6 +29846,7 @@
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
@@ -30106,10 +29902,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..b25ad8c 100644
--- a/package.json
+++ b/package.json
@@ -139,6 +139,7 @@
"sequelize": "^6.37.8",
"tslib": "^2.8.1",
"uuid": "^14.0.0",
+ "zod": "^3.25.76",
"zone.js": "^0.16.1"
},
"overrides": {
From b8e54883e9a8cbc151cc32b16c69083c68455ec8 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:13:59 +0200
Subject: [PATCH 05/24] fix(security): re-read permissions on refresh rotation;
bound remember
[ALTO] T-5: refresh rotation copied permissions from the OLD refresh token, and
remember=true minted a 365-day token, so a downgraded/revoked user kept elevated
access for up to a year. remember was also read truthy from req.body.
- API /internal/refresh/rotate re-reads the user (authService.getUser, which
filters deleted:false) and returns current email+permissions; revokes the
family and 401s if the user is gone/deactivated.
- Gateway uses the API-returned claims on rotation, not the stale token claims.
- Bound remember lifetime via JWT_REFRESH_REMEMBER_DAYS (default 30); JWT expiry
and cookie maxAge share one source (token.service.rememberRefresh*).
- Coerce remember to a strict boolean (=== true) in the login controller.
- Update gateway specs + AGENTS.md; add env to .env.example/compose.yaml.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 3 +++
.../refresh-lifecycle.controller.ts | 20 ++++++++++++++++--
apps/gateway/AGENTS.md | 21 +++++++++++++------
apps/gateway/src/clients/api.client.ts | 4 ++++
.../src/controllers/auth.controller.ts | 5 ++++-
.../src/middleware/auth.middleware.spec.ts | 9 ++++++++
.../gateway/src/middleware/auth.middleware.ts | 11 ++++++----
.../src/services/token.service.spec.ts | 11 ++++++----
apps/gateway/src/services/token.service.ts | 21 ++++++++++++++++++-
compose.yaml | 1 +
10 files changed, 88 insertions(+), 18 deletions(-)
diff --git a/.env.example b/.env.example
index 95bf97c..93988ae 100644
--- a/.env.example
+++ b/.env.example
@@ -23,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
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/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..17fe150 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,
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..6a67440 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';
@@ -71,7 +72,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));
};
@@ -205,10 +206,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/services/token.service.spec.ts b/apps/gateway/src/services/token.service.spec.ts
index 15f681c..d602245 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 () => {
diff --git a/apps/gateway/src/services/token.service.ts b/apps/gateway/src/services/token.service.ts
index 7f6f270..75b0cc7 100644
--- a/apps/gateway/src/services/token.service.ts
+++ b/apps/gateway/src/services/token.service.ts
@@ -4,6 +4,20 @@ import jwt, { JwtPayload } from 'jsonwebtoken';
export type ClientTokenType = 'access' | 'refresh';
+const DEFAULT_REMEMBER_DAYS = 30;
+
+/**
+ * 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;
@@ -81,8 +95,13 @@ class TokenService {
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',
diff --git a/compose.yaml b/compose.yaml
index ac45cb3..7af20f0 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -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}
From 453d38270c8d833e079f89d054cc9c2b4dca0233 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:16:28 +0200
Subject: [PATCH 06/24] feat(security): rate-limit gateway auth endpoints
[MEDIO] T-6: /login and the auth surface had no throttling/lockout, enabling
credential stuffing and password brute force.
- Add express-rate-limit limiters: loginRateLimiter keyed by IP+email counting
only failed attempts (skipSuccessfulRequests), and a coarse per-IP
authRateLimiter over the whole /v1/auth router.
- Configurable via LOGIN_RATE_*/AUTH_RATE_* env (sane defaults); relies on the
existing trust proxy:1 to see the real client IP behind Nginx.
- Add supertest-based tests; document env in .env.example.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 7 +
apps/gateway/src/middleware/index.ts | 1 +
.../middleware/rate-limit.middleware.spec.ts | 45 ++++
.../src/middleware/rate-limit.middleware.ts | 53 ++++
apps/gateway/src/routes/auth.routes.ts | 6 +-
package-lock.json | 228 +++++++++++++++++-
package.json | 3 +
7 files changed, 332 insertions(+), 11 deletions(-)
create mode 100644 apps/gateway/src/middleware/rate-limit.middleware.spec.ts
create mode 100644 apps/gateway/src/middleware/rate-limit.middleware.ts
diff --git a/.env.example b/.env.example
index 93988ae..f3e326d 100644
--- a/.env.example
+++ b/.env.example
@@ -46,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
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/package-lock.json b/package-lock.json
index 7f9b66e..095c687 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "express-rate-limit": "^7.5.1",
"http-proxy-middleware": "^3.0.5",
"jose": "^5.10.0",
"jsonwebtoken": "^9.0.3",
@@ -77,6 +78,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",
@@ -91,6 +93,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",
@@ -6865,6 +6868,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",
@@ -7921,6 +7943,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",
@@ -9970,6 +10005,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,6 +12483,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",
@@ -12599,6 +12651,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",
@@ -12726,6 +12785,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",
@@ -14096,6 +14179,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",
@@ -15345,6 +15435,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",
@@ -15565,6 +15665,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",
@@ -16747,6 +16854,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",
@@ -17749,14 +17867,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"
},
@@ -17827,6 +17941,13 @@
"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",
@@ -18403,6 +18524,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",
@@ -19444,9 +19583,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": {
@@ -21591,6 +21730,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",
@@ -26850,6 +26999,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",
diff --git a/package.json b/package.json
index b25ad8c..b48fe2c 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,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "express-rate-limit": "^7.5.1",
"http-proxy-middleware": "^3.0.5",
"jose": "^5.10.0",
"jsonwebtoken": "^9.0.3",
From 19055a39bca63463e58ce86ea3665fe313964b31 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:18:13 +0200
Subject: [PATCH 07/24] ci(security): make audit gate real and fix invalid job
graph
[MEDIO] T-7: the security job ran 'npm audit ... || true' with
continue-on-error, so vulnerable deps never blocked a merge; and notify needs
[lint,test,build,security,docker] referenced build/docker which are commented
out, making the graph invalid.
- Gate on production deps: npm audit --omit=dev --audit-level=high (blocking),
full report still generated+uploaded for visibility. Dev/build-tool advisories
churn and aren't shipped, so they're reported, not gated.
- Apply non-breaking npm audit fix: clears the production axios high advisory
(remaining prod issues are moderate, below the gate).
- Fix notify needs -> [lint, test, security].
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/ci.yml | 19 +-
package-lock.json | 755 ++++++++++++++++++++++-----------------
2 files changed, 442 insertions(+), 332 deletions(-)
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/package-lock.json b/package-lock.json
index 095c687..d34d86e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -405,17 +405,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",
@@ -426,7 +426,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",
@@ -447,7 +447,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",
@@ -481,7 +481,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",
@@ -537,6 +537,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",
@@ -1181,13 +1246,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": {
@@ -1200,6 +1265,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",
@@ -1337,14 +1449,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",
@@ -1387,7 +1499,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",
@@ -1436,6 +1548,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",
@@ -2236,12 +2395,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"
},
@@ -2464,9 +2623,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==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2488,29 +2647,29 @@
}
},
"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==",
"dev": true,
"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==",
"dev": true,
"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"
@@ -2533,9 +2692,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": {
@@ -2606,9 +2765,9 @@
}
},
"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==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2616,9 +2775,9 @@
}
},
"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"
@@ -2664,13 +2823,13 @@
}
},
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -3504,16 +3663,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"
@@ -4250,48 +4409,65 @@
}
},
"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==",
"dev": true,
"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==",
"dev": true,
"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==",
+ "dev": true,
+ "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==",
"dev": true,
"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"
@@ -6898,13 +7074,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"
}
@@ -6923,14 +7099,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"
},
@@ -6941,46 +7117,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",
@@ -7003,24 +7156,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"
@@ -7046,56 +7198,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"
@@ -7156,19 +7308,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",
@@ -7185,47 +7337,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": {
@@ -7234,9 +7386,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": {
@@ -7245,21 +7397,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"
],
@@ -7271,9 +7423,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"
],
@@ -7285,9 +7437,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"
],
@@ -7299,9 +7451,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"
],
@@ -7313,9 +7465,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"
],
@@ -7327,9 +7479,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"
],
@@ -7927,9 +8079,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": {
@@ -14281,16 +14433,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",
@@ -14748,9 +14926,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": {
@@ -16044,35 +16222,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",
@@ -17949,9 +18098,9 @@
"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": [
{
@@ -18972,28 +19121,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",
@@ -19073,9 +19200,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": {
@@ -20665,28 +20792,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",
@@ -22065,9 +22170,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,
@@ -22076,9 +22181,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",
@@ -22090,12 +22195,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": {
@@ -24124,9 +24229,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": [
{
@@ -24621,9 +24726,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"
@@ -29811,9 +29916,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": {
From b5af66dfda243673f603be780ff97e0ce741eb44 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:19:15 +0200
Subject: [PATCH 08/24] ci(security): harden release workflow against
supply-chain abuse
[MEDIO] T-8: release.yml ran on every push to main with contents:write and
pushed back to main using a token left in the local git config
(persist-credentials default true). Any pre-push lifecycle script/dependency
could reuse that credential to push to main / cut releases.
- Trigger via workflow_dispatch with a bump choice (deliberate, maintainer-gated)
instead of automatic push-to-main.
- Top-level permissions: contents:read; elevate to write only in the job.
- Checkout with persist-credentials:false; inject GITHUB_TOKEN only at the push
step via an x-access-token remote URL.
- npm version --ignore-scripts so no project/dep scripts run on the privileged
runner before the push.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/release.yml | 70 +++++++++++++++++------------------
1 file changed, 35 insertions(+), 35 deletions(-)
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 }}
From 58bf2131478c4f716335b4073459b32b77cd16f3 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:19:34 +0200
Subject: [PATCH 09/24] fix(security): bind dev Postgres to loopback only
[MEDIO] T-9: docker-compose.db.yml published 5432 on 0.0.0.0, exposing the dev
database to the whole network. Combined with the (now-removed, see T-1) default
password it was a direct DB compromise on an untrusted network.
- Publish on 127.0.0.1:${POSTGRESDB_PORT:-5432}:5432 so only the local host can
reach it. Password was already made mandatory in T-1.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docker-compose.db.yml | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docker-compose.db.yml b/docker-compose.db.yml
index cab0bff..1ab6d59 100644
--- a/docker-compose.db.yml
+++ b/docker-compose.db.yml
@@ -15,7 +15,9 @@ services:
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
From 11e14aeccf467837997ebc701ea5ad66cbfa492e Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:22:34 +0200
Subject: [PATCH 10/24] feat(security): wire auth guards to routes + add
unauthorized page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[MEDIO] T-10: app.routes.ts declared no canActivate, so access control relied
solely on @if(auth.isLoggedIn()) in templates, and the guards redirected to a
non-existent 'unauthorized' route (404 inside the router).
- Add a real 'unauthorized' route + page (the guards' redirect target).
- Add a guarded example 'profile' route (canActivateFn) demonstrating the
protected-route pattern; link it from the logged-in home card.
- Add a '**' wildcard redirect to the landing page.
- i18n (en/es/ca) for the new pages; document the guard convention in
apps/front/AGENTS.md (guards are UX only — backend enforces authz).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/front/AGENTS.md | 25 ++++++++++------
apps/front/src/app/app.routes.ts | 19 ++++++++++++
.../src/app/pages/home/home.component.html | 8 +++++
.../src/app/pages/home/home.component.ts | 4 +++
.../app/pages/profile/profile.component.html | 29 +++++++++++++++++++
.../app/pages/profile/profile.component.ts | 25 ++++++++++++++++
.../unauthorized/unauthorized.component.html | 25 ++++++++++++++++
.../unauthorized/unauthorized.component.ts | 12 ++++++++
apps/front/src/assets/i18n/ca.json | 10 +++++++
apps/front/src/assets/i18n/en.json | 10 +++++++
apps/front/src/assets/i18n/es.json | 10 +++++++
11 files changed, 168 insertions(+), 9 deletions(-)
create mode 100644 apps/front/src/app/pages/profile/profile.component.html
create mode 100644 apps/front/src/app/pages/profile/profile.component.ts
create mode 100644 apps/front/src/app/pages/unauthorized/unauthorized.component.html
create mode 100644 apps/front/src/app/pages/unauthorized/unauthorized.component.ts
diff --git a/apps/front/AGENTS.md b/apps/front/AGENTS.md
index 430483b..ab4e1fa 100644
--- a/apps/front/AGENTS.md
+++ b/apps/front/AGENTS.md
@@ -14,15 +14,15 @@
## 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/`)
@@ -31,6 +31,13 @@
- `interceptors/auth.interceptor.ts` — attach the access token / handle refresh.
- `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/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/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",
From 0caab96e90ac02a21bc2d5f3fe5b280e6b1506b9 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:23:25 +0200
Subject: [PATCH 11/24] fix(security): validate post-login redirect against
open redirect
[MEDIO] T-11: login.component took redirectUrl from navigation state and passed
it to navigateByUrl without validation, allowing redirects to arbitrary internal
routes and mishandled '//evil.com'-style values.
- Add sanitizeRedirect() util: accepts only same-origin relative paths (single
leading '/', no '//', no backslashes, no scheme); returns '' otherwise.
- Use it when reading the redirect from navigation state.
- Unit tests covering the open-redirect payloads.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/app/libs/auth/safe-redirect.spec.ts | 31 +++++++++++++++++++
apps/front/src/app/libs/auth/safe-redirect.ts | 13 ++++++++
.../src/app/pages/login/login.component.ts | 4 ++-
3 files changed, 47 insertions(+), 1 deletion(-)
create mode 100644 apps/front/src/app/libs/auth/safe-redirect.spec.ts
create mode 100644 apps/front/src/app/libs/auth/safe-redirect.ts
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/pages/login/login.component.ts b/apps/front/src/app/pages/login/login.component.ts
index 9389b0c..932577b 100644
--- a/apps/front/src/app/pages/login/login.component.ts
+++ b/apps/front/src/app/pages/login/login.component.ts
@@ -6,6 +6,7 @@ import { Router, RouterModule } from '@angular/router';
import { TranslocoModule, TranslocoService } from '@jsverse/transloco';
import { Login } from '@front/app/libs/auth/auth.interface';
+import { sanitizeRedirect } from '@front/app/libs/auth/safe-redirect';
import { AuthService } from '@front/app/libs/auth/services/auth.service';
import { catchError, of, tap } from 'rxjs';
@@ -36,7 +37,8 @@ export default class LoginComponent implements OnInit {
ngOnInit() {
const navigation = this.router.currentNavigation();
const state = navigation?.extras.state as { currentRoute: string };
- this.redirectUrl = state?.currentRoute ?? '';
+ // Only honour safe same-origin paths — never an attacker-controlled URL.
+ this.redirectUrl = sanitizeRedirect(state?.currentRoute);
}
doLogin() {
From 62e99703f3edbf6932aa925756f2e7ff43bd742c Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:25:00 +0200
Subject: [PATCH 12/24] fix(security): only attach token/credentials to trusted
origins
[MEDIO] T-12: the auth interceptor added Authorization + withCredentials to
EVERY request regardless of target. The moment the app calls a third-party
absolute URL (CDN, analytics, avatar), the in-memory access token would leak in
the Authorization header.
- Attach token/withCredentials only when the request resolves to the app origin
or the explicitly-configured API origin (AuthConfig.idpServer).
- Don't capture an Authorization response header from an untrusted origin, and
don't trigger logout on a third-party 401.
- Tests for the cross-origin cases.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../interceptors/auth.interceptor.spec.ts | 23 ++++++++
.../auth/interceptors/auth.interceptor.ts | 59 ++++++++++++++-----
2 files changed, 67 insertions(+), 15 deletions(-)
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);
From 9c4c8bd31e8060acbcd91f3efab24af155f6aa45 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:25:54 +0200
Subject: [PATCH 13/24] fix(security): generate internal requestId server-side;
strip client trust headers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[MEDIO] T-13: the gateway read the client's x-request-id and used it verbatim as
the requestId embedded in the signed internal JWT and forwarded to the API,
enabling correlation forgery/collision and log poisoning. Inbound internal
headers were also not stripped defensively.
- Always mint requestId with randomUUID() (never from the client header).
- Strip inbound x-internal-auth / x-request-id at the start of the proxy, and
removeHeader them on proxyReq before setting the gateway-minted values.
- (API already derives requestId from the verified token claims, not the
header — defense in depth already in place there.)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/gateway/src/routes/proxy.routes.ts | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
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);
}
From 91e5212f98efa581a5cf839a40369d6f70846430 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:27:50 +0200
Subject: [PATCH 14/24] feat(security): add security headers (helmet + nginx
CSP)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[MEDIO] T-14: neither the gateway nor the API used helmet, and the front's nginx
set no CSP/X-Frame-Options/nosniff. Since the access token is JS-visible by
design, a strong CSP is a key XSS mitigation.
- Gateway & API: helmet() (HSTS, nosniff, X-Frame-Options: DENY, …); CSP off
there since they return JSON only — the document CSP belongs to the front.
- Front nginx (nginx/default.conf): CSP (script-src 'self', object-src 'none',
frame-ancestors 'none', style-src 'unsafe-inline' for Angular styles),
X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, HSTS;
re-declared in the /assets and /index.html locations (nginx add_header
inheritance caveat).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/api/src/main.ts | 4 ++++
apps/gateway/src/main.ts | 6 ++++++
nginx/default.conf | 26 ++++++++++++++++++++++++--
package-lock.json | 13 +++++++++++++
package.json | 1 +
5 files changed, 48 insertions(+), 2 deletions(-)
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/gateway/src/main.ts b/apps/gateway/src/main.ts
index 19ec0d5..00adbdf 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,6 +32,11 @@ 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 }));
+
const corsOptions: cors.CorsOptions = {
origin: parseOrigins(process.env.CORS_ORIGIN) ?? true,
credentials: true,
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 d34d86e..0bbadab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"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",
@@ -19186,6 +19187,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",
diff --git a/package.json b/package.json
index b48fe2c..bc7edf8 100644
--- a/package.json
+++ b/package.json
@@ -129,6 +129,7 @@
"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",
From acc9b655f87edaa1914ad1537e09cd3d687bd49c Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:29:03 +0200
Subject: [PATCH 15/24] fix(security): lock down API health detail endpoints
[MEDIO] T-15: /v1/health/detailed and /v1/health/db exposed Node version,
platform, pid, memory/cpu and DB host/port/name with no auth, aiding CVE
targeting and lateral movement if the network were misconfigured.
- Keep /v1/health (liveness) public but minimal (health + timestamp).
- Gate /db and /detailed behind requireInternalAuth (gateway-signed token).
- Strip fingerprinting: drop nodeVersion/pid/platform and DB host/port/name/
dialect; expose only connection booleans/counters + operational metrics.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/api/src/routes/health.routes.ts | 62 ++++++++++++++--------------
1 file changed, 31 insertions(+), 31 deletions(-)
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;
From 933c2991629cfa9aebeba1526cae8903f396f063 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:29:47 +0200
Subject: [PATCH 16/24] fix(security): pass bcrypt cost as number; enforce
minimum 12
[BAJO] T-16: hashPassword passed process.env.HASH_SALT_ROUNDS (a string) to
bcrypt.hash, which treats a string as a pre-generated salt; cost was also only
10 with no floor.
- parseInt the env and enforce a minimum cost of 12 (modern baseline).
- Default HASH_SALT_ROUNDS to 12 in .env.example and compose.yaml.
- Tests for numeric coercion and the minimum-cost clamp.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.example | 3 ++-
apps/api/src/services/auth.service.spec.ts | 18 +++++++++++++++---
apps/api/src/services/auth.service.ts | 9 ++++++++-
compose.yaml | 2 +-
4 files changed, 26 insertions(+), 6 deletions(-)
diff --git a/.env.example b/.env.example
index f3e326d..4fa7d49 100644
--- a/.env.example
+++ b/.env.example
@@ -79,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/apps/api/src/services/auth.service.spec.ts b/apps/api/src/services/auth.service.spec.ts
index e09b560..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', () => {
@@ -74,13 +74,25 @@ describe('AuthService (internal)', () => {
});
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 930339b..d965674 100644
--- a/apps/api/src/services/auth.service.ts
+++ b/apps/api/src/services/auth.service.ts
@@ -25,7 +25,14 @@ class AuthService {
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/compose.yaml b/compose.yaml
index 7af20f0..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
From 56129e5a85a65f676d0a1f3f7e31e0d248d0a6ff Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:31:11 +0200
Subject: [PATCH 17/24] fix(security): filter deleted on put read-back + clamp
pagination
[BAJO] T-17: after a put the base controller re-read with getById({id}) without
deleted:false, allowing read/edit of soft-deleted rows; and page/limit came
straight from req.query (NaN/negative offset, unbounded limit -> memory/DoS).
- put: pre-check the row with deleted:false (404 if gone), and read back with
deleted:false so soft-deleted records stay invisible.
- getAllPaged: parse+clamp via paginationQuerySchema (page>=1, 1<=limit<=100),
falling back to defaults on invalid input; reads res.locals.query when a
validate('query') middleware ran.
- Add abstract-crud.controller spec.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../abstract-crud.controller.spec.ts | 90 +++++++++++++++++++
.../controllers/abstract-crud.controller.ts | 25 +++++-
2 files changed, 113 insertions(+), 2 deletions(-)
create mode 100644 apps/api/src/controllers/abstract-crud.controller.spec.ts
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..b1ccecf 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,8 +60,19 @@ 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);
From 952e35622e6706540ff3f90dcf97ef217280aee4 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:32:49 +0200
Subject: [PATCH 18/24] =?UTF-8?q?fix(security):=20logging=20hygiene=20?=
=?UTF-8?q?=E2=80=94=20no=20bodies/PII/full=20errors=20in=20logs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[BAJO] T-18:
- dbLoggingMiddleware logged the response body + client IP + User-Agent on any
5xx (PII / internals leakage) -> log only method/path/status/timestamp.
- sequelizeErrorMiddleware logged the full error object (query fragments/values)
-> log only { name, code }.
- Remove orphan console.log(error) in AbstractCrudController.put.
- pg.connector gated SQL logging on NODE_PRODUCTION only; now fail-safe on
NODE_ENV==='production' || NODE_PRODUCTION==='true' so SQL+params are never
logged in prod if one var is unset.
- Update db-error middleware specs.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/api/src/adapters/db/pg.connector.ts | 8 +++++++-
.../src/controllers/abstract-crud.controller.ts | 1 -
.../src/middleware/db-error.middleware.spec.ts | 14 +++++++-------
apps/api/src/middleware/db-error.middleware.ts | 15 +++++++++------
4 files changed, 23 insertions(+), 15 deletions(-)
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.ts b/apps/api/src/controllers/abstract-crud.controller.ts
index b1ccecf..b933b84 100644
--- a/apps/api/src/controllers/abstract-crud.controller.ts
+++ b/apps/api/src/controllers/abstract-crud.controller.ts
@@ -75,7 +75,6 @@ export abstract class AbstractCrudController {
});
return HttpResponser.successJson(res, updated);
} catch (error) {
- console.log(error);
return HttpResponser.errorJson(res, error);
}
};
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,
});
}
From 809439cc1446bc83ddca92d039d8860710bb8a6c Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:34:30 +0200
Subject: [PATCH 19/24] fix(security): pin nginx image + add clockTolerance to
internal verify
[BAJO] T-19:
- apps/front/Dockerfile used FROM nginx (=> nginx:latest): non-reproducible
builds. Pin to nginx:1.27 (Debian, keeps the groupadd/useradd block working;
-alpine would break it).
- verifyInternalAuth had no clockTolerance while the internal token TTL is 60s,
so clock drift between containers could cause intermittent 401s on legit
gateway->API calls. Add clockTolerance: '5s'.
- Tests: expired-beyond-tolerance still rejected; small drift now tolerated.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/front/Dockerfile | 4 +++-
.../src/lib/internal-auth.signer.spec.ts | 17 +++++++++++++++--
.../src/lib/internal-auth.signer.ts | 3 +++
3 files changed, 21 insertions(+), 3 deletions(-)
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/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 ?? '';
From bd02ec1306dd56f6f4d83feeaf5783e878f3dbca Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:37:29 +0200
Subject: [PATCH 20/24] fix(security): harden gateway token flows (T-20)
[BAJO] T-20, several:
- CORS fail-closed: drop the dead '?? true' fallback; empty CORS_ORIGIN allows
no cross-origin (logged as misconfig) instead of risking reflect-any.
- Logout revokes the whole refresh family: embed familyId in the refresh JWT and
revoke by familyId (fallback to jti), so logout ends the session everywhere.
- Public tokens now carry issuer/audience and are verified (issuer+audience+
clockTolerance), so a token minted elsewhere can't be replayed.
- express.json with an explicit 100kb limit, applied only to /api/v1/auth; proxy
traffic is streamed, never parsed here.
- Login always returns a generic 401 instead of forwarding the upstream message
(prevents account enumeration).
- Tests for iss/aud binding; keep typ-enforcement test meaningful.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/controllers/auth.controller.ts | 11 ++++++---
apps/gateway/src/main.ts | 23 ++++++++++++++++--
.../gateway/src/middleware/auth.middleware.ts | 7 +++++-
.../src/services/token.service.spec.ts | 24 ++++++++++++++++++-
apps/gateway/src/services/token.service.ts | 20 ++++++++++++++++
5 files changed, 78 insertions(+), 7 deletions(-)
diff --git a/apps/gateway/src/controllers/auth.controller.ts b/apps/gateway/src/controllers/auth.controller.ts
index 17fe150..042a18a 100644
--- a/apps/gateway/src/controllers/auth.controller.ts
+++ b/apps/gateway/src/controllers/auth.controller.ts
@@ -40,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 00adbdf..3a11763 100644
--- a/apps/gateway/src/main.ts
+++ b/apps/gateway/src/main.ts
@@ -37,8 +37,17 @@ class Main {
// 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'],
@@ -46,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.ts b/apps/gateway/src/middleware/auth.middleware.ts
index 6a67440..69b734c 100644
--- a/apps/gateway/src/middleware/auth.middleware.ts
+++ b/apps/gateway/src/middleware/auth.middleware.ts
@@ -60,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(
@@ -121,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 {
diff --git a/apps/gateway/src/services/token.service.spec.ts b/apps/gateway/src/services/token.service.spec.ts
index d602245..68e5e5a 100644
--- a/apps/gateway/src/services/token.service.spec.ts
+++ b/apps/gateway/src/services/token.service.spec.ts
@@ -83,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 75b0cc7..fb5286e 100644
--- a/apps/gateway/src/services/token.service.ts
+++ b/apps/gateway/src/services/token.service.ts
@@ -6,6 +6,13 @@ 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.
@@ -31,6 +38,7 @@ export interface RefreshTokenPayload extends JwtPayload {
email: string;
permissions: Permission[];
remember?: boolean;
+ familyId?: string;
typ: 'refresh';
jti: string;
}
@@ -47,6 +55,7 @@ export interface RefreshTokenInput {
email: string;
permissions: Permission[];
remember?: boolean;
+ familyId?: string;
jti?: string;
}
@@ -78,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',
});
@@ -92,6 +103,7 @@ class TokenService {
email: input.email,
permissions: input.permissions,
remember: input.remember,
+ familyId: input.familyId,
typ: 'refresh',
jti: input.jti ?? randomUUID(),
};
@@ -105,6 +117,8 @@ class TokenService {
) as jwt.SignOptions['expiresIn'];
return jwt.sign(payload, this.#refreshSecret(), {
algorithm: 'HS256',
+ issuer: TOKEN_ISSUER,
+ audience: TOKEN_AUDIENCE,
expiresIn,
});
};
@@ -112,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'}`);
@@ -122,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(
From fddae8843ef570570ce07f6f3815655580de1fb1 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:38:34 +0200
Subject: [PATCH 21/24] fix(security): fix tokenDecoded truthiness bug;
document CSRF posture
[BAJO] T-21:
- auth.service.tokenDecoded tested the signal reference (this.token, always
truthy) instead of its value, so an empty token led to atob/JSON.parse on
undefined and threw. Read this.token(), guard empty, and try/catch -> {}.
- Document the CSRF posture in apps/front/AGENTS.md: state-changing requests
require the Authorization header (non-cookie proof) + SameSite=strict refresh
cookie in prod; no cookie XSRF token, so no withXsrfConfiguration.
- Tests for empty and malformed tokens.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/front/AGENTS.md | 8 +++++++-
.../src/app/libs/auth/services/auth.service.spec.ts | 9 +++++++++
.../src/app/libs/auth/services/auth.service.ts | 13 ++++++++++---
3 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/apps/front/AGENTS.md b/apps/front/AGENTS.md
index ab4e1fa..03363b9 100644
--- a/apps/front/AGENTS.md
+++ b/apps/front/AGENTS.md
@@ -28,7 +28,13 @@
- `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`
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';
From 39415e55e2f163c3e4c8d4237e45606b3513436e Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Tue, 9 Jun 2026 23:39:28 +0200
Subject: [PATCH 22/24] docs(security): document internal-token replay
assumption (T-22)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
[BAJO] T-22: the internal EdDSA token carries a requestId but the verifier does
not enforce jti/nonce uniqueness, so within its 60s TTL a captured
X-Internal-Auth could be replayed. This is acceptable for the starter given the
network design — document the assumption explicitly in docs/SECURITY.md and
describe how to harden (short-lived jti cache) if a less-trusted service ever
crosses the internal boundary.
Also refresh SECURITY.md for prior fixes: bounded remember lifetime
(JWT_REFRESH_REMEMBER_DAYS), iss/aud on client tokens, authoritative
permission re-read on rotation, server-minted requestId + header stripping, and
logout-by-family.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/SECURITY.md | 46 ++++++++++++++++++++++++++++++++++++++++------
1 file changed, 40 insertions(+), 6 deletions(-)
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
From 42733e552f7b337f132cfea203485135a75d3658 Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Wed, 10 Jun 2026 05:46:23 +0200
Subject: [PATCH 23/24] chore: update package lock
---
package-lock.json | 336 +++++++++++++++++++++++++++++++++++++---------
1 file changed, 275 insertions(+), 61 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 0bbadab..e7878b1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2174,7 +2174,6 @@
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.10.tgz",
"integrity": "sha512-FDcnj3ogRmnTca4m2GbKP2khFOCtoVvWDZyfw2ZCPAf+zsQlKTyscKvx4GpTFo+KHrYXpawUpDIWHORFpuqFEA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "7.29.0",
@@ -2261,7 +2260,6 @@
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-21.2.10.tgz",
"integrity": "sha512-pQNL1LwI/6e8SbwiPxGJkXhV5AZxODhS/UJMEeYTx2fRvm4Ob3aMxWTl3ixhFZeYAx9bcU3QpfEJyGr7YDO24w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "7.29.0",
@@ -2413,7 +2411,6 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2423,7 +2420,6 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
@@ -2454,14 +2450,12 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
"license": "MIT"
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2471,7 +2465,6 @@
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
@@ -2501,7 +2494,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
@@ -2518,7 +2510,6 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2627,7 +2618,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2651,7 +2641,6 @@
"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==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
@@ -2665,7 +2654,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
@@ -2769,7 +2757,6 @@
"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==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2788,7 +2775,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2813,7 +2799,6 @@
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
@@ -2827,7 +2812,6 @@
"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==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -4413,7 +4397,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
@@ -4428,7 +4411,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
@@ -4447,7 +4429,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
@@ -4464,7 +4445,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@@ -6310,7 +6290,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -6321,7 +6300,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -6332,7 +6310,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -6353,14 +6330,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -11440,6 +11415,277 @@
"win32"
]
},
+ "node_modules/@rspack/binding": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz",
+ "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "optionalDependencies": {
+ "@rspack/binding-darwin-arm64": "1.7.11",
+ "@rspack/binding-darwin-x64": "1.7.11",
+ "@rspack/binding-linux-arm64-gnu": "1.7.11",
+ "@rspack/binding-linux-arm64-musl": "1.7.11",
+ "@rspack/binding-linux-x64-gnu": "1.7.11",
+ "@rspack/binding-linux-x64-musl": "1.7.11",
+ "@rspack/binding-wasm32-wasi": "1.7.11",
+ "@rspack/binding-win32-arm64-msvc": "1.7.11",
+ "@rspack/binding-win32-ia32-msvc": "1.7.11",
+ "@rspack/binding-win32-x64-msvc": "1.7.11"
+ }
+ },
+ "node_modules/@rspack/binding-darwin-arm64": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz",
+ "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/binding-darwin-x64": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz",
+ "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/binding-linux-arm64-gnu": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz",
+ "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/binding-linux-arm64-musl": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz",
+ "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/binding-linux-x64-gnu": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz",
+ "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/binding-linux-x64-musl": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz",
+ "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/binding-wasm32-wasi": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz",
+ "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "1.0.7"
+ }
+ },
+ "node_modules/@rspack/binding-win32-arm64-msvc": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz",
+ "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/binding-win32-ia32-msvc": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz",
+ "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/binding-win32-x64-msvc": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz",
+ "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true
+ },
+ "node_modules/@rspack/core": {
+ "version": "1.7.11",
+ "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz",
+ "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@module-federation/runtime-tools": "0.22.0",
+ "@rspack/binding": "1.7.11",
+ "@rspack/lite-tapable": "1.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ },
+ "peerDependencies": {
+ "@swc/helpers": ">=0.5.1"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rspack/core/node_modules/@module-federation/error-codes": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz",
+ "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@rspack/core/node_modules/@module-federation/runtime": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz",
+ "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@module-federation/error-codes": "0.22.0",
+ "@module-federation/runtime-core": "0.22.0",
+ "@module-federation/sdk": "0.22.0"
+ }
+ },
+ "node_modules/@rspack/core/node_modules/@module-federation/runtime-core": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz",
+ "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@module-federation/error-codes": "0.22.0",
+ "@module-federation/sdk": "0.22.0"
+ }
+ },
+ "node_modules/@rspack/core/node_modules/@module-federation/runtime-tools": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz",
+ "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@module-federation/runtime": "0.22.0",
+ "@module-federation/webpack-bundler-runtime": "0.22.0"
+ }
+ },
+ "node_modules/@rspack/core/node_modules/@module-federation/sdk": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz",
+ "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@rspack/core/node_modules/@module-federation/webpack-bundler-runtime": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz",
+ "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@module-federation/runtime": "0.22.0",
+ "@module-federation/sdk": "0.22.0"
+ }
+ },
"node_modules/@rspack/dev-server": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-1.2.1.tgz",
@@ -12400,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": {
@@ -12532,7 +12778,6 @@
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.20.7",
@@ -12546,7 +12791,6 @@
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
@@ -12556,7 +12800,6 @@
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.1.0",
@@ -12567,7 +12810,6 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
@@ -14750,7 +14992,6 @@
"version": "2.10.23",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz",
"integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==",
- "dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -14955,7 +15196,6 @@
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -15212,7 +15452,6 @@
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -15276,7 +15515,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
@@ -15813,7 +16051,6 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
- "dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -17221,7 +17458,6 @@
"version": "1.5.344",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
- "dev": true,
"license": "ISC"
},
"node_modules/emittery": {
@@ -18162,7 +18398,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -18799,7 +19034,6 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -18818,7 +19052,6 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -20809,7 +21042,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -20860,7 +21092,6 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -21676,7 +21907,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
- "dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -22529,7 +22759,6 @@
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
- "dev": true,
"license": "MIT"
},
"node_modules/node-schedule": {
@@ -24075,7 +24304,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -24843,7 +25071,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -24870,7 +25097,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
- "dev": true,
"license": "Apache-2.0"
},
"node_modules/regenerate": {
@@ -27646,7 +27872,6 @@
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -28140,7 +28365,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -28296,7 +28521,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -30022,7 +30246,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "dev": true,
"license": "ISC"
},
"node_modules/yaml": {
@@ -30045,7 +30268,6 @@
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
@@ -30072,7 +30294,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -30085,7 +30306,6 @@
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -30098,7 +30318,6 @@
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
- "dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
@@ -30113,14 +30332,12 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
- "dev": true,
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
@@ -30138,7 +30355,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
@@ -30154,7 +30370,6 @@
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
@@ -30172,7 +30387,6 @@
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
- "dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
From e0a0f201567ee7aa7d9ba4b696644412f78f835f Mon Sep 17 00:00:00 2001
From: Dani Herrero
Date: Wed, 10 Jun 2026 10:59:18 +0200
Subject: [PATCH 24/24] style: fix prettier formatting in AGENTS.md and
README_eng.md
Co-Authored-By: Claude Opus 4.8 (1M context)
---
AGENTS.md | 30 +++++++++++++++---------------
docs/README_eng.md | 6 +++---
2 files changed, 18 insertions(+), 18 deletions(-)
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/docs/README_eng.md b/docs/README_eng.md
index 07c8feb..d8f750c 100644
--- a/docs/README_eng.md
+++ b/docs/README_eng.md
@@ -308,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.