diff --git a/package-lock.json b/package-lock.json index a8ed941..cd6ba5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8715,7 +8715,7 @@ }, "packages/platform": { "name": "@ai4u/platform", - "version": "0.6.2", + "version": "0.8.0", "dependencies": { "@ai4u/mc-sso": "github:ai4u-com-co/mc-sso#v1.3.0", "@vercel/oidc": "^3.8.9" @@ -8724,6 +8724,9 @@ "@types/node": "^20", "typescript": "^5", "vitest": "^2" + }, + "engines": { + "node": ">=22" } }, "packages/platform/node_modules/@ai4u/mc-sso": { diff --git a/packages/mc-sso/tests/handler.test.ts b/packages/mc-sso/tests/handler.test.ts index e6b0b5a..ef7b4e1 100644 --- a/packages/mc-sso/tests/handler.test.ts +++ b/packages/mc-sso/tests/handler.test.ts @@ -244,7 +244,11 @@ describe.skipIf(!existsSync(PLATFORM_AUTH))("interop con @ai4u/platform readIden roles: ["admin"], allowedModules: ["ventas", "finanzas"], displayName: "Ana Pérez", + serviceId: SERVICE, // platform >= 0.8.0 expone el serviceId de la sesión }) + // Con el serviceId de ESTA app la lee; con el de otra app (mismo secreto) no. + expect(readIdentity(req, { secret: SECRET, serviceId: SERVICE })).not.toBeNull() + expect(readIdentity(req, { secret: SECRET, serviceId: SERVICE + "-otra" })).toBeNull() expect(readIdentity(req, { secret: SECRET + "x" })).toBeNull() }) }) diff --git a/packages/platform/CHANGELOG.md b/packages/platform/CHANGELOG.md index 22df6dc..9c95d8c 100644 --- a/packages/platform/CHANGELOG.md +++ b/packages/platform/CHANGELOG.md @@ -1,5 +1,92 @@ # Changelog — @ai4u/platform +## 0.8.0 — 2026-10-30 + +Seguridad y robustez (auditoría de 0.7.0). Compatible salvo lo listado en "Cambios visibles". + +### Cambios visibles (revisa tus consumidores antes de actualizar) +- **Cuerpo de un 500 no operacional.** `toErrorResponse` / `withApiHandler` ya no devuelven + `err.message` cuando el error NO es operacional y su status es >= 500 (excepciones desconocidas, + `InfrastructureError`/`ExternalServiceError`, `AppError` con `isOperational:false`). Responden + `{ "error": "Error interno", "code": "UNEXPECTED_ERROR" | , "category", "requestId" }`; el mensaje real + (redactado) queda solo en el log. Los 4xx y los `AppError` operacionales conservan su mensaje. + Tampoco se envía `details` en ese caso. Antes: `{"error":"connect ECONNREFUSED 10.0.0.5:5432 password authentication failed…"}`. + Si una app mostraba `body.error` de un 500 al usuario, ahora verá "Error interno": usa `requestId` + para correlacionar con el log. +- **Nivel de log de errores lanzados.** Un 4xx LANZADO y operacional (`UnauthorizedError`, + `ForbiddenError`, `NotFoundError`, `ValidationError`…) se loguea como `warn` (sin stack), igual que un 4xx + retornado como `Response`; antes era `error`. **Los 401/403 lanzados pasan de ERROR a WARN**: los paneles y + el nightly fixer que filtren por `level=ERROR` para detectar problemas de auth dejarán de verlos y deben pasar + a WARN. Un `AppError` con `isOperational:false` (bug inesperado) sigue en `error` con stack aunque sea 4xx; + los 5xx siguen en `error`. +- **`x-request-id` entrante** se acepta solo si cumple `/^[A-Za-z0-9._-]{1,128}$/`; si no, se genera uno + nuevo (antes se reflejaba tal cual, hasta 5000+ caracteres). +- **Logger**: `msg` y los extras (recursivo, profundidad 4) pasan por `redactSecrets` y las claves + sensibles se enmascaran también en objetos anidados; `tenant`, `userId` y `requestId` del contexto de la + request ya no se pueden pisar con extras (si no hay contexto, el extra sigue valiendo); cada línea lleva + `platformVersion`. Los extras tienen tope (100 claves/elementos, profundidad 4), `Buffer`/typed arrays se + resumen (`[Buffer n bytes]`), `sanitize` nunca lanza (fallback `{_sanitizeFailed:true}`), y las claves de + conteo de tokens (patrón explícito: `max|min|input|output|prompt|completion|total|cached|reasoning` + `tokens`, o + `tokens_used|count|in|out|limit|remaining`, también en camelCase) y las palabras `bypass`, `compass`, `passenger`, + `passive`, `passthrough`, `passed`, `passing` no se enmascaran; toda otra clave con `token` o `pass` + (`secretTokens`, `my_tokens`, `dbpass`, `userpass`, `pass_word`…) sí (fail-closed). +- **client-errors**: un `service` fuera de `/^[A-Za-z0-9._-]{1,80}$/` se registra como `unknown-client` y un `path` + con caracteres no seguros se neutraliza (`_`; sin `/` inicial => `/invalid`); el reporte NO se descarta + (los pathnames que el navegador produce, como `/a|b` o `/x[1]`, se conservan). Cuerpo sin `content-length` por encima de `maxBytes` => 413 sin + leerlo entero; la clave del rate limit sale de `x-vercel-forwarded-for` / `x-real-ip` antes que de + `x-forwarded-for`. +- `engines.node` pasa a `>=22`. **Es declarativo**: el código de platform corre en Node 20 y no se verifica + nada en runtime. Qué revisar antes de actualizar: proyectos con yarn v1 o pnpm con `engine-strict` (la + instalación fallará con Node < 22) y proyectos de Vercel que aún estén en Node 20 (deprecado el 1-oct-2026; + súbelos a 22). + +### Añadido +- `readIdentity(req, { serviceId?, allowedTenants?, normalizeTenant?, acceptLegacy? })` delega en + `readMcSession` de mc-sso 1.3.0 (pin `#v1.3.0`, sin cambio en el árbol). `configureIdentity({...})` fija el + default de la app (las opciones por llamada lo pisan; el estado vive en `globalThis` para sobrevivir a + bundlers que instancian el módulo varias veces). `Identity.serviceId` expone el servicio de la sesión. + **Opt-in**: sin opciones el comportamiento es idéntico al de 0.7.0 (no se deriva nada de `SERVICE_ID`: en + varias apps no coincide con el `serviceId` de su handler SSO y cortaría sesiones válidas). Si hay una cookie + de sesión y no hay `serviceId` configurado, se emite UN WARN por proceso (medir adopción). +- `isModuleAllowedStrict(moduleId, allowedModules)` y `requireModule(identity, id, { strict: true })` + (`null`/`undefined`/`[]` => ningún módulo); `withApiHandler({ strictModules: true })` lo aplica a + `requireModule`. `isModuleAllowed` / `requireModule` sin opción conservan su semántica (null/[] = todo). +- `platformVersion` (constante `PLATFORM_VERSION` en `src/version.ts`, atada a package.json por test). + +### Corregido +- Logger: `fetch` al ingest con `AbortSignal.timeout(2000)` (un ingest colgado retenía la respuesta de la API + hasta el timeout de la función); `flushLogs()` concurrente espera al envío en curso en vez de retornar con + el buffer vacío; si el envío falla (red, timeout, 5xx/408/429) el lote se reencola con tope de 1000 + registros (los más viejos se descartan) y los envíos automáticos esperan 5 s tras un fallo. Tras 3 fallos + consecutivos, `flushLogs()` (ruta de request) también espera ese enfriamiento (circuit breaker simple). + **Limitación**: si el POST expira pero el ingest SÍ insertó el lote, el reenvío puede **duplicar** registros + (no hay idempotencia por registro). Ya no lanza + `TypeError` al loguear un objeto con referencias circulares. + +### Cómo adoptar el endurecimiento de auth +1. Averigua el `serviceId` con el que la app crea su handler SSO (`createMcAuthHandler({ serviceId })`); + NO asumas que es `SERVICE_ID`. +2. Una vez, al arrancar la app: `configureIdentity({ serviceId: "" })` (o pasa `sessionAuth: + { serviceId }` a `withApiHandler`). Apps de un solo tenant: añade `allowedTenants: [""]` + (y `normalizeTenant` de `@ai4u/config` si hay alias). +3. Mide: el WARN "readIdentity sin serviceId configurado" deja de salir y una cookie de otra app/tenant + pasa a dar 401. Las sesiones "legacy" (emitidas por mc-sso <= 1.2.0, sin `serviceId`) se aceptan hasta + que actives `acceptLegacy: false` (hazlo >= 8 h después de que todas las apps emitan sesiones con + `serviceId`, que es lo que duran). +4. Rutas en apps que no son Mission Control: usa `requireModule(..., { strict: true })` / `strictModules`. + +### Decisión pendiente (no cambiada a propósito) +`allowedModules` ausente o `[]` sigue significando acceso a TODOS los módulos en `isModuleAllowed` / +`requireModule` por defecto (copia de `access.ts` de Mission Control; puede ser intencional para admins). +Cambiarlo altera permisos reales: requiere confirmación de Mariano. Mientras tanto el modo estricto es opt-in. + +### No incluido +- `@vercel/oidc` sigue en `^3.8.9`: la 4.0.5 mantiene la API usada (`getVercelOidcToken`) y pasa los tests, pero + arrastra `@vercel/cli-config` con `zod` fijado a 4.1.11, así que no cierra los 3 high de `npm audit`. +- Sin `peerDependencies` de next/react: el código no los importa. +- devDependencies (vitest 5, etc.) sin tocar: `npm install vitest@5` da ERESOLVE en el monorepo (otros paquetes + están en vitest 2/4). + ## 0.7.0 — 2026-10-28 ### Añadido diff --git a/packages/platform/CLAUDE.md b/packages/platform/CLAUDE.md index f96a43a..a936a41 100644 --- a/packages/platform/CLAUDE.md +++ b/packages/platform/CLAUDE.md @@ -13,7 +13,7 @@ del ecosistema; no es una app, no se despliega: se **publica como tag**. ## Stack - TypeScript (`tsc`), tests con `vitest`. CI usa Node 22. Dependencias: `@ai4u/mc-sso` (pin por tag - de GitHub) y `@vercel/oidc`. Versión actual: ver `package.json` (0.7.0). + de GitHub) y `@vercel/oidc`. Versión actual: ver `package.json` (0.8.0). ## Comandos (de `package.json`) - `npm ci` — instalar @@ -25,7 +25,7 @@ del ecosistema; no es una app, no se despliega: se **publica como tag**. - `src/logger` — `getLogger`, `configureTransport`, `setServiceName`, `flushLogs`, contexto por request. - `src/errors` — errores tipados (`ValidationError`, `NotFoundError`, ...). - `src/http` — `withApiHandler` (x-request-id, logging, clasificación de errores, JSON uniforme). -- `src/auth` — `readIdentity`, `requireModule`, `verifyServiceRequest`. +- `src/auth` — `readIdentity` (+ `configureIdentity`, opt-in `serviceId`/`allowedTenants`/`acceptLegacy` vía mc-sso `readMcSession`), `requireModule` (`{strict}`), `verifyServiceRequest`. NO derivar `serviceId` de `SERVICE_ID`. - `src/security` (+ `edge.ts`) — `safeEqual` / `safeEqualEdge`. - `src/client`, `src/client-errors`, `src/instrumentation`, `src/jobs` — reporter de navegador, receptor `createClientErrorHandler`, `onRequestError`, `runJob`. - `src/gateway-identity` — `getGatewayIdentityHeaders` (header `x-ai4u-identity`, fail-open). @@ -44,6 +44,9 @@ del ecosistema; no es una app, no se despliega: se **publica como tag**. permisos. No hardcodear tenants; todo permiso/consulta con scope por tenant. - Cualquier llamada a SAP se hace solo vía `sap-b1-backend` (:4100); este paquete solo aporta la identidad hacia ese gateway. +- Contrato de errores: un 500 no operacional responde "Error interno" (el mensaje real solo al log); + un 4xx lanzado se loguea `warn`, 5xx `error`. `src/version.ts` (`PLATFORM_VERSION`) se sube junto a `version` + de package.json (un test las ata). `@vercel/oidc` queda en 3.x (la 4.x no cierra el audit de zod). - Cambios de comportamiento: agregar entrada en `CHANGELOG.md` y test en `tests/`. ## Variables de entorno (solo nombres; las lee el código) diff --git a/packages/platform/README.md b/packages/platform/README.md index 2039e9a..e63aa99 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -42,7 +42,16 @@ export const POST = withApiHandler(async (req, ctx) => { }, { label: "POST cosa", requireModule: "kpis" }) // Auth: identidad + permisos uniformes -import { readIdentity, requireModule, verifyServiceRequest } from "@ai4u/platform/auth" +import { readIdentity, requireModule, verifyServiceRequest, configureIdentity } from "@ai4u/platform/auth" +// Endurecimiento opt-in (0.8.0): ata la cookie a ESTA app/tenant (el secreto se comparte entre apps) +configureIdentity({ serviceId: "mi-app", allowedTenants: ["mi-tenant"] }) // serviceId = el de tu createMcAuthHandler +// requireModule(identity, "kpis", { strict: true }) → sin allowedModules (null/[]) NO pasa +// Ver CHANGELOG 0.8.0 "Cómo adoptar el endurecimiento de auth". Decisión pendiente: por defecto +// allowedModules null/[] = acceso a todo (compatibilidad con access.ts de Mission Control). + +// Contrato de errores (0.8.0): un 500 no operacional responde { error: "Error interno", code, requestId }; +// el mensaje real va solo al log. 4xx y AppError operacionales conservan su mensaje. +// Un 4xx lanzado se loguea como warn; solo los 5xx como error. // Comparación de secretos en tiempo constante import { safeEqual } from "@ai4u/platform/security" @@ -106,7 +115,8 @@ if (process.env.PLATFORM_INGEST_URL && process.env.INGEST_SECRET) { ``` El transporte es **fire-and-forget en lote**: nunca bloquea ni rompe la app; si el ingest -cae, los logs siguen en stdout. En jobs serverless (cron) llamá `await flushLogs()` al final. +cae, los logs siguen en stdout (0.8.0: el POST tiene timeout de 2 s, el lote fallido se reencola con tope +de 1000 (un timeout con ingest que sí insertó puede duplicar; circuit breaker tras 3 fallos) y cada línea lleva `platformVersion`). En jobs serverless (cron) llamá `await flushLogs()` al final. ## Errores que no pasan por `withApiHandler` (v0.7.0) diff --git a/packages/platform/dist/auth/index.d.ts b/packages/platform/dist/auth/index.d.ts index c71fdd7..ba55bf7 100644 --- a/packages/platform/dist/auth/index.d.ts +++ b/packages/platform/dist/auth/index.d.ts @@ -4,13 +4,37 @@ export interface Identity { roles?: string[]; allowedModules?: string[] | null; displayName?: string; + /** App para la que se emitió la sesión (mc-sso >= 1.3.0). undefined = sesión "legacy". */ + serviceId?: string; } -export interface SessionAuthConfig { +/** + * Restricciones de app/tenant sobre la sesión (se pasan tal cual a `readMcSession` de mc-sso). + * El secreto de firma se comparte entre apps: sin estas opciones una cookie de otra app/tenant + * con firma válida PASA. Son opt-in; ver "Cómo adoptar el endurecimiento de auth" en CHANGELOG. + */ +export interface IdentityScope { + /** `serviceId` de ESTA app (el mismo de su `createMcAuthHandler`). Sesión de otro servicio => null. */ + serviceId?: string; + /** Tenants aceptados (apps de un solo tenant). Fuera de la lista => null; `[]` rechaza todo. */ + allowedTenants?: readonly string[]; + /** Normalizador de tenant para comparar `allowedTenants` (p. ej. `normalizeTenant` de @ai4u/config). */ + normalizeTenant?: (tenantId: string) => string; + /** Aceptar sesiones sin `serviceId` (emitidas por mc-sso <= 1.2.0). Default de mc-sso: true. */ + acceptLegacy?: boolean; +} +export interface SessionAuthConfig extends IdentityScope { /** Nombre de la cookie de sesión (default "mc_session"). */ cookieName?: string; /** Secreto de firma (default process.env.MISSION_CONTROL_SECRET). */ secret?: string; } +/** + * Fija el alcance de identidad por defecto de ESTA app (una vez, p. ej. en instrumentation.ts o + * en un módulo de config). Las opciones pasadas a `readIdentity`/`withApiHandler({sessionAuth})` + * lo pisan campo a campo. No se deriva nada de `SERVICE_ID`: puede no coincidir con el serviceId + * de su handler SSO y cortaría sesiones válidas. + */ +export declare function configureIdentity(scope: IdentityScope): void; type HeaderCarrier = { headers: { get(name: string): string | null; @@ -19,13 +43,20 @@ type HeaderCarrier = { /** Lee y verifica la identidad del usuario desde la cookie de sesión. null si no hay/no es válida. */ export declare function readIdentity(req: HeaderCarrier, cfg?: SessionAuthConfig): Identity | null; export declare function isModuleAllowed(moduleId: string, allowedModules: string[] | null | undefined): boolean; +/** + * Variante ESTRICTA: sin lista (null/undefined/[]) => NINGÚN módulo. Úsala en apps que no son + * Mission Control, donde una sesión sin `allowedModules` no debería ver todo. + */ +export declare function isModuleAllowedStrict(moduleId: string, allowedModules: string[] | null | undefined): boolean; export declare function hasRole(identity: Identity | null, role: string): boolean; export declare function isAdmin(identity: Identity | null): boolean; /** * Exige identidad y acceso a un módulo (acepta varios: any-of). Lanza errores * tipados que el wrapper HTTP convierte en 401/403 uniformes. */ -export declare function requireModule(identity: Identity | null, moduleId: string | string[]): Identity; +export declare function requireModule(identity: Identity | null, moduleId: string | string[], opts?: { + strict?: boolean; +}): Identity; export declare function requireRole(identity: Identity | null, role: string | string[]): Identity; export interface ServiceAuthConfig { /** Secreto compartido aceptado en x-mc-secret (default process.env.MISSION_CONTROL_SECRET). */ diff --git a/packages/platform/dist/auth/index.d.ts.map b/packages/platform/dist/auth/index.d.ts.map index e028b96..2578db2 100644 --- a/packages/platform/dist/auth/index.d.ts.map +++ b/packages/platform/dist/auth/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAWD,MAAM,WAAW,iBAAiB;IAChC,4DAA4D;IAC5D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,KAAK,aAAa,GAAG;IAAE,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAAE,CAAA;AA0BtE,sGAAsG;AACtG,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,GAAE,iBAAsB,GAAG,QAAQ,GAAG,IAAI,CAe7F;AAID,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAGtG;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,GAAG,OAAO,CAE1D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,QAAQ,CAO9F;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,QAAQ,CAOxF;AAID,MAAM,WAAW,iBAAiB;IAChC,+FAA+F;IAC/F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAA;IACX,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,8GAA8G;AAC9G,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,GAAE,iBAAsB,GAAG,iBAAiB,CAevG"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,0FAA0F;IAC1F,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,uGAAuG;IACvG,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gGAAgG;IAChG,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,wGAAwG;IACxG,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAA;IAC9C,gGAAgG;IAChG,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,iBAAkB,SAAQ,aAAa;IACtD,4DAA4D;IAC5D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAiBD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAE5D;AAGD,KAAK,aAAa,GAAG;IAAE,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAAE,CAAA;AA0BtE,sGAAsG;AACtG,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,GAAE,iBAAsB,GAAG,QAAQ,GAAG,IAAI,CAgC7F;AAID,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAGtG;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5G;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,GAAG,OAAO,CAE1D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,QAAQ,GAAG,IAAI,EACzB,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,EAC3B,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GAC9B,QAAQ,CAQV;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,QAAQ,CAOxF;AAID,MAAM,WAAW,iBAAiB;IAChC,+FAA+F;IAC/F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAA;IACX,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,8GAA8G;AAC9G,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,GAAE,iBAAsB,GAAG,iBAAiB,CAevG"} \ No newline at end of file diff --git a/packages/platform/dist/auth/index.js b/packages/platform/dist/auth/index.js index 242d1c3..8d3be58 100644 --- a/packages/platform/dist/auth/index.js +++ b/packages/platform/dist/auth/index.js @@ -1,7 +1,9 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.configureIdentity = configureIdentity; exports.readIdentity = readIdentity; exports.isModuleAllowed = isModuleAllowed; +exports.isModuleAllowedStrict = isModuleAllowedStrict; exports.hasRole = hasRole; exports.isAdmin = isAdmin; exports.requireModule = requireModule; @@ -19,11 +21,27 @@ exports.verifyServiceRequest = verifyServiceRequest; * esquemas actuales (x-mc-secret compartido / X-API-Key por tenant). * * La regla de visibilidad de módulos es la misma de mission-control-main/lib/access.ts: - * sin lista (null/[]) ⇒ ve todo; con lista ⇒ solo esos ids. + * sin lista (null/[]) ⇒ ve todo; con lista ⇒ solo esos ids. Existe además el modo estricto + * (`requireModule(..., { strict: true })` / `isModuleAllowedStrict`): sin lista ⇒ ninguno. */ const mc_sso_1 = require("@ai4u/mc-sso"); const errors_1 = require("../errors"); +const logger_1 = require("../logger"); const security_1 = require("../security"); +const STATE_KEY = Symbol.for("@ai4u/platform/identity"); +function state() { + const g = globalThis; + return (g[STATE_KEY] ?? (g[STATE_KEY] = { scope: {}, warned: false })); +} +/** + * Fija el alcance de identidad por defecto de ESTA app (una vez, p. ej. en instrumentation.ts o + * en un módulo de config). Las opciones pasadas a `readIdentity`/`withApiHandler({sessionAuth})` + * lo pisan campo a campo. No se deriva nada de `SERVICE_ID`: puede no coincidir con el serviceId + * de su handler SSO y cortaría sesiones válidas. + */ +function configureIdentity(scope) { + state().scope = { ...scope }; +} /** * Parsea el header Cookie. Nunca lanza: una cookie con percent-encoding inválido * (p.ej. `%E0%A4%A`, que hace lanzar URIError a decodeURIComponent) se IGNORA y se @@ -59,7 +77,21 @@ function readIdentity(req, cfg = {}) { const token = parseCookies(req.headers.get("cookie"))[cookieName]; if (!token) return null; - const data = (0, mc_sso_1.verifySession)(token, secret); + const appScope = state().scope; + const scope = { + serviceId: cfg.serviceId ?? appScope.serviceId, + allowedTenants: cfg.allowedTenants ?? appScope.allowedTenants, + normalizeTenant: cfg.normalizeTenant ?? appScope.normalizeTenant, + acceptLegacy: cfg.acceptLegacy ?? appScope.acceptLegacy, + }; + if (scope.serviceId === undefined && !state().warned) { + state().warned = true; + (0, logger_1.getLogger)("platform/auth").warn("readIdentity sin serviceId configurado: una sesión firmada para otra app con el mismo secreto sería aceptada. " + + "Pasa { serviceId } a readIdentity/sessionAuth o llama configureIdentity({ serviceId }) (platform 0.8.0)."); + } + // Se reenvía solo el token ya parseado (parseCookies ignora duplicados mal codificados) y mc-sso + // aplica firma, expiración y las restricciones de app/tenant. + const data = (0, mc_sso_1.readMcSession)(`${cookieName}=${encodeURIComponent(token)}`, secret, { cookieName, ...scope }); if (!data) return null; return { @@ -68,6 +100,7 @@ function readIdentity(req, cfg = {}) { roles: data.roles, allowedModules: data.allowedModules ?? null, displayName: data.displayName, + serviceId: data.serviceId, }; } /* ── Permisos (misma regla que access.ts) ────────────────────────────────── */ @@ -76,6 +109,13 @@ function isModuleAllowed(moduleId, allowedModules) { return true; return allowedModules.includes(moduleId); } +/** + * Variante ESTRICTA: sin lista (null/undefined/[]) => NINGÚN módulo. Úsala en apps que no son + * Mission Control, donde una sesión sin `allowedModules` no debería ver todo. + */ +function isModuleAllowedStrict(moduleId, allowedModules) { + return Array.isArray(allowedModules) && allowedModules.includes(moduleId); +} function hasRole(identity, role) { return !!identity?.roles?.includes(role); } @@ -86,11 +126,12 @@ function isAdmin(identity) { * Exige identidad y acceso a un módulo (acepta varios: any-of). Lanza errores * tipados que el wrapper HTTP convierte en 401/403 uniformes. */ -function requireModule(identity, moduleId) { +function requireModule(identity, moduleId, opts = {}) { if (!identity) throw new errors_1.UnauthorizedError(); const ids = Array.isArray(moduleId) ? moduleId : [moduleId]; - if (!ids.some((id) => isModuleAllowed(id, identity.allowedModules))) { + const allowed = opts.strict ? isModuleAllowedStrict : isModuleAllowed; + if (!ids.some((id) => allowed(id, identity.allowedModules))) { throw new errors_1.ForbiddenError("No autorizado para este módulo"); } return identity; diff --git a/packages/platform/dist/client-errors/index.d.ts.map b/packages/platform/dist/client-errors/index.d.ts.map index fae27cb..4ee1327 100644 --- a/packages/platform/dist/client-errors/index.d.ts.map +++ b/packages/platform/dist/client-errors/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client-errors/index.ts"],"names":[],"mappings":"AAsBA,MAAM,WAAW,yBAAyB;IACxC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAmFD,wBAAgB,wBAAwB,CAAC,OAAO,GAAE,yBAA8B,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6DrH"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client-errors/index.ts"],"names":[],"mappings":"AAsBA,MAAM,WAAW,yBAAyB;IACxC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAiID,wBAAgB,wBAAwB,CAAC,OAAO,GAAE,yBAA8B,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAoErH"} \ No newline at end of file diff --git a/packages/platform/dist/client-errors/index.js b/packages/platform/dist/client-errors/index.js index aa2aa94..050a9ee 100644 --- a/packages/platform/dist/client-errors/index.js +++ b/packages/platform/dist/client-errors/index.js @@ -11,7 +11,7 @@ exports.createClientErrorHandler = createClientErrorHandler; * ⚠ Es un endpoint SIN AUTENTICACIÓN (el navegador del visitante no tiene credenciales de servicio). * Riesgo: cualquiera puede intentar llenar platform_logs de basura. Mitigaciones incluidas: * solo application/json, tope de tamaño (413), mismo origen (Origin/Sec-Fetch-Site, 403), límite - * por IP en memoria (429), validación estricta de forma y truncado de campos, y respuesta 204 sin + * por IP en memoria (429; clave desde x-vercel-forwarded-for/x-real-ip antes que x-forwarded-for, Map con tope), validación estricta de forma y truncado de campos, y respuesta 204 sin * eco. El límite por IP es de MEJOR ESFUERZO: en serverless cada instancia tiene su propio Map * y se reinicia en cold start; no sustituye a un WAF/rate-limit de plataforma. El origen se * comprueba por cabeceras que un cliente no-navegador puede falsificar: sirve contra abuso @@ -26,6 +26,13 @@ const MAX_MESSAGE = 2000; const MAX_STACK = 4000; const WINDOW_MS = 60000; const KINDS = ["error", "unhandledrejection", "manual"]; +/** Tope de entradas del Map del rate limit: al llenarse se podan expirados y luego las más viejas. */ +const MAX_HITS = 5000; +/** `service`: id de app corto (sin espacios ni saltos de línea). */ +const SERVICE_RE = /^[A-Za-z0-9._-]{1,80}$/; +/** Caracteres de un pathname que el navegador produce tal cual (incluye `|`, `[`, `]`, `^`, `{`, `}`). */ +const PATH_UNSAFE_RE = /[^A-Za-z0-9._~%!$&'()*+,;=:@/|[\]^{}-]/g; +const UNKNOWN_SERVICE = "unknown-client"; const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|authorization|cookie|credential/i; /** Quita caracteres de control (conserva \n y \t) y trunca. */ function clean(v, max) { @@ -50,18 +57,23 @@ function validate(raw) { return null; if (o.extra !== undefined && (typeof o.extra !== "object" || o.extra === null || Array.isArray(o.extra))) return null; + // Solo pathname: descarta querystring/hash aunque el cliente los mande. + // Un path raro NO descarta el reporte: se neutralizan los caracteres peligrosos (o "/invalid"). + const rawPath = clean(o.path.split(/[?#]/)[0], 200); + const path = rawPath.startsWith("/") ? rawPath.replace(PATH_UNSAFE_RE, "_") : "/invalid"; const out = { message: clean(o.message, MAX_MESSAGE), kind: o.kind, - // Solo pathname: descarta querystring/hash aunque el cliente los mande. - path: clean(o.path.split(/[?#]/)[0], 200), + path, }; if (typeof o.stack === "string") out.stack = clean(o.stack, MAX_STACK); if (typeof o.name === "string") out.name = clean(o.name, 100); - if (typeof o.service === "string") - out.service = clean(o.service, 80); + if (typeof o.service === "string") { + // Un service con formato raro tampoco descarta el reporte: se reemplaza por un valor por defecto. + out.service = SERVICE_RE.test(o.service) ? o.service : UNKNOWN_SERVICE; + } if (o.extra) { const extra = {}; for (const [k, v] of Object.entries(o.extra).slice(0, 10)) { @@ -98,11 +110,46 @@ function sameOrigin(req) { // Sin Origin: solo vale si el navegador declaró same-origin vía Sec-Fetch-Site. return site === "same-origin"; } +/** + * IP para el rate limit. Se prefieren las cabeceras que pone la plataforma (Vercel reescribe + * `x-vercel-forwarded-for` / `x-real-ip` y el cliente no las controla) sobre `x-forwarded-for`, + * cuyo primer valor puede venir falsificado por el cliente. + */ function clientIp(req) { + const trusted = req.headers.get("x-vercel-forwarded-for") ?? req.headers.get("x-real-ip"); + const first = trusted?.split(",")[0].trim(); + if (first) + return first; const xff = req.headers.get("x-forwarded-for"); if (xff) return xff.split(",")[0].trim() || "unknown"; - return req.headers.get("x-real-ip") ?? "unknown"; + return "unknown"; +} +/** Lee el cuerpo como stream y corta apenas supera `maxBytes` (null = excedido). Nunca carga todo en memoria. */ +async function readBodyCapped(req, maxBytes) { + if (!req.body) + return ""; + const reader = req.body.getReader(); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) + break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + const all = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + all.set(c, off); + off += c.byteLength; + } + return new TextDecoder().decode(all); } const empty = (status) => new Response(null, { status }); function createClientErrorHandler(options = {}) { @@ -111,10 +158,17 @@ function createClientErrorHandler(options = {}) { const hits = new Map(); function limited(ip) { const now = Date.now(); - if (hits.size > 1000) + if (hits.size >= MAX_HITS) { for (const [k, v] of hits) if (v.reset <= now) hits.delete(k); + // Aún lleno (muchas IPs en 1 min): se descartan las más viejas (Map conserva el orden de inserción). + for (const k of hits.keys()) { + if (hits.size < MAX_HITS) + break; + hits.delete(k); + } + } const h = hits.get(ip); if (!h || h.reset <= now) { hits.set(ip, { count: 1, reset: now + WINDOW_MS }); @@ -138,12 +192,12 @@ function createClientErrorHandler(options = {}) { return empty(413); let text; try { - text = await req.text(); + text = await readBodyCapped(req, maxBytes); } catch { return empty(400); } - if (new TextEncoder().encode(text).length > maxBytes) + if (text === null) return empty(413); let payload; try { diff --git a/packages/platform/dist/errors/index.d.ts b/packages/platform/dist/errors/index.d.ts index 2ad0a1e..654dae2 100644 --- a/packages/platform/dist/errors/index.d.ts +++ b/packages/platform/dist/errors/index.d.ts @@ -64,6 +64,9 @@ export declare function isAppError(err: unknown): err is AppError; /** * Convierte cualquier `unknown` en un AppError. Lo que no reconozca se trata como * fallo de infraestructura inesperado (500), nunca se ignora. + * + * El `message` del AppError devuelto conserva el texto original SOLO para logging; lo que se + * envía al cliente lo decide `toErrorResponse` (que lo reemplaza por "Error interno"). */ export declare function classifyUnknownError(err: unknown): AppError; export interface ErrorResponseBody { @@ -73,7 +76,16 @@ export interface ErrorResponseBody { requestId?: string; details?: unknown; } -/** Cuerpo JSON uniforme y seguro para responder al cliente. */ +/** Mensaje genérico que ve el cliente cuando el error no es operacional y es 5xx. */ +export declare const GENERIC_ERROR_MESSAGE = "Error interno"; +/** + * Cuerpo JSON uniforme y seguro para responder al cliente. + * + * Desde 0.8.0: un error NO operacional con status >= 500 (excepciones desconocidas, fallos de + * infraestructura) responde `error: "Error interno"` en vez de su mensaje: ese texto suele traer + * internals (hosts, SQL, URLs con claves). El mensaje real queda en el log (redactado) y el + * cliente correlaciona por `requestId`; tampoco se envía `details`. Los operacionales y los 4xx conservan mensaje y details. + */ export declare function toErrorResponse(err: unknown, requestId?: string): { status: number; body: ErrorResponseBody; diff --git a/packages/platform/dist/errors/index.d.ts.map b/packages/platform/dist/errors/index.d.ts.map index 36c4a28..09e8221 100644 --- a/packages/platform/dist/errors/index.d.ts.map +++ b/packages/platform/dist/errors/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/errors/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,UAAU,GAAG,gBAAgB,CAAA;AAExE,MAAM,WAAW,eAAe;IAC9B,0FAA0F;IAC1F,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8BAA8B;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,oEAAoE;IACpE,KAAK,CAAC,EAAE,OAAO,CAAA;IACf;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,mFAAmF;AACnF,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;IAChC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAA;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;gBAEZ,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,IAAI,GAAE,eAAoB;CAYjF;AAoBD,qBAAa,eAAgB,SAAQ,QAAQ;gBAC/B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAED,qBAAa,aAAc,SAAQ,QAAQ;gBAC7B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAED,qBAAa,mBAAoB,SAAQ,QAAQ;gBACnC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAID,qBAAa,aAAc,SAAQ,aAAa;gBAClC,OAAO,SAA0B,EAAE,IAAI,GAAE,eAAoB;CAG1E;AAED,qBAAa,iBAAkB,SAAQ,QAAQ;gBACjC,OAAO,SAAmB,EAAE,IAAI,GAAE,eAAoB;CAGnE;AAED,qBAAa,cAAe,SAAQ,QAAQ;gBAC9B,OAAO,SAAkB,EAAE,IAAI,GAAE,eAAoB;CAGlE;AAED,qBAAa,aAAc,SAAQ,aAAa;gBAClC,OAAO,SAAwB,EAAE,IAAI,GAAE,eAAoB;CAGxE;AAED,qBAAa,oBAAqB,SAAQ,mBAAmB;gBAC/C,OAAO,SAAmC,EAAE,IAAI,GAAE,eAAoB;CAGnF;AAID,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,QAAQ,CAExD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,QAAQ,CAS3D;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,aAAa,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,+DAA+D;AAC/D,wBAAgB,eAAe,CAC7B,GAAG,EAAE,OAAO,EACZ,SAAS,CAAC,EAAE,MAAM,GACjB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,iBAAiB,CAAA;CAAE,CAY7C"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/errors/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,UAAU,GAAG,gBAAgB,CAAA;AAExE,MAAM,WAAW,eAAe;IAC9B,0FAA0F;IAC1F,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8BAA8B;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,oEAAoE;IACpE,KAAK,CAAC,EAAE,OAAO,CAAA;IACf;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,mFAAmF;AACnF,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;IAChC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAA;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;gBAEZ,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,IAAI,GAAE,eAAoB;CAYjF;AAoBD,qBAAa,eAAgB,SAAQ,QAAQ;gBAC/B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAED,qBAAa,aAAc,SAAQ,QAAQ;gBAC7B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAED,qBAAa,mBAAoB,SAAQ,QAAQ;gBACnC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAID,qBAAa,aAAc,SAAQ,aAAa;gBAClC,OAAO,SAA0B,EAAE,IAAI,GAAE,eAAoB;CAG1E;AAED,qBAAa,iBAAkB,SAAQ,QAAQ;gBACjC,OAAO,SAAmB,EAAE,IAAI,GAAE,eAAoB;CAGnE;AAED,qBAAa,cAAe,SAAQ,QAAQ;gBAC9B,OAAO,SAAkB,EAAE,IAAI,GAAE,eAAoB;CAGlE;AAED,qBAAa,aAAc,SAAQ,aAAa;gBAClC,OAAO,SAAwB,EAAE,IAAI,GAAE,eAAoB;CAGxE;AAED,qBAAa,oBAAqB,SAAQ,mBAAmB;gBAC/C,OAAO,SAAmC,EAAE,IAAI,GAAE,eAAoB;CAGnF;AAID,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,QAAQ,CAExD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,QAAQ,CAS3D;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,aAAa,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,qFAAqF;AACrF,eAAO,MAAM,qBAAqB,kBAAkB,CAAA;AAEpD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,OAAO,EACZ,SAAS,CAAC,EAAE,MAAM,GACjB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,iBAAiB,CAAA;CAAE,CAa7C"} \ No newline at end of file diff --git a/packages/platform/dist/errors/index.js b/packages/platform/dist/errors/index.js index 03725ea..a10134b 100644 --- a/packages/platform/dist/errors/index.js +++ b/packages/platform/dist/errors/index.js @@ -11,7 +11,7 @@ * responda errores de forma uniforme y NUNCA falle en silencio. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.ExternalServiceError = exports.ConflictError = exports.ForbiddenError = exports.UnauthorizedError = exports.NotFoundError = exports.InfrastructureError = exports.BusinessError = exports.ValidationError = exports.AppError = void 0; +exports.GENERIC_ERROR_MESSAGE = exports.ExternalServiceError = exports.ConflictError = exports.ForbiddenError = exports.UnauthorizedError = exports.NotFoundError = exports.InfrastructureError = exports.BusinessError = exports.ValidationError = exports.AppError = void 0; exports.isAppError = isAppError; exports.classifyUnknownError = classifyUnknownError; exports.toErrorResponse = toErrorResponse; @@ -102,6 +102,9 @@ function isAppError(err) { /** * Convierte cualquier `unknown` en un AppError. Lo que no reconozca se trata como * fallo de infraestructura inesperado (500), nunca se ignora. + * + * El `message` del AppError devuelto conserva el texto original SOLO para logging; lo que se + * envía al cliente lo decide `toErrorResponse` (que lo reemplaza por "Error interno"). */ function classifyUnknownError(err) { if (isAppError(err)) @@ -114,17 +117,27 @@ function classifyUnknownError(err) { cause: err, }); } -/** Cuerpo JSON uniforme y seguro para responder al cliente. */ +/** Mensaje genérico que ve el cliente cuando el error no es operacional y es 5xx. */ +exports.GENERIC_ERROR_MESSAGE = "Error interno"; +/** + * Cuerpo JSON uniforme y seguro para responder al cliente. + * + * Desde 0.8.0: un error NO operacional con status >= 500 (excepciones desconocidas, fallos de + * infraestructura) responde `error: "Error interno"` en vez de su mensaje: ese texto suele traer + * internals (hosts, SQL, URLs con claves). El mensaje real queda en el log (redactado) y el + * cliente correlaciona por `requestId`; tampoco se envía `details`. Los operacionales y los 4xx conservan mensaje y details. + */ function toErrorResponse(err, requestId) { const appErr = classifyUnknownError(err); + const masked = !appErr.isOperational && appErr.httpStatus >= 500; return { status: appErr.httpStatus, body: { - error: appErr.message, + error: masked ? exports.GENERIC_ERROR_MESSAGE : appErr.message, code: appErr.code, category: appErr.category, ...(requestId ? { requestId } : {}), - ...(appErr.details !== undefined ? { details: appErr.details } : {}), + ...(!masked && appErr.details !== undefined ? { details: appErr.details } : {}), }, }; } diff --git a/packages/platform/dist/http/index.d.ts b/packages/platform/dist/http/index.d.ts index f4c9fa9..047abfb 100644 --- a/packages/platform/dist/http/index.d.ts +++ b/packages/platform/dist/http/index.d.ts @@ -26,6 +26,11 @@ export interface WithApiHandlerOptions { label?: string; /** Exige sesión + acceso a este módulo (any-of si es array). */ requireModule?: string | string[]; + /** + * Con `requireModule`: modo estricto, una sesión sin `allowedModules` (null/[]) NO pasa. + * Default false (compatibilidad: null/[] = acceso a todo). + */ + strictModules?: boolean; /** Exige sesión + este rol (any-of si es array). */ requireRole?: string | string[]; /** Exige auth de servicio (x-mc-secret / X-API-Key). */ diff --git a/packages/platform/dist/http/index.d.ts.map b/packages/platform/dist/http/index.d.ts.map index 2cbb4a4..63055d5 100644 --- a/packages/platform/dist/http/index.d.ts.map +++ b/packages/platform/dist/http/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAwE,MAAM,EAAE,MAAM,WAAW,CAAA;AAoCxG,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,kGAAkG;IAClG,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,qBAAqB;IACpC,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC/B,wDAAwD;IACxD,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,mCAAmC;IACnC,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,kCAAkC;IAClC,WAAW,CAAC,EAAE,iBAAiB,CAAA;CAChC;AAED,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;AASxF,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EACtD,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,OAAO,GAAE,qBAA0B,GAClC,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6EnD"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAwE,MAAM,EAAE,MAAM,WAAW,CAAA;AAoCxG,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,kGAAkG;IAClG,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,qBAAqB;IACpC,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC/B,wDAAwD;IACxD,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,mCAAmC;IACnC,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,kCAAkC;IAClC,WAAW,CAAC,EAAE,iBAAiB,CAAA;CAChC;AAED,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;AAexF,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EACtD,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,OAAO,GAAE,qBAA0B,GAClC,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAuFnD"} \ No newline at end of file diff --git a/packages/platform/dist/http/index.js b/packages/platform/dist/http/index.js index 77075a3..6889b5b 100644 --- a/packages/platform/dist/http/index.js +++ b/packages/platform/dist/http/index.js @@ -58,10 +58,15 @@ function jsonResponse(body, status, requestId) { headers: { "content-type": "application/json", "x-request-id": requestId }, }); } +/** x-request-id entrante: solo charset/longitud acotados; si no, se regenera (evita inflado e inyección en logs). */ +const REQUEST_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; +function safeIncomingRequestId(v) { + return v !== null && REQUEST_ID_RE.test(v) ? v : null; +} function withApiHandler(handler, options = {}) { const label = options.label ?? "api"; return async (req, routeCtx) => { - const requestId = req.headers.get("x-request-id") ?? (0, logger_1.newRequestId)(); + const requestId = safeIncomingRequestId(req.headers.get("x-request-id")) ?? (0, logger_1.newRequestId)(); const log = (0, logger_1.getLogger)(label); return (0, logger_1.runWithContext)({ requestId }, async () => { const start = Date.now(); @@ -83,7 +88,7 @@ function withApiHandler(handler, options = {}) { if (identity) (0, logger_1.setContextFields)({ tenant: identity.tenantId, userId: identity.userId }); if (options.requireModule) - (0, auth_1.requireModule)(identity, options.requireModule); + (0, auth_1.requireModule)(identity, options.requireModule, { strict: options.strictModules }); if (options.requireRole) (0, auth_1.requireRole)(identity, options.requireRole); } @@ -123,8 +128,16 @@ function withApiHandler(handler, options = {}) { catch (err) { const durationMs = Date.now() - start; const appErr = (0, errors_1.classifyUnknownError)(err); - // Log con detalle completo (stack incluido); la respuesta al cliente es segura. - log.error({ durationMs, status: appErr.httpStatus, code: appErr.code, err: appErr.cause ?? appErr }, `${label} error`); + // 4xx lanzado y operacional (Unauthorized/NotFound/Validation…) es tráfico normal: WARN sin stack, + // igual que un 4xx retornado como Response. Va a ERROR el 5xx y todo AppError marcado + // isOperational:false (bug inesperado, ver errors/index.ts): lo que consumen paneles y el fixer. + if (appErr.httpStatus < 500 && appErr.isOperational) { + log.warn({ durationMs, status: appErr.httpStatus, code: appErr.code }, `${label} ${appErr.name}: ${appErr.message}`); + } + else { + // Log con detalle completo (stack incluido); la respuesta al cliente es segura. + log.error({ durationMs, status: appErr.httpStatus, code: appErr.code, err: appErr.cause ?? appErr }, `${label} error`); + } const { status, body } = (0, errors_1.toErrorResponse)(appErr, requestId); return jsonResponse(body, status, requestId); } diff --git a/packages/platform/dist/logger/index.d.ts.map b/packages/platform/dist/logger/index.d.ts.map index c348129..b2e2c49 100644 --- a/packages/platform/dist/logger/index.d.ts.map +++ b/packages/platform/dist/logger/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/logger/index.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAavD,MAAM,WAAW,UAAU;IACzB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAID,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEjE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAGzD;AAED,wBAAgB,UAAU,IAAI,UAAU,CAEvC;AAID,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAOjD;AA0GD,0GAA0G;AAC1G,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAelD;AA+BD,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,QAAQ,EAAE,MAAM,CAAA;IAChB,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAA;IACd,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AA4BD,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAM7D;AA0BD,kFAAkF;AAClF,wBAAgB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAEzC;AAWD,KAAK,SAAS,GAAG;IACf,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CACrD,CAAA;AAwDD,qBAAa,MAAM;IACL,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,MAAM;IAE9C,KAAK,EAAE,SAAS,CAA2F;IAC3G,IAAI,EAAE,SAAS,CAA0F;IACzG,IAAI,EAAE,SAAS,CAA0F;IACzG,KAAK,EAAE,SAAS,CAA2F;IAE3G,KAAK,CAAC,QAAQ,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM;CAGhD;AAED,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,MAAM,CAErC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/logger/index.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAavD,MAAM,WAAW,UAAU;IACzB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAID,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEjE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAGzD;AAED,wBAAgB,UAAU,IAAI,UAAU,CAEvC;AAID,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAOjD;AAuKD,0GAA0G;AAC1G,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAelD;AA+BD,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,QAAQ,EAAE,MAAM,CAAA;IAChB,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAA;IACd,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAyCD,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAM7D;AAmED,kFAAkF;AAClF,wBAAgB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAEzC;AAYD,KAAK,SAAS,GAAG;IACf,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CACrD,CAAA;AAqED,qBAAa,MAAM;IACL,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,MAAM;IAE9C,KAAK,EAAE,SAAS,CAA2F;IAC3G,IAAI,EAAE,SAAS,CAA0F;IACzG,IAAI,EAAE,SAAS,CAA0F;IACzG,KAAK,EAAE,SAAS,CAA2F;IAE3G,KAAK,CAAC,QAAQ,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM;CAGhD;AAED,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,MAAM,CAErC"} \ No newline at end of file diff --git a/packages/platform/dist/logger/index.js b/packages/platform/dist/logger/index.js index 7060993..45a708d 100644 --- a/packages/platform/dist/logger/index.js +++ b/packages/platform/dist/logger/index.js @@ -26,6 +26,7 @@ exports.newRequestId = newRequestId; */ const node_async_hooks_1 = require("node:async_hooks"); const node_crypto_1 = require("node:crypto"); +const version_1 = require("../version"); const LEVEL_VALUES = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 }; // Se leen dinámicamente del entorno para ser configurables (y testeables) sin // reimportar el módulo. @@ -63,14 +64,82 @@ function resolveServiceName() { return process.env.PLATFORM_SERVICE ?? process.env.SERVICE_ID ?? SERVICE_NAME; } /* ── Redacción de secretos (red de seguridad de sap-b1-backend) ──────────── */ -const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|authorization|cookie|credential/i; +/** Palabras que contienen "pass" pero no son credenciales (comparación por segmento completo). */ +const PASS_SAFE_WORDS = new Set(["bypass", "compass", "passenger", "passive", "passthrough", "passed", "passing"]); +/** Métricas de conteo de tokens de LLM (únicas claves con "token" que no se enmascaran). */ +const TOKEN_COUNT_KEY = /^(?:(?:max|min|input|output|prompt|completion|total|cached|reasoning)[-_]?tokens|tokens[-_]?(?:used|count|in|out|limit|remaining))$/i; +/** + * FAIL-CLOSED: se enmascara toda clave que contenga password/passwd/secret/api key/authorization/cookie/ + * credential/pass/token, salvo (a) segmentos de una lista explícita de palabras seguras con "pass" y + * (b) un patrón explícito de conteo de tokens (maxTokens, inputTokens, tokens_used…). + */ +function isSensitiveKey(k) { + if (/password|passwd|secret|api[-_]?key|authorization|cookie|credential/i.test(k)) + return true; + const norm = k.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(); + if (norm.includes("token")) + return !TOKEN_COUNT_KEY.test(norm); + if (norm.includes("pass")) { + return norm.split(/[^a-z0-9]+/).some((seg) => seg.includes("pass") && !PASS_SAFE_WORDS.has(seg)); + } + return false; +} function sanitize(fields) { + // Un fallo de observabilidad nunca debe lanzar (Proxy / getter hostil, estructuras raras). + try { + return sanitizeObject(fields, 0, new WeakSet()); + } + catch { + return { _sanitizeFailed: true }; + } +} +/** Profundidad máxima al recorrer los extras de un log (lo más hondo se reemplaza por un marcador). */ +const MAX_EXTRA_DEPTH = 4; +/** Tope de claves por objeto y de elementos por array en los extras. */ +const MAX_EXTRA_ITEMS = 100; +function sanitizeObject(obj, depth, seen) { const out = {}; - for (const [k, v] of Object.entries(fields)) { - out[k] = SENSITIVE_KEY.test(k) ? "[REDACTED]" : v; + let n = 0; + for (const k of Object.keys(obj)) { + if (n++ >= MAX_EXTRA_ITEMS) { + out["…[truncado]"] = `+${Object.keys(obj).length - MAX_EXTRA_ITEMS} claves`; + break; + } + out[k] = isSensitiveKey(k) ? "[REDACTED]" : sanitizeValue(obj[k], depth + 1, seen); } return out; } +/** Redacta strings (con tope) y recorre objetos/arrays hasta una profundidad acotada; tolera ciclos. */ +function sanitizeValue(v, depth, seen) { + if (typeof v === "string") + return redactAndCap(v, MAX_MESSAGE); + if (v === null || typeof v !== "object") + return v; + if (v instanceof Date) + return v; + if (v instanceof Error) + return serializeErr(v); + // Buffer / typed arrays / DataView: se resumen, nunca se expanden por índice. + if (ArrayBuffer.isView(v)) + return `[${v.constructor?.name ?? "Buffer"} ${v.byteLength} bytes]`; + if (v instanceof ArrayBuffer) + return `[ArrayBuffer ${v.byteLength} bytes]`; + if (depth >= MAX_EXTRA_DEPTH || seen.has(v)) + return "[truncado]"; + seen.add(v); + let res; + if (Array.isArray(v)) { + const items = v.slice(0, MAX_EXTRA_ITEMS).map((x) => sanitizeValue(x, depth + 1, seen)); + if (v.length > MAX_EXTRA_ITEMS) + items.push(`…[truncado] +${v.length - MAX_EXTRA_ITEMS} elementos`); + res = items; + } + else { + res = sanitizeObject(v, depth, seen); + } + seen.delete(v); + return res; +} /* * Secretos que viajan DENTRO de textos de error (p. ej. googleapis pone `?key=AIza…` * en la URL del mensaje; node-fetch dice `request to failed`). Se enmascara solo @@ -200,6 +269,19 @@ function serializeErr(err) { let transport = null; let buffer = []; let timer = null; +/** Envío en curso: los flush concurrentes lo esperan en vez de retornar con el buffer ya vaciado. */ +let inflight = null; +/** Tope de registros retenidos (reencolados tras un fallo o acumulados): se descartan los más viejos. */ +const MAX_BUFFER = 1000; +/** Timeout del POST al ingest: un ingest lento no puede retener la respuesta de la API. */ +const FLUSH_TIMEOUT_MS = 2000; +/** Tras un fallo, los envíos automáticos (timer / lote lleno) esperan esto; flushLogs() siempre intenta. */ +const FAIL_COOLDOWN_MS = 5000; +let lastFailAt = 0; +let lastFailed = false; +/** Fallos consecutivos del ingest: al llegar a BREAKER_FAILS, TAMBIÉN flushLogs() espera el enfriamiento. */ +let consecutiveFails = 0; +const BREAKER_FAILS = 3; /** * Bundlers como Turbopack pueden instanciar este módulo más de una vez dentro * del mismo proceso (una copia por bundle de función serverless: una para @@ -227,34 +309,75 @@ function configureTransport(cfg) { transport = cfg; if (timer) clearInterval(timer); - timer = setInterval(() => void flush(), cfg.flushMs ?? 2000); + timer = setInterval(autoFlush, cfg.flushMs ?? 2000); // No mantener vivo el proceso solo por el logger. timer.unref?.(); } -async function flush() { - const cfg = resolveTransport(); - if (!cfg || buffer.length === 0) - return; - const batch = buffer; - buffer = []; +/** Devuelve los registros de un lote fallido al frente del buffer, respetando el tope. */ +function requeue(batch) { + buffer = batch.concat(buffer); + if (buffer.length > MAX_BUFFER) + buffer = buffer.slice(buffer.length - MAX_BUFFER); +} +async function send(cfg, batch) { try { const res = await fetch(cfg.endpoint, { method: "POST", headers: { "content-type": "application/json", "x-ingest-secret": cfg.secret }, body: JSON.stringify({ logs: batch }), keepalive: true, + signal: AbortSignal.timeout(FLUSH_TIMEOUT_MS), }); // fetch() NO lanza por status HTTP no-2xx (p.ej. una redirección de Vercel // Deployment Protection hacia su login) — hay que chequearlo explícitamente // o el fallo pasa inadvertido igual que el bug de arriba. if (!res.ok) { process.stderr.write(`[platform/logger] ingest flush respondió ${res.status}: ${cfg.endpoint}\n`); + // Transitorio (5xx / 408 / 429): se reintenta. Otro 4xx (secreto malo) no mejora reintentando. + if (res.status >= 500 || res.status === 408 || res.status === 429) { + requeue(batch); + lastFailed = true; + consecutiveFails++; + lastFailAt = Date.now(); + return; + } } + lastFailed = false; + consecutiveFails = 0; } catch (err) { - // El ingest cayó: no perder del todo el dato ni romper la app. + // El ingest cayó o tardó demasiado: el lote vuelve al buffer (con tope) y la app no se rompe. process.stderr.write(`[platform/logger] ingest flush failed: ${String(err)}\n`); + requeue(batch); + lastFailed = true; + consecutiveFails++; + lastFailAt = Date.now(); + } +} +function flush() { + if (inflight) { + // Espera el envío en curso; si mientras tanto entró algo nuevo (y el envío no falló), lo manda. + return inflight.then(() => (buffer.length > 0 && !lastFailed ? flush() : undefined)); } + const cfg = resolveTransport(); + if (!cfg || buffer.length === 0) + return Promise.resolve(); + // Circuit breaker simple: con el ingest caído de forma sostenida, la ruta de request deja de pagar el + // timeout en cada respuesta; el lote sigue en el buffer (con tope) y se reintenta pasado el enfriamiento. + if (consecutiveFails >= BREAKER_FAILS && Date.now() - lastFailAt < FAIL_COOLDOWN_MS) + return Promise.resolve(); + const batch = buffer; + buffer = []; + inflight = send(cfg, batch).finally(() => { + inflight = null; + }); + return inflight; +} +/** Envío automático (timer / lote lleno): respeta el enfriamiento tras un fallo. */ +function autoFlush() { + if (lastFailed && Date.now() - lastFailAt < FAIL_COOLDOWN_MS) + return; + void flush(); } /** Fuerza el envío inmediato del buffer (útil en cron/scripts antes de salir). */ function flushLogs() { @@ -265,8 +388,10 @@ function enqueue(record) { if (!cfg) return; buffer.push(record); + if (buffer.length > MAX_BUFFER) + buffer = buffer.slice(buffer.length - MAX_BUFFER); if (buffer.length >= (cfg.batchMax ?? 50)) - void flush(); + autoFlush(); } function emit(level, component, fieldsOrMsg, msg) { // ERROR siempre se emite y persiste, aunque MIN_LEVEL sea más alto (nada de @@ -280,7 +405,12 @@ function emit(level, component, fieldsOrMsg, msg) { } else { message = msg ?? ""; - extra = { ...fieldsOrMsg }; + try { + extra = { ...fieldsOrMsg }; // el spread ejecuta getters: un getter que lanza no debe romper el log + } + catch { + extra = { _sanitizeFailed: true }; + } } const ctx = getContext(); const errField = extra.err; @@ -290,15 +420,25 @@ function emit(level, component, fieldsOrMsg, msg) { // `extra` (primer argumento de log.info/warn/error/debug). Bug real: un // caller pasando `{ service: mod.id }` como campo de negocio silenciaba el // nombre real del servicio en cada log (mission-control/app/api/handoff). + // tenant/userId/requestId: el contexto de la request manda (evita logs mal atribuidos entre + // tenants); si no hay contexto, el extra sigue valiendo como dato informativo. const safeExtra = sanitize(extra); delete safeExtra.ts; delete safeExtra.level; delete safeExtra.service; delete safeExtra.component; delete safeExtra.msg; + delete safeExtra.platformVersion; + for (const k of ["tenant", "userId", "requestId"]) { + if (ctx[k] !== undefined) + delete safeExtra[k]; + } + // El mensaje también puede traer secretos/PII (p. ej. texto de error de un navegador o de un tercero). + message = redactAndCap(message, MAX_MESSAGE); const record = { ts: new Date().toISOString(), level, + platformVersion: version_1.PLATFORM_VERSION, service: resolveServiceName(), component, ...ctx, diff --git a/packages/platform/dist/version.d.ts b/packages/platform/dist/version.d.ts new file mode 100644 index 0000000..bd3aef7 --- /dev/null +++ b/packages/platform/dist/version.d.ts @@ -0,0 +1,8 @@ +/** + * Versión de @ai4u/platform, emitida como `platformVersion` en cada línea de log (permite medir + * qué versión corre cada app en platform_logs). Se mantiene a mano junto a `version` de + * package.json; tests/platform-version.test.ts falla si se desincronizan (un import de + * package.json no compila con `rootDir: src`). + */ +export declare const PLATFORM_VERSION = "0.8.0"; +//# sourceMappingURL=version.d.ts.map \ No newline at end of file diff --git a/packages/platform/dist/version.d.ts.map b/packages/platform/dist/version.d.ts.map new file mode 100644 index 0000000..3b817d5 --- /dev/null +++ b/packages/platform/dist/version.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,UAAU,CAAA"} \ No newline at end of file diff --git a/packages/platform/dist/version.js b/packages/platform/dist/version.js new file mode 100644 index 0000000..36299a0 --- /dev/null +++ b/packages/platform/dist/version.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PLATFORM_VERSION = void 0; +/** + * Versión de @ai4u/platform, emitida como `platformVersion` en cada línea de log (permite medir + * qué versión corre cada app en platform_logs). Se mantiene a mano junto a `version` de + * package.json; tests/platform-version.test.ts falla si se desincronizan (un import de + * package.json no compila con `rootDir: src`). + */ +exports.PLATFORM_VERSION = "0.8.0"; diff --git a/packages/platform/package.json b/packages/platform/package.json index dba1b2a..12ed82a 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -1,6 +1,6 @@ { "name": "@ai4u/platform", - "version": "0.7.0", + "version": "0.8.0", "description": "Preocupaciones transversales para el ecosistema superAI: logging, errores tipados y auth (identidad + permisos)", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -58,6 +58,9 @@ "dist", "src" ], + "engines": { + "node": ">=22" + }, "scripts": { "build": "tsc", "test": "vitest run", diff --git a/packages/platform/src/auth/index.ts b/packages/platform/src/auth/index.ts index 56b0993..978eb1a 100644 --- a/packages/platform/src/auth/index.ts +++ b/packages/platform/src/auth/index.ts @@ -10,10 +10,12 @@ * esquemas actuales (x-mc-secret compartido / X-API-Key por tenant). * * La regla de visibilidad de módulos es la misma de mission-control-main/lib/access.ts: - * sin lista (null/[]) ⇒ ve todo; con lista ⇒ solo esos ids. + * sin lista (null/[]) ⇒ ve todo; con lista ⇒ solo esos ids. Existe además el modo estricto + * (`requireModule(..., { strict: true })` / `isModuleAllowedStrict`): sin lista ⇒ ninguno. */ -import { verifySession } from "@ai4u/mc-sso" +import { readMcSession } from "@ai4u/mc-sso" import { ForbiddenError, UnauthorizedError } from "../errors" +import { getLogger } from "../logger" import { safeEqual } from "../security" export interface Identity { @@ -22,24 +24,59 @@ export interface Identity { roles?: string[] allowedModules?: string[] | null displayName?: string + /** App para la que se emitió la sesión (mc-sso >= 1.3.0). undefined = sesión "legacy". */ + serviceId?: string } -/** Forma extendida de la sesión (mc-sso lleva estos campos opcionales tras el handoff). */ -interface ExtendedSession { - tenantId: string - userId?: string - roles?: string[] - allowedModules?: string[] | null - displayName?: string +/** + * Restricciones de app/tenant sobre la sesión (se pasan tal cual a `readMcSession` de mc-sso). + * El secreto de firma se comparte entre apps: sin estas opciones una cookie de otra app/tenant + * con firma válida PASA. Son opt-in; ver "Cómo adoptar el endurecimiento de auth" en CHANGELOG. + */ +export interface IdentityScope { + /** `serviceId` de ESTA app (el mismo de su `createMcAuthHandler`). Sesión de otro servicio => null. */ + serviceId?: string + /** Tenants aceptados (apps de un solo tenant). Fuera de la lista => null; `[]` rechaza todo. */ + allowedTenants?: readonly string[] + /** Normalizador de tenant para comparar `allowedTenants` (p. ej. `normalizeTenant` de @ai4u/config). */ + normalizeTenant?: (tenantId: string) => string + /** Aceptar sesiones sin `serviceId` (emitidas por mc-sso <= 1.2.0). Default de mc-sso: true. */ + acceptLegacy?: boolean } -export interface SessionAuthConfig { +export interface SessionAuthConfig extends IdentityScope { /** Nombre de la cookie de sesión (default "mc_session"). */ cookieName?: string /** Secreto de firma (default process.env.MISSION_CONTROL_SECRET). */ secret?: string } +/** + * Estado compartido por proceso en globalThis: los bundlers (Turbopack) pueden instanciar este módulo + * varias veces (una copia por bundle) y una variable de módulo haría que configureIdentity() en una + * copia no lo viera otra, ni el WARN "una vez". (process.env no sirve: normalizeTenant es una función.) + */ +interface IdentityState { + scope: IdentityScope + warned: boolean +} +const STATE_KEY = Symbol.for("@ai4u/platform/identity") +function state(): IdentityState { + const g = globalThis as unknown as Record + return (g[STATE_KEY] ??= { scope: {}, warned: false }) +} + +/** + * Fija el alcance de identidad por defecto de ESTA app (una vez, p. ej. en instrumentation.ts o + * en un módulo de config). Las opciones pasadas a `readIdentity`/`withApiHandler({sessionAuth})` + * lo pisan campo a campo. No se deriva nada de `SERVICE_ID`: puede no coincidir con el serviceId + * de su handler SSO y cortaría sesiones válidas. + */ +export function configureIdentity(scope: IdentityScope): void { + state().scope = { ...scope } +} + + type HeaderCarrier = { headers: { get(name: string): string | null } } /** @@ -73,7 +110,23 @@ export function readIdentity(req: HeaderCarrier, cfg: SessionAuthConfig = {}): I if (!secret) return null const token = parseCookies(req.headers.get("cookie"))[cookieName] if (!token) return null - const data = verifySession(token, secret) as ExtendedSession | null + const appScope = state().scope + const scope: IdentityScope = { + serviceId: cfg.serviceId ?? appScope.serviceId, + allowedTenants: cfg.allowedTenants ?? appScope.allowedTenants, + normalizeTenant: cfg.normalizeTenant ?? appScope.normalizeTenant, + acceptLegacy: cfg.acceptLegacy ?? appScope.acceptLegacy, + } + if (scope.serviceId === undefined && !state().warned) { + state().warned = true + getLogger("platform/auth").warn( + "readIdentity sin serviceId configurado: una sesión firmada para otra app con el mismo secreto sería aceptada. " + + "Pasa { serviceId } a readIdentity/sessionAuth o llama configureIdentity({ serviceId }) (platform 0.8.0).", + ) + } + // Se reenvía solo el token ya parseado (parseCookies ignora duplicados mal codificados) y mc-sso + // aplica firma, expiración y las restricciones de app/tenant. + const data = readMcSession(`${cookieName}=${encodeURIComponent(token)}`, secret, { cookieName, ...scope }) if (!data) return null return { tenantId: data.tenantId, @@ -81,6 +134,7 @@ export function readIdentity(req: HeaderCarrier, cfg: SessionAuthConfig = {}): I roles: data.roles, allowedModules: data.allowedModules ?? null, displayName: data.displayName, + serviceId: data.serviceId, } } @@ -91,6 +145,14 @@ export function isModuleAllowed(moduleId: string, allowedModules: string[] | nul return allowedModules.includes(moduleId) } +/** + * Variante ESTRICTA: sin lista (null/undefined/[]) => NINGÚN módulo. Úsala en apps que no son + * Mission Control, donde una sesión sin `allowedModules` no debería ver todo. + */ +export function isModuleAllowedStrict(moduleId: string, allowedModules: string[] | null | undefined): boolean { + return Array.isArray(allowedModules) && allowedModules.includes(moduleId) +} + export function hasRole(identity: Identity | null, role: string): boolean { return !!identity?.roles?.includes(role) } @@ -103,10 +165,15 @@ export function isAdmin(identity: Identity | null): boolean { * Exige identidad y acceso a un módulo (acepta varios: any-of). Lanza errores * tipados que el wrapper HTTP convierte en 401/403 uniformes. */ -export function requireModule(identity: Identity | null, moduleId: string | string[]): Identity { +export function requireModule( + identity: Identity | null, + moduleId: string | string[], + opts: { strict?: boolean } = {}, +): Identity { if (!identity) throw new UnauthorizedError() const ids = Array.isArray(moduleId) ? moduleId : [moduleId] - if (!ids.some((id) => isModuleAllowed(id, identity.allowedModules))) { + const allowed = opts.strict ? isModuleAllowedStrict : isModuleAllowed + if (!ids.some((id) => allowed(id, identity.allowedModules))) { throw new ForbiddenError("No autorizado para este módulo") } return identity diff --git a/packages/platform/src/client-errors/index.ts b/packages/platform/src/client-errors/index.ts index 08ab12b..5128d88 100644 --- a/packages/platform/src/client-errors/index.ts +++ b/packages/platform/src/client-errors/index.ts @@ -8,7 +8,7 @@ * ⚠ Es un endpoint SIN AUTENTICACIÓN (el navegador del visitante no tiene credenciales de servicio). * Riesgo: cualquiera puede intentar llenar platform_logs de basura. Mitigaciones incluidas: * solo application/json, tope de tamaño (413), mismo origen (Origin/Sec-Fetch-Site, 403), límite - * por IP en memoria (429), validación estricta de forma y truncado de campos, y respuesta 204 sin + * por IP en memoria (429; clave desde x-vercel-forwarded-for/x-real-ip antes que x-forwarded-for, Map con tope), validación estricta de forma y truncado de campos, y respuesta 204 sin * eco. El límite por IP es de MEJOR ESFUERZO: en serverless cada instancia tiene su propio Map * y se reinicia en cold start; no sustituye a un WAF/rate-limit de plataforma. El origen se * comprueba por cabeceras que un cliente no-navegador puede falsificar: sirve contra abuso @@ -31,6 +31,13 @@ const MAX_MESSAGE = 2000 const MAX_STACK = 4000 const WINDOW_MS = 60_000 const KINDS = ["error", "unhandledrejection", "manual"] as const +/** Tope de entradas del Map del rate limit: al llenarse se podan expirados y luego las más viejas. */ +const MAX_HITS = 5000 +/** `service`: id de app corto (sin espacios ni saltos de línea). */ +const SERVICE_RE = /^[A-Za-z0-9._-]{1,80}$/ +/** Caracteres de un pathname que el navegador produce tal cual (incluye `|`, `[`, `]`, `^`, `{`, `}`). */ +const PATH_UNSAFE_RE = /[^A-Za-z0-9._~%!$&'()*+,;=:@/|[\]^{}-]/g +const UNKNOWN_SERVICE = "unknown-client" const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|authorization|cookie|credential/i interface ValidPayload { @@ -60,15 +67,21 @@ function validate(raw: unknown): ValidPayload | null { if (!KINDS.includes(o.kind as (typeof KINDS)[number])) return null if (o.extra !== undefined && (typeof o.extra !== "object" || o.extra === null || Array.isArray(o.extra))) return null + // Solo pathname: descarta querystring/hash aunque el cliente los mande. + // Un path raro NO descarta el reporte: se neutralizan los caracteres peligrosos (o "/invalid"). + const rawPath = clean(o.path.split(/[?#]/)[0], 200) + const path = rawPath.startsWith("/") ? rawPath.replace(PATH_UNSAFE_RE, "_") : "/invalid" const out: ValidPayload = { message: clean(o.message, MAX_MESSAGE), kind: o.kind as ValidPayload["kind"], - // Solo pathname: descarta querystring/hash aunque el cliente los mande. - path: clean(o.path.split(/[?#]/)[0], 200), + path, } if (typeof o.stack === "string") out.stack = clean(o.stack, MAX_STACK) if (typeof o.name === "string") out.name = clean(o.name, 100) - if (typeof o.service === "string") out.service = clean(o.service, 80) + if (typeof o.service === "string") { + // Un service con formato raro tampoco descarta el reporte: se reemplaza por un valor por defecto. + out.service = SERVICE_RE.test(o.service) ? o.service : UNKNOWN_SERVICE + } if (o.extra) { const extra: Record = {} for (const [k, v] of Object.entries(o.extra as Record).slice(0, 10)) { @@ -100,10 +113,43 @@ function sameOrigin(req: Request): boolean { return site === "same-origin" } +/** + * IP para el rate limit. Se prefieren las cabeceras que pone la plataforma (Vercel reescribe + * `x-vercel-forwarded-for` / `x-real-ip` y el cliente no las controla) sobre `x-forwarded-for`, + * cuyo primer valor puede venir falsificado por el cliente. + */ function clientIp(req: Request): string { + const trusted = req.headers.get("x-vercel-forwarded-for") ?? req.headers.get("x-real-ip") + const first = trusted?.split(",")[0].trim() + if (first) return first const xff = req.headers.get("x-forwarded-for") if (xff) return xff.split(",")[0].trim() || "unknown" - return req.headers.get("x-real-ip") ?? "unknown" + return "unknown" +} + +/** Lee el cuerpo como stream y corta apenas supera `maxBytes` (null = excedido). Nunca carga todo en memoria. */ +async function readBodyCapped(req: Request, maxBytes: number): Promise { + if (!req.body) return "" + const reader = req.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => undefined) + return null + } + chunks.push(value) + } + const all = new Uint8Array(total) + let off = 0 + for (const c of chunks) { + all.set(c, off) + off += c.byteLength + } + return new TextDecoder().decode(all) } const empty = (status: number) => new Response(null, { status }) @@ -115,7 +161,14 @@ export function createClientErrorHandler(options: ClientErrorHandlerOptions = {} function limited(ip: string): boolean { const now = Date.now() - if (hits.size > 1000) for (const [k, v] of hits) if (v.reset <= now) hits.delete(k) + if (hits.size >= MAX_HITS) { + for (const [k, v] of hits) if (v.reset <= now) hits.delete(k) + // Aún lleno (muchas IPs en 1 min): se descartan las más viejas (Map conserva el orden de inserción). + for (const k of hits.keys()) { + if (hits.size < MAX_HITS) break + hits.delete(k) + } + } const h = hits.get(ip) if (!h || h.reset <= now) { hits.set(ip, { count: 1, reset: now + WINDOW_MS }) @@ -134,13 +187,13 @@ export function createClientErrorHandler(options: ClientErrorHandlerOptions = {} const declared = Number(req.headers.get("content-length")) if (Number.isFinite(declared) && declared > maxBytes) return empty(413) - let text: string + let text: string | null try { - text = await req.text() + text = await readBodyCapped(req, maxBytes) } catch { return empty(400) } - if (new TextEncoder().encode(text).length > maxBytes) return empty(413) + if (text === null) return empty(413) let payload: ValidPayload | null try { diff --git a/packages/platform/src/errors/index.ts b/packages/platform/src/errors/index.ts index 91ffbde..878518d 100644 --- a/packages/platform/src/errors/index.ts +++ b/packages/platform/src/errors/index.ts @@ -129,6 +129,9 @@ export function isAppError(err: unknown): err is AppError { /** * Convierte cualquier `unknown` en un AppError. Lo que no reconozca se trata como * fallo de infraestructura inesperado (500), nunca se ignora. + * + * El `message` del AppError devuelto conserva el texto original SOLO para logging; lo que se + * envía al cliente lo decide `toErrorResponse` (que lo reemplaza por "Error interno"). */ export function classifyUnknownError(err: unknown): AppError { if (isAppError(err)) return err @@ -149,20 +152,31 @@ export interface ErrorResponseBody { details?: unknown } -/** Cuerpo JSON uniforme y seguro para responder al cliente. */ +/** Mensaje genérico que ve el cliente cuando el error no es operacional y es 5xx. */ +export const GENERIC_ERROR_MESSAGE = "Error interno" + +/** + * Cuerpo JSON uniforme y seguro para responder al cliente. + * + * Desde 0.8.0: un error NO operacional con status >= 500 (excepciones desconocidas, fallos de + * infraestructura) responde `error: "Error interno"` en vez de su mensaje: ese texto suele traer + * internals (hosts, SQL, URLs con claves). El mensaje real queda en el log (redactado) y el + * cliente correlaciona por `requestId`; tampoco se envía `details`. Los operacionales y los 4xx conservan mensaje y details. + */ export function toErrorResponse( err: unknown, requestId?: string, ): { status: number; body: ErrorResponseBody } { const appErr = classifyUnknownError(err) + const masked = !appErr.isOperational && appErr.httpStatus >= 500 return { status: appErr.httpStatus, body: { - error: appErr.message, + error: masked ? GENERIC_ERROR_MESSAGE : appErr.message, code: appErr.code, category: appErr.category, ...(requestId ? { requestId } : {}), - ...(appErr.details !== undefined ? { details: appErr.details } : {}), + ...(!masked && appErr.details !== undefined ? { details: appErr.details } : {}), }, } } diff --git a/packages/platform/src/http/index.ts b/packages/platform/src/http/index.ts index 37bf1d2..deff04d 100644 --- a/packages/platform/src/http/index.ts +++ b/packages/platform/src/http/index.ts @@ -71,6 +71,11 @@ export interface WithApiHandlerOptions { label?: string /** Exige sesión + acceso a este módulo (any-of si es array). */ requireModule?: string | string[] + /** + * Con `requireModule`: modo estricto, una sesión sin `allowedModules` (null/[]) NO pasa. + * Default false (compatibilidad: null/[] = acceso a todo). + */ + strictModules?: boolean /** Exige sesión + este rol (any-of si es array). */ requireRole?: string | string[] /** Exige auth de servicio (x-mc-secret / X-API-Key). */ @@ -90,13 +95,19 @@ function jsonResponse(body: unknown, status: number, requestId: string): Respons }) } +/** x-request-id entrante: solo charset/longitud acotados; si no, se regenera (evita inflado e inyección en logs). */ +const REQUEST_ID_RE = /^[A-Za-z0-9._-]{1,128}$/ +function safeIncomingRequestId(v: string | null): string | null { + return v !== null && REQUEST_ID_RE.test(v) ? v : null +} + export function withApiHandler( handler: RouteHandler, options: WithApiHandlerOptions = {}, ): (req: Request, routeCtx?: R) => Promise { const label = options.label ?? "api" return async (req: Request, routeCtx?: R): Promise => { - const requestId = req.headers.get("x-request-id") ?? newRequestId() + const requestId = safeIncomingRequestId(req.headers.get("x-request-id")) ?? newRequestId() const log = getLogger(label) return runWithContext({ requestId }, async () => { @@ -122,7 +133,7 @@ export function withApiHandler( if (options.requireModule || options.requireRole) { identity = readIdentity(req, options.sessionAuth) if (identity) setContextFields({ tenant: identity.tenantId, userId: identity.userId }) - if (options.requireModule) requireModule(identity, options.requireModule) + if (options.requireModule) requireModule(identity, options.requireModule, { strict: options.strictModules }) if (options.requireRole) requireRole(identity, options.requireRole) } else { // Lee identidad si está disponible, pero no la exige. @@ -157,8 +168,18 @@ export function withApiHandler( } catch (err) { const durationMs = Date.now() - start const appErr = classifyUnknownError(err) - // Log con detalle completo (stack incluido); la respuesta al cliente es segura. - log.error({ durationMs, status: appErr.httpStatus, code: appErr.code, err: appErr.cause ?? appErr }, `${label} error`) + // 4xx lanzado y operacional (Unauthorized/NotFound/Validation…) es tráfico normal: WARN sin stack, + // igual que un 4xx retornado como Response. Va a ERROR el 5xx y todo AppError marcado + // isOperational:false (bug inesperado, ver errors/index.ts): lo que consumen paneles y el fixer. + if (appErr.httpStatus < 500 && appErr.isOperational) { + log.warn( + { durationMs, status: appErr.httpStatus, code: appErr.code }, + `${label} ${appErr.name}: ${appErr.message}`, + ) + } else { + // Log con detalle completo (stack incluido); la respuesta al cliente es segura. + log.error({ durationMs, status: appErr.httpStatus, code: appErr.code, err: appErr.cause ?? appErr }, `${label} error`) + } const { status, body } = toErrorResponse(appErr, requestId) return jsonResponse(body, status, requestId) } diff --git a/packages/platform/src/logger/index.ts b/packages/platform/src/logger/index.ts index adf5f73..e018ce9 100644 --- a/packages/platform/src/logger/index.ts +++ b/packages/platform/src/logger/index.ts @@ -14,6 +14,7 @@ */ import { AsyncLocalStorage } from "node:async_hooks" import { randomUUID } from "node:crypto" +import { PLATFORM_VERSION } from "../version" export type Level = "DEBUG" | "INFO" | "WARN" | "ERROR" @@ -70,16 +71,77 @@ function resolveServiceName(): string { /* ── Redacción de secretos (red de seguridad de sap-b1-backend) ──────────── */ -const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|authorization|cookie|credential/i +/** Palabras que contienen "pass" pero no son credenciales (comparación por segmento completo). */ +const PASS_SAFE_WORDS = new Set(["bypass", "compass", "passenger", "passive", "passthrough", "passed", "passing"]) +/** Métricas de conteo de tokens de LLM (únicas claves con "token" que no se enmascaran). */ +const TOKEN_COUNT_KEY = + /^(?:(?:max|min|input|output|prompt|completion|total|cached|reasoning)[-_]?tokens|tokens[-_]?(?:used|count|in|out|limit|remaining))$/i + +/** + * FAIL-CLOSED: se enmascara toda clave que contenga password/passwd/secret/api key/authorization/cookie/ + * credential/pass/token, salvo (a) segmentos de una lista explícita de palabras seguras con "pass" y + * (b) un patrón explícito de conteo de tokens (maxTokens, inputTokens, tokens_used…). + */ +function isSensitiveKey(k: string): boolean { + if (/password|passwd|secret|api[-_]?key|authorization|cookie|credential/i.test(k)) return true + const norm = k.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + if (norm.includes("token")) return !TOKEN_COUNT_KEY.test(norm) + if (norm.includes("pass")) { + return norm.split(/[^a-z0-9]+/).some((seg) => seg.includes("pass") && !PASS_SAFE_WORDS.has(seg)) + } + return false +} function sanitize(fields: Record): Record { + // Un fallo de observabilidad nunca debe lanzar (Proxy / getter hostil, estructuras raras). + try { + return sanitizeObject(fields, 0, new WeakSet()) + } catch { + return { _sanitizeFailed: true } + } +} + +/** Profundidad máxima al recorrer los extras de un log (lo más hondo se reemplaza por un marcador). */ +const MAX_EXTRA_DEPTH = 4 +/** Tope de claves por objeto y de elementos por array en los extras. */ +const MAX_EXTRA_ITEMS = 100 + +function sanitizeObject(obj: object, depth: number, seen: WeakSet): Record { const out: Record = {} - for (const [k, v] of Object.entries(fields)) { - out[k] = SENSITIVE_KEY.test(k) ? "[REDACTED]" : v + let n = 0 + for (const k of Object.keys(obj)) { + if (n++ >= MAX_EXTRA_ITEMS) { + out["…[truncado]"] = `+${Object.keys(obj).length - MAX_EXTRA_ITEMS} claves` + break + } + out[k] = isSensitiveKey(k) ? "[REDACTED]" : sanitizeValue((obj as Record)[k], depth + 1, seen) } return out } +/** Redacta strings (con tope) y recorre objetos/arrays hasta una profundidad acotada; tolera ciclos. */ +function sanitizeValue(v: unknown, depth: number, seen: WeakSet): unknown { + if (typeof v === "string") return redactAndCap(v, MAX_MESSAGE) + if (v === null || typeof v !== "object") return v + if (v instanceof Date) return v + if (v instanceof Error) return serializeErr(v) + // Buffer / typed arrays / DataView: se resumen, nunca se expanden por índice. + if (ArrayBuffer.isView(v)) return `[${v.constructor?.name ?? "Buffer"} ${v.byteLength} bytes]` + if (v instanceof ArrayBuffer) return `[ArrayBuffer ${v.byteLength} bytes]` + if (depth >= MAX_EXTRA_DEPTH || seen.has(v)) return "[truncado]" + seen.add(v) + let res: unknown + if (Array.isArray(v)) { + const items = v.slice(0, MAX_EXTRA_ITEMS).map((x) => sanitizeValue(x, depth + 1, seen)) + if (v.length > MAX_EXTRA_ITEMS) items.push(`…[truncado] +${v.length - MAX_EXTRA_ITEMS} elementos`) + res = items + } else { + res = sanitizeObject(v, depth, seen) + } + seen.delete(v) + return res +} + /* * Secretos que viajan DENTRO de textos de error (p. ej. googleapis pone `?key=AIza…` * en la URL del mensaje; node-fetch dice `request to failed`). Se enmascara solo @@ -229,6 +291,19 @@ export interface TransportConfig { let transport: TransportConfig | null = null let buffer: Record[] = [] let timer: ReturnType | null = null +/** Envío en curso: los flush concurrentes lo esperan en vez de retornar con el buffer ya vaciado. */ +let inflight: Promise | null = null +/** Tope de registros retenidos (reencolados tras un fallo o acumulados): se descartan los más viejos. */ +const MAX_BUFFER = 1000 +/** Timeout del POST al ingest: un ingest lento no puede retener la respuesta de la API. */ +const FLUSH_TIMEOUT_MS = 2000 +/** Tras un fallo, los envíos automáticos (timer / lote lleno) esperan esto; flushLogs() siempre intenta. */ +const FAIL_COOLDOWN_MS = 5000 +let lastFailAt = 0 +let lastFailed = false +/** Fallos consecutivos del ingest: al llegar a BREAKER_FAILS, TAMBIÉN flushLogs() espera el enfriamiento. */ +let consecutiveFails = 0 +const BREAKER_FAILS = 3 /** * Bundlers como Turbopack pueden instanciar este módulo más de una vez dentro @@ -256,35 +331,76 @@ function resolveTransport(): TransportConfig | null { export function configureTransport(cfg: TransportConfig): void { transport = cfg if (timer) clearInterval(timer) - timer = setInterval(() => void flush(), cfg.flushMs ?? 2000) + timer = setInterval(autoFlush, cfg.flushMs ?? 2000) // No mantener vivo el proceso solo por el logger. timer.unref?.() } -async function flush(): Promise { - const cfg = resolveTransport() - if (!cfg || buffer.length === 0) return - const batch = buffer - buffer = [] +/** Devuelve los registros de un lote fallido al frente del buffer, respetando el tope. */ +function requeue(batch: Record[]): void { + buffer = batch.concat(buffer) + if (buffer.length > MAX_BUFFER) buffer = buffer.slice(buffer.length - MAX_BUFFER) +} + +async function send(cfg: TransportConfig, batch: Record[]): Promise { try { const res = await fetch(cfg.endpoint, { method: "POST", headers: { "content-type": "application/json", "x-ingest-secret": cfg.secret }, body: JSON.stringify({ logs: batch }), keepalive: true, + signal: AbortSignal.timeout(FLUSH_TIMEOUT_MS), }) // fetch() NO lanza por status HTTP no-2xx (p.ej. una redirección de Vercel // Deployment Protection hacia su login) — hay que chequearlo explícitamente // o el fallo pasa inadvertido igual que el bug de arriba. if (!res.ok) { process.stderr.write(`[platform/logger] ingest flush respondió ${res.status}: ${cfg.endpoint}\n`) + // Transitorio (5xx / 408 / 429): se reintenta. Otro 4xx (secreto malo) no mejora reintentando. + if (res.status >= 500 || res.status === 408 || res.status === 429) { + requeue(batch) + lastFailed = true + consecutiveFails++ + lastFailAt = Date.now() + return + } } + lastFailed = false + consecutiveFails = 0 } catch (err) { - // El ingest cayó: no perder del todo el dato ni romper la app. + // El ingest cayó o tardó demasiado: el lote vuelve al buffer (con tope) y la app no se rompe. process.stderr.write(`[platform/logger] ingest flush failed: ${String(err)}\n`) + requeue(batch) + lastFailed = true + consecutiveFails++ + lastFailAt = Date.now() } } +function flush(): Promise { + if (inflight) { + // Espera el envío en curso; si mientras tanto entró algo nuevo (y el envío no falló), lo manda. + return inflight.then(() => (buffer.length > 0 && !lastFailed ? flush() : undefined)) + } + const cfg = resolveTransport() + if (!cfg || buffer.length === 0) return Promise.resolve() + // Circuit breaker simple: con el ingest caído de forma sostenida, la ruta de request deja de pagar el + // timeout en cada respuesta; el lote sigue en el buffer (con tope) y se reintenta pasado el enfriamiento. + if (consecutiveFails >= BREAKER_FAILS && Date.now() - lastFailAt < FAIL_COOLDOWN_MS) return Promise.resolve() + const batch = buffer + buffer = [] + inflight = send(cfg, batch).finally(() => { + inflight = null + }) + return inflight +} + +/** Envío automático (timer / lote lleno): respeta el enfriamiento tras un fallo. */ +function autoFlush(): void { + if (lastFailed && Date.now() - lastFailAt < FAIL_COOLDOWN_MS) return + void flush() +} + /** Fuerza el envío inmediato del buffer (útil en cron/scripts antes de salir). */ export function flushLogs(): Promise { return flush() @@ -294,7 +410,8 @@ function enqueue(record: Record): void { const cfg = resolveTransport() if (!cfg) return buffer.push(record) - if (buffer.length >= (cfg.batchMax ?? 50)) void flush() + if (buffer.length > MAX_BUFFER) buffer = buffer.slice(buffer.length - MAX_BUFFER) + if (buffer.length >= (cfg.batchMax ?? 50)) autoFlush() } /* ── Logger ──────────────────────────────────────────────────────────────── */ @@ -315,7 +432,11 @@ function emit(level: Level, component: string, fieldsOrMsg: Record = { ts: new Date().toISOString(), level, + platformVersion: PLATFORM_VERSION, service: resolveServiceName(), component, ...ctx, diff --git a/packages/platform/src/version.ts b/packages/platform/src/version.ts new file mode 100644 index 0000000..8267410 --- /dev/null +++ b/packages/platform/src/version.ts @@ -0,0 +1,7 @@ +/** + * Versión de @ai4u/platform, emitida como `platformVersion` en cada línea de log (permite medir + * qué versión corre cada app en platform_logs). Se mantiene a mano junto a `version` de + * package.json; tests/platform-version.test.ts falla si se desincronizan (un import de + * package.json no compila con `rootDir: src`). + */ +export const PLATFORM_VERSION = "0.8.0" diff --git a/packages/platform/tests/auth-multicopy.test.ts b/packages/platform/tests/auth-multicopy.test.ts new file mode 100644 index 0000000..bf1490b --- /dev/null +++ b/packages/platform/tests/auth-multicopy.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { createSession } from "@ai4u/mc-sso" + +const SECRET = "test-secret-multicopy-0123456789abcdef" +const tok = (tenant: string, svc: string) => createSession(tenant, SECRET, 60_000, { serviceId: svc }) +const req = (t: string) => new Request("https://x/a", { headers: { cookie: `mc_session=${t}` } }) + +afterEach(() => { + vi.restoreAllMocks() + delete (globalThis as Record)[Symbol.for("@ai4u/platform/identity")] +}) + +describe("A1 estado de identidad compartido entre copias del módulo (Turbopack)", () => { + it("configureIdentity en una copia lo ve la otra", async () => { + vi.resetModules() + const copyA = await import("../src/auth") + vi.resetModules() + const copyB = await import("../src/auth") + expect(copyA).not.toBe(copyB) + copyA.configureIdentity({ serviceId: "kpis", allowedTenants: ["flexoimpresos"] }) + expect(copyB.readIdentity(req(tok("flexoimpresos", "otra")), { secret: SECRET })).toBeNull() + expect(copyB.readIdentity(req(tok("flexoimpresos", "kpis")), { secret: SECRET })?.serviceId).toBe("kpis") + }) + + it("el WARN sale una sola vez por proceso aunque haya dos copias", async () => { + const warn = vi.fn() + vi.resetModules() + const lg1 = await import("../src/logger") + vi.spyOn(lg1, "getLogger").mockReturnValue({ warn } as never) + const copyA = await import("../src/auth") + vi.resetModules() + const lg2 = await import("../src/logger") + vi.spyOn(lg2, "getLogger").mockReturnValue({ warn } as never) + const copyB = await import("../src/auth") + copyA.readIdentity(req(tok("t", "s")), { secret: SECRET }) + copyB.readIdentity(req(tok("t", "s")), { secret: SECRET }) + expect(warn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/platform/tests/auth-scope.test.ts b/packages/platform/tests/auth-scope.test.ts new file mode 100644 index 0000000..3f43fb2 --- /dev/null +++ b/packages/platform/tests/auth-scope.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { createSession } from "@ai4u/mc-sso" + +const SECRET = "test-secret-auth-scope-0123456789abcdef" + +function reqWith(token: string | null): Request { + return new Request("https://x/api/test", { headers: token ? { cookie: `mc_session=${token}` } : {} }) +} +const session = (tenant: string, serviceId?: string, modules?: string[] | null) => + createSession(tenant, SECRET, 60_000, { userId: "u1", roles: ["admin"], allowedModules: modules, serviceId }) + +// Cada test carga un módulo fresco: el WARN "una vez por proceso" y configureIdentity son estado de módulo. +async function load() { + vi.resetModules() + delete (globalThis as Record)[Symbol.for("@ai4u/platform/identity")] + const logger = await import("../src/logger") + const warn = vi.fn() + vi.spyOn(logger, "getLogger").mockReturnValue({ warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() } as never) + const auth = await import("../src/auth") + return { auth, warn } +} + +beforeEach(() => { + delete process.env.SERVICE_ID +}) +afterEach(() => vi.restoreAllMocks()) + +describe("P2 readIdentity con serviceId / allowedTenants / acceptLegacy", () => { + it("expone serviceId de la sesión en Identity", async () => { + const { auth } = await load() + const id = auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + expect(id?.serviceId).toBe("kpis") + }) + + it("serviceId distinto al de la sesión => null (cookie de otra app)", async () => { + const { auth } = await load() + const t = session("flexoimpresos", "otra-app") + expect(auth.readIdentity(reqWith(t), { secret: SECRET, serviceId: "kpis" })).toBeNull() + expect(auth.readIdentity(reqWith(t), { secret: SECRET, serviceId: "otra-app" })?.tenantId).toBe("flexoimpresos") + }) + + it("allowedTenants rechaza otro tenant y [] es fail-closed", async () => { + const { auth } = await load() + const t = session("tamaprint", "kpis") + expect(auth.readIdentity(reqWith(t), { secret: SECRET, allowedTenants: ["flexoimpresos"] })).toBeNull() + expect(auth.readIdentity(reqWith(t), { secret: SECRET, allowedTenants: [] })).toBeNull() + expect(auth.readIdentity(reqWith(t), { secret: SECRET, allowedTenants: ["Tamaprint"] })?.tenantId).toBe("tamaprint") + }) + + it("acceptLegacy:false rechaza sesiones sin serviceId; por defecto se aceptan", async () => { + const { auth } = await load() + const legacy = session("flexoimpresos") + expect(auth.readIdentity(reqWith(legacy), { secret: SECRET })?.serviceId).toBeUndefined() + expect(auth.readIdentity(reqWith(legacy), { secret: SECRET, acceptLegacy: false })).toBeNull() + }) + + it("SIN opciones el comportamiento no cambia, aunque SERVICE_ID del entorno no coincida", async () => { + process.env.SERVICE_ID = "no-coincide-con-el-handler-sso" + const { auth } = await load() + const id = auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + expect(id?.tenantId).toBe("flexoimpresos") + }) + + it("configureIdentity fija el default de la app; las opciones por llamada lo pisan", async () => { + const { auth } = await load() + auth.configureIdentity({ serviceId: "kpis", allowedTenants: ["flexoimpresos"] }) + expect(auth.readIdentity(reqWith(session("flexoimpresos", "otra")), { secret: SECRET })).toBeNull() + expect(auth.readIdentity(reqWith(session("tamaprint", "kpis")), { secret: SECRET })).toBeNull() + expect(auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET })?.serviceId).toBe("kpis") + expect( + auth.readIdentity(reqWith(session("flexoimpresos", "otra")), { secret: SECRET, serviceId: "otra" }), + ).not.toBeNull() + }) + + it("emite UN solo WARN por proceso cuando no hay serviceId configurado", async () => { + const { auth, warn } = await load() + auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + expect(warn).toHaveBeenCalledTimes(1) + }) + + it("no emite WARN si serviceId está configurado (por opción o por configureIdentity)", async () => { + const a = await load() + a.auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET, serviceId: "kpis" }) + expect(a.warn).not.toHaveBeenCalled() + const b = await load() + b.auth.configureIdentity({ serviceId: "kpis" }) + b.auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + expect(b.warn).not.toHaveBeenCalled() + }) +}) + +describe("P2 modo estricto de módulos (null/[] = ninguno)", () => { + it("isModuleAllowed conserva la semántica actual y isModuleAllowedStrict la invierte", async () => { + const { auth } = await load() + expect(auth.isModuleAllowed("kpis", null)).toBe(true) + expect(auth.isModuleAllowed("kpis", [])).toBe(true) + expect(auth.isModuleAllowedStrict("kpis", null)).toBe(false) + expect(auth.isModuleAllowedStrict("kpis", undefined)).toBe(false) + expect(auth.isModuleAllowedStrict("kpis", [])).toBe(false) + expect(auth.isModuleAllowedStrict("kpis", ["kpis"])).toBe(true) + expect(auth.isModuleAllowedStrict("kpis", ["otro"])).toBe(false) + }) + + it("requireModule({strict:true}) lanza 403 con allowedModules null; sin strict pasa", async () => { + const { auth } = await load() + const id = { tenantId: "t", allowedModules: null } + expect(auth.requireModule(id, "kpis")).toBe(id) + expect(() => auth.requireModule(id, "kpis", { strict: true })).toThrow(/módulo/) + expect(auth.requireModule({ tenantId: "t", allowedModules: ["kpis"] }, ["x", "kpis"], { strict: true })).toBeTruthy() + }) +}) + +describe("P2 withApiHandler({ strictModules })", () => { + it("con strictModules una sesión sin allowedModules => 403; sin la opción pasa (compat)", async () => { + vi.resetModules() + delete (globalThis as Record)[Symbol.for("@ai4u/platform/identity")] + const { withApiHandler } = await import("../src/http") + const req = () => reqWith(session("flexoimpresos", "kpis", null)) + const strict = withApiHandler(async () => ({ ok: true }), { + requireModule: "kpis", + strictModules: true, + sessionAuth: { secret: SECRET, serviceId: "kpis" }, + }) + expect((await strict(req())).status).toBe(403) + const lax = withApiHandler(async () => ({ ok: true }), { + requireModule: "kpis", + sessionAuth: { secret: SECRET, serviceId: "kpis" }, + }) + expect((await lax(req())).status).toBe(200) + }) +}) diff --git a/packages/platform/tests/client-errors-hardening.test.ts b/packages/platform/tests/client-errors-hardening.test.ts new file mode 100644 index 0000000..a569606 --- /dev/null +++ b/packages/platform/tests/client-errors-hardening.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { createClientErrorHandler } from "../src/client-errors" +import * as logger from "../src/logger" + +afterEach(() => vi.restoreAllMocks()) + +const good = { message: "boom", kind: "error", path: "/ventas?x=1", stack: "Error: boom\n at a" } + +function req(body: unknown, headers: Record = {}): Request { + return new Request("https://app.example.com/api/client-error", { + method: "POST", + headers: { "content-type": "application/json", origin: "https://app.example.com", "sec-fetch-site": "same-origin", ...headers }, + body: typeof body === "string" ? body : JSON.stringify(body), + }) +} +function spies() { + const error = vi.fn() + vi.spyOn(logger, "getLogger").mockReturnValue({ error, warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never) + vi.spyOn(logger, "flushLogs").mockResolvedValue() + return { error } +} + +describe("P5 tope de bytes leyendo el stream", () => { + it("sin content-length, corta la lectura al pasar el tope (no lee todo el cuerpo)", async () => { + spies() + let pulled = 0 + const chunk = new Uint8Array(1024 * 1024).fill(97) + const body = new ReadableStream({ + pull(c) { + pulled++ + if (pulled > 50) return c.close() + c.enqueue(chunk) + }, + }) + const r = new Request("https://app.example.com/api/client-error", { + method: "POST", + headers: { "content-type": "application/json", origin: "https://app.example.com", "sec-fetch-site": "same-origin" }, + body, + duplex: "half", + } as RequestInit) + const res = await createClientErrorHandler()(r) + expect(res.status).toBe(413) + expect(pulled).toBeLessThan(10) + }) + + it("un cuerpo chico sin content-length sigue funcionando (204)", async () => { + const { error } = spies() + const r = new Request("https://app.example.com/api/client-error", { + method: "POST", + headers: { "content-type": "application/json", origin: "https://app.example.com", "sec-fetch-site": "same-origin" }, + body: new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode(JSON.stringify(good))) + c.close() + }, + }), + duplex: "half", + } as RequestInit) + expect((await createClientErrorHandler()(r)).status).toBe(204) + expect(error).toHaveBeenCalledTimes(1) + }) +}) + +describe("P5 clave del rate limit", () => { + it("prefiere x-vercel-forwarded-for sobre x-forwarded-for (que el cliente puede falsificar)", async () => { + spies() + const h = createClientErrorHandler({ maxPerMinute: 2 }) + const statuses: number[] = [] + for (let i = 0; i < 4; i++) { + statuses.push((await h(req(good, { "x-vercel-forwarded-for": "9.9.9.9", "x-forwarded-for": `1.1.1.${i}` }))).status) + } + expect(statuses).toEqual([204, 204, 429, 429]) + }) + + it("sin cabecera de Vercel usa x-real-ip antes que x-forwarded-for", async () => { + spies() + const h = createClientErrorHandler({ maxPerMinute: 1 }) + expect((await h(req(good, { "x-real-ip": "8.8.8.8", "x-forwarded-for": "1.1.1.1" }))).status).toBe(204) + expect((await h(req(good, { "x-real-ip": "8.8.8.8", "x-forwarded-for": "2.2.2.2" }))).status).toBe(429) + }) +}) + +describe("P5 Map del rate limit con tope", () => { + it("con muchas IPs distintas se poda (las más viejas salen) en vez de crecer sin límite", async () => { + spies() + const h = createClientErrorHandler({ maxPerMinute: 1 }) + const first = { "x-vercel-forwarded-for": "10.0.0.1" } + expect((await h(req(good, first))).status).toBe(204) + expect((await h(req(good, first))).status).toBe(429) + for (let i = 0; i < 6000; i++) await h(req(good, { "x-vercel-forwarded-for": `20.${i >> 8}.${i & 255}.1` })) + // La entrada más vieja fue podada por el tope: ya no está limitada. + expect((await h(req(good, first))).status).toBe(204) + }, 30_000) +}) + +describe("P5/A2/M2 service y path inválidos se neutralizan, el reporte NO se pierde", () => { + async function logged(patch: Record) { + const { error } = spies() + const res = await createClientErrorHandler()(req({ ...good, ...patch })) + return { status: res.status, fields: error.mock.calls[0]?.[0] as Record | undefined } + } + + it.each([ + ["service con salto de línea", "evil\nservice"], + ["service con espacios", "Mi App"], + ["service con @ y /", "@ai4u/x"], + ["service muy largo", "a".repeat(81)], + ])("%s => 204, se registra con service 'unknown-client'", async (_n, service) => { + const { status, fields } = await logged({ service }) + expect(status).toBe(204) + expect(fields?.clientService).toBe("unknown-client") + }) + + it.each(["orderloader-flexoimpresos", "lamagdalena-web", "multihealth-chat", "mi-app.v2_x"])( + "service legítimo %s se conserva", + async (service) => { + expect((await logged({ service })).fields?.clientService).toBe(service) + }, + ) + + it.each([ + ["/a|b", "/a|b"], + ["/x[1]", "/x[1]"], + ["/a^b", "/a^b"], + ["/ventas/2026-10/%C3%B1?x=1#h", "/ventas/2026-10/%C3%B1"], + ])("path del navegador %s se registra tal cual (sin 400)", async (path, expected) => { + const { status, fields } = await logged({ path }) + expect(status).toBe(204) + expect(fields?.path).toBe(expected) + }) + + it("path sin slash inicial => /invalid; caracteres de markup/espacios/control => neutralizados", async () => { + expect((await logged({ path: "ventas" })).fields?.path).toBe("/invalid") + expect((await logged({ path: "/