Web Kit
El web-kit provee un servidor HTTP basado en Hono con un sistema modular de features.
Inicio Rapido
Sección titulada «Inicio Rapido»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".
Rutas en grupos
Sección titulada «Rutas en grupos»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 grupov1.use(auth); // para las rutas que se agreguen después de esta líneaconst users = v1.group('/users', requireScopes('users:read')); // hereda requestLog y authusers.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/meantes 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 (ouse: [...]), 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. Conthen: 'auto', un path que tiene rutas para otros métodos responde 405 conAllow: GET, HEAD, POST. Los paths fuera de un grupo conunmatched()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 (servidor standalone)
Sección titulada «WebDriver (servidor standalone)»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
optionalDependenciesde una feature se inicializan antes que ella solo si estan registradas (asiCsrfFeaturecorre despues deSessionFeature, 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 deinitialize()(agregalas despues deawait kernel.initialize(), o pasalas comorouterde WebPlugin), rutas que una feature agrega eninitialize()en vez deroutes(), y una segunda feature con un nombre ya registrado (antes reemplazaba a la primera en silencio;RateLimitFeatureacepta unnamepara un segundo limitador) - Aplica headers de seguridad automaticamente (lo que pases en
securityHeadersse combina con los valores por defecto); un header que la ruta define por su cuenta, como unContent-Security-Policymas 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.
Features Disponibles
Sección titulada «Features Disponibles»| 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 |
Validacion de requests
Sección titulada «Validacion de requests»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/jsono+json), o un formulario (urlencoded, multipart) conallowForm(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()yvalidateJson()lo leen igual (con formularios); antes un JSON malformado se validaba como{}. caseInsensitiveKeysrenombra 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_ERRORcon los campos fallidos endetails: elflatten()de Zod por defecto, odetails: 'issues'([{ path, message, code }]),'fields'({ "address.city": ["Required"] }) o una función de los issues, por llamada o para toda la app envalidationDetailsdel contrato.
Generic de Schema en DbFeature
Sección titulada «Generic de Schema en DbFeature»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 schemaReadiness Checks en HealthCheckFeature
Sección titulada «Readiness Checks en HealthCheckFeature»/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).
Detalles de los endpoints
Sección titulada «Detalles de los endpoints»Por defecto includeDetails es false, para los tres endpoints:
/healthresponde{ status, timestamp }: sin lista de features, resultados por check ni strings de error crudos (los errores se registran en el servidor)./health/readyresponde{ 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/liveresponde{ status, timestamp }, sinuptime.
Breaking (0.x):
/health/readyy/health/livedevolvian los nombres de los checks y el uptime sin importarincludeDetails.
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 publicoPaths y cuerpos
Sección titulada «Paths y cuerpos»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.
Documentacion OpenAPI
Sección titulada «Documentacion OpenAPI»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-referencedesde jsDelivr, con su hash de Subresource Integrity ycrossorigin="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, usascalar: { src, integrity }, dondeintegrityes el hashsha384-…de ese archivo exacto. - Sin CDN (una intranet, una CSP estricta):
scalar: 'local'sirve el bundle del@scalar/api-referenceinstalado (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 unstandalone.jsque 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.jses código público y no pasa porauthorize. - La pagina envia su propio
Content-Security-Policy: scripts solo del origen de ese script, requests solo al origen de la app y a losserversdel 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.jsony/docsse registran enroutes(), asi que un middleware agregado a la app despues deinitialize()(unbasicAuth(), por ejemplo) no corre para ellas: usaauthorize, 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"' }),Rutas sin addRoute()
Sección titulada «Rutas sin addRoute()»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 encomponents.schemasy 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 deapp.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 unsecurityglobal.routes: 'described'(el default) lista las rutas con undescribeRoute(), 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 seaall()). Una operación deaddRoute()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 unpattern.- Un contrato propio documenta sus cuerpos en
schemas:{ error, errorType?, success?(data), list?(item) }, como JSON Schema. Sinschemas, 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.
Access log
Sección titulada «Access log»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"}Tracing
Sección titulada «Tracing»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.fullse exporta con el valor de los parametros de query con pinta de secreto reemplazado porREDACTED(SECRET_QUERY_PARAMSde@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.redactedQueryParamsdefine 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: trueempieza una traza nueva por request y descarta elbaggageentrante: 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
captureRequestHeadersse exportan tal cual: no incluyasauthorizationnicookie.
Errores HTTP
Sección titulada «Errores HTTP»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 el404 Not Founden texto plano de Hono). - Un request HEAD recibe el status y los headers, sin cuerpo.
requestIdaparece cuandoRequestIdFeaturepuso uno.- Un
HTTPExceptionque trae su propia respuesta (el 401 debasicAuth) se envía tal cual. includeStack(KernelConfig.includeStackoErrorHandlerFeature, activo por defecto sólo conNODE_ENV=development) agregastack, 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 enerror.
ErrorHandlerFeature es opcional: fija includeStack, un handler por status (customHandlers: { 404: (err, c) => … }) y un logger para los errores.
Códigos de error
Sección titulada «Códigos de error»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.
Contrato de respuestas
Sección titulada «Contrato de respuestas»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.
Rate limiting e IP del cliente
Sección titulada «Rate limiting e IP del cliente»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), eswindowMs, una cota superior. Unhandlerpropio tambien lo envia si responde a traves dec(c.json(),c.text()). Los SDKs cliente lo leen en su excepcion de rate limit. - Solo se lee
clientIpHeader(breaking). AntesX-Real-IPse usaba cuando faltabaX-Forwarded-For, y eso lo decide el cliente: detras de un proxy que solo completaX-Real-IPy deja pasarX-Forwarded-For, unX-Forwarded-Forinventado era un bucket nuevo en cada request. Si tu proxy completaX-Real-IP(nginx:proxy_set_header X-Real-IP $remote_addr), configuraclientIpHeader: 'x-real-ip'; conX-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/livey reiniciaba el pod. ConskipHealthChecks: falsevuelven a contar. - Si las requests traen
X-Forwarded-ForoX-Real-IPperotrustProxyno 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.1cuenta como192.0.2.1.clientIpKey(ip)devuelve esa clave para un limitador propio. - El store en memoria sigue como maximo
maxKeysclientes (100 000 por defecto; por encima descarta los mas viejos) y barre los vencidos cada minuto; el limitador de auth aceptarateLimit: { maxKeys }y el adapter en memoria deCacheFeaturemaxEntries, 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. CacheFeaturecon 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), unusernamede ACL, ytls: trueu opciones TLS de Node. Un comando falla despues decommandTimeoutMs(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,
RateLimitFeaturedeja pasar las requests sin limite y loguea un error como maximo una vez por minuto (passOnStoreError,truepor defecto): antes una caida de Redis hacia fallar todas las requests. ConpassOnStoreError: falseresponde 503 conRetry-After. Las sesiones en cache siguen haciendo fallar la request, porque tratarla como anonima desloguearia a los usuarios, pero despues decommandTimeoutMsen lugar de quedarse colgadas. - Los adapters Redis y en memoria de
CacheFeaturese comportan igual (breaking): Redis guarda todo valor como JSON, tambien los strings, asi que'123'se lee como string y no como123(los strings crudos que guardaban versiones anteriores se siguen leyendo), y por eso un contador debe guardarse como numero (set('k', 5)) o crearse conincrement()(despues deset('k', '5'),increment()falla en Redis); un TTL fraccionario en segundos (0.5) funciona en Redis, que lo rechazaba; yincrement()sobre una key inexistente o vencida la crea en 1 en memoria, comoINCRde Redis (devolvia 0).
Configuracion de Seguridad
Sección titulada «Configuracion de Seguridad»El Kernel envia estos headers de seguridad por defecto:
X-Frame-Options: SAMEORIGINX-Content-Type-Options: nosniffReferrer-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 elsecretconfigurado y comparada en tiempo constante, mas un chequeo deOrigin; ver la seccion CSRF. El kill-switchdisableCSRFCheckdeAuthFeaturese ignora fuera de desarrollo (solo se respeta conNODE_ENVendevelopmentotest), 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 lasstaticKeysconfiguradas en cada request, asi que quitar, vencer o recortar una key tiene efecto inmediato; no se cachea nada (enableCacheycacheTtlse ignoran: la entrada cacheada guardaba la key en claro y seguia valiendo despues de revocarla). Losidde las API keys son aleatorios (UUID) y no filtran ningun prefijo del secreto. La comparacion de keys es en tiempo constante. UnAuthorization: Bearerque 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 usanrequireApiKey()/requireScope(). Una key invalida en el headerX-API-Keysi devuelve 401. -
Scopes de las API keys: un comodin es un segmento entero,
*solo o un:*final (users:*habilitausers:readyusers:x:y, nousersX); cualquier otro*es literal. Breaking: un*final era un prefijo de texto, asi queuser*habilitabausers:readyuser-admin:delete. -
CORS (
CorsFeature):credentials: truenecesita queoriginnombre los origenes permitidos (una lista o una funcion); conoriginsin definir o'*',initialize()tira un error (breaking). Antes mandabaAccess-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 (conCacheFeature) durantecacheTTLsegundos, 60 por defecto (antes una hora): un rol que revocas sigue valiendo ese tiempo. Llama aawait kernel.getFeature('permissions')?.invalidate(userId)despues de cambiar los roles o permisos de un usuario para descartar la copia cacheada; bajacacheTTL, o usacachePermissions: false, para cambiar consultas a la base por una ventana mas corta. Corre despues deauth,sessionycachesea 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 soloanonymousPermissions. -
CSRF en rutas puntuales:
requireCsrf()valida el token en la ruta aunque su metodo este enignoreMethods(p. ej. un GET que modifica estado) y falla cerrado siCsrfFeatureno esta registrada. En formulariosmultipart/form-dataenvia el token en el headerX-CSRF-Token. -
Uploads (
UploadFeature): conexposeRoutes: truees obligatorioauthorize(c, action, target?)(action:upload|list|download|delete); usaauthorize: () => truesolo si las rutas deben ser publicas. El body se corta al superarmaxFileSize(413) sin cargarlo entero en memoria, el nombre del archivo se sanea y los errores internos no se devuelven al cliente.maxFileSizemas 64 KiB de overhead multipart tiene que entrar en elmaxRequestBodySizedel Kernel (16 MiB por defecto): Bun rechaza bodies mas grandes antes de llegar a la ruta, asi queinitialize()falla en vez de que el limite nunca se alcance sin aviso.targetes lo que toca la accion:{ key, subfolder, filename }, massizeytypeenupload(el{ key, subfolder }de la carpeta enlist);subfolderno tiene segmentos vacios ni./...uploadse pregunta dos veces: primero sin target, antes de leer el body, y luego con el, antes de escribir nada. Una comprobacion comoBoolean(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 (
contentTypeForde storage-kit), nunca con el de quien lo sube (Bun deduceFile.typedel nombre:image/svg+xml,text/html). Las descargas se transmiten en streaming y solo las imagenes rasterizadas se sirven inline: todo lo demas llevaContent-Disposition: attachment, y toda descargaContent-Security-Policy: sandbox. En S3/MinIO el objeto guarda la misma disposicion. - Sin
allowedExtensionsse 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 enallowedExtensionspara aceptarla (se sigue guardando comoapplication/octet-streamy se descarga). Con el adaptador de almacenamientolocal, 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 superarmaxFileSizey rechaza una extension queallowedExtensionsno permite, lanzando unHttpError(413File too large, 400Invalid extension, 400 si el campo no trae un archivo). Antes leia cualquier body entero y guardaba cualquier extension. Sigue elmaxFileSizey losallowedExtensionsde la feature, este o no activoexposeRoutes.
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 bodyconst 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) cuyosubjectoheaderslleven un CR/LF, o cuyas entradas deto/cc/bcc/replyTono 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): elsecretsubyacente 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.
Gates: quién hizo el request
Sección titulada «Gates: quién hizo el request»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 challengeWWW-Authenticatede 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)ponec.var.actorcuando 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 respondeunmatched(), así un invitado recibe 401 y no 404. - Un gate es una función
(c) => actor | nullque lanza unAuthError401 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
secretfirma 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 contienechange-me,dev-secret…): ver auth-kit. - En modo
oidc(o si se pasaoidcConfig) el login email/password queda deshabilitado; activalo explicitamente conenableEmailPassword: true.enableSelfRegistration: falserechaza/sign-up/email(las cuentas se crean por otro medio). baseURLes el origen publico de la app (por defecto tomaBETTER_AUTH_URL). Better Auth decide con el el flagSecurede las cookies, asi que es obligatorio en produccion: sin el se usabahttp://localhost:3000y las cookies salian sinSecure. Por lo mismo, en produccion tiene que serhttps://(breaking); sololocalhost,127.0.0.1y[::1]pueden usar http plano (un proxy o docker compose en la misma maquina). Antes se aceptaba un origenhttp://, y las cookies de sesion salian sinSecure.- Los intentos de auth (requests
POSTa{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 conrateLimit: { max, windowMs, maxKeys }, orateLimit: falseapaga 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
ipAddressde las sesiones yRateLimitFeature) es la del socket. Si la app corre detras de un proxy (nginx, load balancer), configuranew Kernel({ trustProxy: 1 })con la cantidad de proxies para usar la direccion reenviada (X-Forwarded-For, oX-Real-IPconclientIpHeader: '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.
Sesiones
Sección titulada «Sesiones»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 objetoSessionData: sus campos sonunknownhasta 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 aawait 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). ConCsrfFeaturetambien 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 conSET ... XX, en una base con unUPDATEde su fila. Antes chequeaba (get) y despues escribia (set), y en Redis o una base un logout que caia entre los dos se deshacia. UnCacheAdapterpropio lo obtiene consetIfExists(); 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 constructuredClone(para los otros stores ya tenian que ser JSON). c.get('sessionPersisted')indica sisessionIdcorresponde 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=LaxySecuresalvo en desarrollo:new Kernel({ environment })o, sin eso,NODE_ENVdistinto dedevelopment/test, tambien sin definir;cookieOptions.securelo 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
Originno es el de la app ni esta entrustedOriginsrecibe 403, igual que una sinOrigincuyoSec-Fetch-Siteescross-site. ElSec-Fetch-Site: same-origindel navegador se toma tal cual, asi que un proxy que termina TLS (la app vehttp://) no rompe los formularios del mismo origen; un navegador que solo mandaOrigindetras de ese proxy necesita el origen publico entrustedOrigins. 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 entrustedOrigins. - La cookie es
__Host-csrfmientras seaSecure(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, ySameSiteno frena una request del mismo sitio. ConcookieOptions.secure: falsees_csrf; uncookieNameque 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 propioOrigino uno de confianza) el token sin atar se sigue aceptando, y se reemplaza por uno atado: la pagina que guardo la sesion (un login sinregenerateSession(), un carrito) se renderizo con el.regenerateSession()emite un token nuevo, disponible comoc.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.CsrfFeaturecorre despues deSessionFeaturesin importar en que orden las registres. - El
secretdebe 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.
Respuestas Estandarizadas
Sección titulada «Respuestas Estandarizadas»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.