@futdevpro/nts-dynamo 1.15.29 → 1.15.33

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 (28) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/_specifications/BACKLOG.md +14 -0
  3. package/build/_modules/rate-limit/_models/rate-limit-config.interface.d.ts +54 -0
  4. package/build/_modules/rate-limit/_models/rate-limit-config.interface.d.ts.map +1 -0
  5. package/build/_modules/rate-limit/_models/rate-limit-config.interface.js +3 -0
  6. package/build/_modules/rate-limit/_models/rate-limit-config.interface.js.map +1 -0
  7. package/build/_modules/rate-limit/_models/rate-limit-policy.interface.d.ts +16 -0
  8. package/build/_modules/rate-limit/_models/rate-limit-policy.interface.d.ts.map +1 -0
  9. package/build/_modules/rate-limit/_models/rate-limit-policy.interface.js +3 -0
  10. package/build/_modules/rate-limit/_models/rate-limit-policy.interface.js.map +1 -0
  11. package/build/_modules/rate-limit/index.d.ts +4 -0
  12. package/build/_modules/rate-limit/index.d.ts.map +1 -0
  13. package/build/_modules/rate-limit/index.js +6 -0
  14. package/build/_modules/rate-limit/index.js.map +1 -0
  15. package/build/_modules/rate-limit/rate-limit.middleware.d.ts +118 -0
  16. package/build/_modules/rate-limit/rate-limit.middleware.d.ts.map +1 -0
  17. package/build/_modules/rate-limit/rate-limit.middleware.js +262 -0
  18. package/build/_modules/rate-limit/rate-limit.middleware.js.map +1 -0
  19. package/build/_modules/server/errors/errors.data-service.d.ts.map +1 -1
  20. package/build/_modules/server/errors/errors.data-service.js +9 -1
  21. package/build/_modules/server/errors/errors.data-service.js.map +1 -1
  22. package/package.json +12 -3
  23. package/src/_modules/rate-limit/_models/rate-limit-config.interface.ts +60 -0
  24. package/src/_modules/rate-limit/_models/rate-limit-policy.interface.ts +16 -0
  25. package/src/_modules/rate-limit/index.ts +3 -0
  26. package/src/_modules/rate-limit/rate-limit.middleware.spec.ts +211 -0
  27. package/src/_modules/rate-limit/rate-limit.middleware.ts +310 -0
  28. package/src/_modules/server/errors/errors.data-service.ts +10 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@futdevpro/nts-dynamo",
3
- "version": "01.15.29",
3
+ "version": "01.15.33",
4
4
  "description": "Dynamic NodeTS (NodeJS-Typescript), MongoDB Backend System Framework by Future Development Program Ltd.",
5
5
  "DyBu_settings": {
6
6
  "packageType": "server-package",
@@ -190,6 +190,12 @@
190
190
  "types": "./build/_modules/oauth2/index.d.ts",
191
191
  "typings": "./build/_modules/oauth2/index.d.ts"
192
192
  },
193
+ "./rate-limit": {
194
+ "default": "./build/_modules/rate-limit/index.js",
195
+ "module": "./build/_modules/rate-limit/index.js",
196
+ "types": "./build/_modules/rate-limit/index.d.ts",
197
+ "typings": "./build/_modules/rate-limit/index.d.ts"
198
+ },
193
199
  "./server": {
194
200
  "default": "./build/_modules/server/index.js",
195
201
  "module": "./build/_modules/server/index.js",
@@ -277,6 +283,9 @@
277
283
  "oauth2": [
278
284
  "build/_modules/oauth2/index.d.ts"
279
285
  ],
286
+ "rate-limit": [
287
+ "build/_modules/rate-limit/index.d.ts"
288
+ ],
280
289
  "server": [
281
290
  "build/_modules/server/index.d.ts"
282
291
  ],
@@ -299,7 +308,7 @@
299
308
  "empty": ""
300
309
  },
301
310
  "peerDependencies": {
302
- "@futdevpro/fsm-dynamo": "1.15.8",
311
+ "@futdevpro/fsm-dynamo": "1.15.13",
303
312
  "@types/express": "4.17.21",
304
313
  "@types/geoip-lite": "~1.4.1",
305
314
  "@types/node": "~24.1.0",
@@ -314,7 +323,7 @@
314
323
  "ts-node": "~10.9.2"
315
324
  },
316
325
  "devDependencies": {
317
- "@futdevpro/dynamo-eslint": "1.15.7",
326
+ "@futdevpro/dynamo-eslint": "1.15.10",
318
327
  "@discordjs/opus": "^0.10.0",
319
328
  "@discordjs/voice": "^0.18.0",
320
329
  "@types/jasmine": "~4.3.5",
@@ -0,0 +1,60 @@
1
+ import { Request } from 'express';
2
+
3
+ import { DyNTS_RateLimit_Policy } from './rate-limit-policy.interface';
4
+
5
+ /**
6
+ * Config a `DyNTS_RateLimit_Middleware.configure(...)`-hoz.
7
+ *
8
+ * Minden mezo opcionalis — a default-ok megfelelnek a tipikus API
9
+ * rate-limit-elvarasoknak (100 req/min, sliding window, IP-alapu kulcs,
10
+ * path-alapu endpoint-csoportositas, X-RateLimit-* response header-ek).
11
+ */
12
+ export interface DyNTS_RateLimit_Config {
13
+ /**
14
+ * Default request-limit `windowMs` alatt, ha az adott kulcsra nincs
15
+ * `setPolicyForKey()`-szel beallitott egyedi policy. Default: `100`.
16
+ */
17
+ defaultLimit?: number;
18
+
19
+ /**
20
+ * Default sliding-window hossza milliszekundumban. Default: `60000` (1 perc).
21
+ */
22
+ defaultWindowMs?: number;
23
+
24
+ /**
25
+ * Rate-limit subject-extractor. Visszater a karakterlanccal, ami azonositja
26
+ * a rate-limit alanyat (pl. IP-cim, API-kulcs ID, account ID).
27
+ *
28
+ * Default: a `req.headers['x-forwarded-for']` (csak az elso IP, ha lista)
29
+ * vagy ha hianyzik akkor `req.ip` vagy `'unknown'`. Ez webszerver elotti
30
+ * proxy/CDN-mentes setup-ra megfelelo; ha az nd-space mar identifikalja az
31
+ * API-kulcsot a `req`-ben, a host adhat sajat extractort ami a key-ID-t
32
+ * adja vissza.
33
+ */
34
+ keyExtractor?: (req: Request) => string;
35
+
36
+ /**
37
+ * Endpoint-csoportosito. Visszater a karakterlanccal, ami azonositja az
38
+ * adott endpoint-csoportot. A storage-key `${subject}|${endpoint}` lesz.
39
+ *
40
+ * Default: `req.path` — minden uri-path kulon vodorbe kerul. Csoportositas
41
+ * mas szempontok szerint (pl. "datasets" csoport = `/api/datasets/*`)
42
+ * a hostnal felulirhato.
43
+ */
44
+ endpointGrouper?: (req: Request) => string;
45
+
46
+ /**
47
+ * Beallitja-e a `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
48
+ * `X-RateLimit-Reset` (es a 429-re `Retry-After`) response header-eket.
49
+ * Default: `true`.
50
+ *
51
+ * Test/dev kornyezetben opcionalisan kikapcsolhato.
52
+ */
53
+ responseHeaders?: boolean;
54
+
55
+ /**
56
+ * Per-policy override-ok az induloskor. A `setPolicyForKey()` runtime-ban is
57
+ * felulhatja ezeket.
58
+ */
59
+ initialKeyPolicies?: Record<string, DyNTS_RateLimit_Policy>;
60
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Per-kulcs rate-limit policy.
3
+ *
4
+ * A `DyNTS_RateLimit_Middleware.setPolicyForKey(key, policy)` allitja be,
5
+ * vagy a `DyNTS_RateLimit_Config.initialKeyPolicies` map-en at.
6
+ *
7
+ * Tipikus use-case: subscription-tier-up — egy belepett user API-kulcsa
8
+ * magasabb `limit`-et kap, mint a default (anonim/IP-alapu) limit.
9
+ */
10
+ export interface DyNTS_RateLimit_Policy {
11
+ /** Hany request engedelyezett a `windowMs` alatt. */
12
+ limit: number;
13
+
14
+ /** A sliding-window hossza milliszekundumban. */
15
+ windowMs: number;
16
+ }
@@ -0,0 +1,3 @@
1
+ export { DyNTS_RateLimit_Middleware } from './rate-limit.middleware';
2
+ export { DyNTS_RateLimit_Config } from './_models/rate-limit-config.interface';
3
+ export { DyNTS_RateLimit_Policy } from './_models/rate-limit-policy.interface';
@@ -0,0 +1,211 @@
1
+ import { Request, Response } from 'express';
2
+
3
+ import { DyFM_Error } from '@futdevpro/fsm-dynamo';
4
+
5
+ import { DyNTS_RateLimit_Middleware } from './rate-limit.middleware';
6
+
7
+
8
+ /** Test-only — minimal Request mock-ot ad vissza. */
9
+ const mockReq = (overrides: Partial<{ ip: string; path: string; headers: Record<string, string> }> = {}): Request => {
10
+ return {
11
+ ip: overrides.ip ?? '127.0.0.1',
12
+ path: overrides.path ?? '/api/test',
13
+ headers: overrides.headers ?? {},
14
+ } as unknown as Request;
15
+ };
16
+
17
+ /** Test-only — Response stub a setHeader-rel. */
18
+ const mockRes = (): Response & { _headers: Record<string, string> } => {
19
+ const headers: Record<string, string> = {};
20
+ return {
21
+ _headers: headers,
22
+ setHeader: function(name: string, value: string): void {
23
+ headers[name] = value;
24
+ },
25
+ } as unknown as Response & { _headers: Record<string, string> };
26
+ };
27
+
28
+
29
+ describe('| DyNTS_RateLimit_Middleware', (): void => {
30
+ let svc: DyNTS_RateLimit_Middleware;
31
+
32
+ beforeEach((): void => {
33
+ svc = DyNTS_RateLimit_Middleware.getInstance();
34
+ svc._resetForTesting();
35
+ });
36
+
37
+ afterEach((): void => {
38
+ svc._resetForTesting();
39
+ });
40
+
41
+
42
+ describe('| singleton', (): void => {
43
+ it('| ugyanazt az instance-t adja vissza', (): void => {
44
+ const a: DyNTS_RateLimit_Middleware = DyNTS_RateLimit_Middleware.getInstance();
45
+ const b: DyNTS_RateLimit_Middleware = DyNTS_RateLimit_Middleware.getInstance();
46
+ expect(a).toBe(b);
47
+ });
48
+ });
49
+
50
+
51
+ describe('| check() — default limit', (): void => {
52
+ it('| atengedi az elso request-et', async (): Promise<void> => {
53
+ let thrown: any = null;
54
+ try { await svc.check(mockReq(), mockRes()); } catch (e) { thrown = e; }
55
+ expect(thrown).toBeNull();
56
+ });
57
+
58
+ it('| 429 amikor a limit-et eleri', async (): Promise<void> => {
59
+ svc.configure({ defaultLimit: 3, defaultWindowMs: 10000 });
60
+
61
+ // 3 sikeres request a limit-en belul
62
+ await svc.check(mockReq(), mockRes());
63
+ await svc.check(mockReq(), mockRes());
64
+ await svc.check(mockReq(), mockRes());
65
+
66
+ // 4. request: 429
67
+ let thrown: any = null;
68
+ try { await svc.check(mockReq(), mockRes()); } catch (e) { thrown = e; }
69
+ expect(thrown).not.toBeNull();
70
+ expect(DyFM_Error.getErrorStatus(thrown)).toBe(429);
71
+ expect(DyFM_Error.getErrorCode(thrown)).toContain('DyNTS-RL-LIMIT');
72
+ });
73
+
74
+ it('| kulonbozo IP-k kulon vodorbe kerulnek', async (): Promise<void> => {
75
+ svc.configure({ defaultLimit: 2, defaultWindowMs: 10000 });
76
+
77
+ await svc.check(mockReq({ ip: '1.1.1.1' }), mockRes());
78
+ await svc.check(mockReq({ ip: '1.1.1.1' }), mockRes());
79
+ // 1.1.1.1 most a limit-en van; 2.2.2.2 meg friss
80
+ let thrown: any = null;
81
+ try { await svc.check(mockReq({ ip: '2.2.2.2' }), mockRes()); } catch (e) { thrown = e; }
82
+ expect(thrown).toBeNull();
83
+ });
84
+
85
+ it('| kulonbozo endpoint-ok kulon vodorbe kerulnek', async (): Promise<void> => {
86
+ svc.configure({ defaultLimit: 2, defaultWindowMs: 10000 });
87
+
88
+ await svc.check(mockReq({ path: '/api/a' }), mockRes());
89
+ await svc.check(mockReq({ path: '/api/a' }), mockRes());
90
+ // /api/a most a limit-en van; /api/b meg friss
91
+ let thrown: any = null;
92
+ try { await svc.check(mockReq({ path: '/api/b' }), mockRes()); } catch (e) { thrown = e; }
93
+ expect(thrown).toBeNull();
94
+ });
95
+ });
96
+
97
+
98
+ describe('| response headers', (): void => {
99
+ it('| beallitja az X-RateLimit-* header-eket sikeres request-nel', async (): Promise<void> => {
100
+ svc.configure({ defaultLimit: 10, defaultWindowMs: 60000 });
101
+ const res = mockRes();
102
+ await svc.check(mockReq(), res);
103
+ expect(res._headers['X-RateLimit-Limit']).toBe('10');
104
+ expect(res._headers['X-RateLimit-Remaining']).toBe('9');
105
+ expect(res._headers['X-RateLimit-Reset']).toBeDefined();
106
+ });
107
+
108
+ it('| beallitja a Retry-After header-t 429-nel', async (): Promise<void> => {
109
+ svc.configure({ defaultLimit: 1, defaultWindowMs: 10000 });
110
+ await svc.check(mockReq(), mockRes());
111
+
112
+ const res = mockRes();
113
+ let thrown: any = null;
114
+ try { await svc.check(mockReq(), res); } catch (e) { thrown = e; }
115
+ expect(thrown).not.toBeNull();
116
+ expect(res._headers['Retry-After']).toBeDefined();
117
+ expect(res._headers['X-RateLimit-Remaining']).toBe('0');
118
+ });
119
+
120
+ it('| nem allit be header-t ha responseHeaders=false', async (): Promise<void> => {
121
+ svc.configure({ defaultLimit: 10, defaultWindowMs: 60000, responseHeaders: false });
122
+ const res = mockRes();
123
+ await svc.check(mockReq(), res);
124
+ expect(res._headers['X-RateLimit-Limit']).toBeUndefined();
125
+ });
126
+ });
127
+
128
+
129
+ describe('| setPolicyForKey() — subscription-tier-up', (): void => {
130
+ it('| egyedi limit a kulcsra felulhatja a default-ot', async (): Promise<void> => {
131
+ svc.configure({
132
+ defaultLimit: 2,
133
+ defaultWindowMs: 10000,
134
+ keyExtractor: (req: Request): string => (req.headers['x-api-key'] as string) ?? req.ip ?? 'anon',
135
+ });
136
+ svc.setPolicyForKey('premium-key', { limit: 10, windowMs: 10000 });
137
+
138
+ // premium-key 10-et kap
139
+ for (let i: number = 0; i < 10; i++) {
140
+ await svc.check(mockReq({ headers: { 'x-api-key': 'premium-key' } }), mockRes());
141
+ }
142
+ let thrown: any = null;
143
+ try { await svc.check(mockReq({ headers: { 'x-api-key': 'premium-key' } }), mockRes()); } catch (e) { thrown = e; }
144
+ expect(thrown).not.toBeNull();
145
+ expect(DyFM_Error.getErrorStatus(thrown)).toBe(429);
146
+ });
147
+
148
+ it('| clearPolicyForKey visszaallit a default-ra', async (): Promise<void> => {
149
+ svc.configure({
150
+ defaultLimit: 1,
151
+ defaultWindowMs: 10000,
152
+ keyExtractor: (req: Request): string => (req.headers['x-api-key'] as string) ?? 'anon',
153
+ });
154
+ svc.setPolicyForKey('k1', { limit: 5, windowMs: 10000 });
155
+ svc.clearPolicyForKey('k1');
156
+
157
+ // most a default 1-es limit ervenyes
158
+ await svc.check(mockReq({ headers: { 'x-api-key': 'k1' } }), mockRes());
159
+ let thrown: any = null;
160
+ try { await svc.check(mockReq({ headers: { 'x-api-key': 'k1' } }), mockRes()); } catch (e) { thrown = e; }
161
+ expect(thrown).not.toBeNull();
162
+ });
163
+ });
164
+
165
+
166
+ describe('| keyExtractor override', (): void => {
167
+ it('| custom keyExtractor felulhatja a default IP-alapu-t', async (): Promise<void> => {
168
+ svc.configure({
169
+ defaultLimit: 1,
170
+ defaultWindowMs: 10000,
171
+ keyExtractor: (req: Request): string => (req.headers['x-api-key'] as string) ?? 'anon',
172
+ });
173
+ // ugyanaz a IP, kulonbozo api-key — kulon limit
174
+ await svc.check(mockReq({ ip: '1.1.1.1', headers: { 'x-api-key': 'k1' } }), mockRes());
175
+ await svc.check(mockReq({ ip: '1.1.1.1', headers: { 'x-api-key': 'k2' } }), mockRes());
176
+ // k1 most a limit-en, k2 meg friss; ujabb k1 → 429
177
+ let thrown: any = null;
178
+ try { await svc.check(mockReq({ ip: '1.1.1.1', headers: { 'x-api-key': 'k1' } }), mockRes()); } catch (e) { thrown = e; }
179
+ expect(thrown).not.toBeNull();
180
+ });
181
+ });
182
+
183
+
184
+ describe('| GC', (): void => {
185
+ it('| runGc eltavolitja az ures kulcsokat', async (): Promise<void> => {
186
+ svc.configure({ defaultLimit: 10, defaultWindowMs: 10 }); // 10ms-os window
187
+ await svc.check(mockReq(), mockRes());
188
+ expect(svc.getConfig().trackedStorageKeys).toBe(1);
189
+
190
+ // varjunk meg amig a window lejar
191
+ await new Promise<void>((resolve: () => void): void => { setTimeout(resolve, 50); });
192
+
193
+ svc.runGc();
194
+ expect(svc.getConfig().trackedStorageKeys).toBe(0);
195
+ });
196
+ });
197
+
198
+
199
+ describe('| getConfig() diagnosztika', (): void => {
200
+ it('| visszaadja az aktualis allapotot', (): void => {
201
+ svc.configure({ defaultLimit: 50, defaultWindowMs: 30000 });
202
+ svc.setPolicyForKey('a', { limit: 100, windowMs: 60000 });
203
+ svc.setPolicyForKey('b', { limit: 200, windowMs: 60000 });
204
+
205
+ const cfg = svc.getConfig();
206
+ expect(cfg.defaultLimit).toBe(50);
207
+ expect(cfg.defaultWindowMs).toBe(30000);
208
+ expect(cfg.activeKeyPolicies).toBe(2);
209
+ });
210
+ });
211
+ });
@@ -0,0 +1,310 @@
1
+ import { Request, Response } from 'express';
2
+
3
+ import { DyFM_Error } from '@futdevpro/fsm-dynamo';
4
+
5
+ import { DyNTS_SingletonServiceBase } from '../../_services/base/singleton.service-base';
6
+ import { DyNTS_global_settings } from '../../_collections/global-settings.const';
7
+
8
+ import { DyNTS_RateLimit_Config } from './_models/rate-limit-config.interface';
9
+ import { DyNTS_RateLimit_Policy } from './_models/rate-limit-policy.interface';
10
+
11
+
12
+ /** Default request-limit per default-window. */
13
+ const DEFAULT_LIMIT: number = 100;
14
+
15
+ /** Default sliding-window hossza ms-ben (1 perc). */
16
+ const DEFAULT_WINDOW_MS: number = 60000;
17
+
18
+ /** Default response-header allitas. */
19
+ const DEFAULT_RESPONSE_HEADERS: boolean = true;
20
+
21
+ /** Periodikus garbage-collection intervallum ms-ben (5 perc).
22
+ * A request-log-bol takaritja a regen inaktiv kulcsokat (memory-leak prevention).
23
+ */
24
+ const GC_INTERVAL_MS: number = 5 * 60 * 1000;
25
+
26
+ /** Service-nev az error-okhoz. */
27
+ const SERVICE_NAME: string = 'DyNTS_RateLimit_Middleware';
28
+
29
+ /** ErrorCode-builder — system shortcode + sajat kod. */
30
+ const buildErrorCode = (subcode: string): string => {
31
+ const sys: string = DyNTS_global_settings.systemShortCodeName ?? 'DyNTS';
32
+ return `${sys}|DyNTS-RL-${subcode}`;
33
+ };
34
+
35
+
36
+ /**
37
+ * Sliding-window HTTP rate-limit middleware — opt-in service-szel, a meglevo
38
+ * `DyNTS_Endpoint_Params.preProcesses` mechanizmus mellol mukodik.
39
+ *
40
+ * **Hasznalat (host app):**
41
+ * ```ts
42
+ * const rateLimit = DyNTS_RateLimit_Middleware.getInstance();
43
+ * rateLimit.configure({
44
+ * defaultLimit: 100, // 100 req/perc default
45
+ * defaultWindowMs: 60_000,
46
+ * keyExtractor: (req) => req.headers['x-api-key'] as string || req.ip,
47
+ * });
48
+ *
49
+ * new DyNTS_Endpoint_Params({
50
+ * ...,
51
+ * preProcesses: [rateLimit.check, ...other],
52
+ * });
53
+ *
54
+ * // subscription-tier-up: per-kulcs egyedi limit
55
+ * rateLimit.setPolicyForKey('subscriber-tier-key-123', {
56
+ * limit: 1000,
57
+ * windowMs: 60_000,
58
+ * });
59
+ * ```
60
+ *
61
+ * **Viselkedes:**
62
+ * - Sliding-window algoritmus: minden request egy timestamp; a window-on
63
+ * kivuli timestamp-ek nem szamolnak. Tobb pontos mint a fix-bucket
64
+ * (boundary-burst nincs).
65
+ * - In-memory storage — single-instance MVP-nek megfelelo. Multi-instance
66
+ * prod-hoz Redis-backed extension kell (lasd a kozelebb dokumentumaltot).
67
+ * - Limit lepes: 429 DyFM_Error + `X-RateLimit-*` + `Retry-After` header-ek.
68
+ *
69
+ * **Storage:** `Map<storageKey, timestamps[]>` ahol storageKey = `${subject}|${endpoint}`.
70
+ * Periodikus GC takaritja a inaktiv kulcsokat.
71
+ *
72
+ * **Singleton:** `getInstance()`-szel hivd. A `.check` mezo binding-elve van
73
+ * `this`-re, igy direkt atadhato `preProcesses`-be ujracsomagolas nelkul.
74
+ */
75
+ export class DyNTS_RateLimit_Middleware extends DyNTS_SingletonServiceBase {
76
+
77
+ static getInstance(): DyNTS_RateLimit_Middleware {
78
+ return DyNTS_RateLimit_Middleware.getSingletonInstance() as DyNTS_RateLimit_Middleware;
79
+ }
80
+
81
+ private defaultLimit: number = DEFAULT_LIMIT;
82
+ private defaultWindowMs: number = DEFAULT_WINDOW_MS;
83
+ private responseHeaders: boolean = DEFAULT_RESPONSE_HEADERS;
84
+
85
+ private keyExtractor: (req: Request) => string =
86
+ (req: Request): string => this.defaultKeyExtractor(req);
87
+ private endpointGrouper: (req: Request) => string =
88
+ (req: Request): string => req.path;
89
+
90
+ /** request-log: storageKey → timestamp-tomb (Date.now() ms). */
91
+ private requestLog: Map<string, number[]> = new Map();
92
+
93
+ /** Per-kulcs egyedi policy-k. */
94
+ private keyPolicies: Map<string, DyNTS_RateLimit_Policy> = new Map();
95
+
96
+ /** GC timer handle. */
97
+ private gcTimer: NodeJS.Timeout | null = null;
98
+
99
+
100
+ /**
101
+ * Konfig override. Hianyzo mezok a default-okat orzik. Hivhato barmikor —
102
+ * a `check()` a friss config-ot olvassa.
103
+ */
104
+ configure(config: DyNTS_RateLimit_Config): void {
105
+ if (config.defaultLimit !== undefined) {
106
+ this.defaultLimit = config.defaultLimit;
107
+ }
108
+ if (config.defaultWindowMs !== undefined) {
109
+ this.defaultWindowMs = config.defaultWindowMs;
110
+ }
111
+ if (config.keyExtractor !== undefined) {
112
+ this.keyExtractor = config.keyExtractor;
113
+ }
114
+ if (config.endpointGrouper !== undefined) {
115
+ this.endpointGrouper = config.endpointGrouper;
116
+ }
117
+ if (config.responseHeaders !== undefined) {
118
+ this.responseHeaders = config.responseHeaders;
119
+ }
120
+ if (config.initialKeyPolicies !== undefined) {
121
+ for (const [ key, policy ] of Object.entries(config.initialKeyPolicies)) {
122
+ this.keyPolicies.set(key, policy);
123
+ }
124
+ }
125
+ this.startGcTimer();
126
+ }
127
+
128
+ /**
129
+ * Aktualis konfig olvasasa (diagnosztika celokra).
130
+ */
131
+ getConfig(): {
132
+ defaultLimit: number;
133
+ defaultWindowMs: number;
134
+ responseHeaders: boolean;
135
+ activeKeyPolicies: number;
136
+ trackedStorageKeys: number;
137
+ } {
138
+ return {
139
+ defaultLimit: this.defaultLimit,
140
+ defaultWindowMs: this.defaultWindowMs,
141
+ responseHeaders: this.responseHeaders,
142
+ activeKeyPolicies: this.keyPolicies.size,
143
+ trackedStorageKeys: this.requestLog.size,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Per-kulcs egyedi policy beallitas (pl. subscription-tier alapjan).
149
+ * A `key`-nek pontosan azzal a stringgel kell egyeznie, amit a `keyExtractor`
150
+ * visszaad.
151
+ */
152
+ setPolicyForKey(key: string, policy: DyNTS_RateLimit_Policy): void {
153
+ this.keyPolicies.set(key, policy);
154
+ }
155
+
156
+ /**
157
+ * Per-kulcs policy torlese (visszaall a default-ra).
158
+ */
159
+ clearPolicyForKey(key: string): void {
160
+ this.keyPolicies.delete(key);
161
+ }
162
+
163
+
164
+ /**
165
+ * Pre-process function — atadhato `DyNTS_Endpoint_Params.preProcesses`-be.
166
+ *
167
+ * Throws:
168
+ * - 429 ha az aktualis request meghaladna a limit-et a sliding window-on
169
+ *
170
+ * Side-effect: ha `responseHeaders === true`, beallitja az `X-RateLimit-Limit`,
171
+ * `X-RateLimit-Remaining`, `X-RateLimit-Reset` header-eket; 429 eseten
172
+ * a `Retry-After` header-t is.
173
+ */
174
+ readonly check = async (req: Request, res: Response): Promise<void> => {
175
+ const subject: string = this.keyExtractor(req);
176
+ const endpoint: string = this.endpointGrouper(req);
177
+ const storageKey: string = `${subject}|${endpoint}`;
178
+
179
+ const policy: DyNTS_RateLimit_Policy = this.keyPolicies.get(subject) ?? {
180
+ limit: this.defaultLimit,
181
+ windowMs: this.defaultWindowMs,
182
+ };
183
+
184
+ const now: number = Date.now();
185
+ const windowStart: number = now - policy.windowMs;
186
+
187
+ // sliding-window: tartomanyon kivuli timestamp-eket eldobjuk
188
+ const existing: number[] = this.requestLog.get(storageKey) ?? [];
189
+ const recent: number[] = existing.filter((t: number): boolean => t > windowStart);
190
+
191
+ if (recent.length >= policy.limit) {
192
+ const oldest: number = recent[0];
193
+ const resetAt: number = oldest + policy.windowMs;
194
+ const retryAfterSec: number = Math.max(1, Math.ceil((resetAt - now) / 1000));
195
+
196
+ if (this.responseHeaders) {
197
+ res.setHeader('X-RateLimit-Limit', policy.limit.toString());
198
+ res.setHeader('X-RateLimit-Remaining', '0');
199
+ res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt / 1000).toString());
200
+ res.setHeader('Retry-After', retryAfterSec.toString());
201
+ }
202
+
203
+ // Frissitjuk a log-ot a kiszurt verzioval (felesleges regi timestamp-eket eldobtuk)
204
+ this.requestLog.set(storageKey, recent);
205
+
206
+ throw new DyFM_Error({
207
+ status: 429,
208
+ errorCode: buildErrorCode('LIMIT'),
209
+ addECToUserMsg: true,
210
+ message: `Rate limit exceeded: ${policy.limit} req per ${policy.windowMs}ms for ${storageKey}`,
211
+ userMessage: `Too many requests, retry after ${retryAfterSec}s`,
212
+ issuerService: SERVICE_NAME,
213
+ });
214
+ }
215
+
216
+ // alatta vagyunk a limit-nek: append the current request
217
+ recent.push(now);
218
+ this.requestLog.set(storageKey, recent);
219
+
220
+ if (this.responseHeaders) {
221
+ res.setHeader('X-RateLimit-Limit', policy.limit.toString());
222
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, policy.limit - recent.length).toString());
223
+ res.setHeader('X-RateLimit-Reset', Math.ceil((now + policy.windowMs) / 1000).toString());
224
+ }
225
+ };
226
+
227
+
228
+ /**
229
+ * Default key-extractor: x-forwarded-for vagy req.ip vagy 'unknown'.
230
+ * Csak akkor hasznalt, ha a host nem allit be sajat extractort a configure-ben.
231
+ */
232
+ private defaultKeyExtractor(req: Request): string {
233
+ const xff: unknown = req.headers['x-forwarded-for'];
234
+ if (typeof xff === 'string' && xff.length > 0) {
235
+ const first: string = xff.split(',')[0]?.trim() ?? '';
236
+ if (first.length > 0) {
237
+ return first;
238
+ }
239
+ }
240
+ if (Array.isArray(xff) && xff.length > 0) {
241
+ const first: string = xff[0]?.split(',')[0]?.trim() ?? '';
242
+ if (first.length > 0) {
243
+ return first;
244
+ }
245
+ }
246
+ return req.ip ?? 'unknown';
247
+ }
248
+
249
+
250
+ /**
251
+ * GC timer inditasa (idempotent). Periodikusan eltavolitja az inaktiv
252
+ * storage-key-eket a request-log-bol — memory-leak prevention.
253
+ */
254
+ private startGcTimer(): void {
255
+ if (this.gcTimer !== null) {
256
+ return;
257
+ }
258
+ this.gcTimer = setInterval((): void => {
259
+ this.runGc();
260
+ }, GC_INTERVAL_MS);
261
+ // unref hogy a process ne maradjon eletben a timer miatt
262
+ if (typeof this.gcTimer.unref === 'function') {
263
+ this.gcTimer.unref();
264
+ }
265
+ }
266
+
267
+ /**
268
+ * GC sweep — minden storage-key-rol levagja a regi timestamp-eket, es
269
+ * eltavolitja az ureseket. Hivhato kulonosen test-bol.
270
+ */
271
+ runGc(): void {
272
+ const now: number = Date.now();
273
+ const horizon: number = now - this.defaultWindowMs;
274
+
275
+ for (const [ key, timestamps ] of this.requestLog) {
276
+ const recent: number[] = timestamps.filter((t: number): boolean => t > horizon);
277
+ if (recent.length === 0) {
278
+ this.requestLog.delete(key);
279
+ } else {
280
+ this.requestLog.set(key, recent);
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * GC timer leallitasa (graceful shutdown vagy test-cleanup).
287
+ */
288
+ stopGcTimer(): void {
289
+ if (this.gcTimer !== null) {
290
+ clearInterval(this.gcTimer);
291
+ this.gcTimer = null;
292
+ }
293
+ }
294
+
295
+
296
+ /**
297
+ * Test-only: visszaallitja a default config-ot + uriti a state-et, hogy a
298
+ * specfajlok ne szivarogjak at egymas state-jet. Production code NE hivja.
299
+ */
300
+ _resetForTesting(): void {
301
+ this.defaultLimit = DEFAULT_LIMIT;
302
+ this.defaultWindowMs = DEFAULT_WINDOW_MS;
303
+ this.responseHeaders = DEFAULT_RESPONSE_HEADERS;
304
+ this.keyExtractor = (req: Request): string => this.defaultKeyExtractor(req);
305
+ this.endpointGrouper = (req: Request): string => req.path;
306
+ this.requestLog.clear();
307
+ this.keyPolicies.clear();
308
+ this.stopGcTimer();
309
+ }
310
+ }
@@ -178,7 +178,7 @@ export class DyNTS_Errors_DataService<
178
178
  if (this.debugLog) DyFM_Log.error('Error:', errorsRecord);
179
179
 
180
180
  errorsRecord.priority = this.getPriorityMultiplierByLevel(errorsRecord?.level);
181
-
181
+
182
182
  errorsRecord.duplications ??= [];
183
183
  errorsRecord.duplications.push(DyFM_Object.clone(errorsRecord));
184
184
 
@@ -187,7 +187,15 @@ export class DyNTS_Errors_DataService<
187
187
 
188
188
  this.duplicationCounter = 1;
189
189
 
190
- await this.saveData();
190
+ // FR-027 (2026-05-24) — Pass `errorsRecord` explicitly. Korabban
191
+ // `saveData()` no-arg fallback `ensureData()`-on keresztul `this.data`-t
192
+ // hasznalt, ami a controller-szinten csak a `{issuer}` constructor
193
+ // wrapper, NEM az actual req.body adat. Eredmenykent minden client-
194
+ // forwarded error EMPTY rekordkent landolt: csak `issuer` mezo,
195
+ // semmilyen message/source/stackTrace/error/level/additionalContent
196
+ // mezo NEM kerult MongoDB-be. (Overseer-en 14454 ilyen empty record
197
+ // halmozodott fel — debug-level details elveszve.)
198
+ await this.saveData(errorsRecord);
191
199
  DyFM_Log.warn('error saved');
192
200
  }
193
201
  } catch (error) {