sashiko 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +212 -117
  2. package/dist/core/app.d.ts +19 -0
  3. package/dist/core/app.d.ts.map +1 -1
  4. package/dist/core/app.js +37 -5
  5. package/dist/core/app.js.map +1 -1
  6. package/dist/core/body-parser.d.ts +2 -1
  7. package/dist/core/body-parser.d.ts.map +1 -1
  8. package/dist/core/body-parser.js +7 -3
  9. package/dist/core/body-parser.js.map +1 -1
  10. package/dist/core/compiler.d.ts.map +1 -1
  11. package/dist/core/compiler.js +9 -5
  12. package/dist/core/compiler.js.map +1 -1
  13. package/dist/core/context.d.ts.map +1 -1
  14. package/dist/core/health.d.ts +26 -0
  15. package/dist/core/health.d.ts.map +1 -0
  16. package/dist/core/health.js +62 -0
  17. package/dist/core/health.js.map +1 -0
  18. package/dist/core/index.d.ts +7 -6
  19. package/dist/core/index.d.ts.map +1 -1
  20. package/dist/core/index.js +5 -5
  21. package/dist/core/index.js.map +1 -1
  22. package/dist/core/infer.d.ts +1 -3
  23. package/dist/core/infer.d.ts.map +1 -1
  24. package/dist/core/inject.d.ts.map +1 -1
  25. package/dist/core/inject.js.map +1 -1
  26. package/dist/core/logger.d.ts +17 -1
  27. package/dist/core/logger.d.ts.map +1 -1
  28. package/dist/core/logger.js +71 -3
  29. package/dist/core/logger.js.map +1 -1
  30. package/dist/core/metadata.js.map +1 -1
  31. package/dist/core/mounted-group.d.ts +2 -0
  32. package/dist/core/mounted-group.d.ts.map +1 -1
  33. package/dist/core/mounted-group.js +55 -18
  34. package/dist/core/mounted-group.js.map +1 -1
  35. package/dist/core/request-validation.d.ts.map +1 -1
  36. package/dist/core/request-validation.js +4 -1
  37. package/dist/core/request-validation.js.map +1 -1
  38. package/dist/core/response.d.ts.map +1 -1
  39. package/dist/core/response.js +4 -4
  40. package/dist/core/response.js.map +1 -1
  41. package/dist/core/route-table.d.ts.map +1 -1
  42. package/dist/core/route-table.js +1 -2
  43. package/dist/core/route-table.js.map +1 -1
  44. package/dist/core/shutdown.d.ts +5 -0
  45. package/dist/core/shutdown.d.ts.map +1 -1
  46. package/dist/core/shutdown.js.map +1 -1
  47. package/dist/decorators/http.d.ts.map +1 -1
  48. package/dist/decorators/http.js +2 -1
  49. package/dist/decorators/http.js.map +1 -1
  50. package/dist/decorators/log.d.ts.map +1 -1
  51. package/dist/decorators/log.js +1 -1
  52. package/dist/decorators/log.js.map +1 -1
  53. package/dist/decorators/middlewares.d.ts.map +1 -1
  54. package/dist/decorators/middlewares.js +1 -1
  55. package/dist/decorators/middlewares.js.map +1 -1
  56. package/dist/decorators/router.d.ts.map +1 -1
  57. package/dist/decorators/router.js +2 -1
  58. package/dist/decorators/router.js.map +1 -1
  59. package/dist/decorators/schema.d.ts.map +1 -1
  60. package/dist/decorators/schema.js +2 -1
  61. package/dist/decorators/schema.js.map +1 -1
  62. package/dist/middlewares/auth.d.ts +41 -0
  63. package/dist/middlewares/auth.d.ts.map +1 -0
  64. package/dist/middlewares/auth.js +74 -0
  65. package/dist/middlewares/auth.js.map +1 -0
  66. package/dist/middlewares/cors.d.ts.map +1 -1
  67. package/dist/middlewares/cors.js +5 -1
  68. package/dist/middlewares/cors.js.map +1 -1
  69. package/dist/middlewares/index.d.ts +1 -0
  70. package/dist/middlewares/index.d.ts.map +1 -1
  71. package/dist/middlewares/index.js +1 -0
  72. package/dist/middlewares/index.js.map +1 -1
  73. package/dist/openapi/generate.d.ts.map +1 -1
  74. package/dist/openapi/generate.js +5 -10
  75. package/dist/openapi/generate.js.map +1 -1
  76. package/dist/schema/validation.d.ts.map +1 -1
  77. package/dist/schema/validation.js +2 -0
  78. package/dist/schema/validation.js.map +1 -1
  79. package/dist/utils/content-type.d.ts.map +1 -1
  80. package/dist/utils/content-type.js +5 -2
  81. package/dist/utils/content-type.js.map +1 -1
  82. package/dist/utils/cookie.d.ts.map +1 -1
  83. package/dist/utils/cookie.js +1 -0
  84. package/dist/utils/cookie.js.map +1 -1
  85. package/dist/utils/origin-match.d.ts.map +1 -1
  86. package/dist/utils/origin-match.js +1 -2
  87. package/dist/utils/origin-match.js.map +1 -1
  88. package/dist/utils/path.d.ts.map +1 -1
  89. package/dist/utils/path.js.map +1 -1
  90. package/dist/utils/request-target.d.ts.map +1 -1
  91. package/dist/utils/request-target.js +4 -1
  92. package/dist/utils/request-target.js.map +1 -1
  93. package/dist/utils/safe-json.d.ts +19 -0
  94. package/dist/utils/safe-json.d.ts.map +1 -0
  95. package/dist/utils/safe-json.js +52 -0
  96. package/dist/utils/safe-json.js.map +1 -0
  97. package/package.json +5 -1
