Ir al contenido

Web Kit

El web-kit provee un servidor HTTP basado en Hono con un sistema modular de features.

import { App } from '@iskra-bun/core';
import { WebPlugin, CorsFeature, HealthCheckFeature } from '@iskra-bun/web-kit';
import { Hono } from '@iskra-bun/web-kit/hono';
const app = new App({ name: 'MiAPI' });
// Tus rutas, como una app de Hono montada en "/".
const router = new Hono();
router.get('/api/users', (c) => c.json({ users: [] }));
router.post('/api/users', async (c) => {
const body = await c.req.json();
return c.json({ created: body }, 201);
});
const web = new WebPlugin({
port: 3000,
features: [
new CorsFeature({ origin: '*' }),
new HealthCheckFeature(),
],
router,
});
app.register(web);
await app.start();

web-kit está escrito con tipos de Hono (Kernel.getApp(), Feature.routes(app), las variables de c.get(...)), así que la app y web-kit tienen que compartir una sola copia de hono. Con dos copias, un HTTPException que lanza la app no es instanceof del que chequea web-kit, y las variables del contexto quedan tipadas contra la otra copia.

hono es una peer dependency de web-kit (^4.12.34): Bun, npm 7+ y pnpm la instalan, y una app que ya declara hono tiene que mantenerla en ese rango. Para no depender de hono directamente, impórtalo desde web-kit:

import { Hono, HTTPException, createMiddleware, isHTTPException, statusText } from '@iskra-bun/web-kit/hono';
import type { Context, MiddlewareHandler, ContentfulStatusCode } from '@iskra-bun/web-kit/hono';

isHTTPException(err) también reconoce un HTTPException de otra copia de hono (un Error con un status HTTP y getResponse()); lo usa el manejador de errores del Kernel, así que esa excepción conserva su status en vez de convertirse en un 500. statusText(404) devuelve "Not Found".

Router organiza las rutas en grupos con los middlewares que comparten, y decide qué recibe un request que ninguna ruta toma:

import { Router, WebPlugin } from '@iskra-bun/web-kit';
const api = new Router();
const v1 = api.group('/api/v1', requestLog); // middleware de todas las rutas del grupo
v1.use(auth); // para las rutas que se agreguen después de esta línea
const users = v1.group('/users', requireScopes('users:read')); // hereda requestLog y auth
users.get('/me', getMe);
users.get('/:id', getUser);
v1.unmatched({ then: 'auto' });
new WebPlugin({ router: api, features: [/* … */] });
  • Una ruta corre los middlewares de sus grupos (primero el de afuera) y después los suyos: users.get('/:id', audit, getUser).
  • use() agrega middleware a las rutas y subgrupos que se agregan después, como en Echo.
  • Las rutas se registran por prioridad, no en el orden en que se agregaron: un segmento estático antes que un parámetro y éste antes que un comodín (/users/me antes que /users/:id), y una ruta de un método antes que una de todos los métodos (all()). Dos rutas con el mismo método y la misma forma de path (/users/:id, /users/:userId) lanzan un error.
  • group.unmatched() se ocupa de lo que ninguna ruta bajo el prefijo del grupo toma (un path desconocido, un método sin ruta, un método inventado): corre los middlewares del grupo (o use: [...]), así un invitado recibe el 401 del chequeo de auth en vez de enterarse de qué paths existen, y después responde 404 según el contrato de respuestas. Con then: 'auto', un path que tiene rutas para otros métodos responde 405 con Allow: GET, HEAD, POST. Los paths fuera de un grupo con unmatched() reciben el 404 del Kernel; decide el prefijo más específico; llamarlo de nuevo para un prefijo reemplaza la regla anterior.
  • \: en un path es un dos puntos literal (/items\:batch).

WebPlugin compila el router sobre la app del Kernel después de los middlewares de todas las features, así que los headers de seguridad, CORS, CSRF o el rate limit cubren sus rutas. router.compile(app) lo hace sobre una app de Hono propia. Una app de Hono común sigue sirviendo como router: se monta en /.

WebDriver es un driver liviano basado en OpenAPIHono para exponer rutas tipadas sin el Kernel de features. Acepta { port, routes } (antes se llamaba WebServer — cambio incompatible, actualiza tus imports).

import { WebDriver } from '@iskra-bun/web-kit';
const driver = new WebDriver({
port: 3000,
routes: [
{
method: 'GET',
path: '/api/users',
handler: async (ctx) => ({ users: [] }),
},
],
});
app.register(driver);
await app.start();

Una ruta con schema valida el request con el; envolvela en defineRoute() para que ctx.body y ctx.query del handler tomen sus tipos del schema (dentro de una lista routes comun son unknown):

import { defineRoute } from '@iskra-bun/web-kit';
defineRoute({
method: 'POST',
path: '/api/users',
schema: { body: z.object({ name: z.string() }) },
handler: async (ctx) => userService.create(ctx.body.name), // ctx.body.name: string
});

Aplica los headers de seguridad por defecto del Kernel (X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin); un header que la ruta define por si misma, como X-Frame-Options: DENY, se conserva. El schema.body de una ruta se valida sea cual sea el Content-Type del request. Los errores lanzados por un handler se registran en el servidor; al cliente solo se le devuelve { error: 'Internal Server Error' } con status 500 (nunca se serializa el mensaje crudo, que podria filtrar connection strings u otros secretos). El cuerpo de los requests se limita a 16 MiB, como en el Kernel (maxRequestBodySize; Bun solo permite 128 MiB).

El Kernel es el micro-kernel que orquesta las features web:

  • Resuelve dependencias entre features (sort topologico); las optionalDependencies de una feature se inicializan antes que ella solo si estan registradas (asi CsrfFeature corre despues de SessionFeature, sin importar en que orden las registres)
  • Detecta dependencias circulares
  • Inicializa todas las features (que registran su middleware) antes de registrar las rutas de cualquiera, asi el middleware de cada feature (CSRF, rate limit, auth, CORS…) se aplica a todas las rutas, sin importar el orden en que se registraron las features
  • Rechaza lo que se saltearia ese middleware: rutas agregadas a getApp() antes de initialize() (agregalas despues de await kernel.initialize(), o pasalas como router de WebPlugin), rutas que una feature agrega en initialize() en vez de routes(), y una segunda feature con un nombre ya registrado (antes reemplazaba a la primera en silencio; RateLimitFeature acepta un name para un segundo limitador)
  • Aplica headers de seguridad automaticamente (lo que pases en securityHeaders se combina con los valores por defecto); un header que la ruta define por su cuenta, como un Content-Security-Policy mas estricto, se respeta
  • Maneja el ciclo de vida (init, start, shutdown)

