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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/modules/executor.mjs +4 -0
  3. package/src/modules/executor.test.mjs +214 -0
  4. package/src/modules/i18n.mjs +113 -0
  5. package/src/modules/rate-limit.mjs +346 -0
  6. package/src/modules/rbac.mjs +324 -0
  7. package/src/modules/registry.mjs +39 -3
  8. package/src/run-add-module.mjs +83 -6
  9. package/templates/base/README.md +2 -2
  10. package/templates/base/docs/AI/MODULE_SPEC.md +9 -4
  11. package/templates/module-fragments/rate-limit/00_title.md +1 -0
  12. package/templates/module-fragments/rate-limit/10_overview.md +6 -0
  13. package/templates/module-fragments/rate-limit/20_idea.md +11 -0
  14. package/templates/module-fragments/rate-limit/30_what_it_adds.md +10 -0
  15. package/templates/module-fragments/rate-limit/40_how_it_works.md +13 -0
  16. package/templates/module-fragments/rate-limit/50_how_to_use.md +21 -0
  17. package/templates/module-fragments/rate-limit/60_configuration.md +15 -0
  18. package/templates/module-fragments/rate-limit/70_operational_notes.md +10 -0
  19. package/templates/module-fragments/rate-limit/90_status_implemented.md +3 -0
  20. package/templates/module-fragments/rbac/00_title.md +1 -0
  21. package/templates/module-fragments/rbac/10_overview.md +6 -0
  22. package/templates/module-fragments/rbac/20_idea.md +9 -0
  23. package/templates/module-fragments/rbac/30_what_it_adds.md +11 -0
  24. package/templates/module-fragments/rbac/40_how_it_works.md +20 -0
  25. package/templates/module-fragments/rbac/50_how_to_use.md +19 -0
  26. package/templates/module-fragments/rbac/60_configuration.md +9 -0
  27. package/templates/module-fragments/rbac/70_operational_notes.md +10 -0
  28. package/templates/module-fragments/rbac/90_status_implemented.md +3 -0
  29. package/templates/module-presets/rate-limit/packages/rate-limit/package.json +22 -0
  30. package/templates/module-presets/rate-limit/packages/rate-limit/src/forgeon-rate-limit.module.ts +50 -0
  31. package/templates/module-presets/rate-limit/packages/rate-limit/src/index.ts +5 -0
  32. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.loader.ts +25 -0
  33. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.module.ts +8 -0
  34. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-config.service.ts +35 -0
  35. package/templates/module-presets/rate-limit/packages/rate-limit/src/rate-limit-env.schema.ts +16 -0
  36. package/templates/module-presets/rate-limit/packages/rate-limit/tsconfig.json +9 -0
  37. package/templates/module-presets/rbac/packages/rbac/package.json +19 -0
  38. package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.guard.ts +91 -0
  39. package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.module.ts +8 -0
  40. package/templates/module-presets/rbac/packages/rbac/src/index.ts +6 -0
  41. package/templates/module-presets/rbac/packages/rbac/src/rbac.constants.ts +4 -0
  42. package/templates/module-presets/rbac/packages/rbac/src/rbac.decorators.ts +11 -0
  43. package/templates/module-presets/rbac/packages/rbac/src/rbac.helpers.ts +31 -0
  44. package/templates/module-presets/rbac/packages/rbac/src/rbac.types.ts +7 -0
  45. 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
+ }