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

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 +138 -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,452 @@
1
+ import { RequestMethod } from '@nestjs/common';
2
+ import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, MODULE_METADATA, PATH_METADATA, ROUTE_ARGS_METADATA, } from '@nestjs/common/constants.js';
3
+ import { RouteParamtypes } from '@nestjs/common/enums/route-paramtypes.enum.js';
4
+ import { VerdeectAuthGuard } from '@verdeect/auth/nest';
5
+ import { IDEMPOTENT_METADATA } from '../../internal/metadata.js';
6
+ import { z } from 'zod';
7
+ import { errorStatus } from '../errors.js';
8
+ import { ZodValidationPipe } from '../zod-validation.pipe.js';
9
+ import { API_METADATA } from './api.decorator.js';
10
+ // Ключи метаданных Require* @verdeect/auth 1.0.1 (dist/nest/decorators.js): пакет их не экспортирует.
11
+ const REQUIRE_SCOPES = 'verdeect-auth:require-scopes';
12
+ const REQUIRE_USER = 'verdeect-auth:require-user';
13
+ const REQUIRE_SERVICE = 'verdeect-auth:require-service';
14
+ const REQUIRE_TENANT = 'verdeect-auth:require-tenant';
15
+ const METHOD_ORDER = ['get', 'post', 'put', 'patch', 'delete'];
16
+ const METHODS = {
17
+ [RequestMethod.GET]: 'get',
18
+ [RequestMethod.POST]: 'post',
19
+ [RequestMethod.PUT]: 'put',
20
+ [RequestMethod.PATCH]: 'patch',
21
+ [RequestMethod.DELETE]: 'delete',
22
+ };
23
+ const ERROR_RESPONSE_REF = { $ref: '#/components/schemas/ErrorResponse' };
24
+ const ERROR_RESPONSE = {
25
+ type: 'object',
26
+ properties: {
27
+ error: {
28
+ type: 'object',
29
+ properties: {
30
+ code: { type: 'string' },
31
+ message: { type: 'string' },
32
+ details: { type: 'object' },
33
+ },
34
+ required: ['code', 'message', 'details'],
35
+ },
36
+ },
37
+ required: ['error'],
38
+ };
39
+ function toSchema(schema, io) {
40
+ const json = z.toJSONSchema(schema, { io, unrepresentable: 'any' });
41
+ delete json.$schema;
42
+ return json;
43
+ }
44
+ function joinPath(...parts) {
45
+ const path = parts
46
+ .flatMap((part) => part.split('/'))
47
+ .filter((segment) => segment !== '')
48
+ .map((segment) => (segment.startsWith(':') ? `{${segment.slice(1)}}` : segment))
49
+ .join('/');
50
+ return `/${path}`;
51
+ }
52
+ function pathsOf(value) {
53
+ if (Array.isArray(value)) {
54
+ return value.filter((item) => typeof item === 'string');
55
+ }
56
+ return [typeof value === 'string' ? value : ''];
57
+ }
58
+ function isObject(value) {
59
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
60
+ }
61
+ /** Глубокое слияние `extra` (операция, `contracts/openapi.extra.json`): объекты сливаются, прочее заменяется. */
62
+ export function deepMerge(target, extra) {
63
+ const result = { ...target };
64
+ for (const [key, value] of Object.entries(extra)) {
65
+ const current = result[key];
66
+ result[key] = isObject(current) && isObject(value) ? deepMerge(current, value) : value;
67
+ }
68
+ return result;
69
+ }
70
+ function enveloped(schema, envelope) {
71
+ switch (envelope) {
72
+ case 'page':
73
+ return {
74
+ type: 'object',
75
+ properties: {
76
+ items: { type: 'array', items: schema },
77
+ nextCursor: { anyOf: [{ type: 'string' }, { type: 'null' }] },
78
+ },
79
+ required: ['items', 'nextCursor'],
80
+ additionalProperties: false,
81
+ };
82
+ case 'items':
83
+ return {
84
+ type: 'object',
85
+ properties: { items: { type: 'array', items: schema } },
86
+ required: ['items'],
87
+ additionalProperties: false,
88
+ };
89
+ case 'array':
90
+ return { type: 'array', items: schema };
91
+ default:
92
+ return schema;
93
+ }
94
+ }
95
+ function zodPipeOf(argument) {
96
+ const pipe = argument.pipes?.find((item) => item instanceof ZodValidationPipe);
97
+ return pipe instanceof ZodValidationPipe ? pipe : null;
98
+ }
99
+ /** Метаданные метода перекрывают метаданные класса (как у guard'а @verdeect/auth). */
100
+ function metadataOf(key, handler, controller) {
101
+ const own = Reflect.getMetadata(key, handler);
102
+ return own === undefined ? Reflect.getMetadata(key, controller) : own;
103
+ }
104
+ function hasAuthGuard(target) {
105
+ const guards = Reflect.getMetadata(GUARDS_METADATA, target);
106
+ return (Array.isArray(guards) &&
107
+ guards.some((guard) => guard === VerdeectAuthGuard ||
108
+ guard instanceof VerdeectAuthGuard ||
109
+ (typeof guard === 'function' && guard.name === VerdeectAuthGuard.name)));
110
+ }
111
+ function securityOf(controller, handler) {
112
+ const scopes = metadataOf(REQUIRE_SCOPES, handler, controller);
113
+ const user = metadataOf(REQUIRE_USER, handler, controller) === true;
114
+ const service = metadataOf(REQUIRE_SERVICE, handler, controller) === true;
115
+ const tenant = metadataOf(REQUIRE_TENANT, handler, controller) === true;
116
+ const guarded = hasAuthGuard(handler) || hasAuthGuard(controller);
117
+ if (!guarded && !user && !service && !tenant && scopes === undefined) {
118
+ return null;
119
+ }
120
+ const schemes = [];
121
+ if (service || !user) {
122
+ schemes.push('serviceToken');
123
+ }
124
+ if (user || !service) {
125
+ schemes.push('userToken');
126
+ }
127
+ return {
128
+ schemes,
129
+ tenant,
130
+ scopes: Array.isArray(scopes)
131
+ ? scopes.filter((s) => typeof s === 'string')
132
+ : [],
133
+ };
134
+ }
135
+ function queryParameters(schema, data) {
136
+ if (typeof data === 'string') {
137
+ return [{ name: data, in: 'query', required: false, schema }];
138
+ }
139
+ const properties = (schema.properties ?? {});
140
+ const required = new Set((schema.required ?? []));
141
+ return Object.entries(properties).map(([name, property]) => ({
142
+ name,
143
+ in: 'query',
144
+ required: required.has(name),
145
+ schema: property,
146
+ }));
147
+ }
148
+ /** Схема успешного ответа маршрута (с конвертом). */
149
+ function responseSchemaOf(api) {
150
+ return api.response === undefined
151
+ ? null
152
+ : enveloped(toSchema(api.response, 'output'), api.envelope);
153
+ }
154
+ /** Опции `@Idempotent` обработчика (декоратор методный) или `null`. */
155
+ function idempotentOf(route) {
156
+ const options = Reflect.getMetadata(IDEMPOTENT_METADATA, route.handler);
157
+ return typeof options === 'object' && options !== null
158
+ ? options
159
+ : null;
160
+ }
161
+ /** Ошибки операции: статус → коды. */
162
+ function errorsOf(route, security) {
163
+ const errors = new Map([
164
+ [400, ['BAD_REQUEST']],
165
+ [422, ['VALIDATION_FAILED']],
166
+ [429, ['RATE_LIMITED']],
167
+ ]);
168
+ if (security !== null) {
169
+ errors.set(401, ['UNAUTHENTICATED']);
170
+ errors.set(403, security.scopes.length > 0 ? ['FORBIDDEN', 'SCOPE_REQUIRED'] : ['FORBIDDEN']);
171
+ if (security.tenant) {
172
+ // Тело — {error: {code: 'TENANT_REQUIRED', message: 'Не выбрана компания', details: {}}} guard'а.
173
+ errors.set(409, ['TENANT_REQUIRED']);
174
+ }
175
+ }
176
+ for (const code of route.api.errors ?? []) {
177
+ const status = errorStatus(code);
178
+ if (status === undefined) {
179
+ throw new Error(`openapi: ${route.controller.name}.${route.handlerName} — код ${code} не зарегистрирован в defineErrors`);
180
+ }
181
+ errors.set(status, [...(errors.get(status) ?? []), code]);
182
+ }
183
+ const idempotent = idempotentOf(route);
184
+ if (idempotent !== null) {
185
+ const add = (status, code) => void errors.set(status, [...(errors.get(status) ?? []), code]);
186
+ add(409, 'IDEMPOTENCY_IN_PROGRESS');
187
+ add(422, 'IDEMPOTENCY_KEY_REUSED');
188
+ if (idempotent.required === true) {
189
+ add(400, 'IDEMPOTENCY_KEY_REQUIRED');
190
+ }
191
+ }
192
+ return errors;
193
+ }
194
+ function parametersOf(route, path) {
195
+ const args = (Reflect.getMetadata(ROUTE_ARGS_METADATA, route.controller, route.handlerName) ??
196
+ {});
197
+ const pathSchemas = new Map();
198
+ const parameters = [];
199
+ let body;
200
+ for (const [key, argument] of Object.entries(args)) {
201
+ const paramtype = Number(key.split(':', 1)[0]);
202
+ const pipe = zodPipeOf(argument);
203
+ if (pipe === null) {
204
+ continue;
205
+ }
206
+ const schema = toSchema(pipe.schema, 'input');
207
+ if (paramtype === RouteParamtypes.BODY) {
208
+ body = { required: true, content: { 'application/json': { schema } } };
209
+ }
210
+ else if (paramtype === RouteParamtypes.QUERY) {
211
+ parameters.push(...queryParameters(schema, argument.data));
212
+ }
213
+ else if (paramtype === RouteParamtypes.PARAM && typeof argument.data === 'string') {
214
+ pathSchemas.set(argument.data, schema);
215
+ }
216
+ }
217
+ const pathParameters = [...path.matchAll(/\{([^}]+)\}/g)].map((match) => {
218
+ const name = match[1] ?? '';
219
+ return {
220
+ name,
221
+ in: 'path',
222
+ required: true,
223
+ schema: pathSchemas.get(name) ?? { type: 'string' },
224
+ };
225
+ });
226
+ const idempotent = idempotentOf(route);
227
+ const idempotencyKey = idempotent === null
228
+ ? []
229
+ : [
230
+ {
231
+ name: 'Idempotency-Key',
232
+ in: 'header',
233
+ required: idempotent.required === true,
234
+ schema: { type: 'string', pattern: '^[\\x21-\\x7E]{1,200}$' },
235
+ },
236
+ ];
237
+ return {
238
+ parameters: [...pathParameters, ...parameters, ...idempotencyKey],
239
+ ...(body === undefined ? {} : { body }),
240
+ };
241
+ }
242
+ function buildOperation(route, path) {
243
+ const security = securityOf(route.controller, route.handler);
244
+ const { parameters, body } = parametersOf(route, path);
245
+ const schema = responseSchemaOf(route.api);
246
+ const responses = {
247
+ [String(route.status)]: schema === null
248
+ ? { description: 'успех, без тела' }
249
+ : { description: 'успех', content: { 'application/json': { schema } } },
250
+ };
251
+ if (idempotentOf(route) !== null) {
252
+ const success = responses[String(route.status)];
253
+ responses[String(route.status)] = {
254
+ ...success,
255
+ headers: {
256
+ 'Idempotent-Replayed': {
257
+ schema: { type: 'string', enum: ['true'] },
258
+ description: 'ответ сохранён и повторён',
259
+ },
260
+ },
261
+ };
262
+ }
263
+ for (const [status, codes] of [...errorsOf(route, security).entries()].sort(([a], [b]) => a - b)) {
264
+ responses[String(status)] = {
265
+ description: [...new Set(codes)].join(', '),
266
+ content: { 'application/json': { schema: ERROR_RESPONSE_REF } },
267
+ ...(status === 429
268
+ ? {
269
+ headers: {
270
+ 'Retry-After': {
271
+ schema: { type: 'integer' },
272
+ description: 'секунды до повтора',
273
+ },
274
+ },
275
+ }
276
+ : {}),
277
+ };
278
+ }
279
+ const name = route.controller.name.replace(/Controller$/, '');
280
+ const operationId = `${name.charAt(0).toLowerCase()}${name.slice(1)}${route.handlerName.charAt(0).toUpperCase()}${route.handlerName.slice(1)}`;
281
+ const operation = {
282
+ operationId,
283
+ summary: route.api.summary,
284
+ ...(security === null
285
+ ? {}
286
+ : { security: security.schemes.map((scheme) => ({ [scheme]: [...security.scopes] })) }),
287
+ ...(parameters.length === 0 ? {} : { parameters }),
288
+ ...(body === undefined ? {} : { requestBody: body }),
289
+ responses,
290
+ };
291
+ return {
292
+ path,
293
+ method: route.method,
294
+ operation: route.api.extra === undefined ? operation : deepMerge(operation, route.api.extra),
295
+ security,
296
+ };
297
+ }
298
+ /** Маршрут `[Controller, 'method']` с `@Api`; без `@Api` — `null`; `@Api` без метода HTTP — Error. */
299
+ function apiRouteOf(controller, handlerName) {
300
+ const prototype = controller.prototype;
301
+ const handler = prototype[handlerName];
302
+ if (typeof handler !== 'function') {
303
+ return null;
304
+ }
305
+ const api = Reflect.getMetadata(API_METADATA, handler);
306
+ if (api === undefined) {
307
+ return null;
308
+ }
309
+ const method = METHODS[Reflect.getMetadata(METHOD_METADATA, handler)];
310
+ const handlerPath = Reflect.getMetadata(PATH_METADATA, handler);
311
+ if (method === undefined || handlerPath === undefined) {
312
+ throw new Error(`openapi: ${controller.name}.${handlerName} — @Api без пути или метода HTTP`);
313
+ }
314
+ const status = Reflect.getMetadata(HTTP_CODE_METADATA, handler) ??
315
+ (method === 'post' ? 201 : 200);
316
+ const paths = pathsOf(Reflect.getMetadata(PATH_METADATA, controller)).flatMap((prefix) => pathsOf(handlerPath).map((suffix) => joinPath(prefix, suffix)));
317
+ return { controller, handlerName, handler, api, method, paths, status };
318
+ }
319
+ /** Схема успешного ответа, статус и описанные статусы ошибок маршрута для `expectMatchesApi`. Внутренняя функция. */
320
+ export function routeResponseSchema(route) {
321
+ const [controller, handlerName] = route;
322
+ const api = apiRouteOf(controller, handlerName);
323
+ if (api === null) {
324
+ throw new Error(`expectMatchesApi: у ${controller.name}.${handlerName} нет @Api`);
325
+ }
326
+ const errors = errorsOf(api, securityOf(api.controller, api.handler));
327
+ return {
328
+ status: api.status,
329
+ schema: responseSchemaOf(api.api),
330
+ errorStatuses: [...errors.keys()].sort((a, b) => a - b),
331
+ errorSchema: ERROR_RESPONSE,
332
+ };
333
+ }
334
+ /**
335
+ * Имена методов класса и его предков до `Object.prototype`, как у `MetadataScanner` Nest: метод наследника с тем же
336
+ * именем заменяет метод предка (берётся `prototype[name]` наследника).
337
+ */
338
+ function handlerNamesOf(controller) {
339
+ const names = new Set();
340
+ let prototype = controller.prototype;
341
+ while (typeof prototype === 'object' && prototype !== null && prototype !== Object.prototype) {
342
+ for (const name of Object.getOwnPropertyNames(prototype)) {
343
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
344
+ if (name !== 'constructor' && typeof descriptor?.value === 'function') {
345
+ names.add(name);
346
+ }
347
+ }
348
+ prototype = Object.getPrototypeOf(prototype);
349
+ }
350
+ return [...names].sort();
351
+ }
352
+ async function resolveEntry(entry) {
353
+ const resolved = await entry;
354
+ if (resolved === null || resolved === undefined) {
355
+ return null;
356
+ }
357
+ if (isObject(resolved) && typeof resolved.forwardRef === 'function') {
358
+ return resolveEntry(resolved.forwardRef());
359
+ }
360
+ if (isObject(resolved) && typeof resolved.module === 'function') {
361
+ return { cls: resolved.module, dynamic: resolved };
362
+ }
363
+ if (typeof resolved === 'function') {
364
+ return { cls: resolved };
365
+ }
366
+ return null;
367
+ }
368
+ /** Контроллеры графа модулей по метаданным: приложение не создаётся. */
369
+ async function collectControllers(root) {
370
+ const seen = new Set();
371
+ const controllers = new Set();
372
+ const queue = [root];
373
+ for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) {
374
+ const resolved = await resolveEntry(entry);
375
+ const key = resolved?.dynamic ?? resolved?.cls;
376
+ if (resolved === null || seen.has(key)) {
377
+ continue;
378
+ }
379
+ seen.add(key);
380
+ const staticImports = (Reflect.getMetadata(MODULE_METADATA.IMPORTS, resolved.cls) ??
381
+ []);
382
+ const staticControllers = (Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, resolved.cls) ??
383
+ []);
384
+ queue.push(...staticImports, ...(resolved.dynamic?.imports ?? []));
385
+ for (const controller of [...staticControllers, ...(resolved.dynamic?.controllers ?? [])]) {
386
+ controllers.add(controller);
387
+ }
388
+ }
389
+ return [...controllers];
390
+ }
391
+ function securitySchemes(built) {
392
+ const scopesOf = (scheme) => Object.fromEntries([
393
+ ...new Set(built.flatMap((item) => item.security?.schemes.includes(scheme) === true
394
+ ? item.security.scopes
395
+ : [])),
396
+ ]
397
+ .sort()
398
+ .map((scope) => [scope, scope]));
399
+ return {
400
+ serviceToken: {
401
+ type: 'oauth2',
402
+ description: 'сервисный токен client_credentials (at+jwt) Identity',
403
+ flows: {
404
+ clientCredentials: { tokenUrl: '/oidc/token', scopes: scopesOf('serviceToken') },
405
+ },
406
+ },
407
+ userToken: {
408
+ type: 'oauth2',
409
+ description: 'токен пользователя (at+jwt) Identity',
410
+ flows: {
411
+ authorizationCode: {
412
+ authorizationUrl: '/oidc/auth',
413
+ tokenUrl: '/oidc/token',
414
+ scopes: scopesOf('userToken'),
415
+ },
416
+ },
417
+ },
418
+ };
419
+ }
420
+ /**
421
+ * Документ OpenAPI 3.1 сервиса по метаданным модулей и контроллеров (§3.5): приложение не создаётся, окружение не
422
+ * нужно. В документ входят только маршруты с `@Api`; пути и коды отсортированы. Внутренняя функция (CLI, тесты).
423
+ */
424
+ export async function buildOpenApiDocument(module, info, logger) {
425
+ const built = [];
426
+ for (const controller of await collectControllers(module)) {
427
+ for (const handlerName of handlerNamesOf(controller)) {
428
+ const route = apiRouteOf(controller, handlerName);
429
+ for (const path of route?.paths ?? []) {
430
+ built.push(buildOperation(route, path));
431
+ }
432
+ }
433
+ }
434
+ const paths = {};
435
+ const sorted = [...built].sort((a, b) => a.path.localeCompare(b.path) ||
436
+ METHOD_ORDER.indexOf(a.method) - METHOD_ORDER.indexOf(b.method));
437
+ for (const item of sorted) {
438
+ const operations = paths[item.path] ?? {};
439
+ operations[item.method] = item.operation;
440
+ paths[item.path] = operations;
441
+ }
442
+ logger?.debug({ operations: built.length }, 'openapi: собран');
443
+ return {
444
+ openapi: '3.1.0',
445
+ info: { title: info.title, version: info.version },
446
+ paths,
447
+ components: {
448
+ securitySchemes: securitySchemes(built),
449
+ schemas: { ErrorResponse: ERROR_RESPONSE },
450
+ },
451
+ };
452
+ }
@@ -0,0 +1,33 @@
1
+ import type { DynamicModule, Type } from '@nestjs/common';
2
+ import type { NestFastifyApplication } from '@nestjs/platform-fastify';
3
+ import { z } from 'zod';
4
+ import { type KitBaseEnv } from '../config/fragments.js';
5
+ import { type KitRedaction } from '../observability/redaction.js';
6
+ import type { KitHttpLoggerOptions } from './kit-http-core.module.js';
7
+ /** Схема окружения сервиса: результат содержит ключи `envBase`. */
8
+ type ServiceEnvSchema = z.ZodType<KitBaseEnv & Record<string, unknown>>;
9
+ /** Опции `bootstrapService` (§3.5). */
10
+ export interface KitBootstrapOptions<S extends ServiceEnvSchema = ServiceEnvSchema> {
11
+ /** Корневой модуль сервиса; импортирует `KitConfigModule.forRoot({schema: env})` с той же схемой. */
12
+ readonly module: Type | DynamicModule;
13
+ /** Имя сервиса `^[a-z][a-z0-9-]{1,31}$`; вне формата — отказ запуска. */
14
+ readonly service: string;
15
+ /** Схема окружения (`defineEnv`): разбирается до создания приложения. */
16
+ readonly env: S;
17
+ /** Опции логгера по роли: BFF — `trustTraceparent: false`; P, O — `quietRoutes`. */
18
+ readonly logger?: KitHttpLoggerOptions;
19
+ /** Опции HTTP: `bodyLimit` — лимит тела, байт (по умолчанию — лимит Fastify). */
20
+ readonly http?: {
21
+ readonly bodyLimit?: number;
22
+ };
23
+ /** Правила редактирования сервиса (общие с `initSentry` в `instrument.ts`). */
24
+ readonly redaction?: KitRedaction;
25
+ }
26
+ /**
27
+ * Точка входа `main.ts` сервиса (§3.5): `parseEnv(env, process.env)` до создания приложения → логгер →
28
+ * предупреждение `SENTRY_NOT_PRELOADED` → `createHttpApp` → сервер метрик (`METRICS_PORT`, шаг 5) →
29
+ * `KitLifecycle.listen()` (SIGTERM, SIGINT) → прослушивание `PORT` на `0.0.0.0`. Отказ на любом шаге — `failStartup`
30
+ * (`fatal`, GlitchTip, `exit(1)`): при неверном окружении приложение не создаётся и порт не открывается.
31
+ */
32
+ export declare function bootstrapService<S extends ServiceEnvSchema>(options: KitBootstrapOptions<S>): Promise<NestFastifyApplication>;
33
+ export {};
@@ -0,0 +1,107 @@
1
+ import * as Sentry from '@sentry/nestjs';
2
+ import { pino } from 'pino';
3
+ import { z } from 'zod';
4
+ import { envSentry } from '../config/fragments.js';
5
+ import { parseEnv } from '../config/parse-env.js';
6
+ import { setPreparsedEnv } from '../internal/preparsed-env.js';
7
+ import { assertServiceName } from '../internal/service-name.js';
8
+ import { KIT_METRICS } from '../internal/tokens.js';
9
+ import { failStartup } from '../observability/fail-startup.js';
10
+ import { HealthRegistry } from '../observability/health-registry.js';
11
+ import { KitLifecycle } from '../observability/lifecycle.js';
12
+ import { createLoggerOptions } from '../observability/logger-options.js';
13
+ import { MetricsServer } from '../observability/metrics-server.js';
14
+ import { redactPaths } from '../observability/redaction.js';
15
+ import { isValidDsn } from '../observability/sentry.js';
16
+ import { buildHttpApp } from './create-http-app.js';
17
+ /** Sentry не подключён через `--import instrument`, хотя конфигурация верна и он должен был включиться (§3.5, AC-3.11). */
18
+ function sentryNotPreloaded() {
19
+ // Окружение Sentry — как в initSentry: схема сервиса может не содержать envSentry.
20
+ const parsed = z.object(envSentry).safeParse(process.env);
21
+ return (parsed.success &&
22
+ parsed.data.SENTRY_DSN !== null &&
23
+ // Неверный DSN initSentry выключает намеренно (AC-2.4): предупреждать не о чем.
24
+ isValidDsn(parsed.data.SENTRY_DSN) &&
25
+ parsed.data.NODE_ENV === 'production' &&
26
+ Sentry.getClient() === undefined);
27
+ }
28
+ /** Фактический порт HTTP-сервера приложения (в тестах PORT=0). */
29
+ function httpPort(app) {
30
+ const address = app.getHttpServer().address();
31
+ if (typeof address !== 'object' || address === null || !('port' in address)) {
32
+ return null;
33
+ }
34
+ return typeof address.port === 'number' ? address.port : null;
35
+ }
36
+ /**
37
+ * Точка входа `main.ts` сервиса (§3.5): `parseEnv(env, process.env)` до создания приложения → логгер →
38
+ * предупреждение `SENTRY_NOT_PRELOADED` → `createHttpApp` → сервер метрик (`METRICS_PORT`, шаг 5) →
39
+ * `KitLifecycle.listen()` (SIGTERM, SIGINT) → прослушивание `PORT` на `0.0.0.0`. Отказ на любом шаге — `failStartup`
40
+ * (`fatal`, GlitchTip, `exit(1)`): при неверном окружении приложение не создаётся и порт не открывается.
41
+ */
42
+ export async function bootstrapService(options) {
43
+ // 1. Запасной логгер: отказ до разбора окружения тоже пишется JSON в stdout.
44
+ let logger = pino({
45
+ level: 'info',
46
+ base: { service: options.service, entry: 'http' },
47
+ redact: { paths: redactPaths(options.redaction), censor: '[Filtered]' },
48
+ }, process.stdout);
49
+ let env;
50
+ try {
51
+ // 2. Имя сервиса.
52
+ assertServiceName(options.service, 'bootstrapService');
53
+ // 3. Окружение до создания приложения (AC-3.13).
54
+ env = parseEnv(options.env, process.env);
55
+ // 4. KitConfigModule того же процесса берёт разобранное окружение без повторного разбора.
56
+ setPreparsedEnv(options.env, env);
57
+ // 5. Логгер сервиса.
58
+ logger = pino(...createLoggerOptions({
59
+ ...options.logger,
60
+ service: options.service,
61
+ entry: 'http',
62
+ env,
63
+ redaction: options.redaction,
64
+ }).pinoHttp);
65
+ }
66
+ catch (error) {
67
+ return failStartup(error, logger);
68
+ }
69
+ logger.debug('bootstrap: окружение разобрано');
70
+ // 6. Sentry инициализируется в instrument.ts до загрузки приложения (AC-3.11).
71
+ if (sentryNotPreloaded()) {
72
+ logger.warn({ code: 'SENTRY_NOT_PRELOADED' }, 'Sentry не подключён: запустите сервис с node --import ./dist/instrument.js');
73
+ }
74
+ try {
75
+ // 7. Приложение: участники шагов 2 (http) и 3 (nest) KitLifecycle регистрирует buildHttpApp.
76
+ const app = await buildHttpApp({
77
+ module: options.module,
78
+ service: options.service,
79
+ bodyLimit: options.http?.bodyLimit,
80
+ logger: options.logger,
81
+ redaction: options.redaction,
82
+ caller: 'bootstrapService',
83
+ });
84
+ logger.debug('bootstrap: приложение собрано');
85
+ // 8. Сервер метрик — участник шага 5.
86
+ const lifecycle = app.get(KitLifecycle);
87
+ const metricsServer = new MetricsServer({
88
+ port: env.METRICS_PORT,
89
+ registry: app.get(KIT_METRICS).registry,
90
+ health: app.get(HealthRegistry),
91
+ logger,
92
+ });
93
+ await metricsServer.listen();
94
+ lifecycle.register({ name: 'metrics', stop: () => metricsServer.close() }, { step: 5 });
95
+ logger.debug('bootstrap: метрики слушают');
96
+ // 9. Сигналы ОС — только KitLifecycle: сторож срока и exit.
97
+ lifecycle.listen();
98
+ // 10. Прослушивание PORT.
99
+ await app.listen(env.PORT, '0.0.0.0');
100
+ // 11. Фактические порты: в тестах PORT=0 и METRICS_PORT=0.
101
+ logger.info({ port: httpPort(app), metricsPort: metricsServer.address() }, 'сервис слушает');
102
+ return app;
103
+ }
104
+ catch (error) {
105
+ return failStartup(error, logger);
106
+ }
107
+ }
@@ -0,0 +1,38 @@
1
+ import { type DynamicModule, type Type } from '@nestjs/common';
2
+ import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
3
+ import type { KitRedaction } from '../observability/redaction.js';
4
+ import { type KitHttpLoggerOptions } from './kit-http-core.module.js';
5
+ /** Опции `createHttpApp` (§3.5). */
6
+ export interface KitHttpAppOptions {
7
+ /** Корневой модуль сервиса; обязан импортировать `KitConfigModule.forRoot({schema})`. */
8
+ readonly module: Type | DynamicModule;
9
+ /** Имя сервиса `^[a-z][a-z0-9-]{1,31}$`: поле `service` логов и метка метрик. */
10
+ readonly service: string;
11
+ /** Лимит тела запроса, байт; по умолчанию — лимит Fastify (1 MiB). Больше — 413 `PAYLOAD_TOO_LARGE`. */
12
+ readonly bodyLimit?: number;
13
+ /** Набор заголовков безопасности; единственный — `api` (`nosniff`, `Referrer-Policy: no-referrer`). */
14
+ readonly securityHeaders?: 'api';
15
+ }
16
+ /** Сборка Nest-приложения на адаптере (createTestApp — через Test.createTestingModule). */
17
+ type Compile = (root: DynamicModule, adapter: FastifyAdapter) => Promise<NestFastifyApplication>;
18
+ /** Внутренние опции сборки: общие для createHttpApp, bootstrapService и createTestApp. */
19
+ export interface BuildHttpAppOptions extends KitHttpAppOptions {
20
+ /** Опции логгера по роли сервиса. */
21
+ readonly logger?: KitHttpLoggerOptions;
22
+ /** Правила редактирования сервиса. */
23
+ readonly redaction?: KitRedaction;
24
+ /** Своя сборка приложения на корневом модуле и адаптере. */
25
+ readonly compile?: Compile;
26
+ /** Имя вызывающего для текста ошибки формата имени. */
27
+ readonly caller?: string;
28
+ }
29
+ /** Внутренняя сборка приложения: «тот же createHttpApp» для bootstrapService и createTestApp (§3.8). */
30
+ export declare function buildHttpApp(options: BuildHttpAppOptions): Promise<NestFastifyApplication>;
31
+ /**
32
+ * Fastify-приложение Nest сервиса (§3.5): заголовки безопасности API, trace-id из `traceparent`, `GET /ok`, фильтр
33
+ * `KitExceptionFilter`, 404 `ROUTE_NOT_FOUND`, логгер nestjs-pino, `KitLifecycle` и `HealthRegistry` для модулей сервиса.
34
+ * `app.close()` — программная остановка по шагам 1–5 (§3.2) без сторожа и `exit`. Процесс сервиса запускает
35
+ * `bootstrapService`; `createHttpApp` — для своих точек входа и тестов.
36
+ */
37
+ export declare function createHttpApp(options: KitHttpAppOptions): Promise<NestFastifyApplication>;
38
+ export {};