Defaults del servidor, configurables en new Kernel({ ... }):

Opcion Default Que hace
hostname "0.0.0.0" Interfaz donde escucha (todas; "127.0.0.1" para solo local)
maxRequestBodySize 16 MiB Tamano maximo del body; por encima Bun responde 413
idleTimeout 10 s (Bun) Segundos que una conexion puede quedar inactiva
shutdownGraceMs 5000 Cuanto espera shutdown() a los requests en curso antes de cerrar las conexiones
logger la consola Donde loguean el Kernel y sus features (ver abajo); false para nada
trustProxy false Cantidad de reverse proxies delante de la app (true = 1); solo entonces se lee clientIpHeader (ver Rate limiting e IP del cliente)
clientIpHeader "x-forwarded-for" El header donde esos proxies ponen la direccion del cliente: "x-forwarded-for" o "x-real-ip"

El Kernel y sus features reportan el arranque, los fallbacks y los errores que manejan a traves de un logger: un objeto con debug, info, warn y error(message, details?). WebPlugin le pasa el logger del App salvo que definas logger, asi estos mensajes comparten el formato y el nivel de la app (la linea de arranque de cada feature es debug). Una feature propia lo obtiene con kernel.getLogger() en initialize(). fromStructuredLogger(pinoLogger) adapta un logger estilo pino.

shutdown() deja de aceptar conexiones, espera los requests en curso (hasta shutdownGraceMs) y apaga las features en orden inverso de dependencias; si alguna falla, sigue con las demas y al final tira un AggregateError.

Feature Descripcion
AuthFeature Autenticacion con Better Auth (OIDC, email/password) — impulsado por @iskra-bun/auth-kit
CorsFeature Control de origenes (CORS)
CsrfFeature Proteccion CSRF con tokens
RateLimitFeature Limitacion de requests (memory o Redis)
ApiKeyFeature Validacion de API keys con scopes
DbFeature Integracion con Drizzle ORM
CacheFeature Cache con Redis o memoria
SessionFeature Sesiones (DB, cache, o memoria)
PermissionsFeature RBAC (roles y permisos)
HealthCheckFeature Health checks (readiness/liveness)
OpenAPIFeature Documentacion Swagger/OpenAPI
LoggerFeature Logging de request/response
ErrorHandlerFeature Opciones del manejador de errores del Kernel: includeStack, un handler por status, un logger
RequestIdFeature Tracking de request con ID unico
OtelTracingFeature Tracing con OpenTelemetry (@hono/otel)
UploadFeature Subida de archivos
StorageFeature Almacenamiento de archivos (local) — impulsado por @iskra-bun/storage-kit
EmailFeature Envio de emails (SMTP, SendGrid) — impulsado por @iskra-bun/mailer-kit

La validacion es un middleware, no una feature: validate() recibe schemas de Zod (v3 o v4) y validateJson() JSON Schemas (AJV, con ajv-formats y los mensajes de ajv-errors). Ambos responden 400 con los errores si la entrada no es valida; si no, el handler lee los datos de c.get('validated'), tipados.

import { validate, validateJson } from '@iskra-bun/web-kit';
app.post('/users', validate({ body: z.object({ name: z.string() }) }), (c) => {
const { name } = c.get('validated').body; // string, inferido del schema
return c.json({ name }, 201);
});
// Un JSON Schema no tiene tipo de TypeScript: nombra la forma validada.
app.post('/orders', validateJson<CreateOrder>({ body: orderSchema }), (c) => c.json(c.get('validated').body));

Dentro de un handler, bindBody() y bindQuery() hacen lo mismo y devuelven los datos, tipados, o lanzan un ValidationError que responde el contrato de respuestas:

import { bindBody, bindQuery, queryParams } from '@iskra-bun/web-kit';
app.post('/personas', async (c) => {
// `{ "NOMBRE": "Ana" }` completa `nombre`, como hace encoding/json de Go.
const persona = await bindBody(c, personaSchema, { caseInsensitiveKeys: true, allowForm: true });
return ok(c, await personas.create(persona), { status: 201 });
});
app.get('/personas', (c) => {
// ?ids=1&ids=2: un campo declarado como array recibe todos los valores, los demás el primero.
const { ids, q } = bindQuery(c, z.object({ ids: z.array(z.coerce.number()).optional(), q: z.string().optional() }));
return list(c, await personas.find({ ids, q }));
});
queryParams(c); // ?id=1&id=2&q=ana → { id: ['1', '2'], q: 'ana' }
  • Un cuerpo es JSON (application/json o +json), o un formulario (urlencoded, multipart) con allowForm (los campos repetidos quedan como arrays). Un JSON malformado responde 400 (BAD_REQUEST), otro content type 415 (UNSUPPORTED_MEDIA_TYPE), y un cuerpo vacío es {}. validate() y validateJson() lo leen igual (con formularios); antes un JSON malformado se validaba como {}.
  • caseInsensitiveKeys renombra las claves que coinciden con las del schema sin distinguir mayúsculas, también en objetos anidados y arrays (lee la forma de un objeto de Zod). En la query, un parámetro con el nombre exacto del campo gana sobre uno que sólo coincide sin distinguir mayúsculas. validate() también lo acepta como opción.
  • Una validación fallida responde 400 VALIDATION_ERROR con los campos fallidos en details: el flatten() de Zod por defecto, o details: 'issues' ([{ path, message, code }]), 'fields' ({ "address.city": ["Required"] }) o una función de los issues, por llamada o para toda la app en validationDetails del contrato.

DbFeature acepta un generic de schema opcional para queries tipados via c.get("db"). Omitirlo reproduce el comportamiento anterior sin tipos (compatible hacia atras).

import * as schema from './db/schema';
new DbFeature<typeof schema>({ adapter: 'postgres', connection: { connectionString: process.env.DATABASE_URL } })
// En un route handler:
const users = await c.get('db').query.users.findMany();
// ^-- tipado segun tu schema

/health/ready ahora ejecuta checks reales en lugar de reportar siempre listo. Registra checks con addReadinessCheck o la opcion de configuracion readinessChecks:

const health = new HealthCheckFeature();
health.addReadinessCheck('db', async () => {
// retorna true = listo, false o excepcion = no listo
await db.execute(sql`SELECT 1`);
return true;
});