package/README.md CHANGED
@@ -42,6 +42,7 @@ await app.listen({ port: 3000 });
42
42
  - [Segurança](#segurança)
43
43
  - [Logs e request id](#logs-e-request-id)
44
44
  - [Ciclo de vida e testes](#ciclo-de-vida-e-testes)
45
+ - [Deploy em produção](#deploy-em-produção)
45
46
  - [Referência: `createApp(options)`](#referência-createappoptions)
46
47
  - [Princípios](#princípios)
47
48
 
@@ -54,7 +55,7 @@ await app.listen({ port: 3000 });
54
55
  ```bash
55
56
  pnpm add sashiko
56
57
  ```
57
- *(O TypeBox já vem incluído e re-exportado como `Type`, `Static` e `TSchema`)*
58
+ _(O TypeBox já vem incluído e re-exportado como `Type`, `Static` e `TSchema`)_
58
59
 
59
60
  `tsconfig.json` mínimo:
60
61
 
@@ -77,12 +78,16 @@ As rotas ficam em classes marcadas com `@Router`, a "permissão" para o app usar
77
78
  ```ts
78
79
  @Router('/users')
79
80
  class UserRoutes {
80
- @Get('/') list() {}
81
- @Get('/:id') get(ctx: Context) { return { id: ctx.params.id }; }
82
- @Post('/') create() {}
83
- @Put('/:id') replace() {}
84
- @Patch('/:id') update() {}
85
- @Delete('/:id') remove(ctx: Context) { ctx.status(204); }
81
+ @Get('/') list() {}
82
+ @Get('/:id') get(ctx: Context) {
83
+ return { id: ctx.params.id };
84
+ }
85
+ @Post('/') create() {}
86
+ @Put('/:id') replace() {}
87
+ @Patch('/:id') update() {}
88
+ @Delete('/:id') remove(ctx: Context) {
89
+ ctx.status(204);
90
+ }
86
91
  }
87
92
  ```
88
93
 
@@ -100,21 +105,22 @@ const userRoutes = new UserRoutes(repo);
100
105
 
101
106
  app
102
107
  .mount(HealthRoutes)
103
- .mount(userRoutes) // como declarado
104
- .mount(userRoutes, { prefix: '/v2/users' }) // a mesma instância em outro prefixo
108
+ .mount(userRoutes) // como declarado
109
+ .mount(userRoutes, { prefix: '/v2/users' }) // a mesma instância em outro prefixo
105
110
  .mount(userRoutes, { prefix: '/public/users', only: (r) => [r.list, r.get] }) // exposição parcial
106
111
  .mount(new AdminRoutes(repo), { middlewares: [authenticate], guards: [isAdmin] }); // regras para o grupo
107
112
  ```
108
113
 
109
- | Opção | Efeito |
110
- |---|---|
111
- | `prefix` | Substitui o prefixo do `@Router` |
114
+ | Opção | Efeito |
115
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | `prefix` | Substitui o prefixo do `@Router` |
112
117
  | `only` / `except` | Registra só os métodos selecionados, ou todos menos esses: `(r) => [r.list, r.get]`. O seletor é checado pelo TypeScript; usar os dois juntos gera erro |
113
- | `auto` | `false` registra só as [rotas manuais](#mapeamento-manual). Padrão: `true` |
114
- | `middlewares` | Valem para todas as rotas do grupo; rodam antes dos `@Middlewares` da classe |
115
- | `guards` | Valem para todas as rotas do grupo; rodam antes dos `@Guards` da classe |
118
+ | `auto` | `false` registra só as [rotas manuais](#mapeamento-manual). Padrão: `true` |
119
+ | `middlewares` | Valem para todas as rotas do grupo; rodam antes dos `@Middlewares` da classe |
120
+ | `guards` | Valem para todas as rotas do grupo; rodam antes dos `@Guards` da classe |
116
121
 
117
122
  Erros de registro aparecem na inicialização, com mensagem clara:
123
+
118
124
  - classe sem `@Router`;
119
125
  - seletor em `only`/`except` que não aponta para um método de rota;
120
126
  - a mesma classe registrada duas vezes com o mesmo prefixo.
@@ -126,9 +132,7 @@ Erros de registro aparecem na inicialização, com mensagem clara:
126
132
  Para aplicar prefixo, middlewares e guards a **várias** classes de uma vez:
127
133
 
128
134
  ```ts
129
- app.group({ prefix: '/v2', middlewares: [authenticate], guards: [isAdmin] }, (g) =>
130
- g.mount(UserRoutes).mount(new OrderRoutes(db)),
131
- );
135
+ app.group({ prefix: '/v2', middlewares: [authenticate], guards: [isAdmin] }, (g) => g.mount(UserRoutes).mount(new OrderRoutes(db)));
132
136
  // GET /v2/users, GET /v2/orders…
133
137
  ```
134
138
 
@@ -142,9 +146,7 @@ app.group({ prefix: '/v2', middlewares: [authenticate], guards: [isAdmin] }, (g)
142
146
 
143
147
  ```ts
144
148
  export function audit(app: SashikoApp, opts: { store: AuditStore }) {
145
- return app
146
- .group({ prefix: '/audit', guards: [isAdmin] }, (g) => g.mount(new AuditRoutes(opts.store)))
147
- .onClose(() => opts.store.flush());
149
+ return app.group({ prefix: '/audit', guards: [isAdmin] }, (g) => g.mount(new AuditRoutes(opts.store))).onClose(() => opts.store.flush());
148
150
  }
149
151
 
150
152
  audit(app, { store });
@@ -157,16 +159,16 @@ Depois do `mount`, os verbos `.get()`, `.post()`, `.put()`, `.patch()` e `.delet
157
159
  ```ts
158
160
  @Router('/users')
159
161
  class UserRoutes {
160
- @Get('/:id', byId) getUser(ctx: Context<typeof byId>) {}
161
- @Schema(csv) exportCsv(ctx: Context<typeof csv>) {} // sem verbo: só por mapeamento manual
162
- private findOr404() {} // sem decorator: nunca pode ser exposto
162
+ @Get('/:id', byId) getUser(ctx: Context<typeof byId>) {}
163
+ @Schema(csv) exportCsv(ctx: Context<typeof csv>) {} // sem verbo: só por mapeamento manual
164
+ private findOr404() {} // sem decorator: nunca pode ser exposto
163
165
  }
164
166
 
165
167
  app
166
168
  .mount(new UserRoutes(repo))
167
- .get('/legacy/:id', (r) => r.getUser) // GET /users/legacy/:id
169
+ .get('/legacy/:id', (r) => r.getUser) // GET /users/legacy/:id
168
170
  .get('/export.csv', (r) => r.exportCsv, { middlewares: [cache()], guards: [isAuditor] })
169
- .mount(HealthRoutes); // o encadeamento continua
171
+ .mount(HealthRoutes); // o encadeamento continua
170
172
  ```
171
173
 
172
174
  - **Seletor:** funciona com instância (inclusive inline, `mount(new X(repo))`) e com a classe. "Ir para a definição" e renomear no editor funcionam.
@@ -207,11 +209,13 @@ list(ctx: Context<typeof list>) {
207
209
  ```
208
210
 
209
211
  **Path e `params` conferidos**
212
+
210
213
  - **Em compilação:** com schema de `params` no verbo, todo parâmetro do path precisa estar no schema. `@Get('/:userId', { params: Type.Object({ id: Type.String() }) })` é erro de tipo (`Missing in params schema: "userId"`).
211
214
  - **No registro:** o `app.mount()` confere path x schema nos dois sentidos, já com o prefixo do `@Router`/`mount` (que pode ter parâmetros, como `'/orgs/:org'`), também para `@Schema` separado e rotas manuais. Também recusa path malformado: `/:`, nome repetido, regex sem fechamento, `*` fora do fim.
212
215
  - **Sem schema:** `Context<'/:org/users/:id'>` tipa `ctx.params` pelo path (`{ org: string; id: string }`). O utilitário `PathParams<'/:a/:b'>` dá `'a' | 'b'`.
213
216
 
214
217
  **Validação**
218
+
215
219
  - **Tipagem:** com `Context<typeof schemas>`, `ctx.params`, `ctx.query` e `ctx.body` são tipados a partir do schema. Sem o genérico, `Context` usa tipos genéricos.
216
220
  - **Coerção:** params, query e headers chegam como texto e são convertidos para o tipo declarado. O **body não sofre coerção**: `"30"` não vira `30`.
217
221
  - **Defaults:** valores `default` são aplicados.
@@ -228,6 +232,7 @@ list(ctx: Context<typeof list>) {
228
232
  - **Campos extras:** use `additionalProperties: false` nos bodies para rejeitar campos não previstos. É a proteção contra mass assignment.
229
233
 
230
234
  **Resposta**
235
+
231
236
  - **Filtro:** o schema de `response` serializa a saída com `fast-json-stringify`, e **campos não declarados não saem**, por exemplo `password`.
232
237
  - **Status padrão:** se só existe um 2xx declarado (como `201`), ele é o status padrão; não é preciso chamar `ctx.status(201)`.
233
238
  - **Status sem schema:** se a rota declara schemas de resposta e o handler devolve um status sem schema, a resposta é **500**, com o motivo no log, e não o objeto sem filtro.
@@ -239,13 +244,13 @@ list(ctx: Context<typeof list>) {
239
244
 
240
245
  O valor retornado pelo handler vira a resposta. Sem schema de resposta, o formato vem do tipo do valor:
241
246
 
242
- | Retorno | Resposta |
243
- |---|---|
244
- | objeto, array, número, booleano, `null` | `application/json` |
245
- | `string` | `text/plain; charset=utf-8`, sem aspas |
246
- | `Buffer` / `Uint8Array` | `application/octet-stream` |
247
- | `Readable` (stream) | enviado em stream |
248
- | `undefined` | sem corpo |
247
+ | Retorno | Resposta |
248
+ | --------------------------------------- | -------------------------------------- |
249
+ | objeto, array, número, booleano, `null` | `application/json` |
250
+ | `string` | `text/plain; charset=utf-8`, sem aspas |
251
+ | `Buffer` / `Uint8Array` | `application/octet-stream` |
252
+ | `Readable` (stream) | enviado em stream |
253
+ | `undefined` | sem corpo |
249
254
 
250
255
  Um `Content-Type` definido no handler (`ctx.header('Content-Type', ...)`) sempre prevalece.
251
256
 
@@ -295,6 +300,7 @@ run(ctx: Context<typeof job>) {
295
300
  **O `status()` tipa o corpo.** Com schemas de resposta, `ctx.status()` só aceita os status declarados, e o `json()`/`send()` seguinte exige o corpo daquele status. `ctx.status(202).json({ result })` e `ctx.status(201)` são erros de compilação no exemplo acima.
296
301
 
297
302
  Estilo recomendado:
303
+
298
304
  - `return` para o caso comum;
299
305
  - `ctx.status(x).json(body)` quando o status é decidido em runtime;
300
306
  - `throw` para erros.
@@ -303,23 +309,23 @@ Os schemas de resposta valem para o que o **handler** envia. Middlewares e guard
303
309
 
304
310
  ### Helpers do `ctx`
305
311
 
306
- | Método | Uso |
307
- |---|---|
308
- | `ctx.status(code)` | Define o status |
309
- | `ctx.header(name, value)` | Define um header da resposta |
310
- | `ctx.get(name)` | Lê um header da requisição |
311
- | `ctx.json(data)` | Envia JSON pelo pipeline (schema, filtro, status padrão) |
312
- | `ctx.text(str)` | `text/plain; charset=utf-8`, pelo pipeline |
313
- | `ctx.send(body, contentType?)` | Qualquer corpo pelo pipeline: string, bytes, stream ou objeto (negociado pelo `Accept`) |
314
- | `ctx.redirect(url, status?)` | 302 por padrão; aceita 301, 303, 307, 308 |
315
- | `ctx.cookies` | Cookies da requisição (objeto sem protótipo) |
312
+ | Método | Uso |
313
+ | ----------------------------------- | ---------------------------------------------------------------------------------------- |
314
+ | `ctx.status(code)` | Define o status |
315
+ | `ctx.header(name, value)` | Define um header da resposta |
316
+ | `ctx.get(name)` | Lê um header da requisição |
317
+ | `ctx.json(data)` | Envia JSON pelo pipeline (schema, filtro, status padrão) |
318
+ | `ctx.text(str)` | `text/plain; charset=utf-8`, pelo pipeline |
319
+ | `ctx.send(body, contentType?)` | Qualquer corpo pelo pipeline: string, bytes, stream ou objeto (negociado pelo `Accept`) |
320
+ | `ctx.redirect(url, status?)` | 302 por padrão; aceita 301, 303, 307, 308 |
321
+ | `ctx.cookies` | Cookies da requisição (objeto sem protótipo) |
316
322
  | `ctx.setCookie(name, value, opts?)` | Adiciona um `Set-Cookie`, com `HttpOnly`, `Secure`, `SameSite=Lax` e `Path=/` por padrão |
317
- | `ctx.clearCookie(name, opts?)` | Remove um cookie (use o mesmo `path` e `domain` da criação) |
318
- | `ctx.throw(status, message)` | Lança um `HttpError` |
323
+ | `ctx.clearCookie(name, opts?)` | Remove um cookie (use o mesmo `path` e `domain` da criação) |
324
+ | `ctx.throw(status, message)` | Lança um `HttpError` |
319
325
 
320
326
  Outros campos: `ctx.params`, `ctx.query`, `ctx.body`, `ctx.state`, `ctx.id`, `ctx.log`, `ctx.sentBody`, `ctx.sentType`, `ctx.ip`, `ctx.host`, `ctx.hostname`, `ctx.protocol`, `ctx.method`, `ctx.path`, `ctx.url`, `ctx.req` e `ctx.res`.
321
327
 
322
- > ⚠️ `ctx.send` não faz escape de HTML; escape o conteúdo dinâmico antes. `ctx.redirect` com uma URL vinda do usuário abre um *open redirect*; valide o destino.
328
+ > ⚠️ `ctx.send` não faz escape de HTML; escape o conteúdo dinâmico antes. `ctx.redirect` com uma URL vinda do usuário abre um _open redirect_; valide o destino.
323
329
 
324
330
  ## Middlewares e guards
325
331
 
@@ -337,7 +343,9 @@ Por classe ou por método, use `@Middlewares` e `@Guards`; para um grupo inteiro
337
343
 
338
344
  ```ts
339
345
  declare module 'sashiko' {
340
- interface ContextState { user?: { id: string; role: 'admin' | 'user' } } // ctx.state tipado
346
+ interface ContextState {
347
+ user?: { id: string; role: 'admin' | 'user' };
348
+ } // ctx.state tipado
341
349
  }
342
350
 
343
351
  const authenticate: Middleware = async (ctx, next) => {
@@ -373,9 +381,13 @@ Para o que acontece **antes** do handler (autenticação, rate limit, preparar o
373
381
 
374
382
  ```ts
375
383
  app
376
- .onSend((ctx, payload) => { ctx.header('ETag', etag(payload.body)); }) // antes de escrever (síncrono)
384
+ .onSend((ctx, payload) => {
385
+ ctx.header('ETag', etag(payload.body));
386
+ }) // antes de escrever (síncrono)
377
387
  .onResponse((ctx, { status, ms }) => metrics.observe(ctx.path, status, ms)) // depois do envio
378
- .onClose(async () => { await db.close(); }); // em qualquer close()
388
+ .onClose(async () => {
389
+ await db.close();
390
+ }); // em qualquer close()
379
391
  ```
380
392
 
381
393
  - **`onSend`:** roda imediatamente antes de escrever uma resposta com corpo (exceto streams), inclusive erros e 404. É **síncrono**, como a escrita do pipeline. Pode alterar headers ou devolver `{ body, contentType }` novos. Se lançar, o erro é logado e a resposta original é enviada.
@@ -385,23 +397,25 @@ app
385
397
  **Cache com middleware.** Depois do `await next()`, `ctx.sentBody` e `ctx.sentType` contêm o corpo exatamente como foi enviado, já filtrado pelo schema:
386
398
 
387
399
  ```ts
388
- const cache = (ttlMs: number): Middleware => async (ctx, next) => {
389
- const hit = store.get(ctx.url.href);
390
- if (hit) return void ctx.header('X-Cache', 'HIT').send(hit.body, hit.type);
391
- ctx.header('X-Cache', 'MISS'); // headers antes do next(): depois dele a resposta já foi enviada
392
- await next();
393
- if (ctx.res.statusCode === 200 && ctx.sentBody) store.set(ctx.url.href, { body: ctx.sentBody, type: ctx.sentType! });
394
- };
400
+ const cache =
401
+ (ttlMs: number): Middleware =>
402
+ async (ctx, next) => {
403
+ const hit = store.get(ctx.url.href);
404
+ if (hit) return void ctx.header('X-Cache', 'HIT').send(hit.body, hit.type);
405
+ ctx.header('X-Cache', 'MISS'); // headers antes do next(): depois dele a resposta já foi enviada
406
+ await next();
407
+ if (ctx.res.statusCode === 200 && ctx.sentBody) store.set(ctx.url.href, { body: ctx.sentBody, type: ctx.sentType! });
408
+ };
395
409
  app.mount(products, { middlewares: [cache(60_000)] });
396
410
  ```
397
411
 
398
- | Fastify | Sashiko |
399
- |---|---|
412
+ | Fastify | Sashiko |
413
+ | ------------------------- | ----------------------------------------------------------------- |
400
414
  | `onRequest`, `preHandler` | middlewares (`app.use`, `mount({ middlewares })`, `@Middlewares`) |
401
- | `onError` | `app.onError` |
402
- | `onSend` | `app.onSend` (síncrono) |
403
- | `onResponse` | `app.onResponse` |
404
- | `onClose` | `app.onClose` |
415
+ | `onError` | `app.onError` |
416
+ | `onSend` | `app.onSend` (síncrono) |
417
+ | `onResponse` | `app.onResponse` |
418
+ | `onClose` | `app.onClose` |
405
419
 
406
420
  ## Erros
407
421
 
@@ -420,14 +434,14 @@ Todos os erros, inclusive 404, 405, validação e `Host` inválido, saem no form
420
434
  { "type": "about:blank", "title": "Not Found", "status": 404, "detail": "user not found", "code": "NOT_FOUND" }
421
435
  ```
422
436
 
423
- | Campo | Conteúdo |
424
- |---|---|
425
- | `type` | URI do tipo do problema (opção `type` do `HttpError`); `about:blank` por padrão |
426
- | `title` | Frase do status (`Not Found`) |
427
- | `status` | Status HTTP |
428
- | `detail` | A mensagem do erro; omitido quando igual ao título ou em 5xx não expostos |
429
- | `code` | Código estável para o cliente tratar: `VALIDATION_ERROR`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`, `NOT_ACCEPTABLE`, `INVALID_HOST`, `FORBIDDEN`, `RATE_LIMITED`, `CSRF_ORIGIN`, `CSRF_TOKEN`… |
430
- | `details` | Dados extras: os erros de validação, `allowed` no 405, `available` no 406 |
437
+ | Campo | Conteúdo |
438
+ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
439
+ | `type` | URI do tipo do problema (opção `type` do `HttpError`); `about:blank` por padrão |
440
+ | `title` | Frase do status (`Not Found`) |
441
+ | `status` | Status HTTP |
442
+ | `detail` | A mensagem do erro; omitido quando igual ao título ou em 5xx não expostos |
443
+ | `code` | Código estável para o cliente tratar: `VALIDATION_ERROR`, `INVALID_JSON`, `PAYLOAD_TOO_LARGE`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`, `NOT_ACCEPTABLE`, `INVALID_HOST`, `FORBIDDEN`, `RATE_LIMITED`, `CSRF_ORIGIN`, `CSRF_TOKEN`… |
444
+ | `details` | Dados extras: os erros de validação, `allowed` no 405, `available` no 406 |
431
445
 
432
446
  - **Erros 4xx:** a mensagem vai para o cliente.
433
447
  - **Erros 5xx e erros comuns** (`throw new Error(...)`): a resposta é sempre `{ "type": "about:blank", "title": "Internal Server Error", "status": 500 }`. O erro completo vai para o log, com `reqId`. Para mostrar uma mensagem de 5xx ao cliente, use `expose: true`.
@@ -442,8 +456,8 @@ createApp()
442
456
  .openapi({
443
457
  info: { title: 'Minha API', version: '1.0.0' },
444
458
  servers: [{ url: 'https://api.exemplo.dev' }],
445
- docs: true, // Swagger UI em /docs (desligado por padrão)
446
- guards: [isAdmin], // só para /openapi.json e /docs
459
+ docs: true, // Swagger UI em /docs (desligado por padrão)
460
+ guards: [isAdmin], // só para /openapi.json e /docs
447
461
  });
448
462
  ```
449
463
 
@@ -455,14 +469,14 @@ createApp()
455
469
  - **`app.openapiDocument()`:** devolve o objeto, para gravar em arquivo, gerar clientes ou testar no CI.
456
470
  - **O que entra no documento:**
457
471
 
458
- | Origem | Vira |
459
- |---|---|
460
- | Path (`/:id(^\\d+)`) | `/{id}` |
461
- | `params`, `query`, `headers` | `parameters`, com `required` |
462
- | `body` | `requestBody` em JSON |
463
- | `response` | uma resposta por status, com os `contentType` declarados |
464
- | Erros | resposta `default` com o schema `ProblemDetails`; com `formatError`, só a descrição |
465
- | Classe e método | `tags` e `operationId` (`UserRoutes.getUser`) |
472
+ | Origem | Vira |
473
+ | ---------------------------- | ----------------------------------------------------------------------------------- |
474
+ | Path (`/:id(^\\d+)`) | `/{id}` |
475
+ | `params`, `query`, `headers` | `parameters`, com `required` |
476
+ | `body` | `requestBody` em JSON |
477
+ | `response` | uma resposta por status, com os `contentType` declarados |
478
+ | Erros | resposta `default` com o schema `ProblemDetails`; com `formatError`, só a descrição |
479
+ | Classe e método | `tags` e `operationId` (`UserRoutes.getUser`) |
466
480
 
467
481
  - **Limites:** só schemas JSON Schema (TypeBox) entram como estão. Rotas sem schema de resposta aparecem com um `200` genérico. Em produção, proteja as rotas com `guards`/`middlewares` ou gere só o arquivo com `openapiDocument()`.
468
482
 
@@ -470,15 +484,17 @@ createApp()
470
484
 
471
485
  Ligado por padrão:
472
486
 
473
- | Proteção | Detalhe |
474
- |---|---|
487
+ | Proteção | Detalhe |
488
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
475
489
  | Headers de segurança | `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy: same-origin`. HSTS é opt-in. Para servir páginas, use `securityHeaders: 'web'` (CSP `default-src 'self'`, `X-Frame-Options: SAMEORIGIN`). |
476
- | Limite de body | 1 MiB (`bodyLimit`). Recusado já pelo `Content-Length`, com a conexão fechada. |
477
- | Timeouts | `requestTimeout` 30s, `headersTimeout` 10s, `keepAliveTimeout` 5s (contra Slowloris). |
478
- | Query | Até 1000 pares (`queryMaxKeys`); `__proto__`, `constructor` e `prototype` são descartados. |
479
- | Erros 5xx | Sem detalhes para o cliente. |
480
- | Request id | Ids recebidos só são aceitos se forem seguros (contra log injection). |
481
- | Host e path | `Host` malformado → 400. O path vem do request-target com base fixa: `//evil.com/x` continua sendo esse path e não muda o host. `ctx.url` usa o host validado. |
490
+ | Limite de body | 1 MiB (`bodyLimit`). Recusado já pelo `Content-Length`, com a conexão fechada. |
491
+ | Timeouts | `requestTimeout` 30s, `headersTimeout` 10s, `keepAliveTimeout` 5s (contra Slowloris). |
492
+ | Query | Até 1000 pares (`queryMaxKeys`); `__proto__`, `constructor` e `prototype` são descartados. |
493
+ | Body JSON | `__proto__` e `constructor.prototype` em qualquer nível, inclusive com escapes `\u`, recebem 400 (`PROTO_POISONING`). Com `protoPoisoning: 'remove'`, as chaves são descartadas e a requisição segue. |
494
+ | Logs | `authorization`, `cookie`, `password`, `token`, `apiKey` e afins saem como `[REDACTED]` no logger padrão. |
495
+ | Erros 5xx | Sem detalhes para o cliente. |
496
+ | Request id | Ids recebidos só são aceitos se forem seguros (contra log injection). |
497
+ | Host e path | `Host` malformado → 400. O path vem do request-target com base fixa: `//evil.com/x` continua sendo esse path e não muda o host. `ctx.url` usa o host validado. |
482
498
 
483
499
  Opt-in:
484
500
 
@@ -490,22 +506,31 @@ createApp({
490
506
  securityHeaders: { strictTransportSecurity: true }, // só atrás de HTTPS
491
507
  })
492
508
  .use(cors({ origin: ['https://app.exemplo.com', 'https://*.exemplo.dev'], credentials: true }))
493
- .use(rateLimit({ max: 100, windowMs: 60_000 })); // 429 + Retry-After
509
+ .use(rateLimit({ max: 100, windowMs: 60_000 })); // 429 + Retry-After
494
510
  ```
495
511
 
496
512
  - **`cors()`:** a origem é obrigatória, sem default permissivo. Responde ao preflight com 204 e envia `Vary: Origin`. Combinar `origin: '*'` com `credentials: true` gera erro.
497
513
  - **Regras de origem e de host, seguras por padrão** (as mesmas para `cors()` e `allowedHosts`):
498
514
 
499
- | Regra | Casa com | Não casa com |
500
- |---|---|---|
515
+ | Regra | Casa com | Não casa com |
516
+ | --------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------- |
501
517
  | `'https://app.exemplo.com'` | a mesma origem, depois de normalizar (maiúsculas, porta padrão, barra final) | `http://…`, outra porta, `app.exemplo.com.atacante.com` |
502
- | `'https://*.exemplo.dev'` | qualquer subdomínio | `exemplo.dev` em si, `exemplo.dev.atacante.com` |
503
- | RegExp | a string **inteira**, como se tivesse `^…$` | qualquer casamento parcial |
504
- | função | o que ela decidir, sob sua responsabilidade | — |
505
-
518
+ | `'https://*.exemplo.dev'` | qualquer subdomínio | `exemplo.dev` em si, `exemplo.dev.atacante.com` |
519
+ | RegExp | a string **inteira**, como se tivesse `^…$` | qualquer casamento parcial |
520
+ | função | o que ela decidir, sob sua responsabilidade | — |
506
521
  - **RegExp sem âncora não vira brecha:** `/app\.exemplo\.com/` não aceita `app.exemplo.com.atacante.com`. As flags `g`, `y` e `m` são descartadas, então o resultado não muda entre requisições.
507
522
  - **Prefira o curinga à RegExp:** uma regex mal escrita pode ser lenta com uma entrada maliciosa (ReDoS).
508
523
  - **Regras inválidas falham na inicialização,** por exemplo uma origem sem esquema (`'app.exemplo.com'`).
524
+ - **Autenticação: `bearerAuth()` e `basicAuth()`.** São guards prontos. A verificação é sua: JWT com a biblioteca que preferir, sessão no banco, lista de chaves. O usuário devolvido vai para `ctx.state.user`.
525
+
526
+ ```ts
527
+ const auth = bearerAuth({ verify: async (token) => (await sessions.find(token)) ?? false });
528
+ app.group({ guards: [auth] }, (g) => g.mount(UserRoutes));
529
+
530
+ const painel = basicAuth({ verify: (user, pass) => safeEqual(user, 'admin') && safeEqual(pass, process.env.ADMIN_PASS!) });
531
+ ```
532
+
533
+ Credencial ausente, malformada ou recusada recebe **o mesmo** 401, com `WWW-Authenticate` e sem dizer o motivo. Para comparar segredos, use `safeEqual()`, que tem tempo constante e também não revela o tamanho. Use o `basicAuth` só sobre HTTPS.
509
534
  - **`rateLimit()`:** guarda os contadores em memória por padrão. Para várias instâncias, implemente `RateLimitStore` (Redis etc.).
510
535
  - **Cookies:** `ctx.setCookie()` usa `HttpOnly`, `Secure`, `SameSite=Lax` e `Path=/` por padrão (os navegadores aceitam `Secure` em `http://localhost`). Nome, `path` e `domain` são validados contra injeção de atributos, e os prefixos `__Host-` e `__Secure-` têm as regras conferidas.
511
536
  - **`csrf()`: ative sempre que a autenticação usar cookies.** Métodos que alteram estado (todos menos GET, HEAD e OPTIONS) vindos de outra origem recebem 403. A checagem usa o `Sec-Fetch-Site` e, na falta dele, compara o `Origin` com a própria origem. Requisições sem esses headers (curl, servidor a servidor) passam, porque não há navegador para anexar os cookies da vítima.
@@ -515,6 +540,7 @@ createApp({
515
540
  app.use(csrf({ trustedOrigins: ['https://app.exemplo.com'] })); // front em outra origem
516
541
  app.use(csrf({ token: true })); // + double-submit token
517
542
  ```
543
+
518
544
  Com `token: true`, o middleware cria o cookie `__Host-csrf` (legível pelo JS, `SameSite=Strict`) e o expõe em `ctx.state.csrfToken`; o cliente o devolve no header `X-CSRF-Token`. Rotas GET nunca devem alterar estado.
519
545
  - **Atrás de proxy:** `ctx.ip`, `ctx.host` e `ctx.protocol` usam o socket e o `Host` por padrão. Com `trustProxy: 1` (o número de proxies à frente do app) ou `true` (toda a cadeia), passam a usar o `X-Forwarded-For`, o `-Host` e o `-Proto`. Só ligue isso atrás de um proxy que reescreve esses headers, porque o cliente pode forjá-los.
520
546
  - **`allowedHosts`:** com `allowedHosts: ['api.exemplo.com', '*.exemplo.dev']`, qualquer outro `Host` recebe 400. Protege contra host header injection e DNS rebinding. Um host sem porta aceita qualquer porta; com porta (`'api.exemplo.com:8443'`), exige a porta exata.
@@ -528,11 +554,17 @@ import pino from 'pino';
528
554
  createApp({ logger: pino(), logRequests: true });
529
555
  ```
530
556
 
531
- - **Logger:** compatível com o pino (`info(obj, msg)`, `child()`). O padrão, `consoleLogger({ level })`, usa o `console`. `logger: false` desliga tudo.
557
+ - **Logger:** compatível com o pino (`info(obj, msg)`, `child()`). O padrão, `consoleLogger({ level, format, redact })`, usa o `console`: `format: 'json'` gera uma linha JSON por evento, para coletores de log. `logger: false` desliga tudo.
532
558
  - **Request id:** cada requisição recebe `ctx.id`, também devolvido no header `X-Request-Id`. Um id recebido nesse header é reaproveitado se for seguro; isso é configurável em `requestId`.
533
559
  - **`ctx.log`:** já inclui o `reqId`: `ctx.log.info({ userId }, 'user created')`.
534
560
  - **`logRequests: true`:** registra método, path, status e duração de cada requisição.
535
- - **Dados sensíveis:** headers e body nunca são logados automaticamente.
561
+ - **Dados sensíveis:** headers e body nunca são logados automaticamente. O que você logar passa pelo `redact` do logger padrão. As chaves `authorization`, `cookie`, `set-cookie`, `x-api-key`, `password`, `secret`, `token`, `access_token`, `refresh_token` e afins, em qualquer nível, saem como `[REDACTED]`. A comparação ignora maiúsculas, `-` e `_`.
562
+
563
+ ```ts
564
+ createApp({ logger: consoleLogger({ redact: ['cpf', 'cardNumber'] }) }); // soma às chaves padrão; false desliga
565
+ ```
566
+
567
+ Com o pino, configure o [`redact`](https://getpino.io/#/docs/redaction) dele.
536
568
 
537
569
  ## Ciclo de vida e testes
538
570
 
@@ -577,6 +609,7 @@ await app.listen({
577
609
  },
578
610
  }
579
611
  ```
612
+
580
613
  Sem certificado principal, um domínio fora da lista não completa o handshake. Para recusar o domínio também na camada HTTP, combine com `allowedHosts`.
581
614
  - **Renovação sem reiniciar:** `app.reloadTLS({ key, cert, sni })` troca os certificados. As conexões novas usam os novos, e as abertas continuam com os antigos. Um certificado inválido lança erro e mantém os atuais. É o ponto de integração para um cliente ACME externo, como Let's Encrypt via certbot:
582
615
 
@@ -599,8 +632,10 @@ await app.listen({
599
632
  ```ts
600
633
  createApp({
601
634
  shutdown: {
602
- timeoutMs: 10_000, // prazo para as requisições em andamento (padrão)
603
- onShutdown: async () => { await db.close(); }, // depois que o servidor fecha
635
+ timeoutMs: 10_000, // prazo para as requisições em andamento (padrão)
636
+ onShutdown: async () => {
637
+ await db.close();
638
+ }, // depois que o servidor fecha
604
639
  },
605
640
  });
606
641
  ```
@@ -609,6 +644,28 @@ createApp({
609
644
  - **Vários apps no mesmo processo:** compartilham um único listener por sinal e fecham todos juntos.
610
645
  - **Limpeza:** os listeners são removidos no `close()`, então testes com vários `listen(0)` não acumulam listeners. O `inject()` e o `callback()` não registram nada.
611
646
  - **Opções:** `signals` muda os sinais tratados, `exit: false` fecha sem chamar `process.exit`, e `shutdown: false` desliga tudo (por exemplo, se outra ferramenta controla o processo).
647
+ - **Drain para load balancers:** com `shutdown: { drainDelayMs: 5000 }`, o app espera esse tempo antes de parar de aceitar conexões, já com o `/readyz` em 503, para o balanceador tirar a instância de rotação sem cortar requisições.
648
+
649
+ ### Healthchecks
650
+
651
+ ```ts
652
+ app.health({
653
+ checks: {
654
+ db: () => db.query('select 1'), // lançar ou devolver false = falha
655
+ cache: () => redis.ping(),
656
+ },
657
+ timeoutMs: 2000, // por check (padrão)
658
+ });
659
+ ```
660
+
661
+ | Rota | Responde | Uso |
662
+ | ------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------- |
663
+ | `GET /livez` | 200 `{ "status": "ok" }` enquanto o processo estiver de pé | liveness: reiniciar o processo travado |
664
+ | `GET /readyz` | 200 com o resultado de cada check; 503 se algum falhar ou estourar o tempo, e durante o encerramento | readiness: tirar a instância do balanceamento |
665
+
666
+ - **Os checks rodam em paralelo.** O motivo da falha vai só para o log, nunca para a resposta, porque pode conter host, usuário ou URL do banco.
667
+ - **As rotas são servidas antes dos middlewares globais,** já que sondas não têm credenciais nem devem consumir rate limit. Para restringir, use `guards`/`middlewares` no próprio `health()`.
668
+ - **Opções:** `live: '/health'` muda o path, e `ready: false` desliga a rota.
612
669
 
613
670
  **Testes sem rede.** `app.inject()` executa a requisição em memória:
614
671
 
@@ -618,25 +675,60 @@ assert.equal(res.status, 201);
618
675
  assert.equal(res.json<{ name: string }>().name, 'Ana');
619
676
  ```
620
677
 
678
+ ## Deploy em produção
679
+
680
+ ```ts
681
+ const app = createApp({
682
+ logger: consoleLogger({ format: 'json' }), // uma linha JSON por evento, com redact
683
+ trustProxy: 1, // atrás de 1 proxy/load balancer
684
+ allowedHosts: ['api.exemplo.com'],
685
+ securityHeaders: { strictTransportSecurity: true }, // HSTS: só com HTTPS na frente
686
+ shutdown: { drainDelayMs: 5_000, timeoutMs: 10_000 },
687
+ })
688
+ .use(rateLimit({ max: 100, windowMs: 60_000 }))
689
+ .mount(UserRoutes)
690
+ .health({ checks: { db: () => db.ping() } });
691
+
692
+ await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });
693
+ ```
694
+
695
+ Checklist:
696
+
697
+ | Item | Como |
698
+ | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
699
+ | Escutar fora do container | `host: '0.0.0.0'` (o padrão é `127.0.0.1`), porta por `PORT` |
700
+ | TLS | No proxy ou load balancer (recomendado), ou `listen({ tls })` |
701
+ | Proxy à frente | `trustProxy` com o número **exato** de proxies; nunca `true` se o app for acessível direto |
702
+ | Domínios | `allowedHosts` com os domínios servidos |
703
+ | HSTS | `securityHeaders: { strictTransportSecurity: true }`, só quando todo acesso for HTTPS |
704
+ | Logs | `consoleLogger({ format: 'json' })` ou pino; campos sensíveis já mascarados |
705
+ | Healthchecks | `/livez` (liveness) e `/readyz` (readiness) com `app.health()` |
706
+ | Encerramento | SIGTERM já tratado; `drainDelayMs` maior que o intervalo da sonda de readiness |
707
+ | Rate limit com várias instâncias | `RateLimitStore` compartilhado (Redis), porque o padrão é em memória por processo |
708
+ | Autenticação por cookie | `csrf()` ativo |
709
+ | Documentação da API | `openapi({ guards })`, ou gerar só o arquivo com `openapiDocument()` |
710
+ | Processo | Rode `node dist/server.js` direto (não via `npm start`), para o SIGTERM chegar ao Node; em container, `--init` ou `tini` como PID 1 |
711
+
621
712
  ## Referência: `createApp(options)`
622
713
 
623
- | Opção | Padrão | Descrição |
624
- |---|---|---|
625
- | `bodyLimit` | `1048576` | Tamanho máximo do body JSON, em bytes |
626
- | `queryMaxKeys` | `1000` | Máximo de pares lidos da query string |
627
- | `requireSchemas` | `false` | Exige schemas de body e de resposta no registro das rotas |
628
- | `securityHeaders` | `'api'` | `'api'`, `'web'`, um objeto de opções, ou `false` para desligar |
629
- | `serializers` | — | Serializadores por content type, para responder em outros formatos |
630
- | `shutdown` | SIGINT/SIGTERM, 10s | `signals`, `timeoutMs`, `onShutdown`, `exit`; `false` desliga |
631
- | `trustProxy` | `false` | `true` ou o número de proxies: `ctx.ip`/`host`/`protocol` passam a usar os `X-Forwarded-*` |
632
- | `allowedHosts` | qualquer host válido | Lista de hosts (string ou RegExp); os demais recebem 400 |
633
- | `formatError` | Problem Details | `(problem, ctx) => corpo`, para personalizar o corpo de todos os erros |
634
- | `timeouts` | 30s / 10s / 5s | `requestTimeout`, `headersTimeout`, `keepAliveTimeout` |
635
- | `logger` | `consoleLogger()` | Logger compatível com pino; `false` desliga |
636
- | `logRequests` | `false` | Log de cada requisição concluída |
637
- | `requestId` | header `x-request-id` | `header`, `trustIncoming`, `generate` |
638
-
639
- Métodos do app: `use()`, `mount()`, `openapi()`, `openapiDocument()`, `onError()`, `onSend()`, `onResponse()`, `onClose()`, `listen()`, `close()`, `callback()`, `inject()`, `log`. O `mount()` devolve um `MountedGroup`, com os verbos do mapeamento manual e os mesmos métodos do app, para continuar o encadeamento.
714
+ | Opção | Padrão | Descrição |
715
+ | ----------------- | --------------------- | ------------------------------------------------------------------------------------------ |
716
+ | `bodyLimit` | `1048576` | Tamanho máximo do body JSON, em bytes |
717
+ | `queryMaxKeys` | `1000` | Máximo de pares lidos da query string |
718
+ | `requireSchemas` | `false` | Exige schemas de body e de resposta no registro das rotas |
719
+ | `securityHeaders` | `'api'` | `'api'`, `'web'`, um objeto de opções, ou `false` para desligar |
720
+ | `serializers` | — | Serializadores por content type, para responder em outros formatos |
721
+ | `shutdown` | SIGINT/SIGTERM, 10s | `signals`, `timeoutMs`, `drainDelayMs`, `onShutdown`, `exit`; `false` desliga |
722
+ | `protoPoisoning` | `'error'` | Body com `__proto__`/`constructor.prototype`: `'error'` (400) ou `'remove'` |
723
+ | `trustProxy` | `false` | `true` ou o número de proxies: `ctx.ip`/`host`/`protocol` passam a usar os `X-Forwarded-*` |
724
+ | `allowedHosts` | qualquer host válido | Lista de hosts (string ou RegExp); os demais recebem 400 |
725
+ | `formatError` | Problem Details | `(problem, ctx) => corpo`, para personalizar o corpo de todos os erros |
726
+ | `timeouts` | 30s / 10s / 5s | `requestTimeout`, `headersTimeout`, `keepAliveTimeout` |
727
+ | `logger` | `consoleLogger()` | Logger compatível com pino; `false` desliga |
728
+ | `logRequests` | `false` | Log de cada requisição concluída |
729
+ | `requestId` | header `x-request-id` | `header`, `trustIncoming`, `generate` |
730
+
731
+ Métodos do app: `use()`, `mount()`, `group()`, `health()`, `openapi()`, `openapiDocument()`, `onError()`, `onSend()`, `onResponse()`, `onClose()`, `listen()`, `close()`, `callback()`, `inject()`, `log`. O `mount()` devolve um `MountedGroup`, com os verbos do mapeamento manual e os mesmos métodos do app, para continuar o encadeamento.
640
732
 
641
733
  O body só é lido como JSON quando o `Content-Type` é `application/json` ou `application/*+json`.
642
734
 
@@ -669,10 +761,13 @@ pnpm install
669
761
  pnpm test # node:test + tsx
670
762
  pnpm test:coverage # com limites mínimos de cobertura
671
763
  pnpm typecheck
764
+ pnpm check # lint + formatação (Biome); pnpm format corrige a formatação
672
765
  pnpm build # dist/
673
766
  ```
674
767
 
675
- Mudanças notáveis estão no [CHANGELOG](CHANGELOG.md).
768
+ Para o `git blame` ignorar o commit de formatação: `git config blame.ignoreRevsFile .git-blame-ignore-revs`.
769
+
770
+ Mudanças notáveis estão no [CHANGELOG](CHANGELOG.md). Para reportar uma vulnerabilidade e conhecer a política de dependências, veja o [SECURITY.md](SECURITY.md).
676
771
 
677
772
  ## Licença
678
773
 
@@ -7,7 +7,9 @@ import { MountedGroup } from './mounted-group.ts';
7
7
  import { type Middleware } from './compose.ts';
8
8
  import { Context, type OnSendHook } from './context.ts';
9
9
  import { type OpenAPIOptions } from '../openapi/generate.ts';
10
+ import { type HealthOptions } from './health.ts';
10
11
  import '../shims/symbol-metadata.ts';
12
+ import type { ProtoPoisoning } from '../utils/safe-json.ts';
11
13
  import { type Serializer } from './response.ts';
12
14
  import type { ErrorFormatter } from './errors.ts';
13
15
  import { type InjectOptions, type InjectResponse } from './inject.ts';
@@ -60,6 +62,11 @@ export interface AppOptions {
60
62
  * em toda rota, falhando no `app.mount()`. Padrão: `false`.
61
63
  */
62
64
  requireSchemas?: boolean;
65
+ /**
66
+ * Body JSON com `__proto__` ou `constructor.prototype` (S-08): `'error'` responde 400
67
+ * (`PROTO_POISONING`); `'remove'` descarta as chaves e segue. Padrão: `'error'`.
68
+ */
69
+ protoPoisoning?: ProtoPoisoning;
63
70
  /** Máximo de pares chave/valor lidos da query string. Padrão: 1000 (S-06). */
64
71
  queryMaxKeys?: number;
65
72
  /** Logger compatível com pino. Padrão: `consoleLogger()`; `false` desliga (F-06). */
@@ -112,6 +119,8 @@ export declare class SashikoApp {
112
119
  private readonly onResponseHooks;
113
120
  private readonly onCloseHooks;
114
121
  private readonly routes;
122
+ private draining;
123
+ private healthConfig;
115
124
  private openapiConfig;
116
125
  private closeHooksPending;
117
126
  private applySecurityHeaders;
@@ -179,6 +188,16 @@ export declare class SashikoApp {
179
188
  * `app.openapi({ guards: [isAdmin] })`. O documento é gerado na primeira requisição, depois dos `mount()`.
180
189
  */
181
190
  openapi(opts?: OpenAPIOptions): this;
191
+ /**
192
+ * Healthchecks para load balancers e orquestradores (Kubernetes, ECS…).
193
+ * - `GET /livez`: o processo está de pé (200 sempre).
194
+ * - `GET /readyz`: pronto para receber tráfego. Roda os `checks` (banco, fila…) e responde 503 se algum
195
+ * falhar ou estourar o tempo, e também durante o encerramento.
196
+ *
197
+ * As rotas são servidas **antes** dos middlewares globais (sondas não têm credenciais nem devem
198
+ * consumir rate limit); use `guards`/`middlewares` aqui para restringir.
199
+ */
200
+ health(opts?: HealthOptions): this;
182
201
  /** Documento OpenAPI 3.1 das rotas registradas até agora, para gravar em arquivo ou testar (F-16). */
183
202
  openapiDocument(opts?: OpenAPIOptions): Record<string, unknown>;
184
203
  /** Se a requisição é para `/openapi.json` ou `/docs` (quando ligados), responde e devolve `true`. */