@verdeect/kit 0.0.0-stage → 1.0.0-rc.1

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 (216) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +804 -2
  3. package/alerts/verdeect-kit.rules.yml +133 -0
  4. package/dist/cli/commands/crypto.d.ts +9 -0
  5. package/dist/cli/commands/crypto.js +87 -0
  6. package/dist/cli/commands/db.d.ts +11 -0
  7. package/dist/cli/commands/db.js +151 -0
  8. package/dist/cli/commands/openapi.d.ts +9 -0
  9. package/dist/cli/commands/openapi.js +144 -0
  10. package/dist/cli/commands/rabbitmq.d.ts +11 -0
  11. package/dist/cli/commands/rabbitmq.js +124 -0
  12. package/dist/cli/commands/redis.d.ts +11 -0
  13. package/dist/cli/commands/redis.js +119 -0
  14. package/dist/cli/main.d.ts +2 -0
  15. package/dist/cli/main.js +31 -0
  16. package/dist/config/cli-schemas.d.ts +29 -0
  17. package/dist/config/cli-schemas.js +15 -0
  18. package/dist/config/define-env.d.ts +21 -0
  19. package/dist/config/define-env.js +43 -0
  20. package/dist/config/fragments.d.ts +105 -0
  21. package/dist/config/fragments.js +86 -0
  22. package/dist/config/helpers.d.ts +26 -0
  23. package/dist/config/helpers.js +62 -0
  24. package/dist/config/index.d.ts +7 -0
  25. package/dist/config/index.js +7 -0
  26. package/dist/config/kit-config.module.d.ts +20 -0
  27. package/dist/config/kit-config.module.js +23 -0
  28. package/dist/config/parse-env.d.ts +14 -0
  29. package/dist/config/parse-env.js +28 -0
  30. package/dist/crypto/cipher.d.ts +61 -0
  31. package/dist/crypto/cipher.js +136 -0
  32. package/dist/crypto/index.d.ts +2 -0
  33. package/dist/crypto/index.js +2 -0
  34. package/dist/database/batches.d.ts +42 -0
  35. package/dist/database/batches.js +49 -0
  36. package/dist/database/db.d.ts +93 -0
  37. package/dist/database/db.js +234 -0
  38. package/dist/database/if-newer.d.ts +9 -0
  39. package/dist/database/if-newer.js +15 -0
  40. package/dist/database/index.d.ts +17 -0
  41. package/dist/database/index.js +13 -0
  42. package/dist/database/kit-database.module.d.ts +47 -0
  43. package/dist/database/kit-database.module.js +181 -0
  44. package/dist/database/migrations.d.ts +42 -0
  45. package/dist/database/migrations.js +142 -0
  46. package/dist/database/pg.d.ts +20 -0
  47. package/dist/database/pg.js +19 -0
  48. package/dist/database/prepare-database.d.ts +39 -0
  49. package/dist/database/prepare-database.js +75 -0
  50. package/dist/database/run-exclusive.d.ts +19 -0
  51. package/dist/database/run-exclusive.js +62 -0
  52. package/dist/database/tenant.d.ts +34 -0
  53. package/dist/database/tenant.js +28 -0
  54. package/dist/http/api/api.decorator.d.ts +18 -0
  55. package/dist/http/api/api.decorator.js +9 -0
  56. package/dist/http/api/openapi-document.d.ts +40 -0
  57. package/dist/http/api/openapi-document.js +452 -0
  58. package/dist/http/bootstrap-service.d.ts +33 -0
  59. package/dist/http/bootstrap-service.js +107 -0
  60. package/dist/http/create-http-app.d.ts +38 -0
  61. package/dist/http/create-http-app.js +137 -0
  62. package/dist/http/errors.d.ts +118 -0
  63. package/dist/http/errors.js +94 -0
  64. package/dist/http/index.d.ts +12 -0
  65. package/dist/http/index.js +8 -0
  66. package/dist/http/kit-exception.filter.d.ts +18 -0
  67. package/dist/http/kit-exception.filter.js +151 -0
  68. package/dist/http/kit-http-core.module.d.ts +19 -0
  69. package/dist/http/kit-http-core.module.js +122 -0
  70. package/dist/http/pagination.d.ts +30 -0
  71. package/dist/http/pagination.js +73 -0
  72. package/dist/http/zod-validation.pipe.d.ts +15 -0
  73. package/dist/http/zod-validation.pipe.js +47 -0
  74. package/dist/idempotency/idempotency.interceptor.d.ts +55 -0
  75. package/dist/idempotency/idempotency.interceptor.js +317 -0
  76. package/dist/idempotency/idempotent.decorator.d.ts +7 -0
  77. package/dist/idempotency/idempotent.decorator.js +16 -0
  78. package/dist/idempotency/index.d.ts +7 -0
  79. package/dist/idempotency/index.js +6 -0
  80. package/dist/idempotency/kit-idempotency.module.d.ts +47 -0
  81. package/dist/idempotency/kit-idempotency.module.js +173 -0
  82. package/dist/idempotency/peer.d.ts +1 -0
  83. package/dist/idempotency/peer.js +4 -0
  84. package/dist/idempotency/table.d.ts +203 -0
  85. package/dist/idempotency/table.js +86 -0
  86. package/dist/internal/cleanup-result.d.ts +7 -0
  87. package/dist/internal/cleanup-result.js +12 -0
  88. package/dist/internal/ids.d.ts +13 -0
  89. package/dist/internal/ids.js +41 -0
  90. package/dist/internal/metadata.d.ts +12 -0
  91. package/dist/internal/metadata.js +3 -0
  92. package/dist/internal/outbox-policy.d.ts +8 -0
  93. package/dist/internal/outbox-policy.js +10 -0
  94. package/dist/internal/peer.d.ts +16 -0
  95. package/dist/internal/peer.js +56 -0
  96. package/dist/internal/pg-errors.d.ts +21 -0
  97. package/dist/internal/pg-errors.js +115 -0
  98. package/dist/internal/preparsed-env.d.ts +4 -0
  99. package/dist/internal/preparsed-env.js +14 -0
  100. package/dist/internal/rabbitmq-definitions.d.ts +85 -0
  101. package/dist/internal/rabbitmq-definitions.js +73 -0
  102. package/dist/internal/redis-acl.d.ts +21 -0
  103. package/dist/internal/redis-acl.js +30 -0
  104. package/dist/internal/service-name.d.ts +4 -0
  105. package/dist/internal/service-name.js +10 -0
  106. package/dist/internal/sql-identifier.d.ts +5 -0
  107. package/dist/internal/sql-identifier.js +12 -0
  108. package/dist/internal/tokens.d.ts +10 -0
  109. package/dist/internal/tokens.js +12 -0
  110. package/dist/internal/tx-context.d.ts +5 -0
  111. package/dist/internal/tx-context.js +9 -0
  112. package/dist/messaging/cleanup.d.ts +49 -0
  113. package/dist/messaging/cleanup.js +129 -0
  114. package/dist/messaging/config.d.ts +43 -0
  115. package/dist/messaging/config.js +8 -0
  116. package/dist/messaging/define-event.d.ts +29 -0
  117. package/dist/messaging/define-event.js +38 -0
  118. package/dist/messaging/envelope.d.ts +66 -0
  119. package/dist/messaging/envelope.js +49 -0
  120. package/dist/messaging/errors.d.ts +45 -0
  121. package/dist/messaging/errors.js +63 -0
  122. package/dist/messaging/inbox.d.ts +4 -0
  123. package/dist/messaging/inbox.js +9 -0
  124. package/dist/messaging/index.d.ts +13 -0
  125. package/dist/messaging/index.js +10 -0
  126. package/dist/messaging/kit-messaging.module.d.ts +39 -0
  127. package/dist/messaging/kit-messaging.module.js +309 -0
  128. package/dist/messaging/outbox-relay.d.ts +67 -0
  129. package/dist/messaging/outbox-relay.js +260 -0
  130. package/dist/messaging/outbox-store.d.ts +75 -0
  131. package/dist/messaging/outbox-store.js +126 -0
  132. package/dist/messaging/peer.d.ts +1 -0
  133. package/dist/messaging/peer.js +5 -0
  134. package/dist/messaging/rabbit-connection.d.ts +144 -0
  135. package/dist/messaging/rabbit-connection.js +472 -0
  136. package/dist/messaging/redelivery.d.ts +6 -0
  137. package/dist/messaging/redelivery.js +17 -0
  138. package/dist/messaging/subscriber.d.ts +102 -0
  139. package/dist/messaging/subscriber.js +412 -0
  140. package/dist/messaging/tables.d.ts +315 -0
  141. package/dist/messaging/tables.js +72 -0
  142. package/dist/messaging/topology.d.ts +56 -0
  143. package/dist/messaging/topology.js +56 -0
  144. package/dist/observability/fail-startup.d.ts +14 -0
  145. package/dist/observability/fail-startup.js +51 -0
  146. package/dist/observability/health-registry.d.ts +34 -0
  147. package/dist/observability/health-registry.js +81 -0
  148. package/dist/observability/index.d.ts +16 -0
  149. package/dist/observability/index.js +10 -0
  150. package/dist/observability/lifecycle.d.ts +52 -0
  151. package/dist/observability/lifecycle.js +151 -0
  152. package/dist/observability/logger-options.d.ts +54 -0
  153. package/dist/observability/logger-options.js +91 -0
  154. package/dist/observability/metrics-server.d.ts +33 -0
  155. package/dist/observability/metrics-server.js +108 -0
  156. package/dist/observability/metrics.d.ts +30 -0
  157. package/dist/observability/metrics.js +53 -0
  158. package/dist/observability/redaction.d.ts +23 -0
  159. package/dist/observability/redaction.js +65 -0
  160. package/dist/observability/sanitize-error.d.ts +47 -0
  161. package/dist/observability/sanitize-error.js +194 -0
  162. package/dist/observability/sentry.d.ts +64 -0
  163. package/dist/observability/sentry.js +233 -0
  164. package/dist/redis/channels.d.ts +34 -0
  165. package/dist/redis/channels.js +128 -0
  166. package/dist/redis/client.d.ts +42 -0
  167. package/dist/redis/client.js +85 -0
  168. package/dist/redis/index.d.ts +7 -0
  169. package/dist/redis/index.js +7 -0
  170. package/dist/redis/kit-redis.module.d.ts +18 -0
  171. package/dist/redis/kit-redis.module.js +131 -0
  172. package/dist/redis/peer.d.ts +1 -0
  173. package/dist/redis/peer.js +4 -0
  174. package/dist/redis/rate-limiter.d.ts +49 -0
  175. package/dist/redis/rate-limiter.js +139 -0
  176. package/dist/testing/count-queries.d.ts +30 -0
  177. package/dist/testing/count-queries.js +58 -0
  178. package/dist/testing/create-test-app.d.ts +26 -0
  179. package/dist/testing/create-test-app.js +32 -0
  180. package/dist/testing/expect-matches-api.d.ts +8 -0
  181. package/dist/testing/expect-matches-api.js +49 -0
  182. package/dist/testing/expect-problem.d.ts +24 -0
  183. package/dist/testing/expect-problem.js +23 -0
  184. package/dist/testing/images.d.ts +10 -0
  185. package/dist/testing/images.js +14 -0
  186. package/dist/testing/index.d.ts +23 -0
  187. package/dist/testing/index.js +17 -0
  188. package/dist/testing/launch-command.d.ts +52 -0
  189. package/dist/testing/launch-command.js +510 -0
  190. package/dist/testing/outbox-coverage.d.ts +26 -0
  191. package/dist/testing/outbox-coverage.js +98 -0
  192. package/dist/testing/rabbitmq-admin.d.ts +81 -0
  193. package/dist/testing/rabbitmq-admin.js +146 -0
  194. package/dist/testing/redis-worker.d.ts +10 -0
  195. package/dist/testing/redis-worker.js +15 -0
  196. package/dist/testing/repo-conventions.d.ts +10 -0
  197. package/dist/testing/repo-conventions.js +524 -0
  198. package/dist/testing/repo-files.d.ts +113 -0
  199. package/dist/testing/repo-files.js +401 -0
  200. package/dist/testing/scratch-database.d.ts +19 -0
  201. package/dist/testing/scratch-database.js +75 -0
  202. package/dist/testing/start-test-stack.d.ts +85 -0
  203. package/dist/testing/start-test-stack.js +342 -0
  204. package/dist/testing/tcp-proxy.d.ts +40 -0
  205. package/dist/testing/tcp-proxy.js +164 -0
  206. package/dist/testing/tenant-isolation.d.ts +31 -0
  207. package/dist/testing/tenant-isolation.js +205 -0
  208. package/dist/testing/test-events.d.ts +37 -0
  209. package/dist/testing/test-events.js +150 -0
  210. package/dist/testing/timeout-scale.d.ts +4 -0
  211. package/dist/testing/timeout-scale.js +17 -0
  212. package/dist/testing/timing-guard.d.ts +22 -0
  213. package/dist/testing/timing-guard.js +54 -0
  214. package/dist/testing/worker-scope.d.ts +31 -0
  215. package/dist/testing/worker-scope.js +68 -0
  216. package/package.json +177 -4
