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.
- package/package.json +1 -1
- package/src/modules/executor.mjs +2 -0
- package/src/modules/executor.test.mjs +111 -0
- package/src/modules/i18n.mjs +102 -0
- package/src/modules/rate-limit.mjs +346 -0
- package/src/modules/registry.mjs +21 -3
- package/src/run-add-module.mjs +83 -6
- package/templates/base/README.md +2 -2
- package/templates/base/docs/AI/MODULE_SPEC.md +1 -0
- package/templates/module-fragments/rate-limit/00_title.md +1 -0
- package/templates/module-fragments/rate-limit/10_overview.md +6 -0
- package/templates/module-fragments/rate-limit/20_idea.md +11 -0
- package/templates/module-fragments/rate-limit/30_what_it_adds.md +10 -0
- package/templates/module-fragments/rate-limit/40_how_it_works.md +13 -0
- package/templates/module-fragments/rate-limit/50_how_to_use.md +21 -0
- package/templates/module-fragments/rate-limit/60_configuration.md +15 -0
- package/templates/module-fragments/rate-limit/70_operational_notes.md +10 -0
- package/templates/module-fragments/rate-limit/90_status_implemented.md +3 -0
- package/templates/module-presets/rate-limit/packages/rate-limit/package.json +22 -0
- package/templates/module-presets/rate-limit/packages/rate-limit/src/forgeon-rate-limit.module.ts +50 -0
- package/templates/module-presets/rate-limit/packages/rate-limit/src/index.ts +5 -0
- package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.loader.ts +25 -0
- package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.module.ts +8 -0
- package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.service.ts +35 -0
- package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-env.schema.ts +16 -0
- package/templates/module-presets/rate-limit/packages/rate-limit/tsconfig.json +9 -0
package/package.json
CHANGED
package/src/modules/executor.mjs
CHANGED
|
@@ -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'],
|
package/src/modules/i18n.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/modules/registry.mjs
CHANGED
|
@@ -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
|
-
|
|
43
|
-
id: '
|
|
44
|
-
label: '
|
|
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).',
|
package/src/run-add-module.mjs
CHANGED
|
@@ -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
|
}
|
package/templates/base/README.md
CHANGED
|
@@ -37,9 +37,9 @@ pnpm forgeon:sync-integrations
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Current sync coverage:
|
|
40
|
-
- `jwt-auth +
|
|
40
|
+
- `jwt-auth + db-prisma`: wires persistent refresh-token storage for auth.
|
|
41
41
|
|
|
42
|
-
`create-forgeon add <module>`
|
|
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,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,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
|
+
}
|
package/templates/module-presets/rate-limit/packages/rate-limit/src/forgeon-rate-limit.module.ts
ADDED
|
@@ -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
|
+
}
|
package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.loader.ts
ADDED
|
@@ -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
|
+
);
|
package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.service.ts
ADDED
|
@@ -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
|
+
}
|