Preocupaciones transversales del ecosistema superAI: logging, errores tipados y auth (identidad + permisos). Un único paquete que toda app consume igual, para que estas funcionalidades NO se copien-peguen en cada módulo.
Distribución: igual que @ai4u/mc-sso y @ai4u/design-system. El código fuente vive en el
monorepo ai4u-com-co/kernel (packages/platform/,
con dist/ commiteado). Cada tag platform-vX.Y.Z de kernel se publica, vía
.github/workflows/mirror.yml, como commit + tag vX.Y.Z en el espejo de solo lectura
ai4u-com-co/platform, que es de donde instalan los consumidores:
"@ai4u/platform": "github:ai4u-com-co/platform#vX.Y.Z".
npm install github:ai4u-com-co/platform#vX.Y.Z # siempre pineado por tag, nunca una ramaDepende de @ai4u/mc-sso (para verificar sesiones SSO).
// Logger central (niveles, JSON|text, redacción de secretos, contexto por request)
import { getLogger } from "@ai4u/platform/logger"
const log = getLogger("mi-componente")
log.info({ requestId, tenant }, "algo pasó")
// `log.error({ err }, ...)`: serializeErr enmascara claves y tokens en message y stack
// (?key=, ?token=, Bearer/Basic, JWT, sk-…/AIza…/ghp_…, user:clave@host). Mantiene el resto
// de la URL. Los correos se enmascaran a `[correo]@dominio` (PII, no un secreto; fail-closed, incluye `@a@b.com` y correos pegados a rutas; las rutas npm `@scope/pkg` se respetan; la ruta previa al correo solo se conserva si la racha empieza por `/`, `./`, `../`, `~/`, `esquema://` o contiene `node_modules/`). La query de las URLs se enmascara entera (`?[redacted]`) y los parámetros con nombre terminado en token/key/secret/sig/password/pass/auth/credential/session también. los tokens se enmascaran completos (sin límite por token); tras redactar, message se recorta a 16 384 caracteres y stack a 32 768 (…[truncado]). También exportada como `redactSecrets(text)` por si necesitas sanear a mano.
// Errores tipados y categorizados (validación / negocio / infraestructura)
import { ValidationError, NotFoundError } from "@ai4u/platform/errors"
throw new ValidationError("falta el nombre")
// Wrapper de rutas: x-request-id + logging + captura/clasificación + JSON uniforme
import { withApiHandler } from "@ai4u/platform/http"
export const POST = withApiHandler(async (req, ctx) => {
// ctx.requestId, ctx.log, ctx.identity
return { ok: true } // → 200 { ok:true } con x-request-id
}, { label: "POST cosa", requireModule: "kpis" })
// Auth: identidad + permisos uniformes
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"Nunca compares un secreto con === (sale en el primer carácter distinto y filtra por tiempo).
// Node (route handlers, backends, crons)
import { safeEqual } from "@ai4u/platform/security"
if (!safeEqual(req.headers.get("authorization"), `Bearer ${process.env.CRON_SECRET}`)) return new Response(null, { status: 401 })
// middleware / proxy / Edge Runtime (Web Crypto, sin node:*)
import { safeEqualEdge } from "@ai4u/platform/security/edge"
if (!(await safeEqualEdge(req.headers.get("x-mc-secret"), process.env.MISSION_CONTROL_SECRET))) { /* 401 */ }safeEqual(a, b): boolean— SHA-256 de ambos +crypto.timingSafeEqual; tolera largos distintos sin filtrarlos.safeEqualEdge(a, b): Promise<boolean>— mismo contrato concrypto.subtle, sin salida temprana.- En ambos,
undefined,nully""devuelvenfalse: un secreto vacío o no configurado nunca autentica. verifyServiceRequest(@ai4u/platform/auth) ya usasafeEqualinternamente.
Toda llamada a sap-b1-backend debe adjuntar el token OIDC de Vercel del deployment
(audiencia https://sap-b1-backend.ai4u) en el header x-ai4u-identity. Dos líneas:
import { getGatewayIdentityHeaders } from "@ai4u/platform/gateway-identity"
const res = await fetch(url, { headers: { "x-mc-secret": secret, ...(await getGatewayIdentityHeaders()) } })getGatewayIdentityHeaders(opts?): Promise<Record<string, string>>devuelve{ "x-ai4u-identity": <token> }o{}. Nunca lanza (fail-open): sin token (local, error del intercambio, timeout deopts.timeoutMs, default 1500 ms) la llamada sale igual que antes. La auth real (x-mc-secret/X-API-Key) no cambia.- El token se pide en cada llamada (llamala dentro del request, nunca a nivel de módulo);
el único caché es el interno de
@vercel/oidc. El token nunca se loguea: en Vercel emite como mucho 1warn/min por instancia si falta; fuera de Vercel solodebug. - Opciones:
timeoutMs,getToken(reemplaza agetVercelOidcToken, útil en tests) ylogger({ warn, debug }, defaultgetLogger("gateway-identity")). - Constantes:
GATEWAY_IDENTITY_HEADER,GATEWAY_OIDC_AUDIENCE,GATEWAY_IDENTITY_TIMEOUT_MS. - Solo servidor (Node). Vive solo en este subpath: importar
@ai4u/platform(raíz) no carga@vercel/oidc.
Cada app arranca el transporte una vez en instrumentation.ts:
import { configureTransport, setServiceName } from "@ai4u/platform/logger"
setServiceName("mi-app")
if (process.env.PLATFORM_INGEST_URL && process.env.INGEST_SECRET) {
configureTransport({
endpoint: process.env.PLATFORM_INGEST_URL, // .../api/ingest/logs del panel admin
secret: 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 (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.
// instrumentation.ts (junto a register())
export { onRequestError } from "@ai4u/platform/instrumentation"Next llama al hook cuando falla un render RSC, un route handler, una server action o el proxy.
Registra a nivel ERROR (mensaje + stack, digest, path sin querystring, método, routerKind,
routeType, routePath) y hace await flushLogs(). Nunca registra headers, cookies ni cuerpo.
Solo corre en runtime Node; en Edge es un no-op. Requiere que register() haya configurado el
transporte (ver sección anterior).
// components/ClientErrors.tsx ("use client"), montado una vez en app/layout.tsx
useEffect(() => { installClientErrorReporter({ service: "mi-app" }) }, [])
// app/error.tsx y app/global-error.tsx ("use client")
useEffect(() => { reportClientError(error, { digest: error.digest ?? "" }) }, [error])// app/api/client-error/route.ts
import { createClientErrorHandler } from "@ai4u/platform/client-errors"
export const POST = createClientErrorHandler() // { maxBytes = 8192, maxPerMinute = 20 }- Cliente: escucha
erroryunhandledrejection(conaddEventListener, sin pisar unwindow.onerrorexistente); idempotente; deduplica la misma firma durante 60 s; máximo 5 reportes por carga de página;sendBeaconcon fallback afetch keepalive; trunca mensaje (2 000) y stack (4 000); solo envíalocation.pathname. Ignora errores de carga de recursos,Script error.yResizeObserver loop. - Servidor: acepta solo
application/json(415), tope de tamaño (413), mismo origen porSec-Fetch-Site/Origin(403), límite por IP (429), forma validada (400); responde 204 sin eco. Registralevel=ERROR,component="client",category="client". - Riesgo (endpoint sin autenticación): cualquiera puede intentar enviar basura a
platform_logs. Mitigaciones: las anteriores + saneado/truncado de campos. El límite por IP vive en memoria de cada instancia (mejor esfuerzo en serverless; se reinicia en cold start) y las cabeceras de origen las puede falsificar un cliente que no sea navegador: para protección real añade el rate-limit/WAF de la plataforma. Los campos del cliente son datos no confiables: se registran como datos y nunca fijan nivel, servicio ni component. - Si la app tiene proxy/middleware con auth, deja pública
/api/client-error.
import { runJob } from "@ai4u/platform/jobs"
export const GET = async () =>
Response.json(await runJob("sync-clientes", async (log) => syncClientes(log)))
// runJob(name, fn, { log?, rethrow = true, tenant? })Registra inicio/fin con duración; si fn lanza, log.error({ err, job }) y relanza (o devuelve
undefined con rethrow: false); si el resultado trae errors/errores > 0 (número o array)
registra log.error con el resumen sin cambiar lo devuelto; siempre await flushLogs() al final.
Ver ADOPTION.md — checklist repetible (perfil A = apps con SSO, perfil B = backend).
npm install
npm test # vitest (errores, logger, http)
npm run build # tsc → dist/Se publica desde ai4u-com-co/kernel, nunca commiteando directo en el espejo
ai4u-com-co/platform (el próximo sync lo pisaría):
-
En una rama de kernel: subir
versionenpackages/platform/package.json, anotar el cambio enpackages/platform/CHANGELOG.mdy regenerardist/(npm run build --workspace=packages/platform, onpm run builddesde la raíz). El CI de kernel falla sidist/no coincide con el build fresco. -
PR a
mainde kernel, con CI en verde, y merge. -
Tag sobre el commit mergeado, con prefijo de paquete:
git tag platform-vX.Y.Z git push origin platform-vX.Y.Z
-
mirror.ymlcompila el paquete y publica commit + tagvX.Y.Zenai4u-com-co/platform. -
En cada consumidor, pinear
"@ai4u/platform": "github:ai4u-com-co/platform#vX.Y.Z"(el bump-bot de la org es el que abre esos PRs en los consumidores).