@smounters/kit 2.2.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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +35 -0
- package/dist/http/index.d.ts +104 -0
- package/dist/http/index.js +155 -0
- package/dist/log/index.d.ts +28 -0
- package/dist/log/index.js +152 -0
- package/dist/money/index.d.ts +45 -0
- package/dist/money/index.js +67 -0
- package/dist/net/index.d.ts +30 -0
- package/dist/net/index.js +122 -0
- package/dist/redis/index.d.ts +66 -0
- package/dist/redis/index.js +127 -0
- package/dist/rpc/index.d.ts +15 -0
- package/dist/rpc/index.js +47 -0
- package/dist/util/index.d.ts +18 -0
- package/dist/util/index.js +57 -0
- package/package.json +117 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.2.0 - 2026-07-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Первая версия `@smounters/kit` — батарейки к `@smounters/core`, собранные из кода
|
|
7
|
+
`smounters-apps`, который к этому моменту был переписан заново в трёх проектах:
|
|
8
|
+
`kit/log` (журнал), `kit/redis` (подключение, pub/sub, кеш, распределённый лок),
|
|
9
|
+
`kit/http` (реальный IP клиента, сквозной id запроса, точные байты тела, лимит частоты),
|
|
10
|
+
`kit/net` (SSRF-проверки и `safeFetch`), `kit/config` (zod-препроцессоры env),
|
|
11
|
+
`kit/money` (decimal, масштабы, округление, проверка проводки), `kit/rpc`
|
|
12
|
+
(`ProtoValidateInterceptor`), `kit/util` (`ulid`, `redactSecrets`).
|
|
13
|
+
- Тесты на чистые части: 24 теста (деньги, диапазоны адресов и `assertPublicUrl`, ulid и сокрытие
|
|
14
|
+
секретов).
|
|
15
|
+
|
|
16
|
+
### Добавлено по ходу внедрения
|
|
17
|
+
- `RedisConnectionOptions.awaitReady` — ждать подключения на старте и НЕ поднимать процесс без Redis.
|
|
18
|
+
Два защитимых поведения, поэтому это выбор, а не умолчание: api, который отдаёт ещё и страницы,
|
|
19
|
+
должен стартовать и предупредить (недоступный кеш — не недоступный продукт), а воркер, вся работа
|
|
20
|
+
которого идёт из очереди, честнее не стартовать вовсе. Понадобилось второму потребителю.
|
|
21
|
+
- `kit/log` переизлучает типы ядра (`LogTransport` и рядом): приложению, объявляющему возвращаемый тип
|
|
22
|
+
транспортов, не нужен второй импорт из `@smounters/core` ради имени.
|
|
23
|
+
|
|
24
|
+
### Обобщения при выносе
|
|
25
|
+
- Политика вынесена наружу: лимитер частоты получает `bucketFor(req)` вместо захардкоженных адресов,
|
|
26
|
+
`RedisService` — строку подключения значением-провайдером (`REDIS_CONNECTION`), журнал — имя сервиса
|
|
27
|
+
и окружение параметрами, хук контекста — имя заголовка.
|
|
28
|
+
- `RedisService` очищен от доменных методов (мягкий лок заказа уехал в свою фичу приложения).
|
|
29
|
+
- Добавлено `quantizeToDecimals` — квантование по знакам АКТИВА, а не по масштабу книги: без него
|
|
30
|
+
«ожидаемая» сумма может оказаться неоплатной точно у токена с шестью знаками.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio (@smounters)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# @smounters/kit
|
|
2
|
+
|
|
3
|
+
Батарейки к [`@smounters/core`](https://www.npmjs.com/package/@smounters/core): структурный журнал,
|
|
4
|
+
Redis с распределённым локом, защита от SSRF, деньги на decimal, сквозной id запроса, схемы для env.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm i @smounters/kit @smounters/core
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Каждая часть — отдельный подпуть, а тяжёлые зависимости объявлены **необязательными** peer-зависимостями:
|
|
11
|
+
ставится только то, чем пользуетесь. Взяли `kit/money` — `ioredis` и `fastify` не нужны.
|
|
12
|
+
|
|
13
|
+
| подпуть | что внутри | нужны |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `kit/log` | транспорты для `configureLogger()` + автономный логгер: одна строка JSON вне local, цветная строка локально | `@smounters/core` |
|
|
16
|
+
| `kit/redis` | `RedisService`: подключение, pub/sub, кеш «чтение сквозь», распределённый лок | `@smounters/core`, `ioredis` |
|
|
17
|
+
| `kit/http` | реальный IP клиента, сквозной id запроса, точные байты тела, лимит частоты | `fastify` |
|
|
18
|
+
| `kit/net` | `isPrivateIp`, `assertPublicUrl`, `safeFetch` | — |
|
|
19
|
+
| `kit/config` | zod-препроцессоры для разбора env | `zod` |
|
|
20
|
+
| `kit/money` | decimal-арифметика, масштабы, округление, проверка сбалансированности проводки | — |
|
|
21
|
+
| `kit/rpc` | `ProtoValidateInterceptor` — правила `buf.validate` из контракта, enforced транспортом | `@smounters/core`, `@connectrpc/connect`, `@bufbuild/*` |
|
|
22
|
+
| `kit/util` | `ulid`, `redactSecrets` | — |
|
|
23
|
+
|
|
24
|
+
## Что здесь НЕ лежит и почему
|
|
25
|
+
|
|
26
|
+
**Механизм — здесь, политика — в приложении.** Это правило видно в подписях: лимитер частоты не знает
|
|
27
|
+
ваших адресов, он получает `bucketFor(req)`; `RedisService` не знает, откуда взялась строка подключения,
|
|
28
|
+
она приходит значением-провайдером; журнал не знает имени сервиса, оно передаётся.
|
|
29
|
+
|
|
30
|
+
Поэтому сюда сознательно **не** попали: словарь ваших HTTP-заголовков (это словарь продукта, не
|
|
31
|
+
инфраструктура), план счетов и любые доменные ключи Redis, матрицы прав.
|
|
32
|
+
|
|
33
|
+
## Журнал
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createTransports, createLogger } from "@smounters/kit/log";
|
|
37
|
+
|
|
38
|
+
app.configureLogger({ transports: createTransports({ service: "api" }) });
|
|
39
|
+
const logger = createLogger({ service: "api" }); // для кода вне DI: старт процесса, скрипты
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Вне `local` каждая запись — один объект JSON в строку, поэтому сборщик логов достаёт поля без разбора
|
|
43
|
+
регулярками. Структурный первый аргумент раскладывается в поля верхнего уровня:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
this.logger.info({ type: "payment", event: "credited", amount, currency });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Если в приложении зарегистрирован `requestContextHook`, к КАЖДОЙ строке сам подмешивается `reqId` —
|
|
50
|
+
вызывающий код о нём не знает.
|
|
51
|
+
|
|
52
|
+
## Redis
|
|
53
|
+
|
|
54
|
+
Пакет не решает, откуда берётся конфигурация: подключение приходит значением-провайдером.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { REDIS_CONNECTION, RedisService } from "@smounters/kit/redis";
|
|
58
|
+
|
|
59
|
+
@Module({
|
|
60
|
+
providers: [{ provide: REDIS_CONNECTION, useValue: { url: appConfig.REDIS_URL } }, RedisService],
|
|
61
|
+
exports: [RedisService],
|
|
62
|
+
global: true,
|
|
63
|
+
})
|
|
64
|
+
export class RedisModule {}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`withLock(key, ttlSec, fn)` — для периодических задач при нескольких репликах: работа делается **один
|
|
68
|
+
раз на кластер**, а не по разу на процесс. Освобождение — сравнение-и-удаление по случайному токену,
|
|
69
|
+
поэтому лок, истёкший на середине и перехваченный другим процессом, не будет снят предыдущим владельцем.
|
|
70
|
+
`ttlSec` обязан превышать худшее время работы `fn`.
|
|
71
|
+
|
|
72
|
+
## Сквозной id запроса
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { clientIpNormalizeHook, requestContextHook } from "@smounters/kit/http";
|
|
76
|
+
|
|
77
|
+
app.addHook("onRequest", clientIpNormalizeHook()); // ПЕРВЫМ
|
|
78
|
+
app.addHook("onRequest", requestContextHook({ headerName: "X-Request-Id" }));
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Порядок обязателен: нормализация IP должна быть первой (на `req.ip` опираются журнал и лимитер), а
|
|
82
|
+
контекст — до всего остального, потому что он продолжает цепочку внутри `AsyncLocalStorage` и его видят
|
|
83
|
+
только зарегистрированные ПОЗЖЕ хуки.
|
|
84
|
+
|
|
85
|
+
Зачем вообще: связать «пришёл вебхук → поставлена задача → воркер сделал внешний вызов» иначе нечем,
|
|
86
|
+
кроме метки времени, — процессы разные, а логи в одном потоке. Протаскивать id параметром через все
|
|
87
|
+
слои значило бы править сигнатуру каждой функции ради поля, которое нужно только журналу.
|
|
88
|
+
|
|
89
|
+
## Деньги
|
|
90
|
+
|
|
91
|
+
Ни одна функция не принимает и не возвращает JS-число: `double` не представляет 0.1 точно, а книга,
|
|
92
|
+
которая не может представить свои же суммы, перестаёт сходиться в первый же день. Суммы ходят
|
|
93
|
+
десятичными **строками**.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { isBalanced, quantizeToDecimals, ROUND_UP, toMoneyAmount } from "@smounters/kit/money";
|
|
97
|
+
|
|
98
|
+
toMoneyAmount("10.00001"); // null — точнее, чем хранит колонка: отклонить, а не округлить
|
|
99
|
+
isBalanced(lines); // ≥2 строки, без нулевых, сумма знаковых РОВНО 0
|
|
100
|
+
quantizeToDecimals(due, 6, ROUND_UP); // сумма, достижимая у токена с 6 знаками
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`quantizeToDecimals` нужен, когда сумма должна быть **достижима на другой стороне**: токен с шестью
|
|
104
|
+
знаками не переведёт значение с восемью, и «ожидаемая» сумма, скруглённая до масштаба книги, окажется
|
|
105
|
+
неоплатной точно — а строгое сравнение назовёт это недоплатой, которой плательщик не мог избежать.
|
|
106
|
+
|
|
107
|
+
## Защита от SSRF
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { safeFetch, SsrfBlockedError } from "@smounters/kit/net";
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`assertPublicUrl` в одиночку проверяет только ПЕРВЫЙ адрес, а `fetch` по умолчанию идёт по
|
|
114
|
+
перенаправлениям — публичный хост мог ответить 302 на приватный адрес, и проверка обходилась.
|
|
115
|
+
`safeFetch` идёт по перенаправлениям вручную и проверяет каждый шаг. `SsrfBlockedError` отделён от
|
|
116
|
+
сетевых ошибок намеренно: это ПОСТОЯННАЯ ошибка настройки, её нельзя перевыкладывать в очередь на
|
|
117
|
+
повтор.
|
|
118
|
+
|
|
119
|
+
Известный предел: гонку DNS-rebinding между проверкой и подключением так не закрыть — для этого нужна
|
|
120
|
+
фиксация адреса на момент соединения. Отсекаются реальные случаи: ошибка настройки и попытка достать
|
|
121
|
+
адрес метаданных облака.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Accepts true/1/yes/on and false/0/no/off (case-insensitive); anything else is a validation error. */
|
|
3
|
+
export declare const booleanSchema: () => z.ZodPreprocess<z.ZodBoolean>;
|
|
4
|
+
/** Parses a numeric string. An empty or non-numeric value passes through so zod reports it clearly. */
|
|
5
|
+
export declare const numberSchema: () => z.ZodPreprocess<z.ZodNumber>;
|
|
6
|
+
/** String enum with optional normalization (e.g. lower-casing `ENVIRONMENT` before matching). */
|
|
7
|
+
export declare const enumSchema: <const T extends readonly [string, ...string[]]>(values: T, normalize?: (v: string) => string) => z.ZodPreprocess<z.ZodEnum<{ [k_1 in T[number]]: k_1; } extends infer T_1 ? { [k in keyof T_1]: T_1[k]; } : never>>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// Preprocessors for parsing the environment: every `process.env` value is a string, so a schema that
|
|
3
|
+
// expects a boolean or a number has to coerce first. Used with a single top-level schema so a malformed
|
|
4
|
+
// value fails at startup with a per-field report instead of turning into a silent default at runtime.
|
|
5
|
+
const TRUE_VALUES = new Set(["true", "1", "yes", "on"]);
|
|
6
|
+
const FALSE_VALUES = new Set(["false", "0", "no", "off"]);
|
|
7
|
+
/** Accepts true/1/yes/on and false/0/no/off (case-insensitive); anything else is a validation error. */
|
|
8
|
+
export const booleanSchema = () => z.preprocess((v) => {
|
|
9
|
+
if (typeof v === "boolean")
|
|
10
|
+
return v;
|
|
11
|
+
if (typeof v === "string") {
|
|
12
|
+
const n = v.toLowerCase().trim();
|
|
13
|
+
if (TRUE_VALUES.has(n))
|
|
14
|
+
return true;
|
|
15
|
+
if (FALSE_VALUES.has(n))
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return v;
|
|
19
|
+
}, z.boolean());
|
|
20
|
+
/** Parses a numeric string. An empty or non-numeric value passes through so zod reports it clearly. */
|
|
21
|
+
export const numberSchema = () => z.preprocess((v) => {
|
|
22
|
+
if (typeof v === "number")
|
|
23
|
+
return v;
|
|
24
|
+
if (typeof v === "string") {
|
|
25
|
+
const n = v.trim();
|
|
26
|
+
if (!n)
|
|
27
|
+
return v;
|
|
28
|
+
const num = Number(n);
|
|
29
|
+
if (!Number.isNaN(num))
|
|
30
|
+
return num;
|
|
31
|
+
}
|
|
32
|
+
return v;
|
|
33
|
+
}, z.number());
|
|
34
|
+
/** String enum with optional normalization (e.g. lower-casing `ENVIRONMENT` before matching). */
|
|
35
|
+
export const enumSchema = (values, normalize) => z.preprocess((v) => (typeof v === "string" && normalize ? normalize(v) : v), z.enum(values));
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
3
|
+
type HeaderValue = string | string[] | null | undefined;
|
|
4
|
+
export type HeaderGetter = (name: string) => HeaderValue;
|
|
5
|
+
/**
|
|
6
|
+
* The real external client IP behind a CDN → own edge → ingress → pod chain.
|
|
7
|
+
*
|
|
8
|
+
* Trust order (first non-empty wins) and why it matters: with `trustProxy` enabled the app trusts every
|
|
9
|
+
* hop, so `X-Forwarded-For` is client-controllable end to end — a caller can prepend a forged left-most
|
|
10
|
+
* entry that the CDN only appends to. `CF-Connecting-IP` (set by Cloudflare) and `X-Real-IP` (set by your
|
|
11
|
+
* own edge) are OVERWRITTEN by that infrastructure and therefore cannot be spoofed from outside, so they
|
|
12
|
+
* win; XFF is the last resort. Empty string when nothing usable is present (local dev, health checks).
|
|
13
|
+
*
|
|
14
|
+
* Pass a getter that reads by lower-case name: works for Fastify `req.headers` and for WHATWG
|
|
15
|
+
* `Headers.get` alike.
|
|
16
|
+
*/
|
|
17
|
+
export declare function clientIp(get: HeaderGetter): string;
|
|
18
|
+
/**
|
|
19
|
+
* Fastify `onRequest`: rewrite the raw `x-forwarded-for` so that Fastify's own `req.ip` — and everything
|
|
20
|
+
* built on it (framework access logs, rate-limit keys, stored remote addresses) — resolves to the real
|
|
21
|
+
* client instead of a spoofable left-most entry. Only rewrites when a trustworthy infrastructure header
|
|
22
|
+
* is present, so local development and non-CDN hosts keep default behaviour.
|
|
23
|
+
*
|
|
24
|
+
* MUST be registered FIRST: Fastify runs `onRequest` hooks in registration order, and everything reading
|
|
25
|
+
* `req.ip` afterwards depends on this.
|
|
26
|
+
*/
|
|
27
|
+
export declare function clientIpNormalizeHook(): (req: FastifyRequest, _reply: FastifyReply) => Promise<void>;
|
|
28
|
+
export interface RequestContext {
|
|
29
|
+
requestId: string;
|
|
30
|
+
/**
|
|
31
|
+
* Where the chain started: `http` — an external call, `job` — a queue worker, `event` — a message
|
|
32
|
+
* consumer, `cron` — a scheduled tick. Lets a log query tell "someone called us" from "we started it".
|
|
33
|
+
*/
|
|
34
|
+
source: "http" | "job" | "event" | "cron";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `req_<hex>` — one shape of id across every process, safe to hand to a caller for a support request.
|
|
38
|
+
*/
|
|
39
|
+
export declare function newRequestId(): string;
|
|
40
|
+
export declare function currentRequestContext(): RequestContext | undefined;
|
|
41
|
+
export declare function currentRequestId(): string | undefined;
|
|
42
|
+
/** Run work inside a context. Everything asynchronous started inside sees the same id. */
|
|
43
|
+
export declare function runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T;
|
|
44
|
+
export declare function acceptRequestId(inbound: string | undefined): string;
|
|
45
|
+
/**
|
|
46
|
+
* Fastify `onRequest`: open a context for the whole request and echo the id back in a header.
|
|
47
|
+
*
|
|
48
|
+
* Why AsyncLocalStorage and not a parameter: threading an id through every layer (RPC → service →
|
|
49
|
+
* service → repository) would mean changing every signature for a field only the logs care about. The log
|
|
50
|
+
* transport reads the context itself, so calling code stays unaware.
|
|
51
|
+
*
|
|
52
|
+
* Register AFTER `clientIpNormalizeHook` and BEFORE everything else: this hook continues the chain inside
|
|
53
|
+
* `storage.run`, so only hooks registered later see the context.
|
|
54
|
+
*/
|
|
55
|
+
export declare function requestContextHook(options?: {
|
|
56
|
+
headerName?: string;
|
|
57
|
+
}): (req: FastifyRequest, reply: FastifyReply, done: () => void) => void;
|
|
58
|
+
/** Body over the limit. `statusCode` is the Fastify idiom — the default error handler renders 413. */
|
|
59
|
+
export declare class PayloadTooLargeError extends Error {
|
|
60
|
+
readonly statusCode = 413;
|
|
61
|
+
constructor(limit: number);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Drain a stream into one Buffer, ABORTING as soon as the limit is passed: the offending chunk is not
|
|
65
|
+
* kept and no further chunks are pulled, so an oversized body never sits in the heap in full.
|
|
66
|
+
*/
|
|
67
|
+
export declare function bufferCapped(payload: AsyncIterable<Buffer | string>, limit: number): Promise<Buffer>;
|
|
68
|
+
/**
|
|
69
|
+
* Capture the EXACT request bytes on `req.rawBody` and return a fresh equivalent stream so the normal
|
|
70
|
+
* parser still fills `req.body` for the handler.
|
|
71
|
+
*
|
|
72
|
+
* Needed wherever a signature is computed over the raw bytes: a re-serialized object produces different
|
|
73
|
+
* bytes (key order, spacing, number formatting) and the verification fails for a legitimate caller.
|
|
74
|
+
*/
|
|
75
|
+
export declare function stashRawBodyStream(req: FastifyRequest, payload: Readable, limit: number): Promise<Readable>;
|
|
76
|
+
/** Minimal shape of the counter store — structural, so this module needs no Redis dependency. */
|
|
77
|
+
export interface RateLimitStore {
|
|
78
|
+
incr(key: string): Promise<number>;
|
|
79
|
+
expire(key: string, seconds: number): Promise<unknown>;
|
|
80
|
+
}
|
|
81
|
+
export interface RateLimitBucket {
|
|
82
|
+
/** Bucket name; part of the counter key, so two buckets never share a counter. */
|
|
83
|
+
name: string;
|
|
84
|
+
limit: number;
|
|
85
|
+
}
|
|
86
|
+
export interface RateLimitOptions {
|
|
87
|
+
store: () => RateLimitStore;
|
|
88
|
+
/** Window length in seconds. A getter, because a limit is a business setting that may change live. */
|
|
89
|
+
windowSec: () => Promise<number> | number;
|
|
90
|
+
/** Which bucket a request belongs to — the POLICY, and it stays in the application. null = exempt. */
|
|
91
|
+
bucketFor: (req: FastifyRequest) => Promise<RateLimitBucket | null> | RateLimitBucket | null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Fastify `onRequest`: fixed-window counter per (bucket, IP), kept in the shared store so the limit is
|
|
95
|
+
* global across replicas rather than per-process.
|
|
96
|
+
*
|
|
97
|
+
* FAIL-OPEN by design: a store outage must not become an outage of the product. A limiter that blocks
|
|
98
|
+
* everything when Redis blinks is worse than one that briefly stops limiting.
|
|
99
|
+
*
|
|
100
|
+
* Requires `clientIpNormalizeHook` to be registered earlier, otherwise the key is built from a
|
|
101
|
+
* spoofable address and the limit can be evaded by forging a header.
|
|
102
|
+
*/
|
|
103
|
+
export declare function rateLimitHook(options: RateLimitOptions): (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
104
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
/** Left-most token of a (possibly comma-joined or repeated) header value, trimmed. */
|
|
5
|
+
function firstHop(v) {
|
|
6
|
+
const s = Array.isArray(v) ? v[0] : v;
|
|
7
|
+
return (s ?? "").split(",")[0]?.trim() ?? "";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The real external client IP behind a CDN → own edge → ingress → pod chain.
|
|
11
|
+
*
|
|
12
|
+
* Trust order (first non-empty wins) and why it matters: with `trustProxy` enabled the app trusts every
|
|
13
|
+
* hop, so `X-Forwarded-For` is client-controllable end to end — a caller can prepend a forged left-most
|
|
14
|
+
* entry that the CDN only appends to. `CF-Connecting-IP` (set by Cloudflare) and `X-Real-IP` (set by your
|
|
15
|
+
* own edge) are OVERWRITTEN by that infrastructure and therefore cannot be spoofed from outside, so they
|
|
16
|
+
* win; XFF is the last resort. Empty string when nothing usable is present (local dev, health checks).
|
|
17
|
+
*
|
|
18
|
+
* Pass a getter that reads by lower-case name: works for Fastify `req.headers` and for WHATWG
|
|
19
|
+
* `Headers.get` alike.
|
|
20
|
+
*/
|
|
21
|
+
export function clientIp(get) {
|
|
22
|
+
return firstHop(get("cf-connecting-ip")) || firstHop(get("x-real-ip")) || firstHop(get("x-forwarded-for")) || "";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Fastify `onRequest`: rewrite the raw `x-forwarded-for` so that Fastify's own `req.ip` — and everything
|
|
26
|
+
* built on it (framework access logs, rate-limit keys, stored remote addresses) — resolves to the real
|
|
27
|
+
* client instead of a spoofable left-most entry. Only rewrites when a trustworthy infrastructure header
|
|
28
|
+
* is present, so local development and non-CDN hosts keep default behaviour.
|
|
29
|
+
*
|
|
30
|
+
* MUST be registered FIRST: Fastify runs `onRequest` hooks in registration order, and everything reading
|
|
31
|
+
* `req.ip` afterwards depends on this.
|
|
32
|
+
*/
|
|
33
|
+
export function clientIpNormalizeHook() {
|
|
34
|
+
return async (req, _reply) => {
|
|
35
|
+
const h = req.raw.headers;
|
|
36
|
+
const trusted = firstHop(h["cf-connecting-ip"]) || firstHop(h["x-real-ip"]);
|
|
37
|
+
if (trusted)
|
|
38
|
+
h["x-forwarded-for"] = trusted;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const storage = new AsyncLocalStorage();
|
|
42
|
+
/**
|
|
43
|
+
* `req_<hex>` — one shape of id across every process, safe to hand to a caller for a support request.
|
|
44
|
+
*/
|
|
45
|
+
export function newRequestId() {
|
|
46
|
+
return `req_${randomUUID().replace(/-/g, "")}`;
|
|
47
|
+
}
|
|
48
|
+
export function currentRequestContext() {
|
|
49
|
+
return storage.getStore();
|
|
50
|
+
}
|
|
51
|
+
export function currentRequestId() {
|
|
52
|
+
return storage.getStore()?.requestId;
|
|
53
|
+
}
|
|
54
|
+
/** Run work inside a context. Everything asynchronous started inside sees the same id. */
|
|
55
|
+
export function runWithRequestContext(ctx, fn) {
|
|
56
|
+
return storage.run(ctx, fn);
|
|
57
|
+
}
|
|
58
|
+
// An inbound id is only accepted if it LOOKS like an id: the value ends up in log lines and in a response
|
|
59
|
+
// header, so an arbitrary caller-supplied string is log injection plus noise in the log store.
|
|
60
|
+
const ID_RE = /^[\w.-]{1,80}$/;
|
|
61
|
+
export function acceptRequestId(inbound) {
|
|
62
|
+
return inbound && ID_RE.test(inbound) ? inbound : newRequestId();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Fastify `onRequest`: open a context for the whole request and echo the id back in a header.
|
|
66
|
+
*
|
|
67
|
+
* Why AsyncLocalStorage and not a parameter: threading an id through every layer (RPC → service →
|
|
68
|
+
* service → repository) would mean changing every signature for a field only the logs care about. The log
|
|
69
|
+
* transport reads the context itself, so calling code stays unaware.
|
|
70
|
+
*
|
|
71
|
+
* Register AFTER `clientIpNormalizeHook` and BEFORE everything else: this hook continues the chain inside
|
|
72
|
+
* `storage.run`, so only hooks registered later see the context.
|
|
73
|
+
*/
|
|
74
|
+
export function requestContextHook(options = {}) {
|
|
75
|
+
const headerName = options.headerName ?? "X-Request-Id";
|
|
76
|
+
const lower = headerName.toLowerCase();
|
|
77
|
+
return (req, reply, done) => {
|
|
78
|
+
const raw = req.headers[lower];
|
|
79
|
+
const inbound = Array.isArray(raw) ? raw[0] : raw;
|
|
80
|
+
const requestId = acceptRequestId(inbound?.trim() ? inbound.trim() : undefined);
|
|
81
|
+
reply.header(headerName, requestId);
|
|
82
|
+
runWithRequestContext({ requestId, source: "http" }, done);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Body over the limit. `statusCode` is the Fastify idiom — the default error handler renders 413. */
|
|
86
|
+
export class PayloadTooLargeError extends Error {
|
|
87
|
+
constructor(limit) {
|
|
88
|
+
super(`Request body exceeds the ${limit}-byte limit`);
|
|
89
|
+
this.statusCode = 413;
|
|
90
|
+
this.name = "PayloadTooLargeError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Drain a stream into one Buffer, ABORTING as soon as the limit is passed: the offending chunk is not
|
|
95
|
+
* kept and no further chunks are pulled, so an oversized body never sits in the heap in full.
|
|
96
|
+
*/
|
|
97
|
+
export async function bufferCapped(payload, limit) {
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let total = 0;
|
|
100
|
+
for await (const chunk of payload) {
|
|
101
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
102
|
+
total += buf.length;
|
|
103
|
+
if (total > limit)
|
|
104
|
+
throw new PayloadTooLargeError(limit);
|
|
105
|
+
chunks.push(buf);
|
|
106
|
+
}
|
|
107
|
+
return Buffer.concat(chunks);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Capture the EXACT request bytes on `req.rawBody` and return a fresh equivalent stream so the normal
|
|
111
|
+
* parser still fills `req.body` for the handler.
|
|
112
|
+
*
|
|
113
|
+
* Needed wherever a signature is computed over the raw bytes: a re-serialized object produces different
|
|
114
|
+
* bytes (key order, spacing, number formatting) and the verification fails for a legitimate caller.
|
|
115
|
+
*/
|
|
116
|
+
export async function stashRawBodyStream(req, payload, limit) {
|
|
117
|
+
const raw = await bufferCapped(payload, limit);
|
|
118
|
+
req.rawBody = raw;
|
|
119
|
+
const stream = new Readable();
|
|
120
|
+
stream.push(raw);
|
|
121
|
+
stream.push(null);
|
|
122
|
+
stream.receivedEncodedLength = raw.length;
|
|
123
|
+
return stream;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Fastify `onRequest`: fixed-window counter per (bucket, IP), kept in the shared store so the limit is
|
|
127
|
+
* global across replicas rather than per-process.
|
|
128
|
+
*
|
|
129
|
+
* FAIL-OPEN by design: a store outage must not become an outage of the product. A limiter that blocks
|
|
130
|
+
* everything when Redis blinks is worse than one that briefly stops limiting.
|
|
131
|
+
*
|
|
132
|
+
* Requires `clientIpNormalizeHook` to be registered earlier, otherwise the key is built from a
|
|
133
|
+
* spoofable address and the limit can be evaded by forging a header.
|
|
134
|
+
*/
|
|
135
|
+
export function rateLimitHook(options) {
|
|
136
|
+
return async (req, reply) => {
|
|
137
|
+
try {
|
|
138
|
+
const bucket = await options.bucketFor(req);
|
|
139
|
+
if (!bucket)
|
|
140
|
+
return;
|
|
141
|
+
const win = await options.windowSec();
|
|
142
|
+
const key = `rl:${bucket.name}:${req.ip}`;
|
|
143
|
+
const store = options.store();
|
|
144
|
+
const n = await store.incr(key);
|
|
145
|
+
if (n === 1)
|
|
146
|
+
await store.expire(key, win);
|
|
147
|
+
if (n > bucket.limit) {
|
|
148
|
+
await reply.code(429).header("retry-after", String(win)).send({ error: "rate_limited" });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// fail-open
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LogEntry, LogLevel, LogTransport } from "@smounters/core/core";
|
|
2
|
+
export type { LogEntry, LogLevel, LogTransport };
|
|
3
|
+
export interface LogOptions {
|
|
4
|
+
/** Deployment environment; also decides the format — `local` renders colour, everything else JSON. */
|
|
5
|
+
env?: string;
|
|
6
|
+
/** Which process this is. Surface the same name as the container/deployment so queries can group by it. */
|
|
7
|
+
service?: string;
|
|
8
|
+
/** Minimum level that reaches stdout. */
|
|
9
|
+
minLevel?: LogLevel;
|
|
10
|
+
/** Render colour even outside local (rarely wanted; a collector reads escape codes as noise). */
|
|
11
|
+
colorize?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Transports for `app.configureLogger()` — covers the injected logger service, the framework access log
|
|
15
|
+
* and every structured call across the application.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createTransports(options?: LogOptions): LogTransport[];
|
|
18
|
+
export interface StandaloneLogger {
|
|
19
|
+
info: (...args: unknown[]) => void;
|
|
20
|
+
warn: (...args: unknown[]) => void;
|
|
21
|
+
error: (...args: unknown[]) => void;
|
|
22
|
+
debug: (...args: unknown[]) => void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Emitter for code that runs OUTSIDE dependency injection — entrypoint startup and fatal lines, a script.
|
|
26
|
+
* Same format as the transports, so both kinds of line sit in one stream.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createLogger(options?: LogOptions): StandaloneLogger;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { currentRequestContext } from "../http/index.js";
|
|
2
|
+
// Structured logging.
|
|
3
|
+
//
|
|
4
|
+
// Outside local development every entry is ONE single-line JSON object
|
|
5
|
+
// {"ts","level","env","service","type", ...fields}
|
|
6
|
+
// so a log collector ingests container stdout and exposes the fields without regex parsing. Locally a
|
|
7
|
+
// coloured human line is rendered instead. Container stdout IS the log source — nothing is shipped over
|
|
8
|
+
// HTTP from here, and nothing is filtered beyond the minimum level.
|
|
9
|
+
const LEVEL_PRIORITY = {
|
|
10
|
+
silly: 0,
|
|
11
|
+
trace: 1,
|
|
12
|
+
debug: 2,
|
|
13
|
+
info: 3,
|
|
14
|
+
warn: 4,
|
|
15
|
+
error: 5,
|
|
16
|
+
fatal: 6,
|
|
17
|
+
};
|
|
18
|
+
function resolve(options) {
|
|
19
|
+
const env = (options.env ?? process.env.ENVIRONMENT ?? "local").toLowerCase();
|
|
20
|
+
return {
|
|
21
|
+
env,
|
|
22
|
+
service: (options.service ?? process.env.APP_MODE ?? "api").toLowerCase(),
|
|
23
|
+
min: LEVEL_PRIORITY[options.minLevel ?? (process.env.LOG_LEVEL ?? "info").toLowerCase()] ?? 3,
|
|
24
|
+
colorize: options.colorize ?? env === "local",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const PALETTE = {
|
|
28
|
+
reset: "\x1b[0m",
|
|
29
|
+
dim: "\x1b[2m",
|
|
30
|
+
red: "\x1b[31m",
|
|
31
|
+
yellow: "\x1b[33m",
|
|
32
|
+
green: "\x1b[32m",
|
|
33
|
+
cyan: "\x1b[36m",
|
|
34
|
+
gray: "\x1b[90m",
|
|
35
|
+
white: "\x1b[37m",
|
|
36
|
+
};
|
|
37
|
+
const NO_COLOR = { reset: "", dim: "", red: "", yellow: "", green: "", cyan: "", gray: "", white: "" };
|
|
38
|
+
const LEVEL_COLOR = {
|
|
39
|
+
debug: "gray",
|
|
40
|
+
info: "green",
|
|
41
|
+
warn: "yellow",
|
|
42
|
+
error: "red",
|
|
43
|
+
fatal: "red",
|
|
44
|
+
};
|
|
45
|
+
function toJson(cfg, ts, level, type, rest) {
|
|
46
|
+
return JSON.stringify({ ts, level, env: cfg.env, service: cfg.service, type, ...rest });
|
|
47
|
+
}
|
|
48
|
+
function toColorized(cfg, ts, level, type, rest) {
|
|
49
|
+
const C = cfg.colorize ? PALETTE : NO_COLOR;
|
|
50
|
+
const lc = C[LEVEL_COLOR[level] ?? "white"];
|
|
51
|
+
const parts = [];
|
|
52
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
53
|
+
if (v === undefined || v === null)
|
|
54
|
+
continue;
|
|
55
|
+
if (k === "message") {
|
|
56
|
+
parts.push(String(v));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
parts.push(`${C.dim}${k}=${C.reset}${typeof v === "object" ? JSON.stringify(v) : String(v)}`);
|
|
60
|
+
}
|
|
61
|
+
return `${C.dim}${ts}${C.reset} ${lc}${level.toUpperCase().padEnd(5)}${C.reset} ${C.cyan}${type.padEnd(7)}${C.reset} ${parts.join(" ")}`;
|
|
62
|
+
}
|
|
63
|
+
// The chain id is mixed into EVERY line automatically — calling code never passes it. An explicit field
|
|
64
|
+
// in the entry itself (rare) is not overwritten.
|
|
65
|
+
function withRequestFields(rest) {
|
|
66
|
+
const ctx = currentRequestContext();
|
|
67
|
+
if (!ctx || rest.reqId !== undefined)
|
|
68
|
+
return rest;
|
|
69
|
+
return { ...rest, reqId: ctx.requestId, src: ctx.source };
|
|
70
|
+
}
|
|
71
|
+
function emit(level, line) {
|
|
72
|
+
if (level === "error" || level === "fatal")
|
|
73
|
+
console.error(line);
|
|
74
|
+
else if (level === "warn")
|
|
75
|
+
console.warn(line);
|
|
76
|
+
else
|
|
77
|
+
console.log(line);
|
|
78
|
+
}
|
|
79
|
+
function serialize(a) {
|
|
80
|
+
if (a instanceof Error)
|
|
81
|
+
return `${a.name}: ${a.message}`;
|
|
82
|
+
if (typeof a === "object" && a !== null) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.stringify(a);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return String(a);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return String(a);
|
|
91
|
+
}
|
|
92
|
+
// Split args into a log `type` plus a flat field bag. A structured first argument ({type, ...}) is
|
|
93
|
+
// hoisted to top-level JSON fields (so a log query can filter on them); plain args collapse into
|
|
94
|
+
// `message`.
|
|
95
|
+
function shape(defaultType, args) {
|
|
96
|
+
const first = args[0];
|
|
97
|
+
const structured = typeof first === "object" &&
|
|
98
|
+
first !== null &&
|
|
99
|
+
!(first instanceof Error) &&
|
|
100
|
+
"type" in first;
|
|
101
|
+
if (structured) {
|
|
102
|
+
const obj = { ...first };
|
|
103
|
+
const type = String(obj.type);
|
|
104
|
+
delete obj.type;
|
|
105
|
+
if (args.length > 1)
|
|
106
|
+
obj.extra = args.slice(1).map(serialize).join(" ");
|
|
107
|
+
return { type, rest: obj };
|
|
108
|
+
}
|
|
109
|
+
return { type: defaultType, rest: { message: args.map(serialize).join(" ") } };
|
|
110
|
+
}
|
|
111
|
+
function format(cfg, ts, level, type, rest) {
|
|
112
|
+
const withCtx = withRequestFields(rest);
|
|
113
|
+
return cfg.colorize ? toColorized(cfg, ts, level, type, withCtx) : toJson(cfg, ts, level, type, withCtx);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Transports for `app.configureLogger()` — covers the injected logger service, the framework access log
|
|
117
|
+
* and every structured call across the application.
|
|
118
|
+
*/
|
|
119
|
+
export function createTransports(options = {}) {
|
|
120
|
+
const cfg = resolve(options);
|
|
121
|
+
return [
|
|
122
|
+
{
|
|
123
|
+
log(entry) {
|
|
124
|
+
if ((LEVEL_PRIORITY[entry.level] ?? 0) < cfg.min)
|
|
125
|
+
return;
|
|
126
|
+
const ts = entry.timestamp.toISOString().slice(0, 23);
|
|
127
|
+
const { type, rest } = shape("app", entry.message ? [entry.message, ...entry.args] : entry.args);
|
|
128
|
+
emit(entry.level, format(cfg, ts, entry.level, type, rest));
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Emitter for code that runs OUTSIDE dependency injection — entrypoint startup and fatal lines, a script.
|
|
135
|
+
* Same format as the transports, so both kinds of line sit in one stream.
|
|
136
|
+
*/
|
|
137
|
+
export function createLogger(options = {}) {
|
|
138
|
+
const cfg = resolve(options);
|
|
139
|
+
const log = (level, ...args) => {
|
|
140
|
+
if ((LEVEL_PRIORITY[level] ?? 0) < cfg.min)
|
|
141
|
+
return;
|
|
142
|
+
const ts = new Date().toISOString().slice(0, 23);
|
|
143
|
+
const { type, rest } = shape("app", args);
|
|
144
|
+
emit(level, format(cfg, ts, level, type, rest));
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
info: (...args) => log("info", ...args),
|
|
148
|
+
warn: (...args) => log("warn", ...args),
|
|
149
|
+
error: (...args) => log("error", ...args),
|
|
150
|
+
debug: (...args) => log("debug", ...args),
|
|
151
|
+
};
|
|
152
|
+
}
|