@@ -0,0 +1,131 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ var KitRedisModule_1;
14
+ // KitRedisModule (prd.md §3.7, UC-11, AC-8.1, AC-8.2, AC-8.6). Эталон — O shared/redis/redis.provider.ts + redis.module.ts
15
+ // (опции, проба, закрытие); префикс — keyPrefix и KitRedisChannels вместо ручных префиксов O. Закрытие —
16
+ // onApplicationShutdown (шаг 4), KitLifecycle не нужен.
17
+ import { Inject, Injectable, Module, Optional, } from '@nestjs/common';
18
+ import { ConfigService } from '@nestjs/config';
19
+ import { assertServiceName } from '../internal/service-name.js';
20
+ import { KIT_LOGGER, KIT_REDIS_LOG, KIT_REDIS_PREFIX, KIT_REDIS_SUBSCRIBER, } from '../internal/tokens.js';
21
+ import { HealthRegistry } from '../observability/health-registry.js';
22
+ import { KitRedisChannels } from './channels.js';
23
+ import { KIT_REDIS, closeRedisConnection, createRedisConnection, redisNestLog, } from './client.js';
24
+ import { RateLimiter } from './rate-limiter.js';
25
+ function redisUrl(config) {
26
+ if (config === undefined) {
27
+ throw new Error('KitRedisModule: модуль сервиса не импортирует KitConfigModule.forRoot({schema}) с envRedis');
28
+ }
29
+ return config.getOrThrow('REDIS_URL');
30
+ }
31
+ /** Проба `/health` и закрытие соединений на шаге 4 (внутренний). */
32
+ let RedisLifecycle = class RedisLifecycle {
33
+ redis;
34
+ log;
35
+ subscriber;
36
+ health;
37
+ constructor(redis, log, subscriber, health) {
38
+ this.redis = redis;
39
+ this.log = log;
40
+ this.subscriber = subscriber;
41
+ this.health = health;
42
+ }
43
+ /** Проба `redis` некритичная: недоступен → `degraded` (§3.7). */
44
+ onModuleInit() {
45
+ this.health?.register('redis', async () => (await this.redis.ping()) === 'PONG', {
46
+ critical: false,
47
+ });
48
+ }
49
+ /** Шаг 4 остановки: закрытие соединений. */
50
+ async onApplicationShutdown() {
51
+ this.log.debug({}, 'KitRedisModule: закрытие соединений');
52
+ await Promise.all([
53
+ closeRedisConnection(this.redis, 'main', this.log),
54
+ ...(this.subscriber === undefined
55
+ ? []
56
+ : [closeRedisConnection(this.subscriber, 'subscriber', this.log)]),
57
+ ]);
58
+ }
59
+ };
60
+ RedisLifecycle = __decorate([
61
+ Injectable(),
62
+ __param(0, Inject(KIT_REDIS)),
63
+ __param(1, Inject(KIT_REDIS_LOG)),
64
+ __param(2, Optional()),
65
+ __param(2, Inject(KIT_REDIS_SUBSCRIBER)),
66
+ __param(3, Optional()),
67
+ __metadata("design:paramtypes", [Function, Object, Function, HealthRegistry])
68
+ ], RedisLifecycle);
69
+ /**
70
+ * Redis сервиса (§3.7): основное соединение `KIT_REDIS` с `keyPrefix <svc>:` (срок команды 500 мс), `KitRedisChannels`,
71
+ * `RateLimiter`, проба `/health` `redis` (некритичная), закрытие на шаге 4. `subscriber: true` — второе соединение для
72
+ * подписок (срок 5 с); без него второго соединения нет. `keyPrefix` не действует на шаблоны `KEYS`/`SCAN` — префикс в
73
+ * шаблон ставит сервис. `REDIS_URL` — из `KitConfigModule` (`envRedis`).
74
+ */
75
+ let KitRedisModule = KitRedisModule_1 = class KitRedisModule {
76
+ /** Модуль Redis сервиса; глобальный. */
77
+ static forRoot(options) {
78
+ const { service } = options;
79
+ assertServiceName(service, 'KitRedisModule');
80
+ const subscriber = options.subscriber === true
81
+ ? [
82
+ {
83
+ provide: KIT_REDIS_SUBSCRIBER,
84
+ useFactory: (config, log) => createRedisConnection({
85
+ url: redisUrl(config),
86
+ connection: 'subscriber',
87
+ service,
88
+ log,
89
+ }),
90
+ inject: [{ token: ConfigService, optional: true }, KIT_REDIS_LOG],
91
+ },
92
+ ]
93
+ : [];
94
+ return {
95
+ module: KitRedisModule_1,
96
+ global: true,
97
+ providers: [
98
+ {
99
+ provide: KIT_REDIS_LOG,
100
+ useFactory: (logger) => logger ?? redisNestLog('KitRedis'),
101
+ inject: [{ token: KIT_LOGGER, optional: true }],
102
+ },
103
+ { provide: KIT_REDIS_PREFIX, useValue: `${service}:` },
104
+ {
105
+ provide: KIT_REDIS,
106
+ useFactory: (config, prefix, log) => createRedisConnection({
107
+ url: redisUrl(config),
108
+ connection: 'main',
109
+ service,
110
+ keyPrefix: prefix,
111
+ log,
112
+ }),
113
+ inject: [
114
+ { token: ConfigService, optional: true },
115
+ KIT_REDIS_PREFIX,
116
+ KIT_REDIS_LOG,
117
+ ],
118
+ },
119
+ ...subscriber,
120
+ KitRedisChannels,
121
+ RateLimiter,
122
+ RedisLifecycle,
123
+ ],
124
+ exports: [KIT_REDIS, KitRedisChannels, RateLimiter],
125
+ };
126
+ }
127
+ };
128
+ KitRedisModule = KitRedisModule_1 = __decorate([
129
+ Module({})
130
+ ], KitRedisModule);
131
+ export { KitRedisModule };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ // Проверка опционального peer /redis при загрузке подпути (prd.md NFR-3, AC-1.2; П15): без top-level await. Первый
2
+ // импорт index.ts — текст AC-1.2 называет /redis. Сам ioredis грузит client.ts динамическим import() в фабрике провайдера.
3
+ import { requirePeer } from '../internal/peer.js';
4
+ requirePeer('ioredis', 'redis');
@@ -0,0 +1,49 @@
1
+ import type { Redis } from 'ioredis';
2
+ import type { KitMetrics } from '../observability/metrics.js';
3
+ import { type RedisLog } from './client.js';
4
+ /** Опции проверки лимита. */
5
+ export interface RateLimitOptions {
6
+ /** Допустимое число попаданий в окне, целое ≥ 1. */
7
+ readonly limit: number;
8
+ /** Окно, мс, целое ≥ 1; отсчитывается от первого попадания. */
9
+ readonly windowMs: number;
10
+ }
11
+ /** Итог проверки лимита. */
12
+ export interface RateLimitResult {
13
+ /** Запрос разрешён. */
14
+ readonly allowed: boolean;
15
+ /** Сколько попаданий осталось в окне. */
16
+ readonly remaining: number;
17
+ /** Через сколько мс окно закончится; `0`, если разрешено. */
18
+ readonly retryAfterMs: number;
19
+ }
20
+ /**
21
+ * Ограничитель частоты (§3.7): фиксированное окно от первого попадания, ключ в Redis — `<svc>:rl:<key>`. `hit` считает
22
+ * попадание, `peek` только читает. Redis недоступен — fail-open: `{allowed: true, remaining: limit}`, метрика
23
+ * `verdeect_rate_limit_unavailable_total` +1 на каждую проверку, `warn` не чаще раза в 60 с (алерт
24
+ * `VerdeectRateLimitUnavailable`).
25
+ *
26
+ * @example
27
+ * const result = await limiter.hit(`login:${ip}`, { limit: 30, windowMs: 60_000 });
28
+ * if (!result.allowed) {
29
+ * throwRateLimited(result);
30
+ * }
31
+ */
32
+ export declare class RateLimiter {
33
+ #private;
34
+ private readonly redis;
35
+ private readonly log;
36
+ private readonly metrics?;
37
+ constructor(redis: Redis, log: RedisLog, metrics?: KitMetrics | undefined);
38
+ /** Считает попадание: `INCR`, `PEXPIRE … NX`, `PTTL` одним `MULTI`; `allowed = count ≤ limit`. */
39
+ hit(key: string, options: RateLimitOptions): Promise<RateLimitResult>;
40
+ /** Читает счётчик без изменения: `GET`, `PTTL` одним `MULTI`; `allowed = count < limit`. */
41
+ peek(key: string, options: RateLimitOptions): Promise<RateLimitResult>;
42
+ private resultOf;
43
+ private failOpen;
44
+ }
45
+ /**
46
+ * Бросает `DomainError('RATE_LIMITED', {retryAfterSec})` — 429 и `Retry-After` в секундах вверх (не меньше 1) через
47
+ * `KitExceptionFilter` (§3.5, §3.7). `result.allowed === true` — `TypeError` (ошибка разработчика).
48
+ */
49
+ export declare function throwRateLimited(result: RateLimitResult): never;
@@ -0,0 +1,139 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ // RateLimiter, throwRateLimited (prd.md §3.7, UC-11, AC-8.3, AC-8.4, AC-9.5). Эталон — I shared/security/rate-limiter.ts
14
+ // (consume → hit, peek, scope); из B — PTTL и fail-open без GlitchTip. Окно в мс, PEXPIRE NX в одном MULTI.
15
+ import { Inject, Injectable, Optional } from '@nestjs/common';
16
+ import { KIT_METRICS, KIT_REDIS_LOG } from '../internal/tokens.js';
17
+ import { sanitizeError } from '../observability/sanitize-error.js';
18
+ import { DomainError } from '../http/errors.js';
19
+ import { KIT_REDIS } from './client.js';
20
+ /** Не чаще раза в это время на процесс — `warn` о недоступном Redis, мс. */
21
+ const UNAVAILABLE_WARN_INTERVAL_MS = 60_000;
22
+ function assertOptions(key, options) {
23
+ if (key.length === 0) {
24
+ throw new TypeError('RateLimiter: key — непустая строка');
25
+ }
26
+ if (!Number.isInteger(options.limit) || options.limit < 1) {
27
+ throw new TypeError('RateLimiter: limit — целое ≥ 1');
28
+ }
29
+ if (!Number.isInteger(options.windowMs) || options.windowMs < 1) {
30
+ throw new TypeError('RateLimiter: windowMs — целое ≥ 1');
31
+ }
32
+ }
33
+ /** Значения команд MULTI; ошибка любой команды или `null` (EXEC прерван) — исключение (fail-open выше). */
34
+ function valuesOf(result) {
35
+ if (result === null) {
36
+ throw new Error('RateLimiter: EXEC прерван');
37
+ }
38
+ return result.map(([error, value]) => {
39
+ if (error !== null) {
40
+ throw error;
41
+ }
42
+ return value;
43
+ });
44
+ }
45
+ /**
46
+ * Ограничитель частоты (§3.7): фиксированное окно от первого попадания, ключ в Redis — `<svc>:rl:<key>`. `hit` считает
47
+ * попадание, `peek` только читает. Redis недоступен — fail-open: `{allowed: true, remaining: limit}`, метрика
48
+ * `verdeect_rate_limit_unavailable_total` +1 на каждую проверку, `warn` не чаще раза в 60 с (алерт
49
+ * `VerdeectRateLimitUnavailable`).
50
+ *
51
+ * @example
52
+ * const result = await limiter.hit(`login:${ip}`, { limit: 30, windowMs: 60_000 });
53
+ * if (!result.allowed) {
54
+ * throwRateLimited(result);
55
+ * }
56
+ */
57
+ let RateLimiter = class RateLimiter {
58
+ redis;
59
+ log;
60
+ metrics;
61
+ #lastUnavailableWarnAt;
62
+ constructor(redis, log, metrics) {
63
+ this.redis = redis;
64
+ this.log = log;
65
+ this.metrics = metrics;
66
+ }
67
+ /** Считает попадание: `INCR`, `PEXPIRE … NX`, `PTTL` одним `MULTI`; `allowed = count ≤ limit`. */
68
+ async hit(key, options) {
69
+ assertOptions(key, options);
70
+ const redisKey = `rl:${key}`;
71
+ try {
72
+ const [count, , pttl] = valuesOf(await this.redis
73
+ .multi()
74
+ .incr(redisKey)
75
+ .pexpire(redisKey, options.windowMs, 'NX')
76
+ .pttl(redisKey)
77
+ .exec());
78
+ return this.resultOf(key, Number(count), Number(pttl), options.limit, false);
79
+ }
80
+ catch (error) {
81
+ return this.failOpen(error, options.limit);
82
+ }
83
+ }
84
+ /** Читает счётчик без изменения: `GET`, `PTTL` одним `MULTI`; `allowed = count < limit`. */
85
+ async peek(key, options) {
86
+ assertOptions(key, options);
87
+ const redisKey = `rl:${key}`;
88
+ try {
89
+ const [count, pttl] = valuesOf(await this.redis.multi().get(redisKey).pttl(redisKey).exec());
90
+ return this.resultOf(key, Number(count ?? 0), Number(pttl), options.limit, true);
91
+ }
92
+ catch (error) {
93
+ return this.failOpen(error, options.limit);
94
+ }
95
+ }
96
+ resultOf(key, count, pttl, limit, peek) {
97
+ const allowed = peek ? count < limit : count <= limit;
98
+ if (!allowed) {
99
+ // scope — часть ключа до первого «:»; сам ключ (IP, логин) в лог не пишется.
100
+ this.log.debug({ scope: key.split(':', 1)[0] }, 'лимитер: лимит исчерпан');
101
+ }
102
+ return {
103
+ allowed,
104
+ remaining: Math.max(0, limit - count),
105
+ retryAfterMs: allowed ? 0 : Math.max(0, pttl),
106
+ };
107
+ }
108
+ failOpen(error, limit) {
109
+ this.metrics?.rateLimitUnavailable.inc();
110
+ const now = Date.now();
111
+ // ponytail: один таймер warn на процесс, по ключам — если понадобится разбор по scope.
112
+ if (this.#lastUnavailableWarnAt === undefined ||
113
+ now - this.#lastUnavailableWarnAt >= UNAVAILABLE_WARN_INTERVAL_MS) {
114
+ this.#lastUnavailableWarnAt = now;
115
+ this.log.warn({ err: sanitizeError(error) }, 'лимитер: Redis недоступен, запрос пропущен');
116
+ }
117
+ return { allowed: true, remaining: limit, retryAfterMs: 0 };
118
+ }
119
+ };
120
+ RateLimiter = __decorate([
121
+ Injectable(),
122
+ __param(0, Inject(KIT_REDIS)),
123
+ __param(1, Inject(KIT_REDIS_LOG)),
124
+ __param(2, Optional()),
125
+ __param(2, Inject(KIT_METRICS)),
126
+ __metadata("design:paramtypes", [Function, Object, Object])
127
+ ], RateLimiter);
128
+ export { RateLimiter };
129
+ /**
130
+ * Бросает `DomainError('RATE_LIMITED', {retryAfterSec})` — 429 и `Retry-After` в секундах вверх (не меньше 1) через
131
+ * `KitExceptionFilter` (§3.5, §3.7). `result.allowed === true` — `TypeError` (ошибка разработчика).
132
+ */
133
+ export function throwRateLimited(result) {
134
+ if (result.allowed) {
135
+ throw new TypeError('throwRateLimited: результат allowed — запрос разрешён');
136
+ }
137
+ const retryAfterSec = Math.max(1, Math.ceil(result.retryAfterMs / 1000));
138
+ throw new DomainError('RATE_LIMITED', { retryAfterSec });
139
+ }
@@ -0,0 +1,30 @@
1
+ /** Клиент пула с методом `query` (`pg.PoolClient`). */
2
+ interface CountableClient {
3
+ query: (...args: never[]) => unknown;
4
+ }
5
+ /** Пул с событием `acquire` (`pg.Pool`). */
6
+ export interface KitCountablePool {
7
+ /** Подписка на выдачу клиента. */
8
+ on(event: 'acquire', listener: (client: CountableClient) => void): unknown;
9
+ /** Отписка. */
10
+ off(event: 'acquire', listener: (client: CountableClient) => void): unknown;
11
+ }
12
+ /** Опции `countQueries`. */
13
+ export interface KitCountQueriesOptions {
14
+ /**
15
+ * Вызывается синхронно до отправки каждого запроса пула за время `fn` с текстом SQL без параметров. Исключение
16
+ * колбэка не прерывает запрос, а пробрасывается из `countQueries` после `fn`.
17
+ */
18
+ readonly onQuery?: (text: string) => void;
19
+ }
20
+ /**
21
+ * Считает все запросы пула за время `fn` (по порядку, текст SQL без параметров). Параллельная нагрузка на тот же пул
22
+ * попадёт в счёт; запросы, начатые до вызова, — нет. Методы клиентов восстанавливаются после. `options.onQuery` —
23
+ * сигнал на каждый запрос (например, «первая пачка началась») без пауз в тесте.
24
+ */
25
+ export declare function countQueries<T>(pool: KitCountablePool, fn: () => Promise<T>, options?: KitCountQueriesOptions): Promise<{
26
+ readonly result: T;
27
+ readonly count: number;
28
+ readonly queries: readonly string[];
29
+ }>;
30
+ export {};
@@ -0,0 +1,58 @@
1
+ // countQueries (prd.md §3.8, AC-4.15, UC-14): подсчёт запросов пула за время fn — обёртка query выданных клиентов.
2
+ // pg не импортируется: пул — структурный тип.
3
+ function textOf(query) {
4
+ if (typeof query === 'string') {
5
+ return query;
6
+ }
7
+ const text = query?.text;
8
+ return typeof text === 'string' ? text : String(query);
9
+ }
10
+ /**
11
+ * Считает все запросы пула за время `fn` (по порядку, текст SQL без параметров). Параллельная нагрузка на тот же пул
12
+ * попадёт в счёт; запросы, начатые до вызова, — нет. Методы клиентов восстанавливаются после. `options.onQuery` —
13
+ * сигнал на каждый запрос (например, «первая пачка началась») без пауз в тесте.
14
+ */
15
+ export async function countQueries(pool, fn, options = {}) {
16
+ const queries = [];
17
+ let callbackError;
18
+ // undefined — query был методом прототипа: восстановление снимает собственное свойство.
19
+ const wrapped = new Map();
20
+ const onAcquire = (client) => {
21
+ if (wrapped.has(client)) {
22
+ return;
23
+ }
24
+ const original = client.query;
25
+ wrapped.set(client, Object.hasOwn(client, 'query') ? original : undefined);
26
+ // boundary: обёртка сохраняет все перегрузки query pg (текст, конфиг, обратный вызов).
27
+ client.query = ((...args) => {
28
+ const text = textOf(args[0]);
29
+ queries.push(text);
30
+ try {
31
+ options.onQuery?.(text);
32
+ }
33
+ catch (error) {
34
+ callbackError ??= { error };
35
+ }
36
+ return original.apply(client, args);
37
+ });
38
+ };
39
+ pool.on('acquire', onAcquire);
40
+ try {
41
+ const result = await fn();
42
+ if (callbackError !== undefined) {
43
+ throw callbackError.error;
44
+ }
45
+ return { result, count: queries.length, queries: [...queries] };
46
+ }
47
+ finally {
48
+ pool.off('acquire', onAcquire);
49
+ for (const [client, original] of wrapped) {
50
+ if (original === undefined) {
51
+ delete client.query;
52
+ }
53
+ else {
54
+ client.query = original;
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,26 @@
1
+ import type { DynamicModule, Type } from '@nestjs/common';
2
+ import type { NestFastifyApplication } from '@nestjs/platform-fastify';
3
+ /** Подмена провайдера модуля сервиса. */
4
+ export interface KitTestOverride {
5
+ /** Токен DI: класс, символ или строка. */
6
+ readonly provide: unknown;
7
+ /** Значение вместо провайдера. */
8
+ readonly useValue: unknown;
9
+ }
10
+ /** Опции `createTestApp` (§3.8). */
11
+ export interface KitTestAppOptions {
12
+ /** Корневой модуль сервиса (импортирует `KitConfigModule.forRoot`). */
13
+ readonly module: Type | DynamicModule;
14
+ /** Имя сервиса `^[a-z][a-z0-9-]{1,31}$` — как у `bootstrapService`. */
15
+ readonly service: string;
16
+ /** Подмены провайдеров. */
17
+ readonly overrides?: readonly KitTestOverride[];
18
+ /** Лимит тела запроса, байт. */
19
+ readonly bodyLimit?: number;
20
+ }
21
+ /**
22
+ * Приложение сервиса для тестов (§3.8): тот же `createHttpApp`, запросы — `app.inject(...)` без порта. Окружение —
23
+ * из `process.env` через `KitConfigModule`. Закрытие — `app.close()`: шаги остановки 1–5 без сторожа и `exit`.
24
+ * Нужен `@nestjs/testing` в зависимостях сервиса.
25
+ */
26
+ export declare function createTestApp(options: KitTestAppOptions): Promise<NestFastifyApplication>;
@@ -0,0 +1,32 @@
1
+ import { buildHttpApp } from '../http/create-http-app.js';
2
+ import { loadPeer } from '../internal/peer.js';
3
+ import { assertServiceName } from '../internal/service-name.js';
4
+ import { KIT_LOGGER } from '../internal/tokens.js';
5
+ /**
6
+ * Приложение сервиса для тестов (§3.8): тот же `createHttpApp`, запросы — `app.inject(...)` без порта. Окружение —
7
+ * из `process.env` через `KitConfigModule`. Закрытие — `app.close()`: шаги остановки 1–5 без сторожа и `exit`.
8
+ * Нужен `@nestjs/testing` в зависимостях сервиса.
9
+ */
10
+ export async function createTestApp(options) {
11
+ assertServiceName(options.service, 'createTestApp');
12
+ const { Test } = await loadPeer('@nestjs/testing', 'testing');
13
+ const app = await buildHttpApp({
14
+ module: options.module,
15
+ service: options.service,
16
+ bodyLimit: options.bodyLimit,
17
+ caller: 'createTestApp',
18
+ compile: async (root, adapter) => {
19
+ const builder = Test.createTestingModule({ imports: [root] });
20
+ for (const override of options.overrides ?? []) {
21
+ // boundary: токен DI — класс, символ или строка; тип @nestjs/testing шире unknown не принимает.
22
+ builder.overrideProvider(override.provide).useValue(override.useValue);
23
+ }
24
+ const moduleRef = await builder.compile();
25
+ return moduleRef.createNestApplication(adapter, {
26
+ bufferLogs: true,
27
+ });
28
+ },
29
+ });
30
+ app.get(KIT_LOGGER).debug({ service: options.service, overrides: options.overrides?.length ?? 0 }, 'testing: приложение собрано');
31
+ return app;
32
+ }
@@ -0,0 +1,8 @@
1
+ import type { Type } from '@nestjs/common';
2
+ import type { KitTestResponse } from './expect-problem.js';
3
+ /**
4
+ * Проверяет ответ маршрута `[Controller, 'method']` по его `@Api` (§3.8). Статус операции — тело по схеме `response` с
5
+ * конвертом `envelope`; описанный статус ошибки — тело `{error: {code, message, details}}`; прочий статус — ошибка.
6
+ * Несовпадение — `AssertionError` с путями ошибок Ajv; маршрут без `@Api` — ошибка с именем маршрута.
7
+ */
8
+ export declare function expectMatchesApi(response: KitTestResponse, route: readonly [Type, string]): void;
@@ -0,0 +1,49 @@
1
+ // expectMatchesApi (prd.md §3.8 — E3, AC-3.7; KT-17): ответ соответствует @Api маршрута — тот же построитель, что у CLI
2
+ // verdeect-kit openapi. Успешный статус — схема response с конвертом; описанный статус ошибки (стандартные, errors
3
+ // маршрута, 401/403/409 guard'а @verdeect/auth) — ErrorResponse. Эталона нет: пишется по PRD. Валидатор — Ajv 2020 (ajv —
4
+ // внутренняя деталь /testing, NFR-3), скомпилированная схема кэшируется по маршруту и статусу; утверждения —
5
+ // node:assert/strict.
6
+ import assert from 'node:assert/strict';
7
+ import { Ajv2020 } from 'ajv/dist/2020.js';
8
+ import { routeResponseSchema } from '../http/api/openapi-document.js';
9
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
10
+ /** Скомпилированные схемы: класс контроллера → `<метод>:<статус>` → валидатор. */
11
+ const validators = new WeakMap();
12
+ function validatorOf(route, status, schema) {
13
+ const [controller, handlerName] = route;
14
+ const byRoute = validators.get(controller) ?? new Map();
15
+ validators.set(controller, byRoute);
16
+ const key = `${handlerName}:${status}`;
17
+ const cached = byRoute.get(key);
18
+ if (cached !== undefined) {
19
+ return cached;
20
+ }
21
+ const validate = ajv.compile(schema);
22
+ byRoute.set(key, validate);
23
+ return validate;
24
+ }
25
+ /**
26
+ * Проверяет ответ маршрута `[Controller, 'method']` по его `@Api` (§3.8). Статус операции — тело по схеме `response` с
27
+ * конвертом `envelope`; описанный статус ошибки — тело `{error: {code, message, details}}`; прочий статус — ошибка.
28
+ * Несовпадение — `AssertionError` с путями ошибок Ajv; маршрут без `@Api` — ошибка с именем маршрута.
29
+ */
30
+ export function expectMatchesApi(response, route) {
31
+ const { status, schema, errorStatuses, errorSchema } = routeResponseSchema(route);
32
+ const name = `${route[0].name}.${route[1]}`;
33
+ const isError = response.statusCode !== status;
34
+ if (isError && !errorStatuses.includes(response.statusCode)) {
35
+ assert.fail(`${name}: статус ${response.statusCode} не описан в @Api (успех — ${status}, ошибки — ${errorStatuses.join(', ')})`);
36
+ }
37
+ const expected = isError ? errorSchema : schema;
38
+ if (expected === null) {
39
+ return;
40
+ }
41
+ const validate = validatorOf(route, response.statusCode, expected);
42
+ const body = response.json();
43
+ if (!validate(body)) {
44
+ const errors = (validate.errors ?? [])
45
+ .map((error) => `${error.instancePath || '(root)'} ${error.message ?? ''}`.trim())
46
+ .join('; ');
47
+ assert.fail(`${name}: ответ вне схемы @Api — ${errors}`);
48
+ }
49
+ }
@@ -0,0 +1,24 @@
1
+ /** Ответ inject Fastify или похожий: статус и разбор тела. */
2
+ export interface KitTestResponse {
3
+ /** HTTP-статус. */
4
+ readonly statusCode: number;
5
+ /** Тело ответа, разобранное как JSON. */
6
+ json(): unknown;
7
+ }
8
+ /** Ожидаемая ошибка. */
9
+ export interface KitExpectedProblem {
10
+ /** HTTP-статус. */
11
+ readonly status: number;
12
+ /** Код ошибки словаря. */
13
+ readonly code: string;
14
+ /** Ожидаемые `details.issues` (глубокое сравнение), для 422 `VALIDATION_FAILED`. */
15
+ readonly issues?: readonly {
16
+ readonly path: string;
17
+ readonly code: string;
18
+ }[];
19
+ }
20
+ /**
21
+ * Проверяет ответ-ошибку (§3.5): статус, форма `{error: {code, message: string, details: object}}`, код и (если задано)
22
+ * `details.issues`. Несовпадение — `AssertionError` с понятным текстом.
23
+ */
24
+ export declare function expectProblem(response: KitTestResponse, expected: KitExpectedProblem): void;
@@ -0,0 +1,23 @@
1
+ // expectProblem (prd.md §3.8 — E3, §3.5): утверждение об ответе-ошибке {error: {code, message, details}}. Эталона нет —
2
+ // ближайшее O test/support/http.ts (errorCode). Утверждения — node:assert/strict: peer vitest не загружается.
3
+ import assert from 'node:assert/strict';
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ /**
8
+ * Проверяет ответ-ошибку (§3.5): статус, форма `{error: {code, message: string, details: object}}`, код и (если задано)
9
+ * `details.issues`. Несовпадение — `AssertionError` с понятным текстом.
10
+ */
11
+ export function expectProblem(response, expected) {
12
+ const body = response.json();
13
+ const text = JSON.stringify(body);
14
+ assert.strictEqual(response.statusCode, expected.status, `статус ${response.statusCode}, ожидался ${expected.status}: ${text}`);
15
+ const error = isRecord(body) ? body.error : undefined;
16
+ assert.ok(isRecord(error), `тело не в форме {error: {code, message, details}}: ${text}`);
17
+ assert.strictEqual(typeof error.message, 'string', `error.message не строка: ${text}`);
18
+ assert.ok(isRecord(error.details), `error.details не объект: ${text}`);
19
+ assert.strictEqual(error.code, expected.code, `код ${String(error.code)}, ожидался ${expected.code}`);
20
+ if (expected.issues !== undefined) {
21
+ assert.deepStrictEqual(error.details.issues, expected.issues, `details.issues: ${text}`);
22
+ }
23
+ }
@@ -0,0 +1,10 @@
1
+ /** Образы тестов и compose сервисов (§3.8); мажорная смена образа — мажор пакета (G14). */
2
+ export declare const TEST_IMAGES: {
3
+ readonly postgres: "postgres:17.11-alpine3.24";
4
+ readonly redis: "redis:7.4.11-alpine";
5
+ readonly rabbitmq: "rabbitmq:4.3.6-management-alpine";
6
+ readonly mailpit: "axllent/mailpit:v1.31.6";
7
+ readonly minio: "pgsty/minio:RELEASE.2026-08-04T00-00-00Z";
8
+ };
9
+ /** Репозиторий образа — часть до последнего `:` (`axllent/mailpit`); внутренний помощник правил §3.8. */
10
+ export declare function imageRepository(image: string): string;
@@ -0,0 +1,14 @@
1
+ // Образы тестового контура (prd.md §3.8, E1); эталон — O test/support/images.ts, mailpit — I global-setup.ts.
2
+ /** Образы тестов и compose сервисов (§3.8); мажорная смена образа — мажор пакета (G14). */
3
+ export const TEST_IMAGES = {
4
+ postgres: 'postgres:17.11-alpine3.24',
5
+ redis: 'redis:7.4.11-alpine',
6
+ rabbitmq: 'rabbitmq:4.3.6-management-alpine',
7
+ mailpit: 'axllent/mailpit:v1.31.6',
8
+ minio: 'pgsty/minio:RELEASE.2026-08-04T00-00-00Z',
9
+ };
10
+ /** Репозиторий образа — часть до последнего `:` (`axllent/mailpit`); внутренний помощник правил §3.8. */
11
+ export function imageRepository(image) {
12
+ const colon = image.lastIndexOf(':');
13
+ return colon === -1 ? image : image.slice(0, colon);
14
+ }
@@ -0,0 +1,23 @@
1
+ export { createTestApp } from './create-test-app.js';
2
+ export type { KitTestAppOptions, KitTestOverride } from './create-test-app.js';
3
+ export { expectMatchesApi } from './expect-matches-api.js';
4
+ export { expectProblem } from './expect-problem.js';
5
+ export type { KitExpectedProblem, KitTestResponse } from './expect-problem.js';
6
+ export { TEST_IMAGES } from './images.js';
7
+ export { startTestStack } from './start-test-stack.js';
8
+ export { tcpProxy } from './tcp-proxy.js';
9
+ export type { TcpProxy } from './tcp-proxy.js';
10
+ export { TEST_TIMEOUT_SCALE } from './timeout-scale.js';
11
+ export { assertRepoConventions } from './repo-conventions.js';
12
+ export { timingGuard } from './timing-guard.js';
13
+ export { createScratchDatabase } from './scratch-database.js';
14
+ export type { KitScratchDatabase } from './scratch-database.js';
15
+ export { redisWorkerOverride } from './redis-worker.js';
16
+ export { workerScope } from './worker-scope.js';
17
+ export type { KitWorkerScope } from './worker-scope.js';
18
+ export { countQueries } from './count-queries.js';
19
+ export type { KitCountablePool, KitCountQueriesOptions } from './count-queries.js';
20
+ export { assertAppRoleIsNotOwner, assertStrictTenantIsolation } from './tenant-isolation.js';
21
+ export type { KitQueryable, KitTenantIsolationOptions } from './tenant-isolation.js';
22
+ export { assertOutboxCoverage, expectOutbox } from './outbox-coverage.js';
23
+ export { publishTestEvent, waitForInbox } from './test-events.js';