Si algun check registrado retorna false, lanza una excepcion o tarda mas que checkTimeoutMs (por defecto 2000), /health/ready responde con 503 ({ status: "not ready" }) y registra como warning los nombres de los checks fallidos. Sin checks registrados siempre retorna ready (comportamiento anterior).

Por defecto includeDetails es false, para los tres endpoints:

  • /health responde { status, timestamp }: sin lista de features, resultados por check ni strings de error crudos (los errores se registran en el servidor).
  • /health/ready responde { status }: los nombres de los checks (checks, failed) quedan afuera, porque pueden describir la infraestructura (postgres-primary-10.0.3.12). El codigo de estado es lo que usa un orquestador.
  • /health/live responde { status, timestamp }, sin uptime.

Breaking (0.x): /health/ready y /health/live devolvian los nombres de los checks y el uptime sin importar includeDetails.

Los checks (ping real a la base de DbFeature, cache y checks propios) corren siempre, cada uno con un timeout (checkTimeoutMs, 2 s por defecto). Si alguno falla, /health responde 503 con { status: "error" }, para que el balanceador u orquestador pueda actuar.

Para incluir las features, los resultados y nombres de los checks y el uptime, activa includeDetails: true. Como esto revela informacion interna, gatea los endpoints detras de autenticacion:

const health = new HealthCheckFeature({ includeDetails: true });
// Exponer solo en una ruta protegida — no en el /health publico

Cada endpoint se puede mover (path, readinessPath, livenessPath) o dejar afuera con false; el rate limiter sólo excluye los que se sirven. body reemplaza el JSON de un endpoint por el tuyo, armado a partir de un reporte de lo que encontró:

const health = new HealthCheckFeature({
path: false,
readinessPath: '/healthcheck/ready',
livenessPath: '/healthcheck/live',
readinessChecks: { oracle: () => oracle.ping() },
body: {
live: () => ({ message: 'ok' }),
ready: (report) => ({ message: report.ok ? 'ok' : 'unavailable' }),
},
});

El reporte (HealthReport) tiene endpoint (health, ready o live), ok, cada check por nombre en checks (el resultado de un check propio tal como lo devolvió), los nombres en failed, timestamp y uptime. El status sigue siendo 200 o 503 según ok; devuelve una Response para decidirlo todo tú. El reporte trae los nombres de los checks sin importar includeDetails, así que deja afuera lo que no deba ser público.

OpenAPIFeature sirve el spec en /openapi.json, y una página de referencia de la API (Scalar) en /docs. El spec tiene las rutas agregadas con addRoute() y las rutas Hono propias de la app que describe un describeRoute() (ver abajo):

new OpenAPIFeature({
title: 'Orders API',
version: '1.0.0',
servers: [{ url: 'https://api.example.com' }],
// Ambas rutas: false responde 403, una Response se envia tal cual.
authorize: (c) => c.get('user')?.role === 'admin',
// routes: 'all', // toda ruta Hono, descrita o no; false para solo las de addRoute()
// scalar: 'local', // Scalar desde node_modules, nada desde un CDN
// docs: false, // no sirve ninguna (en produccion, por ejemplo)
// scalar: false, // sirve /openapi.json sin la pagina
});
  • La pagina carga una version fija de @scalar/api-reference desde jsDelivr, con su hash de Subresource Integrity y crossorigin="anonymous": el navegador rechaza el script si el CDN sirve otra cosa (cargaba @latest, asi que lo ultimo que publicara Scalar corria en el origen de la app). Para actualizarlo, o servirlo desde tu propio origen, usa scalar: { src, integrity }, donde integrity es el hash sha384-… de ese archivo exacto.
  • Sin CDN (una intranet, una CSP estricta): scalar: 'local' sirve el bundle del @scalar/api-reference instalado (una peer dependency opcional: bun add @scalar/api-reference) desde la propia app, en /docs/scalar.js, y la CSP de la página permite scripts solo de 'self'. scalar: { file } hace lo mismo con un standalone.js que guardes en disco. La app lee el archivo una vez, en el primer request, y calcula su hash SRI; si falta el paquete, falla al arrancar. /docs/scalar.js es código público y no pasa por authorize.
  • La pagina envia su propio Content-Security-Policy: scripts solo del origen de ese script, requests solo al origen de la app y a los servers del spec (“Try it”), nada mas se carga. Las fuentes web de Scalar y su agente de IA, que envia el spec a los servidores de Scalar, estan apagados. El titulo se escapa como HTML.
  • /openapi.json y /docs se registran en routes(), asi que un middleware agregado a la app despues de initialize() (un basicAuth(), por ejemplo) no corre para ellas: usa authorize, que corre para ambas. Devuelve una Response para responder con ella, como un pedido de Basic auth:
authorize: (c) => isDocsUser(c) || c.text('Unauthorized', 401, { 'WWW-Authenticate': 'Basic realm="docs"' }),

Las rutas escritas como Hono común (en un Router, en la app del Kernel, en una app Hono montada con el router de WebPlugin) también están en el spec, leídas de lo que llevan sus handlers. describeRoute() las describe; en runtime solo llama a next():

import { Router, anyOf, apiKey, bearer, describeRoute, requireActor, requireScopes, validate } from '@iskra-bun/web-kit';
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const api = new Router();
// En un grupo: cada ruta debajo recibe el tag y la seguridad del gate.
const users = api.group('/api/users', describeRoute({ tags: ['Users'] }), requireActor(anyOf(bearer(verify), apiKey(keys))));
users.get('/:id', describeRoute({ summary: 'Un usuario', ok: User }), validate({ params: z.object({ id: z.coerce.number() }) }), getUser);
users.get('', describeRoute({ summary: 'Usuarios', list: User }), listUsers);
users.post(
'',
describeRoute({ summary: 'Crear un usuario', responses: { 201: { schema: User } } }),
requireScopes('users:write'),
validate({ body: z.object({ name: z.string().min(1) }) }),
createUser,
);

Qué entra en cada operación:

