create-forgeon 0.2.5 → 0.2.6

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 (26) hide show
  1. package/package.json +1 -1
  2. package/src/modules/executor.mjs +2 -0
  3. package/src/modules/executor.test.mjs +111 -0
  4. package/src/modules/i18n.mjs +102 -0
  5. package/src/modules/rate-limit.mjs +346 -0
  6. package/src/modules/registry.mjs +21 -3
  7. package/src/run-add-module.mjs +83 -6
  8. package/templates/base/README.md +2 -2
  9. package/templates/base/docs/AI/MODULE_SPEC.md +1 -0
  10. package/templates/module-fragments/rate-limit/00_title.md +1 -0
  11. package/templates/module-fragments/rate-limit/10_overview.md +6 -0
  12. package/templates/module-fragments/rate-limit/20_idea.md +11 -0
  13. package/templates/module-fragments/rate-limit/30_what_it_adds.md +10 -0
  14. package/templates/module-fragments/rate-limit/40_how_it_works.md +13 -0
  15. package/templates/module-fragments/rate-limit/50_how_to_use.md +21 -0
  16. package/templates/module-fragments/rate-limit/60_configuration.md +15 -0
  17. package/templates/module-fragments/rate-limit/70_operational_notes.md +10 -0
  18. package/templates/module-fragments/rate-limit/90_status_implemented.md +3 -0
  19. package/templates/module-presets/rate-limit/packages/rate-limit/package.json +22 -0
  20. package/templates/module-presets/rate-limit/packages/rate-limit/src/forgeon-rate-limit.module.ts +50 -0
  21. package/templates/module-presets/rate-limit/packages/rate-limit/src/index.ts +5 -0
  22. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.loader.ts +25 -0
  23. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.module.ts +8 -0
  24. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.service.ts +35 -0
  25. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-env.schema.ts +16 -0
  26. package/templates/module-presets/rate-limit/packages/rate-limit/tsconfig.json +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -6,6 +6,7 @@ import { applyDbPrismaModule } from './db-prisma.mjs';
6
6
  import { applyI18nModule } from './i18n.mjs';
7
7
  import { applyJwtAuthModule } from './jwt-auth.mjs';
8
8
  import { applyLoggerModule } from './logger.mjs';
9
+ import { applyRateLimitModule } from './rate-limit.mjs';
9
10
  import { applySwaggerModule } from './swagger.mjs';
10
11
 
