create-forgeon 0.2.5 → 0.2.7
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 +4 -0
- package/src/modules/executor.test.mjs +214 -0
- package/src/modules/i18n.mjs +113 -0
- package/src/modules/rate-limit.mjs +346 -0
- package/src/modules/rbac.mjs +324 -0
- package/src/modules/registry.mjs +39 -3
- package/src/run-add-module.mjs +83 -6
- package/templates/base/README.md +2 -2
- package/templates/base/docs/AI/MODULE_SPEC.md +9 -4
- 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-fragments/rbac/00_title.md +1 -0
- package/templates/module-fragments/rbac/10_overview.md +6 -0
- package/templates/module-fragments/rbac/20_idea.md +9 -0
- package/templates/module-fragments/rbac/30_what_it_adds.md +11 -0
- package/templates/module-fragments/rbac/40_how_it_works.md +20 -0
- package/templates/module-fragments/rbac/50_how_to_use.md +19 -0
- package/templates/module-fragments/rbac/60_configuration.md +9 -0
- package/templates/module-fragments/rbac/70_operational_notes.md +10 -0
- package/templates/module-fragments/rbac/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/templates/module-presets/rbac/packages/rbac/package.json +19 -0
- package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.guard.ts +91 -0
- package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.module.ts +8 -0
- package/templates/module-presets/rbac/packages/rbac/src/index.ts +6 -0
- package/templates/module-presets/rbac/packages/rbac/src/rbac.constants.ts +4 -0
- package/templates/module-presets/rbac/packages/rbac/src/rbac.decorators.ts +11 -0
- package/templates/module-presets/rbac/packages/rbac/src/rbac.helpers.ts +31 -0
- package/templates/module-presets/rbac/packages/rbac/src/rbac.types.ts +7 -0
- package/templates/module-presets/rbac/packages/rbac/tsconfig.json +9 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
ensureNestCommonImport,
|
|
12
|
+
} from './shared/patch-utils.mjs';
|
|
13
|
+
|
|
14
|
+
function copyFromPreset(packageRoot, targetRoot, relativePath) {
|
|
15
|
+
const source = path.join(packageRoot, 'templates', 'module-presets', 'rbac', relativePath);
|
|
16
|
+
if (!fs.existsSync(source)) {
|
|
17
|
+
throw new Error(`Missing rbac preset template: ${source}`);
|
|
18
|
+
}
|
|
19
|
+
const destination = path.join(targetRoot, relativePath);
|
|
20
|
+
copyRecursive(source, destination);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function patchApiPackage(targetRoot) {
|
|
24
|
+
const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
|
|
25
|
+
if (!fs.existsSync(packagePath)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
30
|
+
ensureDependency(packageJson, '@forgeon/rbac', 'workspace:*');
|
|
31
|
+
ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/rbac build']);
|
|
32
|
+
writeJson(packagePath, packageJson);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function patchAppModule(targetRoot) {
|
|
36
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
37
|
+
if (!fs.existsSync(filePath)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
42
|
+
if (!content.includes("from '@forgeon/rbac';")) {
|
|
43
|
+
if (content.includes("import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';")) {
|
|
44
|
+
content = ensureLineAfter(
|
|
45
|
+
content,
|
|
46
|
+
"import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';",
|
|
47
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
48
|
+
);
|
|
49
|
+
} else if (
|
|
50
|
+
content.includes("import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';")
|
|
51
|
+
) {
|
|
52
|
+
content = ensureLineAfter(
|
|
53
|
+
content,
|
|
54
|
+
"import { authConfig, authEnvSchema, ForgeonAuthModule } from '@forgeon/auth-api';",
|
|
55
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
56
|
+
);
|
|
57
|
+
} else if (
|
|
58
|
+
content.includes("import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';")
|
|
59
|
+
) {
|
|
60
|
+
content = ensureLineAfter(
|
|
61
|
+
content,
|
|
62
|
+
"import { ForgeonRateLimitModule, rateLimitConfig, rateLimitEnvSchema } from '@forgeon/rate-limit';",
|
|
63
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
64
|
+
);
|
|
65
|
+
} else if (
|
|
66
|
+
content.includes("import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';")
|
|
67
|
+
) {
|
|
68
|
+
content = ensureLineAfter(
|
|
69
|
+
content,
|
|
70
|
+
"import { ForgeonLoggerModule, loggerConfig, loggerEnvSchema } from '@forgeon/logger';",
|
|
71
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
72
|
+
);
|
|
73
|
+
} else if (
|
|
74
|
+
content.includes("import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';")
|
|
75
|
+
) {
|
|
76
|
+
content = ensureLineAfter(
|
|
77
|
+
content,
|
|
78
|
+
"import { ForgeonSwaggerModule, swaggerConfig, swaggerEnvSchema } from '@forgeon/swagger';",
|
|
79
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
80
|
+
);
|
|
81
|
+
} else if (
|
|
82
|
+
content.includes("import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';")
|
|
83
|
+
) {
|
|
84
|
+
content = ensureLineAfter(
|
|
85
|
+
content,
|
|
86
|
+
"import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';",
|
|
87
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
content = ensureLineAfter(
|
|
91
|
+
content,
|
|
92
|
+
"import { ConfigModule } from '@nestjs/config';",
|
|
93
|
+
"import { ForgeonRbacModule } from '@forgeon/rbac';",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!content.includes(' ForgeonRbacModule,')) {
|
|
99
|
+
if (content.includes(' ForgeonI18nModule.register({')) {
|
|
100
|
+
content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonRbacModule,');
|
|
101
|
+
} else if (content.includes(' ForgeonAuthModule.register({')) {
|
|
102
|
+
content = ensureLineBefore(content, ' ForgeonAuthModule.register({', ' ForgeonRbacModule,');
|
|
103
|
+
} else if (content.includes(' ForgeonAuthModule.register(),')) {
|
|
104
|
+
content = ensureLineBefore(content, ' ForgeonAuthModule.register(),', ' ForgeonRbacModule,');
|
|
105
|
+
} else if (content.includes(' ForgeonRateLimitModule,')) {
|
|
106
|
+
content = ensureLineAfter(content, ' ForgeonRateLimitModule,', ' ForgeonRbacModule,');
|
|
107
|
+
} else if (content.includes(' DbPrismaModule,')) {
|
|
108
|
+
content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonRbacModule,');
|
|
109
|
+
} else if (content.includes(' ForgeonLoggerModule,')) {
|
|
110
|
+
content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonRbacModule,');
|
|
111
|
+
} else if (content.includes(' ForgeonSwaggerModule,')) {
|
|
112
|
+
content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonRbacModule,');
|
|
113
|
+
} else {
|
|
114
|
+
content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonRbacModule,');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function patchHealthController(targetRoot) {
|
|
122
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
|
|
123
|
+
if (!fs.existsSync(filePath)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
128
|
+
content = ensureNestCommonImport(content, 'UseGuards');
|
|
129
|
+
|
|
130
|
+
if (!content.includes("from '@forgeon/rbac';")) {
|
|
131
|
+
content = ensureImportLine(
|
|
132
|
+
content,
|
|
133
|
+
"import { ForgeonRbacGuard, Permissions } from '@forgeon/rbac';",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!content.includes("@Get('rbac')")) {
|
|
138
|
+
const method = `
|
|
139
|
+
@Get('rbac')
|
|
140
|
+
@UseGuards(ForgeonRbacGuard)
|
|
141
|
+
@Permissions('health.rbac')
|
|
142
|
+
getRbacProbe() {
|
|
143
|
+
return {
|
|
144
|
+
status: 'ok',
|
|
145
|
+
feature: 'rbac',
|
|
146
|
+
granted: true,
|
|
147
|
+
requiredPermission: 'health.rbac',
|
|
148
|
+
hint: 'Send x-forgeon-permissions: health.rbac to pass this check manually.',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
152
|
+
const beforeNeedle = content.includes("@Get('rate-limit')")
|
|
153
|
+
? "@Get('rate-limit')"
|
|
154
|
+
: content.includes('private translate(')
|
|
155
|
+
? 'private translate('
|
|
156
|
+
: '';
|
|
157
|
+
content = ensureClassMember(content, 'HealthController', method, { beforeNeedle });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function patchWebApp(targetRoot) {
|
|
164
|
+
const filePath = path.join(targetRoot, 'apps', 'web', 'src', 'App.tsx');
|
|
165
|
+
if (!fs.existsSync(filePath)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
170
|
+
content = content
|
|
171
|
+
.replace(/^\s*\{\/\* forgeon:probes:actions:start \*\/\}\r?\n?/gm, '')
|
|
172
|
+
.replace(/^\s*\{\/\* forgeon:probes:actions:end \*\/\}\r?\n?/gm, '')
|
|
173
|
+
.replace(/^\s*\{\/\* forgeon:probes:results:start \*\/\}\r?\n?/gm, '')
|
|
174
|
+
.replace(/^\s*\{\/\* forgeon:probes:results:end \*\/\}\r?\n?/gm, '');
|
|
175
|
+
|
|
176
|
+
if (!content.includes('rbacProbeResult')) {
|
|
177
|
+
const stateAnchors = [
|
|
178
|
+
' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
|
|
179
|
+
' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
|
|
180
|
+
' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
|
|
181
|
+
' const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);',
|
|
182
|
+
];
|
|
183
|
+
const stateAnchor = stateAnchors.find((line) => content.includes(line));
|
|
184
|
+
if (stateAnchor) {
|
|
185
|
+
content = ensureLineAfter(
|
|
186
|
+
content,
|
|
187
|
+
stateAnchor,
|
|
188
|
+
' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!content.includes('Check RBAC access')) {
|
|
194
|
+
const useProxyPath = content.includes("runProbe(setHealthResult, '/health')");
|
|
195
|
+
const probePath = useProxyPath ? '/health/rbac' : '/api/health/rbac';
|
|
196
|
+
const button = ` <button\n onClick={() =>\n runProbe(setRbacProbeResult, '${probePath}', {\n headers: { 'x-forgeon-permissions': 'health.rbac' },\n })\n }\n >\n Check RBAC access\n </button>`;
|
|
197
|
+
|
|
198
|
+
const actionsStart = content.indexOf('<div className="actions">');
|
|
199
|
+
if (actionsStart >= 0) {
|
|
200
|
+
const actionsEnd = content.indexOf('\n </div>', actionsStart);
|
|
201
|
+
if (actionsEnd >= 0) {
|
|
202
|
+
content = `${content.slice(0, actionsEnd)}\n${button}${content.slice(actionsEnd)}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!content.includes("{renderResult('RBAC probe response', rbacProbeResult)}")) {
|
|
208
|
+
const resultLine = " {renderResult('RBAC probe response', rbacProbeResult)}";
|
|
209
|
+
const networkLine = ' {networkError ? <p className="error">{networkError}</p> : null}';
|
|
210
|
+
if (content.includes(networkLine)) {
|
|
211
|
+
content = content.replace(networkLine, `${resultLine}\n${networkLine}`);
|
|
212
|
+
} else {
|
|
213
|
+
const anchors = [
|
|
214
|
+
" {renderResult('Rate limit probe response', rateLimitProbeResult)}",
|
|
215
|
+
" {renderResult('Auth probe response', authProbeResult)}",
|
|
216
|
+
" {renderResult('DB probe response', dbProbeResult)}",
|
|
217
|
+
" {renderResult('Validation probe response', validationProbeResult)}",
|
|
218
|
+
];
|
|
219
|
+
const anchor = anchors.find((line) => content.includes(line));
|
|
220
|
+
if (anchor) {
|
|
221
|
+
content = ensureLineAfter(content, anchor, resultLine);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function patchApiDockerfile(targetRoot) {
|
|
230
|
+
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
231
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
236
|
+
const packageAnchors = [
|
|
237
|
+
'COPY packages/auth-api/package.json packages/auth-api/package.json',
|
|
238
|
+
'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
|
|
239
|
+
'COPY packages/logger/package.json packages/logger/package.json',
|
|
240
|
+
'COPY packages/swagger/package.json packages/swagger/package.json',
|
|
241
|
+
'COPY packages/i18n/package.json packages/i18n/package.json',
|
|
242
|
+
'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
|
|
243
|
+
'COPY packages/core/package.json packages/core/package.json',
|
|
244
|
+
];
|
|
245
|
+
const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
|
|
246
|
+
content = ensureLineAfter(content, packageAnchor, 'COPY packages/rbac/package.json packages/rbac/package.json');
|
|
247
|
+
|
|
248
|
+
const sourceAnchors = [
|
|
249
|
+
'COPY packages/auth-api packages/auth-api',
|
|
250
|
+
'COPY packages/rate-limit packages/rate-limit',
|
|
251
|
+
'COPY packages/logger packages/logger',
|
|
252
|
+
'COPY packages/swagger packages/swagger',
|
|
253
|
+
'COPY packages/i18n packages/i18n',
|
|
254
|
+
'COPY packages/db-prisma packages/db-prisma',
|
|
255
|
+
'COPY packages/core packages/core',
|
|
256
|
+
];
|
|
257
|
+
const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
|
|
258
|
+
content = ensureLineAfter(content, sourceAnchor, 'COPY packages/rbac packages/rbac');
|
|
259
|
+
|
|
260
|
+
content = content.replace(/^RUN pnpm --filter @forgeon\/rbac build\r?\n?/gm, '');
|
|
261
|
+
const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
|
|
262
|
+
? 'RUN pnpm --filter @forgeon/api prisma:generate'
|
|
263
|
+
: 'RUN pnpm --filter @forgeon/api build';
|
|
264
|
+
content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/rbac build');
|
|
265
|
+
|
|
266
|
+
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function patchReadme(targetRoot) {
|
|
270
|
+
const readmePath = path.join(targetRoot, 'README.md');
|
|
271
|
+
if (!fs.existsSync(readmePath)) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const marker = '## RBAC / Permissions Module';
|
|
276
|
+
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
277
|
+
if (content.includes(marker)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const section = `## RBAC / Permissions Module
|
|
282
|
+
|
|
283
|
+
The rbac add-module provides a minimal authorization layer for role and permission checks.
|
|
284
|
+
|
|
285
|
+
What it adds:
|
|
286
|
+
- \`@Roles(...)\` and \`@Permissions(...)\` decorators
|
|
287
|
+
- \`ForgeonRbacGuard\`
|
|
288
|
+
- simple helper functions for role / permission checks
|
|
289
|
+
- a protected probe route: \`GET /api/health/rbac\`
|
|
290
|
+
|
|
291
|
+
How it works:
|
|
292
|
+
- the guard reads metadata from decorators
|
|
293
|
+
- it checks \`request.user\` first
|
|
294
|
+
- if no user payload is present, it can also read test headers:
|
|
295
|
+
- \`x-forgeon-roles\`
|
|
296
|
+
- \`x-forgeon-permissions\`
|
|
297
|
+
|
|
298
|
+
How to verify:
|
|
299
|
+
- the generated frontend button sends \`x-forgeon-permissions: health.rbac\` and should return \`200\`
|
|
300
|
+
- the same route without that header should return \`403\`
|
|
301
|
+
|
|
302
|
+
Current scope:
|
|
303
|
+
- no policy engine
|
|
304
|
+
- no database-backed role store
|
|
305
|
+
- no frontend route-guard layer in this module`;
|
|
306
|
+
|
|
307
|
+
if (content.includes('## Prisma In Docker Start')) {
|
|
308
|
+
content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
|
|
309
|
+
} else {
|
|
310
|
+
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function applyRbacModule({ packageRoot, targetRoot }) {
|
|
317
|
+
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'rbac'));
|
|
318
|
+
patchApiPackage(targetRoot);
|
|
319
|
+
patchAppModule(targetRoot);
|
|
320
|
+
patchHealthController(targetRoot);
|
|
321
|
+
patchWebApp(targetRoot);
|
|
322
|
+
patchApiDockerfile(targetRoot);
|
|
323
|
+
patchReadme(targetRoot);
|
|
324
|
+
}
|