De En el spec
describeRoute({ summary, description, tags, operationId, deprecated }) Tal cual. Los tags de una ruta se suman a los de sus grupos; el resto de la ruta pisa al del grupo.
describeRoute({ ok: schema }) / { list: itemSchema } El 200 de ok(c, data) / list(c, page), envuelto como responde el contrato de respuestas ({ success, data, message }, { success, data, meta }).
describeRoute({ responses: { 201: { schema, description, contentType, headers } } }) Esas respuestas. Sin ninguna, un 200 OK.
describeRoute({ request: { params, query, headers, body, bodyTypes } }) El request, para una ruta que lo lee con bindBody()/bindQuery() en vez de validate().
validate(), validateJson() Parámetros de path, query y body (JSON, y formularios con allowForm), y un 400.
requireActor(gate) La seguridad del gate (anyOf da alternativas, allOf esquemas juntos) y un 401; los esquemas van a components.securitySchemes (bearer, jwt, apiKey, session).
identify(gate) La seguridad del gate, y {}: las credenciales son opcionales.
requireScopes(...scopes) Los scopes en cada requisito de seguridad, y un 403.
El contrato Una respuesta default y los 400/401/403 con el cuerpo de error (components.schemas.ErrorResponse); problemDetailsContract() como application/problem+json.
  • Los esquemas pueden ser Zod v3 o v4, cualquier librería Standard Schema que exporte JSON Schema (Valibot, ArkType) o JSON Schema. Un esquema Zod v4 con nombre, .meta({ id: 'User' }), aparece una vez en components.schemas y se referencia. Lo que JSON Schema no puede representar (una fecha, la salida de un transform) se documenta como cualquier valor.
  • describeRoute() en un path de app.use() aplica a las rutas registradas después bajo ese path, como hace el middleware en runtime. describeRoute({ hidden: true }) deja una ruta afuera, security: [] marca una como pública bajo un security global.
  • routes: 'described' (el default) lista las rutas con un describeRoute(), en ellas o en su grupo; routes: 'all' toda ruta con un path que OpenAPI pueda expresar (sin comodines, con un método que no sea all()). Una operación de addRoute() gana sobre una común con el mismo método y path. Los parámetros opcionales (/:page?) y con regex (/:id{[0-9]+}) de Hono se vuelven dos paths y un pattern.
  • Un contrato propio documenta sus cuerpos en schemas: { error, errorType?, success?(data), list?(item) }, como JSON Schema. Sin schemas, las respuestas de error tienen descripción y no cuerpo.
  • Un gate propio declara sus esquemas en su propiedad openapi: Object.assign(gate, { openapi: [[{ name: 'mtls', scheme: { type: 'mutualTLS' } }]] }). Un gate sin ella igual recibe su 401.
  • documentRoutes(app.routes, { contract }) arma los mismos paths sin la Feature, para un spec propio.

LoggerFeature le da a cada request un logger (c.get('logger')) y, con accessLog: true, escribe una línea por request cuando termina. Con WebPlugin van al pino de la App: el logger del request es su hijo, así cada línea lleva el requestId (registra RequestIdFeature), y la línea de acceso es request completed con method, path, status, durationMs, requestId y actor (kind:id, de un gate) como campos. Con el logger de consola del Kernel son texto.

new WebPlugin({ router, features: [new RequestIdFeature(), new LoggerFeature({ accessLog: true })] });
// {"level":30,"requestId":"…","method":"GET","path":"/api/users/7","status":200,"durationMs":12,"msg":"request completed"}

OtelTracingFeature crea un span por request con @hono/otel, a traves de la configuracion de OpenTelemetry de la app (el tracer provider global, o el tracer o tracerProvider que le pases):

new OtelTracingFeature({
serviceName: 'orders-api',
ignoreIncomingTraceContext: true, // un servicio expuesto a internet
});
  • url.full se exporta con el valor de los parametros de query con pinta de secreto reemplazado por REDACTED (SECRET_QUERY_PARAMS de @iskra-bun/core: token, access_token, api_key, code, state…), y lo mismo el token del link /reset-password/<token> de better-auth: los links de verificacion de email y de reseteo de contrasena, y las API keys en el query string, llegaban al collector. redactedQueryParams define la lista ([] conserva todos los valores). Otros tokens en paths se exportan tal cual.
  • Por defecto un request continua la traza que nombra su header traceparent. ignoreIncomingTraceContext: true empieza una traza nueva por request y descarta el baggage entrante: en un endpoint publico un cliente podria forzar que sus requests se muestreen y colgarlas de una traza que elija. Deja el default detras de un gateway que define el contexto de traza el mismo.
  • Los headers listados en captureRequestHeaders se exportan tal cual: no incluyas authorization ni cookie.

Lanza un error desde una ruta o un middleware y el Kernel lo responde, con o sin ErrorHandlerFeature:

import { HttpError, NotFoundError, ValidationError } from '@iskra-bun/web-kit';
throw new NotFoundError('Usuario no encontrado');
// 404 { "error": "Usuario no encontrado", "status": 404, "code": "NOT_FOUND" }
throw new ValidationError('Datos inválidos', { field: 'email' });
// 400 { "error": "Datos inválidos", "status": 400, "code": "VALIDATION_ERROR", "details": { "field": "email" } }
throw new HttpError(423, 'El pedido se está editando', { headers: { 'Retry-After': '30' } });
// 423, código BAD_REQUEST (el del status) salvo que pases `code`

Por defecto todo error responde { error, status, code, details?, context?, stack?, requestId? }, el cuerpo que leen los SDKs. Cómo se convierte un error en ese cuerpo:

Lanzado Status y código error (el mensaje)
HttpError (y sus subclases) los suyos su mensaje; context se envía en un 4xx
HTTPException de Hono (lo que lanzan CSRF, el rate limiter y auth) su status; el código de ese status (429 → RATE_LIMITED) su mensaje
Cualquier otro IskraError el status de su código (NOT_FOUND → 404, CONFLICT → 409, TIMEOUT → 504, 500 para el resto) el texto del status (Conflict), o su mensaje si el error tiene expose = true
Cualquier otra cosa 500 INTERNAL_ERROR Internal Server Error
  • Una ruta que no existe responde 404 { "error": "Not Found", "status": 404, "code": "NOT_FOUND" } (antes era el 404 Not Found en texto plano de Hono).
  • Un request HEAD recibe el status y los headers, sin cuerpo.
  • requestId aparece cuando RequestIdFeature puso uno.
  • Un HTTPException que trae su propia respuesta (el 401 de basicAuth) se envía tal cual.
  • includeStack (KernelConfig.includeStack o ErrorHandlerFeature, activo por defecto sólo con NODE_ENV=development) agrega stack, y el mensaje y el contexto de los errores 5xx.
  • Los errores lanzados se loguean: un 4xx en debug (cualquier cliente puede causar todos los que quiera), un 5xx en error.

ErrorHandlerFeature es opcional: fija includeStack, un handler por status (customHandlers: { 404: (err, c) => … }) y un logger para los errores.

Los códigos son los ErrorCodes de @iskra-bun/core, y una app agrega los suyos por declaration merging:

declare module '@iskra-bun/core' {
interface ErrorCodeRegistry {
ORDER_LOCKED: true;
}
}
throw new HttpError(423, 'El pedido se está editando', { code: 'ORDER_LOCKED' });

statusForCode('NOT_FOUND') y codeForStatus(404) dan la correspondencia que usa el Kernel.

El cuerpo de cada respuesta sale de un único contrato de respuestas, que se fija una vez en KernelConfig.contract (o en el de WebPlugin): lo siguen el manejador de errores del Kernel y su 404, y también validate() y validateJson(), el hook de validación y las rutas de OpenAPI, los rechazos de ApiKeyFeature, AuthFeature, PermissionsFeature, CsrfFeature, RateLimitFeature y de las rutas de upload, y los helpers ok(), list() y fail(). El de Iskra (iskraContract) es el de por defecto; problemDetailsContract() responde problem details según RFC 9457 (application/problem+json).

import { WebPlugin, problemDetailsContract } from '@iskra-bun/web-kit';
new WebPlugin({ router, contract: problemDetailsContract() });
// 404 { "type": "about:blank", "title": "Not Found", "status": 404, "detail": "Not Found", "code": "NOT_FOUND", "instance": "/x" }

Un contrato propio conserva las respuestas de un servicio que estás migrando. Cada error le llega como un Problem ({ status, code, message, details?, context?, stack?, requestId?, headers? }):

import type { ResponseContract } from '@iskra-bun/web-kit';
const contract: ResponseContract = {
// Tus propias clases de error; undefined deja el resto a la correspondencia de Iskra.
toProblem: (error) =>
error instanceof AppError ? { status: statusOf(error.kind), code: 'APP_ERROR', message: error.message } : undefined,
// El cuerpo del error (o una Response).
error: (problem, c, error) =>
error instanceof AppError
? { error: { code: problem.status, message: problem.message, metadata: error.fields ?? [] } }
: { message: problem.message },
// El cuerpo de ok(c, data) y de list(c, page).
success: (data, c, { message }) => ({ ...(message && { message }), data }),
list: (page, c) => ({
draw: c.req.query('draw') ? Number(c.req.query('draw')) : null,
recordsTotal: page.total,
recordsFiltered: page.filtered,
data: page.items,
}),
// Ve cada problema, también los 400 de una validación.
log: (problem, c) => { /* … */ },
};

En las rutas:

import { ok, list, fail } from '@iskra-bun/web-kit';
app.get('/users/:id', async (c) => ok(c, await users.find(c.req.param('id'))));
app.post('/users', async (c) => ok(c, await users.create(await c.req.json()), { status: 201, message: 'Creado' }));
app.get('/users', async (c) => list(c, await oracle.paginate(query, pageParams(c.req.query()))));
app.delete('/users/:id', async (c) => {
const error = await users.remove(c.req.param('id')); // un error como valor, no lanzado
return error ? fail(c, error) : c.body(null, 204);
});

list() acepta tal cual los resultados de paginate() y paginateByCursor() de db-oracle (items o rows, total, filtered, page, pageSize, offset, limit, pages, nextCursor). Con el contrato de Iskra, ok() responde { success: true, data, message? } y list() { success: true, data, meta: { total, … } }. fail(c, error) responde lo mismo que lanzar error, como valor de retorno.

Para apagar del todo los headers de seguridad (los pone un proxy), securityHeaders: false.

RateLimitFeature y el limitador de las rutas de auth cuentan requests por IP del cliente. Es la direccion del socket, salvo que le indiques al Kernel los proxies que hay delante de la app:

new Kernel({
trustProxy: 1, // un proxy: nginx, un load balancer
clientIpHeader: 'x-forwarded-for', // el default; 'x-real-ip' si el proxy completa ese
});
  • Una request por encima del limite recibe un 429 con Retry-After: los segundos hasta que se reinicia la ventana del cliente (al menos 1). Si el store no sabe cuando se reinicia (store: 'cache' despues del primer hit de la ventana), es windowMs, una cota superior. Un handler propio tambien lo envia si responde a traves de c (c.json(), c.text()). Los SDKs cliente lo leen en su excepcion de rate limit.
  • Solo se lee clientIpHeader (breaking). Antes X-Real-IP se usaba cuando faltaba X-Forwarded-For, y eso lo decide el cliente: detras de un proxy que solo completa X-Real-IP y deja pasar X-Forwarded-For, un X-Forwarded-For inventado era un bucket nuevo en cada request. Si tu proxy completa X-Real-IP (nginx: proxy_set_header X-Real-IP $remote_addr), configura clientIpHeader: 'x-real-ip'; con X-Forwarded-For, cada proxy tiene que agregarse al final (nginx: $proxy_add_x_forwarded_for).
  • Las rutas del health feature (/health, /health/ready, /health/live, o las que tenga configuradas) no cuentan: las probes del orquestador llegan cada pocos segundos desde una sola IP, y un kubelet superaba el default de 100 cada 15 minutos, recibia 429 en /health/live y reiniciaba el pod. Con skipHealthChecks: false vuelven a contar.
  • Si las requests traen X-Forwarded-For o X-Real-IP pero trustProxy no esta configurado, el limitador loguea un warning una vez: detras de un proxy todos los clientes compartirian el bucket del proxy.
  • Los clientes IPv6 se cuentan por su prefijo /64, porque un host suele tener un /64 entero para ir rotando, y ::ffff:192.0.2.1 cuenta como 192.0.2.1. clientIpKey(ip) devuelve esa clave para un limitador propio.
  • El store en memoria sigue como maximo maxKeys clientes (100 000 por defecto; por encima descarta los mas viejos) y barre los vencidos cada minuto; el limitador de auth acepta rateLimit: { maxKeys } y el adapter en memoria de CacheFeature maxEntries, con el mismo default. Sus timers no mantienen vivo el proceso. Ese adapter guarda y devuelve copias (structuredClone), como vuelven los valores de Redis: cambiar un valor leido de la cache ya no cambia el guardado.
  • CacheFeature con Redis tambien conecta con servicios administrados: connection: { url: process.env.REDIS_URL } (rediss:// para TLS; lo que la URL no trae sale de las otras opciones), un username de ACL, y tls: true u opciones TLS de Node. Un comando falla despues de commandTimeoutMs (2000 por defecto) en lugar de esperar todos los reintentos de conexion, y los errores de conexion van al logger del Kernel, un warning por caida, sin la password de AUTH.
  • Mientras el store esta caido, RateLimitFeature deja pasar las requests sin limite y loguea un error como maximo una vez por minuto (passOnStoreError, true por defecto): antes una caida de Redis hacia fallar todas las requests. Con passOnStoreError: false responde 503 con Retry-After. Las sesiones en cache siguen haciendo fallar la request, porque tratarla como anonima desloguearia a los usuarios, pero despues de commandTimeoutMs en lugar de quedarse colgadas.
  • Los adapters Redis y en memoria de CacheFeature se comportan igual (breaking): Redis guarda todo valor como JSON, tambien los strings, asi que '123' se lee como string y no como 123 (los strings crudos que guardaban versiones anteriores se siguen leyendo), y por eso un contador debe guardarse como numero (set('k', 5)) o crearse con increment() (despues de set('k', '5'), increment() falla en Redis); un TTL fraccionario en segundos (0.5) funciona en Redis, que lo rechazaba; y increment() sobre una key inexistente o vencida la crea en 1 en memoria, como INCR de Redis (devolvia 0).

El Kernel envia estos headers de seguridad por defecto:

  • X-Frame-Options: SAMEORIGIN
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin

Content-Security-Policy, Strict-Transport-Security (HSTS) y Permissions-Policy son opcionales, y X-XSS-Protection esta desactivado. Se configuran con securityHeaders, que se combina con los valores por defecto:

new Kernel({
securityHeaders: {
xFrameOptions: 'DENY',
strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true },
contentSecurityPolicy: { directives: { 'default-src': ["'self'"] } },
permissionsPolicy: { camera: [], geolocation: [] },
},
});

Solo false desactiva un header por defecto (xFrameOptions: false). Una opcion undefined, null o vacia mantiene el default: antes xFrameOptions: process.env.X_FRAME_OPTIONS con la variable sin definir quitaba el header. securityHeaders: false no pone ninguno (los pone un proxy adelante).

Notas de hardening de seguridad:

  • CSRF (CsrfFeature): double-submit cookie firmada con HMAC-SHA256 bajo el secret configurado y comparada en tiempo constante, mas un chequeo de Origin; ver la seccion CSRF. El kill-switch disableCSRFCheck de AuthFeature se ignora fuera de desarrollo (solo se respeta con NODE_ENV en development o test), por lo que la proteccion CSRF de better-auth no puede desactivarse silenciosamente en un entorno desplegado.

  • API keys (ApiKeyFeature): las keys se comparan con las staticKeys configuradas en cada request, asi que quitar, vencer o recortar una key tiene efecto inmediato; no se cachea nada (enableCache y cacheTtl se ignoran: la entrada cacheada guardaba la key en claro y seguia valiendo despues de revocarla). Los id de las API keys son aleatorios (UUID) y no filtran ningun prefijo del secreto. La comparacion de keys es en tiempo constante. Un Authorization: Bearer que no es una API key valida no se rechaza globalmente (puede ser un JWT o token de sesion de otro esquema); las rutas que exigen API key usan requireApiKey() / requireScope(). Una key invalida en el header X-API-Key si devuelve 401.

  • Scopes de las API keys: un comodin es un segmento entero, * solo o un :* final (users:* habilita users:read y users:x:y, no usersX); cualquier otro * es literal. Breaking: un * final era un prefijo de texto, asi que user* habilitaba users:read y user-admin:delete.

  • CORS (CorsFeature): credentials: true necesita que origin nombre los origenes permitidos (una lista o una funcion); con origin sin definir o '*', initialize() tira un error (breaking). Antes mandaba Access-Control-Allow-Origin: *, que los navegadores rechazan con credenciales, y la salida habitual era reflejar cualquier origen.

  • Permisos (PermissionsFeature): los permisos y roles de cada usuario se cachean (con CacheFeature) durante cacheTTL segundos, 60 por defecto (antes una hora): un rol que revocas sigue valiendo ese tiempo. Llama a await kernel.getFeature('permissions')?.invalidate(userId) despues de cambiar los roles o permisos de un usuario para descartar la copia cacheada; baja cacheTTL, o usa cachePermissions: false, para cambiar consultas a la base por una ventana mas corta. Corre despues de auth, session y cache sea cual sea el orden en que las registres; ninguna es obligatoria: sin una feature de auth o de sesion cada request es anonimo y recibe solo anonymousPermissions.

  • CSRF en rutas puntuales: requireCsrf() valida el token en la ruta aunque su metodo este en ignoreMethods (p. ej. un GET que modifica estado) y falla cerrado si CsrfFeature no esta registrada. En formularios multipart/form-data envia el token en el header X-CSRF-Token.

  • Uploads (UploadFeature): con exposeRoutes: true es obligatorio authorize(c, action, target?) (action: upload | list | download | delete); usa authorize: () => true solo si las rutas deben ser publicas. El body se corta al superar maxFileSize (413) sin cargarlo entero en memoria, el nombre del archivo se sanea y los errores internos no se devuelven al cliente. maxFileSize mas 64 KiB de overhead multipart tiene que entrar en el maxRequestBodySize del Kernel (16 MiB por defecto): Bun rechaza bodies mas grandes antes de llegar a la ruta, asi que initialize() falla en vez de que el limite nunca se alcance sin aviso.

    • target es lo que toca la accion: { key, subfolder, filename }, mas size y type en upload (el { key, subfolder } de la carpeta en list); subfolder no tiene segmentos vacios ni ./... upload se pregunta dos veces: primero sin target, antes de leer el body, y luego con el, antes de escribir nada. Una comprobacion como Boolean(c.get('user')) deja a cualquier usuario con sesion leer y borrar todos los archivos: limita el target al usuario.
    • Una subida nunca reemplaza un archivo guardado: la ruta responde 409 salvo con overwrite: true.
    • El archivo se guarda con un tipo tomado de su extension (contentTypeFor de storage-kit), nunca con el de quien lo sube (Bun deduce File.type del nombre: image/svg+xml, text/html). Las descargas se transmiten en streaming y solo las imagenes rasterizadas se sirven inline: todo lo demas lleva Content-Disposition: attachment, y toda descarga Content-Security-Policy: sandbox. En S3/MinIO el objeto guarda la misma disposicion.
    • Sin allowedExtensions se acepta cualquier extension salvo el contenido web activo (.html, .htm, .shtml, .xhtml, .xht, .mht, .mhtml, .svg, .svgz, .xml, .xsl, .xslt, .js, .mjs, .cjs), que un navegador ejecuta donde se sirva inline; incluye una en allowedExtensions para aceptarla (se sigue guardando como application/octet-stream y se descarga). Con el adaptador de almacenamiento local, sirve su carpeta con los encabezados que describe storage-kit.
    • c.get('upload').uploadFromRequest(request, field) aplica las mismas reglas (breaking): corta el body al superar maxFileSize y rechaza una extension que allowedExtensions no permite, lanzando un HttpError (413 File too large, 400 Invalid extension, 400 si el campo no trae un archivo). Antes leia cualquier body entero y guardaba cualquier extension. Sigue el maxFileSize y los allowedExtensions de la feature, este o no activo exposeRoutes.
    new UploadFeature({
    projectName: 'app',
    exposeRoutes: true,
    // Cada usuario lee y escribe solo bajo users/<id>/.
    authorize: (c, _action, target) => {
    const user = c.get('user');
    if (!user) return false;
    if (!target) return true; // upload, antes de leer el body
    const own = `users/${user.id}`;
    return target.subfolder === own || target.subfolder?.startsWith(`${own}/`) === true;
    },
    });
  • Email (EmailFeature): el adaptador que entrega rechaza un mensaje (la promesa que devuelve se rechaza) cuyo subject o headers lleven un CR/LF, o cuyas entradas de to/cc/bcc/replyTo no sean cada una una sola direccion o un objeto { name, address }: ver destinatarios en mailer-kit. Los destinatarios de tipo objeto tambien se revisan (pasaban como "[object Object]").

  • Auth (AuthFeature): el secret subyacente debe tener >= 32 caracteres (validado por @iskra-bun/auth-kit); un secreto mas corto o vacio se rechaza al inicializar, igual que un valor de ejemplo en produccion. Ver la seccion de Auth.

Un gate prueba quién hizo un request y devuelve un actor ({ kind, id, scopes? }); los gates se combinan, y requireActor() deja el actor en c.var.actor. No necesitan AuthFeature ni una base propia:

import { anyOf, apiKey, hashedKeys, jwt, requireActor, requireScopes, session, staticKeys } from '@iskra-bun/web-kit';
const who = anyOf(
jwt({ jwksUri: 'https://idp.example.com/.well-known/jwks.json', issuer: 'https://idp.example.com', audience: 'core' }),
apiKey(hashedKeys((hash) => oracle.queryOne('SELECT id, scopes FROM api_keys WHERE key_hash = :hash', { hash }))),
session(), // el usuario de AuthFeature
);
app.get('/reports', requireActor(who), requireScopes('reports:read'), async (c) => {
const actor = c.var.actor; // tipado como los actores de los gates
return ok(c, await reports.for(actor.id));
});
Gate Prueba
bearer(verify) un bearer token que chequea tu función (devuelve el actor, o null para rechazarlo)
jwt({ secret | jwksUri, issuer?, audience?, algorithms?, toActor? }) un JWT, verificado con las utilidades de JWT de Hono: firma, exp/nbf, issuer, audience. Su actor es { kind: 'user', id: sub, scopes: scope | scp, claims }
apiKey(store, { header?, bearer?, query?, toActor? }) una API key en X-API-Key (o en el bearer token, o en un parámetro de query) encontrada en un store: staticKeys([...]) desde la config, hashedKeys(lookup) desde una tabla que guarda hashApiKey(key) (SHA-256) en vez de la clave, o uno propio { find(key) }
session() el usuario que AuthFeature dejó en c.get('user')
anyOf(...gates) el primer gate que prueba uno. Un gate que encuentra su credencial inválida no frena a los demás (un bearer token puede ser un JWT o una API key); si ninguno prueba uno, responde el primer rechazo
allOf(...gates) todos los gates (un certificado de cliente y un token); el actor del primero
  • requireActor(gate) responde 401 cuando el request no trae credencial, con el challenge WWW-Authenticate de los gates (Bearer), y cuando trae una inválida (Bearer error="invalid_token", Invalid API key, API key has expired), según el contrato de respuestas.
  • identify(gate) pone c.var.actor cuando el request prueba uno y deja pasar un request sin credenciales (una ruta pública que muestra más a un usuario); las credenciales inválidas siguen siendo un 401.
  • requireScopes(...scopes) responde 403 salvo que el actor los tenga todos; * y un :* al final (users:*) son comodines.
  • Bajo un grupo del Router, requireActor() como middleware del grupo también corre para los paths que responde unmatched(), así un invitado recibe 401 y no 404.
  • Un gate es una función (c) => actor | null que lanza un AuthError 401 para una credencial mala: escribe el tuyo para cualquier otra cosa (un header firmado, mTLS).

Declara una vez el tipo de actor de la app para c.get('actor') en todos lados:

declare module '@iskra-bun/web-kit' {
interface ActorRegistry {
actor: { kind: 'user' | 'apiKey'; id: string; scopes?: readonly string[]; legajo?: number };
}
}

ApiKeyFeature también acepta un store (store: hashedKeys(...)), que consulta después de sus staticKeys.

AuthFeature envuelve Better Auth (impulsado por @iskra-bun/auth-kit) y depende de DbFeature. Soporta los modos email (email/password) y oidc.

import { AuthFeature } from '@iskra-bun/web-kit';
new AuthFeature({
secret: process.env.AUTH_SECRET!, // requerido, >= 32 caracteres
basePath: '/api/sso', // default
authMode: 'email',
});
  • El secret firma las sesiones y debe tener al menos 32 caracteres; uno mas corto o vacio lanza un error al inicializar. En produccion tambien lo lanza un valor de ejemplo (que contiene change-me, dev-secret…): ver auth-kit.
  • En modo oidc (o si se pasa oidcConfig) el login email/password queda deshabilitado; activalo explicitamente con enableEmailPassword: true. enableSelfRegistration: false rechaza /sign-up/email (las cuentas se crean por otro medio).
  • baseURL es el origen publico de la app (por defecto toma BETTER_AUTH_URL). Better Auth decide con el el flag Secure de las cookies, asi que es obligatorio en produccion: sin el se usaba http://localhost:3000 y las cookies salian sin Secure. Por lo mismo, en produccion tiene que ser https:// (breaking); solo localhost, 127.0.0.1 y [::1] pueden usar http plano (un proxy o docker compose en la misma maquina). Antes se aceptaba un origen http://, y las cookies de sesion salian sin Secure.
  • Los intentos de auth (requests POST a {basePath}/* salvo sign-out: sign-in, sign-up, reset de password…) tienen rate limiting por IP por defecto (20 / 15 min, los clientes IPv6 por /64) para frenar credential stuffing; las lecturas de sesion y los callbacks de OAuth no cuentan. En produccion Better Auth aplica ademas sus propios limites por ruta, mas estrictos. El primero se ajusta con rateLimit: { max, windowMs, maxKeys }, o rateLimit: false apaga los dos si un backend llama a estas rutas en nombre de muchos usuarios desde una sola IP (por ejemplo, con los SDKs) y limita por su cuenta.
  • La IP del cliente (para estos limitadores, el ipAddress de las sesiones y RateLimitFeature) es la del socket. Si la app corre detras de un proxy (nginx, load balancer), configura new Kernel({ trustProxy: 1 }) con la cantidad de proxies para usar la direccion reenviada (X-Forwarded-For, o X-Real-IP con clientIpHeader: 'x-real-ip': ver Rate limiting e IP del cliente); sin eso esos headers se ignoran, porque cualquier cliente puede falsificarlos.
  • Las sesiones se validan contra un cache firmado en cookie sin consultar la base, asi que una sesion revocada con sign-out sigue funcionando hasta que ese cache vence: cookieCacheMaxAge (segundos, 300 por defecto) define cuanto.
  • Usa requireAuth(kernel) como middleware para proteger rutas que requieren sesion. Reusa la sesion que el feature ya leyo para el request (c.get("authUser")) en vez de leerla de nuevo.
import { SessionFeature } from '@iskra-bun/web-kit';
new SessionFeature({
store: 'cache', // 'memory' | 'cache' | 'db'
secret: process.env.SESSION_SECRET!, // requerido, >= 32 caracteres
});
  • c.get('session') es un objeto SessionData: sus campos son unknown hasta que los declaras (declare module '@iskra-bun/web-kit' { interface SessionData { userId?: string } }), y desde ahi quedan tipados en cada request.
  • Para cerrar sesion, vacia la sesion (delete c.get('session').userId) o llama a await c.get('destroySession')(): en ambos casos se borra del store y se elimina la cookie.
  • Despues del login llama a await c.get('regenerateSession')() para emitir un ID nuevo e invalidar el anterior (evita session fixation). Con CsrfFeature tambien emite un token CSRF nuevo.
  • Una sesion que destruye una request (logout, regenerateSession) no la vuelve a crear otra request que la habia cargado y termina despues. Al guardar una sesion almacenada, se escribe solo si todavia existe, chequeado y escrito en un solo paso: en memoria de una vez, en Redis con SET ... XX, en una base con un UPDATE de su fila. Antes chequeaba (get) y despues escribia (set), y en Redis o una base un logout que caia entre los dos se deshacia. Un CacheAdapter propio lo obtiene con setIfExists(); sin el, el chequeo sigue siendo una lectura aparte. Cada request trabaja sobre su propia copia de los datos de sesion, tambien con el store en memoria, asi que los datos deben poder copiarse con structuredClone (para los otros stores ya tenian que ser JSON).
  • c.get('sessionPersisted') indica si sessionId corresponde a una sesion almacenada: es false para una nueva, que se guarda al final de la request solo si el handler le pone datos.
  • Con store: 'db', un error de la base al leer, escribir o borrar una sesion hace fallar la request (500, y no se envia cookie). Antes solo se logueaba: el cliente recibia una cookie de una sesion que nunca se guardo, y un logout fallido parecia exitoso. Una fila cuyos datos no son JSON valido se loguea y se trata como si no hubiera sesion.
  • La cookie es HttpOnly, SameSite=Lax y Secure salvo en desarrollo: new Kernel({ environment }) o, sin eso, NODE_ENV distinto de development / test, tambien sin definir; cookieOptions.secure lo sobreescribe.
import { CsrfFeature } from '@iskra-bun/web-kit';
new CsrfFeature({
secret: process.env.CSRF_SECRET!, // requerido, >= 32 caracteres
trustedOrigins: ['https://admin.example.com'], // otros origenes cuyas paginas pueden hacer POST aca
});

Las requests con un metodo fuera de ignoreMethods (GET, HEAD y OPTIONS por defecto) tienen que traer el token, c.get('csrfToken'), en el header X-CSRF-Token o en un campo _csrf del formulario, igual al de la cookie, y venir de las paginas de la propia app:

  • Una request cuyo Origin no es el de la app ni esta en trustedOrigins recibe 403, igual que una sin Origin cuyo Sec-Fetch-Site es cross-site. El Sec-Fetch-Site: same-origin del navegador se toma tal cual, asi que un proxy que termina TLS (la app ve http://) no rompe los formularios del mismo origen; un navegador que solo manda Origin detras de ese proxy necesita el origen publico en trustedOrigins. Las requests sin ninguno de los dos headers (otros servidores, clientes de linea de comandos) quedan a cargo del token. Breaking: un frontend en otro origen, otro subdominio incluido, tiene que estar en trustedOrigins.
  • La cookie es __Host-csrf mientras sea Secure (el default): solo el propio host de la app puede setearla. Como _csrf, un subdominio hermano podia setearla para el dominio padre con un token que conoce y mandar ese token, y SameSite no frena una request del mismo sitio. Con cookieOptions.secure: false es _csrf; un cookieName que definas se respeta.
  • Con SessionFeature, una request con una sesion almacenada necesita un token firmado con el ID de esa sesion, asi que se rechaza un token de otra sesion o de una visita anonima; las requests anonimas reciben tokens sin atar. Desde las paginas de la propia app (Sec-Fetch-Site: same-origin, o su propio Origin o uno de confianza) el token sin atar se sigue aceptando, y se reemplaza por uno atado: la pagina que guardo la sesion (un login sin regenerateSession(), un carrito) se renderizo con el. regenerateSession() emite un token nuevo, disponible como c.get('csrfToken') desde ese momento (renderizalo, o devolvelo a una SPA). Breaking: una SPA tiene que volver a leer el token despues de iniciar o cerrar sesion; uno viejo se reemplaza en la siguiente request, y una request no segura que lo trae recibe 403. CsrfFeature corre despues de SessionFeature sin importar en que orden las registres.
  • El secret debe tener al menos 32 caracteres (breaking): uno corto se puede romper por fuerza bruta offline a partir de un solo token, y con eso falsificar cualquiera.

ok() y list() (ver Contrato de respuestas) arman los cuerpos de éxito según el contrato de la app. successResponse(data, message) da el objeto { success, data, message } de Iskra para código que arma su propia respuesta; errorResponse() se mantiene para código anterior, pero un error lanzado (o fail()) responde según el contrato y se loguea.