11
12
  function ensureForgeonLikeProject(targetRoot) {
@@ -29,6 +30,7 @@ const MODULE_APPLIERS = {
29
30
  i18n: applyI18nModule,
30
31
  'jwt-auth': applyJwtAuthModule,
31
32
  logger: applyLoggerModule,
33
+ 'rate-limit': applyRateLimitModule,
32
34
  swagger: applySwaggerModule,
33
35
  };
34
36
 
@@ -42,6 +42,45 @@ function assertDbPrismaWiring(projectRoot) {
42
42
  assert.match(healthController, /PrismaService/);
43
43
  }
44
44
 
45
+ function assertRateLimitWiring(projectRoot) {
46
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
47
+ assert.match(appModule, /rateLimitConfig/);
48
+ assert.match(appModule, /rateLimitEnvSchema/);
49
+ assert.match(appModule, /ForgeonRateLimitModule/);
50
+
51
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
52
+ assert.match(apiPackage, /@forgeon\/rate-limit/);
53
+ assert.match(apiPackage, /pnpm --filter @forgeon\/rate-limit build/);
54
+
55
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
56
+ assert.match(apiDockerfile, /COPY packages\/rate-limit\/package\.json packages\/rate-limit\/package\.json/);
57
+ assert.match(apiDockerfile, /COPY packages\/rate-limit packages\/rate-limit/);
58
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rate-limit build/);
59
+
60
+ const compose = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', 'compose.yml'), 'utf8');
61
+ assert.match(compose, /THROTTLE_ENABLED: \$\{THROTTLE_ENABLED\}/);
62
+ assert.match(compose, /THROTTLE_LIMIT: \$\{THROTTLE_LIMIT\}/);
63
+
64
+ const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
65
+ assert.match(apiEnv, /THROTTLE_ENABLED=true/);
66
+ assert.match(apiEnv, /THROTTLE_TTL=10/);
67
+ assert.match(apiEnv, /THROTTLE_LIMIT=3/);
68
+
69
+ const healthController = fs.readFileSync(
70
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
71
+ 'utf8',
72
+ );
73
+ assert.match(healthController, /@Get\('rate-limit'\)/);
74
+ assert.match(healthController, /TOO_MANY_REQUESTS/);
75
+
76
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
77
+ assert.match(appTsx, /Check rate limit \(click repeatedly\)/);
78
+ assert.match(appTsx, /Rate limit probe response/);
79
+
80
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
81
+ assert.match(readme, /## Rate Limit Module/);
82
+ }
83
+
45
84
  function assertJwtAuthWiring(projectRoot, withPrismaStore) {
46
85
  const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
47
86
  assert.match(apiPackage, /@forgeon\/auth-api/);
@@ -529,6 +568,41 @@ describe('addModule', () => {
529
568
  }
530
569
  });
531
570
 
571
+ it('applies rate-limit module on top of scaffold without i18n', () => {
572
+ const targetRoot = mkTmp('forgeon-module-rate-limit-');
573
+ const projectRoot = path.join(targetRoot, 'demo-rate-limit');
574
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
575
+
576
+ try {
577
+ scaffoldProject({
578
+ templateRoot,
579
+ packageRoot,
580
+ targetRoot: projectRoot,
581
+ projectName: 'demo-rate-limit',
582
+ frontend: 'react',
583
+ db: 'prisma',
584
+ dbPrismaEnabled: false,
585
+ i18nEnabled: false,
586
+ proxy: 'caddy',
587
+ });
588
+
589
+ const result = addModule({
590
+ moduleId: 'rate-limit',
591
+ targetRoot: projectRoot,
592
+ packageRoot,
593
+ });
594
+
595
+ assert.equal(result.applied, true);
596
+ assertRateLimitWiring(projectRoot);
597
+
598
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
599
+ assert.match(moduleDoc, /## Idea \/ Why/);
600
+ assert.match(moduleDoc, /## Configuration/);
601
+ } finally {
602
+ fs.rmSync(targetRoot, { recursive: true, force: true });
603
+ }
604
+ });
605
+
532
606
  it('applies swagger module on top of scaffold without i18n', () => {
533
607
  const targetRoot = mkTmp('forgeon-module-swagger-');
534
608
  const projectRoot = path.join(targetRoot, 'demo-swagger');
@@ -1144,6 +1218,43 @@ describe('addModule', () => {
1144
1218
  }
1145
1219
  });
1146
1220
 
1221
+ it('keeps rate-limit wiring valid after mixed module installation order', () => {
1222
+ const targetRoot = mkTmp('forgeon-module-rate-limit-order-');
1223
+ const projectRoot = path.join(targetRoot, 'demo-rate-limit-order');
1224
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1225
+
1226
+ try {
1227
+ scaffoldProject({
1228
+ templateRoot,
1229
+ packageRoot,
1230
+ targetRoot: projectRoot,
1231
+ projectName: 'demo-rate-limit-order',
1232
+ frontend: 'react',
1233
+ db: 'prisma',
1234
+ dbPrismaEnabled: false,
1235
+ i18nEnabled: false,
1236
+ proxy: 'caddy',
1237
+ });
1238
+
1239
+ for (const moduleId of ['jwt-auth', 'logger', 'swagger', 'rate-limit', 'i18n', 'db-prisma']) {
1240
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
1241
+ }
1242
+
1243
+ assertRateLimitWiring(projectRoot);
1244
+
1245
+ const healthController = fs.readFileSync(
1246
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1247
+ 'utf8',
1248
+ );
1249
+ const classStart = healthController.indexOf('export class HealthController {');
1250
+ const classEnd = healthController.lastIndexOf('\n}');
1251
+ const rateLimitProbe = healthController.indexOf("@Get('rate-limit')");
1252
+ assert.equal(rateLimitProbe > classStart && rateLimitProbe < classEnd, true);
1253
+ } finally {
1254
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1255
+ }
1256
+ });
1257
+
1147
1258
  it('keeps db-prisma wiring across module installation orders', () => {
1148
1259
  const sequences = [
1149
1260
  ['logger', 'swagger', 'i18n'],
@@ -405,7 +405,108 @@ function patchRootPackage(targetRoot) {
405
405
  writeJson(packagePath, packageJson);
406
406
  }
407
407
 
408
+ function restoreKnownWebProbes(targetRoot, previousAppContent) {
409
+ if (!previousAppContent) {
410
+ return;
411
+ }
412
+
413
+ const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
414
+ if (!fs.existsSync(filePath)) {
415
+ return;
416
+ }
417
+
418
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
419
+
420
+ const ensureProbeState = (stateLine) => {
421
+ if (content.includes(stateLine)) {
422
+ return;
423
+ }
424
+ const anchors = [
425
+ ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
426
+ ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
427
+ ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
428
+ ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
429
+ ];
430
+ const anchor = anchors.find((line) => content.includes(line));
431
+ if (anchor) {
432
+ content = ensureLineAfter(content, anchor, stateLine);
433
+ }
434
+ };
435
+
436
+ const ensureProbeButton = (buttonText, buttonCode) => {
437
+ if (content.includes(buttonText)) {
438
+ return;
439
+ }
440
+ const actionsStart = content.indexOf('<div className="actions">');
441
+ if (actionsStart < 0) {
442
+ return;
443
+ }
444
+ const actionsEnd = content.indexOf('\n </div>', actionsStart);
445
+ if (actionsEnd < 0) {
446
+ return;
447
+ }
448
+ content = `${content.slice(0, actionsEnd)}\n${buttonCode}${content.slice(actionsEnd)}`;
449
+ };
450
+
451
+ const ensureProbeResult = (resultLine) => {
452
+ if (content.includes(resultLine)) {
453
+ return;
454
+ }
455
+ const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
456
+ if (content.includes(networkLine)) {
457
+ content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
458
+ return;
459
+ }
460
+ const anchors = [
461
+ " {renderResult('Rate limit probe response', rateLimitProbeResult)}",
462
+ " {renderResult('Auth probe response', authProbeResult)}",
463
+ " {renderResult('DB probe response', dbProbeResult)}",
464
+ " {renderResult('Validation probe response', validationProbeResult)}",
465
+ ];
466
+ const anchor = anchors.find((line) => content.includes(line));
467
+ if (anchor) {
468
+ content = ensureLineAfter(content, anchor, resultLine);
469
+ }
470
+ };
471
+
472
+ if (previousAppContent.includes('Check database (create user)')) {
473
+ ensureProbeState(' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);');
474
+ ensureProbeButton(
475
+ 'Check database (create user)',
476
+ " <button onClick={() => runProbe(setDbProbeResult, '/health/db', { method: 'POST' })}>\n Check database (create user)\n </button>",
477
+ );
478
+ ensureProbeResult(" {renderResult('DB probe response', dbProbeResult)}");
479
+ }
480
+
481
+ if (previousAppContent.includes('Check JWT auth probe')) {
482
+ ensureProbeState(' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);');
483
+ ensureProbeButton(
484
+ 'Check JWT auth probe',
485
+ " <button onClick={() => runProbe(setAuthProbeResult, '/health/auth')}>Check JWT auth probe</button>",
486
+ );
487
+ ensureProbeResult(" {renderResult('Auth probe response', authProbeResult)}");
488
+ }
489
+
490
+ if (previousAppContent.includes('Check rate limit (click repeatedly)')) {
491
+ ensureProbeState(
492
+ ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
493
+ );
494
+ ensureProbeButton(
495
+ 'Check rate limit (click repeatedly)',
496
+ " <button onClick={() => runProbe(setRateLimitProbeResult, '/health/rate-limit')}>\n Check rate limit (click repeatedly)\n </button>",
497
+ );
498
+ ensureProbeResult(" {renderResult('Rate limit probe response', rateLimitProbeResult)}");
499
+ }
500
+
501
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
502
+ }
503
+
408
504
  export function applyI18nModule({ packageRoot, targetRoot }) {
505
+ const existingWebAppPath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
506
+ const previousAppContent = fs.existsSync(existingWebAppPath)
507
+ ? fs.readFileSync(existingWebAppPath, 'utf8')
508
+ : '';
509
+
409
510
  copyFromBase(packageRoot, targetRoot, path.join('scripts', 'i18n-add.mjs'));
410
511
  copyFromBase(packageRoot, targetRoot, path.join('packages', 'i18n'));
411
512
  copyFromBase(packageRoot, targetRoot, path.join('resources', 'i18n'));
@@ -415,6 +516,7 @@ export function applyI18nModule({ packageRoot, targetRoot }) {
415
516
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'App.tsx'));
416
517
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'i18n.ts'));
417
518
  copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'main.tsx'));
519
+ restoreKnownWebProbes(targetRoot, previousAppContent);
418
520
 
419
521
  patchI18nPackage(targetRoot);
420
522
  patchApiPackage(targetRoot);
@@ -0,0 +1,346 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { copyRecursive, writeJson } from '../utils/fs.mjs';
4
+ import {
5
+ ensureBuildSteps,
6
+ ensureClassMember,
7
+ ensureDependency,
8
+ ensureImportLine,
9
+ ensureLineAfter,
10
+ ensureLineBefore,
11
+ ensureLoadItem,
12
+ ensureValidatorSchema,
13
+ upsertEnvLines,
14
+ } from './shared/patch-utils.mjs';
15
+
16
+ function copyFromPreset(packageRoot, targetRoot, relativePath) {
17
+ const source = path.join(packageRoot, 'templates', 'module-presets', 'rate-limit', relativePath);
18
+ if (!fs.existsSync(source)) {
19
+ throw new Error(`Missing rate-limit preset template: ${source}`);
20
+ }
21
+ const destination = path.join(targetRoot, relativePath);
22
+ copyRecursive(source, destination);
23
+ }
24
+
25
+ function patchApiPackage(targetRoot) {
26
+ const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
27
+ if (!fs.existsSync(packagePath)) {
28
+ return;
29
+ }
30
+
31
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
32
+ ensureDependency(packageJson, '@forgeon/rate-limit', 'workspace:*');
33
+ ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/rate-limit build']);
34
+ writeJson(packagePath, packageJson);
35
+ }
36
+
37
+ function patchAppModule(targetRoot) {
38
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
39
+ if (!fs.existsSync(filePath)) {
40
+ return;
41
+ }
42
+
43
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
44
+ if (!content.includes("from '@forgeon/rate-limit';")) {
45
+ if (content.includes("import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';")) {
46
+ content = ensureLineAfter(
47
+ content,
48
+ "import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';",
49
+ "import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';",
50
+ );
51
+ } else if (
52
+ content.includes("import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';")
53
+ ) {
54
+ content = ensureLineAfter(
55
+ content,
56
+ "import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
57
+ "import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';",
58
+ );
59
+ } else if (
60
+ content.includes("import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';")
61
+ ) {
62
+ content = ensureLineAfter(
63
+ content,
64
+ "import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';",
65
+ "import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';",
66
+ );
67
+ } else if (
68
+ content.includes("import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';")
69
+ ) {
70
+ content = ensureLineAfter(
71
+ content,
72
+ "import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';",
73
+ "import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';",
74
+ );
75
+ } else if (
76
+ content.includes("import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';")
77
+ ) {
78
+ content = ensureLineAfter(
79
+ content,
80
+ "import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
81
+ "import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';",
82
+ );
83
+ } else {
84
+ content = ensureLineAfter(
85
+ content,
86
+ "import { ConfigModule } from '@nestjs/config';",
87
+ "import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';",
88
+ );
89
+ }
90
+ }
91
+
92
+ content = ensureLoadItem(content, 'rateLimitConfig');
93
+ content = ensureValidatorSchema(content, 'rateLimitEnvSchema');
94
+
95
+ if (!content.includes(' ForgeonRateLimitModule,')) {
96
+ if (content.includes(' ForgeonI18nModule.register({')) {
97
+ content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonRateLimitModule,');
98
+ } else if (content.includes(' ForgeonAuthModule.register({')) {
99
+ content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonRateLimitModule,');
100
+ } else if (content.includes(' ForgeonAuthModule.register(),')) {
101
+ content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonRateLimitModule,');
102
+ } else if (content.includes(' DbPrismaModule,')) {
103
+ content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonRateLimitModule,');
104
+ } else if (content.includes(' ForgeonLoggerModule,')) {
105
+ content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonRateLimitModule,');
106
+ } else if (content.includes(' ForgeonSwaggerModule,')) {
107
+ content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonRateLimitModule,');
108
+ } else {
109
+ content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonRateLimitModule,');
110
+ }
111
+ }
112
+
113
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
114
+ }
115
+
116
+ function patchHealthController(targetRoot) {
117
+ const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
118
+ if (!fs.existsSync(filePath)) {
119
+ return;
120
+ }
121
+
122
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
123
+ if (!content.includes("@Get('rate-limit')")) {
124
+ const method = `
125
+ @Get('rate-limit')
126
+ getRateLimitProbe() {
127
+ return {
128
+ status: 'ok',
129
+ feature: 'rate-limit',
130
+ hint: 'Repeat this request quickly to trigger TOO_MANY_REQUESTS.',
131
+ };
132
+ }
133
+ `;
134
+ content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
135
+ }
136
+
137
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
138
+ }
139
+
140
+ function patchWebApp(targetRoot) {
141
+ const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
142
+ if (!fs.existsSync(filePath)) {
143
+ return;
144
+ }
145
+
146
+ let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
147
+ content = content
148
+ .replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
149
+ .replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
150
+ .replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
151
+ .replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
152
+
153
+ if (!content.includes('rateLimitProbeResult')) {
154
+ const stateAnchors = [
155
+ ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
156
+ ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
157
+ ' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
158
+ ];
159
+ const stateAnchor = stateAnchors.find((line) => content.includes(line));
160
+ if (stateAnchor) {
161
+ content = ensureLineAfter(
162
+ content,
163
+ stateAnchor,
164
+ ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
165
+ );
166
+ }
167
+ }
168
+
169
+ if (!content.includes('Check rate limit (click repeatedly)')) {
170
+ const probePath = content.includes("runProbe(setHealthResult, '/health')")
171
+ ? '/health/rate-limit'
172
+ : '/api/health/rate-limit';
173
+ const button = ` <button onClick={() => runProbe(setRateLimitProbeResult, '${probePath}')}>\n Check rate limit (click repeatedly)\n </button>`;
174
+
175
+ const actionsStart = content.indexOf('<div className="actions">');
176
+ if (actionsStart >= 0) {
177
+ const actionsEnd = content.indexOf('\n </div>', actionsStart);
178
+ if (actionsEnd >= 0) {
179
+ content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
180
+ }
181
+ }
182
+ }
183
+
184
+ if (!content.includes("{renderResult('Rate limit probe response', rateLimitProbeResult)}")) {
185
+ const resultLine = " {renderResult('Rate limit probe response', rateLimitProbeResult)}";
186
+ const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
187
+ if (content.includes(networkLine)) {
188
+ content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
189
+ } else {
190
+ const anchors = [
191
+ "{renderResult('Auth probe response', authProbeResult)}",
192
+ "{renderResult('DB probe response', dbProbeResult)}",
193
+ "{renderResult('Validation probe response', validationProbeResult)}",
194
+ ];
195
+ const anchor = anchors.find((line) => content.includes(line));
196
+ if (anchor) {
197
+ content = ensureLineAfter(content, anchor, resultLine);
198
+ }
199
+ }
200
+ }
201
+
202
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
203
+ }
204
+
205
+ function patchApiDockerfile(targetRoot) {
206
+ const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
207
+ if (!fs.existsSync(dockerfilePath)) {
208
+ return;
209
+ }
210
+
211
+ let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
212
+ const packageAnchors = [
213
+ 'COPY packages/auth-api/package.json packages/auth-api/package.json',
214
+ 'COPY packages/logger/package.json packages/logger/package.json',
215
+ 'COPY packages/swagger/package.json packages/swagger/package.json',
216
+ 'COPY packages/i18n/package.json packages/i18n/package.json',
217
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
218
+ 'COPY packages/core/package.json packages/core/package.json',
219
+ ];
220
+ const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
221
+ content = ensureLineAfter(
222
+ content,
223
+ packageAnchor,
224
+ 'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
225
+ );
226
+
227
+ const sourceAnchors = [
228
+ 'COPY packages/auth-api packages/auth-api',
229
+ 'COPY packages/logger packages/logger',
230
+ 'COPY packages/swagger packages/swagger',
231
+ 'COPY packages/i18n packages/i18n',
232
+ 'COPY packages/db-prisma packages/db-prisma',
233
+ 'COPY packages/core packages/core',
234
+ ];
235
+ const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
236
+ content = ensureLineAfter(content, sourceAnchor, 'COPY packages/rate-limit packages/rate-limit');
237
+
238
+ content = content.replace(/^RUN pnpm --filter @forgeon\/rate-limit build\r?\n?/gm, '');
239
+ const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
240
+ ? 'RUN pnpm --filter @forgeon/api prisma:generate'
241
+ : 'RUN pnpm --filter @forgeon/api build';
242
+ content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/rate-limit build');
243
+
244
+ fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
245
+ }
246
+
247
+ function patchCompose(targetRoot) {
248
+ const composePath = path.join(targetRoot, 'infra', 'docker', 'compose.yml');
249
+ if (!fs.existsSync(composePath)) {
250
+ return;
251
+ }
252
+
253
+ let content = fs.readFileSync(composePath, 'utf8').replace(/\r\n/g, '\n');
254
+ if (!content.includes('THROTTLE_ENABLED: ${THROTTLE_ENABLED}')) {
255
+ const anchors = [
256
+ /^(\s+AUTH_DEMO_PASSWORD:.*)$/m,
257
+ /^(\s+JWT_ACCESS_SECRET:.*)$/m,
258
+ /^(\s+LOGGER_LEVEL:.*)$/m,
259
+ /^(\s+SWAGGER_ENABLED:.*)$/m,
260
+ /^(\s+I18N_DEFAULT_LANG:.*)$/m,
261
+ /^(\s+DATABASE_URL:.*)$/m,
262
+ /^(\s+API_PREFIX:.*)$/m,
263
+ ];
264
+ const anchorPattern = anchors.find((pattern) => pattern.test(content)) ?? anchors.at(-1);
265
+ content = content.replace(
266
+ anchorPattern,
267
+ `$1
268
+ THROTTLE_ENABLED: \${THROTTLE_ENABLED}
269
+ THROTTLE_TTL: \${THROTTLE_TTL}
270
+ THROTTLE_LIMIT: \${THROTTLE_LIMIT}
271
+ THROTTLE_TRUST_PROXY: \${THROTTLE_TRUST_PROXY}`,
272
+ );
273
+ }
274
+
275
+ fs.writeFileSync(composePath, `${content.trimEnd()}\n`, 'utf8');
276
+ }
277
+
278
+ function patchReadme(targetRoot) {
279
+ const readmePath = path.join(targetRoot, 'README.md');
280
+ if (!fs.existsSync(readmePath)) {
281
+ return;
282
+ }
283
+
284
+ const marker = '## Rate Limit Module';
285
+ let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
286
+ if (content.includes(marker)) {
287
+ return;
288
+ }
289
+
290
+ const section = `## Rate Limit Module
291
+
292
+ The rate-limit add-module provides a simple first-line safeguard against burst traffic, accidental request loops, and brute-force style abuse.
293
+
294
+ What it adds:
295
+ - global request throttling for the API
296
+ - proxy-aware trust configuration for Caddy/Nginx setups
297
+ - a probe endpoint: \`GET /api/health/rate-limit\`
298
+ - a frontend probe button to verify the 429 response path
299
+
300
+ How to verify:
301
+ 1. click "Check rate limit (click repeatedly)" several times within a few seconds
302
+ 2. the first requests return \`200\`
303
+ 3. the next request returns a \`429 TOO_MANY_REQUESTS\` envelope
304
+
305
+ Configuration (env):
306
+ - \`THROTTLE_ENABLED=true\`
307
+ - \`THROTTLE_TTL=10\` (seconds)
308
+ - \`THROTTLE_LIMIT=3\`
309
+ - \`THROTTLE_TRUST_PROXY=false\`
310
+
311
+ Operational notes:
312
+ - \`THROTTLE_TRUST_PROXY=true\` is recommended behind reverse proxies
313
+ - this is an in-memory throttle preset, not a distributed limiter`;
314
+
315
+ if (content.includes('## Prisma In Docker Start')) {
316
+ content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
317
+ } else {
318
+ content = `${content.trimEnd()}\n\n${section}\n`;
319
+ }
320
+
321
+ fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
322
+ }
323
+
324
+ export function applyRateLimitModule({ packageRoot, targetRoot }) {
325
+ copyFromPreset(packageRoot, targetRoot, path.join('packages', 'rate-limit'));
326
+ patchApiPackage(targetRoot);
327
+ patchAppModule(targetRoot);
328
+ patchHealthController(targetRoot);
329
+ patchWebApp(targetRoot);
330
+ patchApiDockerfile(targetRoot);
331
+ patchCompose(targetRoot);
332
+ patchReadme(targetRoot);
333
+
334
+ upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
335
+ 'THROTTLE_ENABLED=true',
336
+ 'THROTTLE_TTL=10',
337
+ 'THROTTLE_LIMIT=3',
338
+ 'THROTTLE_TRUST_PROXY=false',
339
+ ]);
340
+ upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
341
+ 'THROTTLE_ENABLED=true',
342
+ 'THROTTLE_TTL=10',
343
+ 'THROTTLE_LIMIT=3',
344
+ 'THROTTLE_TRUST_PROXY=false',
345
+ ]);
346
+ }
@@ -39,9 +39,27 @@ const MODULE_PRESETS = {
39
39
  description: 'JWT auth preset with contracts/api module split, guard+strategy, and DB-aware refresh token storage wiring.',
40
40
  docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
41
41
  },
42
- queue: {
43
- id: 'queue',
44
- label: 'Queue Worker',
42
+ 'rate-limit': {
43
+ id: 'rate-limit',
44
+ label: 'Rate Limit',
45
+ category: 'auth-security',
46
+ implemented: true,
47
+ description: 'Request throttling preset with env-based limits, proxy-aware trust, and a runtime probe endpoint.',
48
+ docFragments: [
49
+ '00_title',
50
+ '10_overview',
51
+ '20_idea',
52
+ '30_what_it_adds',
53
+ '40_how_it_works',
54
+ '50_how_to_use',
55
+ '60_configuration',
56
+ '70_operational_notes',
57
+ '90_status_implemented',
58
+ ],
59
+ },
60
+ queue: {
61
+ id: 'queue',
62
+ label: 'Queue Worker',
45
63
  category: 'background-jobs',
46
64
  implemented: false,
47
65
  description: 'Queue processing preset (BullMQ-style app wiring).',
@@ -7,14 +7,81 @@ import { addModule } from './modules/executor.mjs';
7
7
  import { listModulePresets } from './modules/registry.mjs';
8
8
  import { printModuleAdded, runIntegrationFlow } from './integrations/flow.mjs';
9
9
  import { writeJson } from './utils/fs.mjs';
10
-
10
+
11
11
  function printModuleList() {
12
12
  const modules = listModulePresets();
13
13
  console.log('Available modules:');
14
14
  for (const moduleItem of modules) {
15
15
  const status = moduleItem.implemented ? 'implemented' : 'planned';
16
16
  console.log(`- ${moduleItem.id} (${status}) - ${moduleItem.description}`);
17
- }
17
+ }
18
+ }
19
+
20
+ function toSortedObject(value) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
22
+ return {};
23
+ }
24
+
25
+ return Object.fromEntries(
26
+ Object.entries(value).sort(([left], [right]) => left.localeCompare(right)),
27
+ );
28
+ }
29
+
30
+ function collectDependencyManifestState(targetRoot) {
31
+ const state = new Map();
32
+ if (!fs.existsSync(targetRoot)) {
33
+ return state;
34
+ }
35
+
36
+ const queue = [targetRoot];
37
+ const skipDirs = new Set(['node_modules', '.git', 'dist', 'build']);
38
+
39
+ while (queue.length > 0) {
40
+ const currentDir = queue.shift();
41
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
42
+
43
+ for (const entry of entries) {
44
+ if (entry.isDirectory()) {
45
+ if (!skipDirs.has(entry.name)) {
46
+ queue.push(path.join(currentDir, entry.name));
47
+ }
48
+ continue;
49
+ }
50
+
51
+ if (!entry.isFile() || entry.name !== 'package.json') {
52
+ continue;
53
+ }
54
+
55
+ const filePath = path.join(currentDir, entry.name);
56
+ const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
57
+ const snapshot = {
58
+ name: packageJson.name ?? null,
59
+ dependencies: toSortedObject(packageJson.dependencies),
60
+ devDependencies: toSortedObject(packageJson.devDependencies),
61
+ optionalDependencies: toSortedObject(packageJson.optionalDependencies),
62
+ peerDependencies: toSortedObject(packageJson.peerDependencies),
63
+ onlyBuiltDependencies: Array.isArray(packageJson.pnpm?.onlyBuiltDependencies)
64
+ ? [...packageJson.pnpm.onlyBuiltDependencies].sort()
65
+ : [],
66
+ };
67
+
68
+ state.set(path.relative(targetRoot, filePath), JSON.stringify(snapshot));
69
+ }
70
+ }
71
+
72
+ return state;
73
+ }
74
+
75
+ function getChangedDependencyManifestPaths(beforeState, afterState) {
76
+ const changed = [];
77
+
78
+ for (const [filePath, nextSnapshot] of afterState.entries()) {
79
+ if (beforeState.get(filePath) !== nextSnapshot) {
80
+ changed.push(filePath);
81
+ }
82
+ }
83
+
84
+ return changed.sort();
18
85
  }
19
86
 
20
87
  function ensureSyncTooling({ packageRoot, targetRoot }) {
@@ -64,10 +131,11 @@ export async function runAddModule(argv = process.argv.slice(2)) {
64
131
  throw new Error('Module id is required. Use `create-forgeon add --list` to see available modules.');
65
132
  }
66
133
 
67
- const srcDir = path.dirname(fileURLToPath(import.meta.url));
68
- const packageRoot = path.resolve(srcDir, '..');
69
- const targetRoot = path.resolve(process.cwd(), options.project);
70
-
134
+ const srcDir = path.dirname(fileURLToPath(import.meta.url));
135
+ const packageRoot = path.resolve(srcDir, '..');
136
+ const targetRoot = path.resolve(process.cwd(), options.project);
137
+ const dependencyManifestStateBefore = collectDependencyManifestState(targetRoot);
138
+
71
139
  const result = addModule({
72
140
  moduleId: options.moduleId,
73
141
  targetRoot,
@@ -80,4 +148,13 @@ export async function runAddModule(argv = process.argv.slice(2)) {
80
148
  packageRoot,
81
149
  relatedModuleId: result.preset.id,
82
150
  });
151
+
152
+ const dependencyManifestStateAfter = collectDependencyManifestState(targetRoot);
153
+ const changedDependencyManifestPaths = getChangedDependencyManifestPaths(
154
+ dependencyManifestStateBefore,
155
+ dependencyManifestStateAfter,
156
+ );
157
+ if (changedDependencyManifestPaths.length > 0) {
158
+ console.log('Next: run pnpm install');
159
+ }
83
160
  }
@@ -37,9 +37,9 @@ pnpm forgeon:sync-integrations
37
37
  ```
38
38
 
39
39
  Current sync coverage:
40
- - `jwt-auth + swagger`: adds OpenAPI decorators for auth controller/DTOs.
40
+ - `jwt-auth + db-prisma`: wires persistent refresh-token storage for auth.
41
41
 
42
- `create-forgeon add <module>` also runs integration sync automatically (best effort).
42
+ `create-forgeon add <module>` scans for relevant integration groups and can apply them immediately.
43
43
 
44
44
  ## i18n Configuration
45
45
 
@@ -63,6 +63,7 @@ Must contain:
63
63
  - Contracts package can be imported from both sides without circular dependencies.
64
64
  - Contracts package exports are stable from `dist/index` entrypoint.
65
65
  - Module has docs under `docs/AI/MODULES/<module-id>.md`.
66
+ - Module docs must explain: why it exists, what it adds, how it works, how to use it, how to configure it, and current operational limits.
66
67
  - If module behavior can be runtime-checked, it also includes API+Web probe hooks (see `docs/AI/MODULE_CHECKS.md`).
67
68
  - If i18n is enabled, module-specific namespaces must be created and wired for both API and web.
68
69
  - If module is added before i18n, namespace templates must still be prepared and applied when i18n is installed later.
@@ -0,0 +1 @@
1
+ # {{MODULE_LABEL}}
@@ -0,0 +1,6 @@
1
+ ## Overview
2
+
3
+ - Id: `{{MODULE_ID}}`
4
+ - Category: `{{MODULE_CATEGORY}}`
5
+ - Status: {{MODULE_STATUS}}
6
+ - Description: {{MODULE_DESCRIPTION}}
@@ -0,0 +1,11 @@
1
+ ## Idea / Why
2
+
3
+ This module adds a simple first-line request throttle to the API.
4
+
5
+ It exists to reduce three common classes of problems:
6
+
7
+ 1. burst traffic from accidental frontend loops
8
+ 2. low-cost abuse against public endpoints
9
+ 3. brute-force style retries against auth endpoints
10
+
11
+ It is intentionally small and predictable. The goal is not to replace a WAF, CDN, or distributed rate limiter. The goal is to give every Forgeon project a safe baseline that can be installed in one step.
@@ -0,0 +1,10 @@
1
+ ## What It Adds
2
+
3
+ - `@forgeon/rate-limit` workspace package
4
+ - env-backed throttle configuration
5
+ - global Nest throttler guard wiring
6
+ - reverse-proxy trust toggle for Caddy / Nginx deployments
7
+ - `GET /api/health/rate-limit` probe endpoint
8
+ - frontend probe button on the generated home page
9
+
10
+ This module is API-first. It does not add shared contracts or a web package in v1 because the runtime value is in backend request throttling, not in reusable client-side types.
@@ -0,0 +1,13 @@
1
+ ## How It Works
2
+
3
+ Implementation details:
4
+
5
+ - `RateLimitConfigService` reads `THROTTLE_*` env values through `@nestjs/config`.
6
+ - `ForgeonRateLimitModule` registers `ThrottlerModule` globally.
7
+ - A global guard applies the throttle rules to incoming HTTP requests.
8
+ - When `THROTTLE_TRUST_PROXY=true`, the module enables Express `trust proxy` through the active HTTP adapter so client IPs are resolved correctly behind reverse proxies.
9
+
10
+ Error behavior:
11
+
12
+ - throttled requests return HTTP `429`
13
+ - the existing Forgeon error envelope wraps the response as `TOO_MANY_REQUESTS`
@@ -0,0 +1,21 @@
1
+ ## How To Use
2
+
3
+ Install:
4
+
5
+ ```bash
6
+ npx create-forgeon@latest add rate-limit
7
+ pnpm install
8
+ ```
9
+
10
+ Verify:
11
+
12
+ 1. start the project
13
+ 2. open the generated frontend
14
+ 3. click `Check rate limit (click repeatedly)` multiple times within the throttle window
15
+ 4. observe the transition from `200` to `429`
16
+
17
+ You can also hit the probe route directly:
18
+
19
+ ```bash
20
+ GET /api/health/rate-limit
21
+ ```
@@ -0,0 +1,15 @@
1
+ ## Configuration
2
+
3
+ Environment keys:
4
+
5
+ - `THROTTLE_ENABLED=true`
6
+ - `THROTTLE_TTL=10`
7
+ - `THROTTLE_LIMIT=3`
8
+ - `THROTTLE_TRUST_PROXY=false`
9
+
10
+ Meaning:
11
+
12
+ - `THROTTLE_ENABLED`: hard on/off switch
13
+ - `THROTTLE_TTL`: throttle window in seconds
14
+ - `THROTTLE_LIMIT`: maximum requests allowed inside that window
15
+ - `THROTTLE_TRUST_PROXY`: use forwarded client IPs when behind Caddy / Nginx
@@ -0,0 +1,10 @@
1
+ ## Operational Notes
2
+
3
+ Current scope:
4
+
5
+ - in-memory throttling only
6
+ - one global baseline policy
7
+ - no Redis / distributed storage
8
+ - no route-specific policy DSL in v1
9
+
10
+ That means this module is appropriate for local development, simple deployments, and as a base preset. If a project later needs distributed throttling or different policies per route or user, this module should be extended rather than replaced blindly.
@@ -0,0 +1,3 @@
1
+ ## Status
2
+
3
+ Implemented in the current scaffold.
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@forgeon/rate-limit",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json"
9
+ },
10
+ "dependencies": {
11
+ "@nestjs/common": "^11.0.1",
12
+ "@nestjs/config": "^4.0.2",
13
+ "@nestjs/core": "^11.0.1",
14
+ "@nestjs/throttler": "^6.4.0",
15
+ "rxjs": "^7.8.1",
16
+ "zod": "^3.23.8"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.10.7",
20
+ "typescript": "^5.7.3"
21
+ }
22
+ }
@@ -0,0 +1,50 @@
1
+ import { Module, OnModuleInit } from '@nestjs/common';
2
+ import { APP_GUARD, HttpAdapterHost } from '@nestjs/core';
3
+ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
4
+ import { RateLimitConfigModule } from './rate-limit-config.module';
5
+ import { RateLimitConfigService } from './rate-limit-config.service';
6
+
7
+ @Module({
8
+ imports: [
9
+ RateLimitConfigModule,
10
+ ThrottlerModule.forRootAsync({
11
+ imports: [RateLimitConfigModule],
12
+ inject: [RateLimitConfigService],
13
+ useFactory: (config: RateLimitConfigService) => ({
14
+ errorMessage: 'Too many requests. Please try again later.',
15
+ skipIf: () => !config.enabled,
16
+ throttlers: [
17
+ {
18
+ ttl: config.ttlMs,
19
+ limit: config.limit,
20
+ },
21
+ ],
22
+ }),
23
+ }),
24
+ ],
25
+ providers: [
26
+ {
27
+ provide: APP_GUARD,
28
+ useClass: ThrottlerGuard,
29
+ },
30
+ ],
31
+ exports: [RateLimitConfigModule],
32
+ })
33
+ export class ForgeonRateLimitModule implements OnModuleInit {
34
+ constructor(
35
+ private readonly rateLimitConfig: RateLimitConfigService,
36
+ private readonly httpAdapterHost: HttpAdapterHost,
37
+ ) {}
38
+
39
+ onModuleInit(): void {
40
+ if (!this.rateLimitConfig.trustProxy) {
41
+ return;
42
+ }
43
+
44
+ const adapter = this.httpAdapterHost.httpAdapter;
45
+ const instance = adapter?.getInstance?.();
46
+ if (instance && typeof instance.set === 'function') {
47
+ instance.set('trust proxy', true);
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,5 @@
1
+ export * from './forgeon-rate-limit.module';
2
+ export * from './rate-limit-config.loader';
3
+ export * from './rate-limit-config.module';
4
+ export * from './rate-limit-config.service';
5
+ export * from './rate-limit-env.schema';
@@ -0,0 +1,25 @@
1
+ import { registerAs } from '@nestjs/config';
2
+ import { parseRateLimitEnv } from './rate-limit-env.schema';
3
+
4
+ export const RATE_LIMIT_CONFIG_NAMESPACE = 'rateLimit';
5
+
6
+ export interface RateLimitConfigValues {
7
+ enabled: boolean;
8
+ ttlSeconds: number;
9
+ limit: number;
10
+ trustProxy: boolean;
11
+ }
12
+
13
+ export const rateLimitConfig = registerAs(
14
+ RATE_LIMIT_CONFIG_NAMESPACE,
15
+ (): RateLimitConfigValues => {
16
+ const env = parseRateLimitEnv(process.env);
17
+
18
+ return {
19
+ enabled: env.THROTTLE_ENABLED,
20
+ ttlSeconds: env.THROTTLE_TTL,
21
+ limit: env.THROTTLE_LIMIT,
22
+ trustProxy: env.THROTTLE_TRUST_PROXY,
23
+ };
24
+ },
25
+ );
@@ -0,0 +1,8 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { RateLimitConfigService } from './rate-limit-config.service';
3
+
4
+ @Module({
5
+ providers: [RateLimitConfigService],
6
+ exports: [RateLimitConfigService],
7
+ })
8
+ export class RateLimitConfigModule {}
@@ -0,0 +1,35 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import {
4
+ RATE_LIMIT_CONFIG_NAMESPACE,
5
+ RateLimitConfigValues,
6
+ } from './rate-limit-config.loader';
7
+
8
+ @Injectable()
9
+ export class RateLimitConfigService {
10
+ constructor(private readonly configService: ConfigService) {}
11
+
12
+ get enabled(): boolean {
13
+ return this.configService.getOrThrow<boolean>(`${RATE_LIMIT_CONFIG_NAMESPACE}.enabled`);
14
+ }
15
+
16
+ get ttlSeconds(): RateLimitConfigValues['ttlSeconds'] {
17
+ return this.configService.getOrThrow<RateLimitConfigValues['ttlSeconds']>(
18
+ `${RATE_LIMIT_CONFIG_NAMESPACE}.ttlSeconds`,
19
+ );
20
+ }
21
+
22
+ get ttlMs(): number {
23
+ return this.ttlSeconds * 1000;
24
+ }
25
+
26
+ get limit(): RateLimitConfigValues['limit'] {
27
+ return this.configService.getOrThrow<RateLimitConfigValues['limit']>(
28
+ `${RATE_LIMIT_CONFIG_NAMESPACE}.limit`,
29
+ );
30
+ }
31
+
32
+ get trustProxy(): boolean {
33
+ return this.configService.getOrThrow<boolean>(`${RATE_LIMIT_CONFIG_NAMESPACE}.trustProxy`);
34
+ }
35
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+
3
+ export const rateLimitEnvSchema = z
4
+ .object({
5
+ THROTTLE_ENABLED: z.coerce.boolean().default(true),
6
+ THROTTLE_TTL: z.coerce.number().int().positive().default(10),
7
+ THROTTLE_LIMIT: z.coerce.number().int().positive().default(3),
8
+ THROTTLE_TRUST_PROXY: z.coerce.boolean().default(false),
9
+ })
10
+ .passthrough();
11
+
12
+ export type RateLimitEnv = z.infer<typeof rateLimitEnvSchema>;
13
+
14
+ export function parseRateLimitEnv(input: Record<string, unknown>): RateLimitEnv {
15
+ return rateLimitEnvSchema.parse(input);
16
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.node.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }