Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4b0f133
feat(platform): P1 un 500 no operacional ya no devuelve err.message a…
donchelo Oct 30, 2026
ebfc292
feat(platform): P2 readIdentity con serviceId/allowedTenants/acceptLe…
donchelo Oct 30, 2026
14dea1b
feat(platform): P3 logger endurecido (redacción de msg/extras, claves…
donchelo Oct 30, 2026
8c49885
feat(platform): P4 un 4xx lanzado se loguea como warn, igual que el 4…
donchelo Oct 30, 2026
aa3574a
feat(platform): P5 client-errors con tope de bytes en streaming, rate…
donchelo Oct 30, 2026
d084926
feat(platform): P6 higiene — engines node>=22, platformVersion en cad…
donchelo Oct 30, 2026
6e01ac7
docs(platform): CHANGELOG 0.8.0, README y CLAUDE.md; sincroniza packa…
donchelo Oct 30, 2026
2837c87
test(mc-sso): el test de interop con platform espera serviceId en Ide…
donchelo Oct 30, 2026
7501b73
fix(platform): A1 el estado de identidad (configureIdentity y WARN ún…
donchelo Oct 30, 2026
635e5c7
fix(platform): A2/M2 client-errors neutraliza path y service raros en…
donchelo Oct 30, 2026
ee7cf9d
fix(platform): M1/B5 toErrorResponse no devuelve details en errores n…
donchelo Oct 30, 2026
6d96b9a
fix(platform): M5 un 4xx lanzado baja a warn solo si es operacional
donchelo Oct 30, 2026
ed8e51d
fix(platform): M3/M4/B1/M6 logger — buffers y colecciones acotados, s…
donchelo Oct 30, 2026
d487cf7
docs(platform): CHANGELOG/README — niveles 401/403, details, engines …
donchelo Oct 30, 2026
f1d6652
fix(platform): B1 redacción de claves fail-closed (pass* y *token*)
donchelo Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/mc-sso/tests/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
87 changes: 87 additions & 0 deletions packages/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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" | <code>, "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: "<ese id>" })` (o pasa `sessionAuth:
{ serviceId }` a `withApiHandler`). Apps de un solo tenant: añade `allowedTenants: ["<tenant>"]`
(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
Expand Down
7 changes: 5 additions & 2 deletions packages/platform/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions packages/platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
35 changes: 33 additions & 2 deletions packages/platform/dist/auth/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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). */
Expand Down
2 changes: 1 addition & 1 deletion packages/platform/dist/auth/index.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading