create-forgeon 0.2.6 → 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 (25) hide show
  1. package/package.json +1 -1
  2. package/src/modules/executor.mjs +2 -0
  3. package/src/modules/executor.test.mjs +103 -0
  4. package/src/modules/i18n.mjs +11 -0
  5. package/src/modules/rbac.mjs +324 -0
  6. package/src/modules/registry.mjs +18 -0
  7. package/templates/base/docs/AI/MODULE_SPEC.md +8 -4
  8. package/templates/module-fragments/rbac/00_title.md +1 -0
  9. package/templates/module-fragments/rbac/10_overview.md +6 -0
  10. package/templates/module-fragments/rbac/20_idea.md +9 -0
  11. package/templates/module-fragments/rbac/30_what_it_adds.md +11 -0
  12. package/templates/module-fragments/rbac/40_how_it_works.md +20 -0
  13. package/templates/module-fragments/rbac/50_how_to_use.md +19 -0
  14. package/templates/module-fragments/rbac/60_configuration.md +9 -0
  15. package/templates/module-fragments/rbac/70_operational_notes.md +10 -0
  16. package/templates/module-fragments/rbac/90_status_implemented.md +3 -0
  17. package/templates/module-presets/rbac/packages/rbac/package.json +19 -0
  18. package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.guard.ts +91 -0
  19. package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.module.ts +8 -0
  20. package/templates/module-presets/rbac/packages/rbac/src/index.ts +6 -0
  21. package/templates/module-presets/rbac/packages/rbac/src/rbac.constants.ts +4 -0
  22. package/templates/module-presets/rbac/packages/rbac/src/rbac.decorators.ts +11 -0
  23. package/templates/module-presets/rbac/packages/rbac/src/rbac.helpers.ts +31 -0
  24. package/templates/module-presets/rbac/packages/rbac/src/rbac.types.ts +7 -0
  25. package/templates/module-presets/rbac/packages/rbac/tsconfig.json +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -7,6 +7,7 @@ import { applyI18nModule } from './i18n.mjs';
7
7
  import { applyJwtAuthModule } from './jwt-auth.mjs';
8
8
  import { applyLoggerModule } from './logger.mjs';
9
9
  import { applyRateLimitModule } from './rate-limit.mjs';
10
+ import { applyRbacModule } from './rbac.mjs';
10
11
  import { applySwaggerModule } from './swagger.mjs';
11
12
 
12
13
  function ensureForgeonLikeProject(targetRoot) {
@@ -31,6 +32,7 @@ const MODULE_APPLIERS = {
31
32
  'jwt-auth': applyJwtAuthModule,
32
33
  logger: applyLoggerModule,
33
34
  'rate-limit': applyRateLimitModule,
35
+ rbac: applyRbacModule,
34
36
  swagger: applySwaggerModule,
35
37
  };
36
38
 
@@ -81,6 +81,37 @@ function assertRateLimitWiring(projectRoot) {
81
81
  assert.match(readme, /## Rate Limit Module/);
82
82
  }
83
83
 
84
+ function assertRbacWiring(projectRoot) {
85
+ const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
86
+ assert.match(appModule, /ForgeonRbacModule/);
87
+
88
+ const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
89
+ assert.match(apiPackage, /@forgeon\/rbac/);
90
+ assert.match(apiPackage, /pnpm --filter @forgeon\/rbac build/);
91
+
92
+ const apiDockerfile = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
93
+ assert.match(apiDockerfile, /COPY packages\/rbac\/package\.json packages\/rbac\/package\.json/);
94
+ assert.match(apiDockerfile, /COPY packages\/rbac packages\/rbac/);
95
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/rbac build/);
96
+
97
+ const healthController = fs.readFileSync(
98
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
99
+ 'utf8',
100
+ );
101
+ assert.match(healthController, /UseGuards/);
102
+ assert.match(healthController, /ForgeonRbacGuard/);
103
+ assert.match(healthController, /@Get\('rbac'\)/);
104
+ assert.match(healthController, /@Permissions\('health\.rbac'\)/);
105
+
106
+ const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
107
+ assert.match(appTsx, /Check RBAC access/);
108
+ assert.match(appTsx, /RBAC probe response/);
109
+ assert.match(appTsx, /x-forgeon-permissions/);
110
+
111
+ const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
112
+ assert.match(readme, /## RBAC \/ Permissions Module/);
113
+ }
114
+
84
115
  function assertJwtAuthWiring(projectRoot, withPrismaStore) {
85
116
  const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
86
117
  assert.match(apiPackage, /@forgeon\/auth-api/);
@@ -603,6 +634,41 @@ describe('addModule', () => {
603
634
  }
604
635
  });
605
636
 
637
+ it('applies rbac module on top of scaffold without i18n', () => {
638
+ const targetRoot = mkTmp('forgeon-module-rbac-');
639
+ const projectRoot = path.join(targetRoot, 'demo-rbac');
640
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
641
+
642
+ try {
643
+ scaffoldProject({
644
+ templateRoot,
645
+ packageRoot,
646
+ targetRoot: projectRoot,
647
+ projectName: 'demo-rbac',
648
+ frontend: 'react',
649
+ db: 'prisma',
650
+ dbPrismaEnabled: false,
651
+ i18nEnabled: false,
652
+ proxy: 'caddy',
653
+ });
654
+
655
+ const result = addModule({
656
+ moduleId: 'rbac',
657
+ targetRoot: projectRoot,
658
+ packageRoot,
659
+ });
660
+
661
+ assert.equal(result.applied, true);
662
+ assertRbacWiring(projectRoot);
663
+
664
+ const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
665
+ assert.match(moduleDoc, /## Idea \/ Why/);
666
+ assert.match(moduleDoc, /## How It Works/);
667
+ } finally {
668
+ fs.rmSync(targetRoot, { recursive: true, force: true });
669
+ }
670
+ });
671
+
606
672
  it('applies swagger module on top of scaffold without i18n', () => {
607
673
  const targetRoot = mkTmp('forgeon-module-swagger-');
608
674
  const projectRoot = path.join(targetRoot, 'demo-swagger');
@@ -1255,6 +1321,43 @@ describe('addModule', () => {
1255
1321
  }
1256
1322
  });
1257
1323
 
1324
+ it('keeps rbac wiring valid after mixed module installation order', () => {
1325
+ const targetRoot = mkTmp('forgeon-module-rbac-order-');
1326
+ const projectRoot = path.join(targetRoot, 'demo-rbac-order');
1327
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1328
+
1329
+ try {
1330
+ scaffoldProject({
1331
+ templateRoot,
1332
+ packageRoot,
1333
+ targetRoot: projectRoot,
1334
+ projectName: 'demo-rbac-order',
1335
+ frontend: 'react',
1336
+ db: 'prisma',
1337
+ dbPrismaEnabled: false,
1338
+ i18nEnabled: false,
1339
+ proxy: 'caddy',
1340
+ });
1341
+
1342
+ for (const moduleId of ['jwt-auth', 'logger', 'rate-limit', 'rbac', 'swagger', 'i18n', 'db-prisma']) {
1343
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
1344
+ }
1345
+
1346
+ assertRbacWiring(projectRoot);
1347
+
1348
+ const healthController = fs.readFileSync(
1349
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1350
+ 'utf8',
1351
+ );
1352
+ const classStart = healthController.indexOf('export class HealthController {');
1353
+ const classEnd = healthController.lastIndexOf('\n}');
1354
+ const rbacProbe = healthController.indexOf("@Get('rbac')");
1355
+ assert.equal(rbacProbe > classStart && rbacProbe < classEnd, true);
1356
+ } finally {
1357
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1358
+ }
1359
+ });
1360
+
1258
1361
  it('keeps db-prisma wiring across module installation orders', () => {
1259
1362
  const sequences = [
1260
1363
  ['logger', 'swagger', 'i18n'],
@@ -422,6 +422,7 @@ function restoreKnownWebProbes(targetRoot, previousAppContent) {
422
422
  return;
423
423
  }
424
424
  const anchors = [
425
+ ' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);',
425
426
  ' const [rateLimitProbeResult, setRateLimitProbeResult] = useState<ProbeResult | null>(null);',
426
427
  ' const [authProbeResult, setAuthProbeResult] = useState<ProbeResult | null>(null);',
427
428
  ' const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);',
@@ -458,6 +459,7 @@ function restoreKnownWebProbes(targetRoot, previousAppContent) {
458
459
  return;
459
460
  }
460
461
  const anchors = [
462
+ " {renderResult('RBAC probe response', rbacProbeResult)}",
461
463
  " {renderResult('Rate limit probe response', rateLimitProbeResult)}",
462
464
  " {renderResult('Auth probe response', authProbeResult)}",
463
465
  " {renderResult('DB probe response', dbProbeResult)}",
@@ -498,6 +500,15 @@ function restoreKnownWebProbes(targetRoot, previousAppContent) {
498
500
  ensureProbeResult(" {renderResult('Rate limit probe response', rateLimitProbeResult)}");
499
501
  }
500
502
 
503
+ if (previousAppContent.includes('Check RBAC access')) {
504
+ ensureProbeState(' const [rbacProbeResult, setRbacProbeResult] = useState<ProbeResult | null>(null);');
505
+ ensureProbeButton(
506
+ 'Check RBAC access',
507
+ " <button\n onClick={() =>\n runProbe(setRbacProbeResult, '/health/rbac', {\n headers: { 'x-forgeon-permissions': 'health.rbac' },\n })\n }\n >\n Check RBAC access\n </button>",
508
+ );
509
+ ensureProbeResult(" {renderResult('RBAC probe response', rbacProbeResult)}");
510
+ }
511
+
501
512
  fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
502
513
  }
503
514
 
@@ -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
+ }
@@ -57,6 +57,24 @@ const MODULE_PRESETS = {
57
57
  '90_status_implemented',
58
58
  ],
59
59
  },
60
+ rbac: {
61
+ id: 'rbac',
62
+ label: 'RBAC / Permissions',
63
+ category: 'auth-security',
64
+ implemented: true,
65
+ description: 'Role and permission decorators with a Nest guard and a protected probe endpoint.',
66
+ docFragments: [
67
+ '00_title',
68
+ '10_overview',
69
+ '20_idea',
70
+ '30_what_it_adds',
71
+ '40_how_it_works',
72
+ '50_how_to_use',
73
+ '60_configuration',
74
+ '70_operational_notes',
75
+ '90_status_implemented',
76
+ ],
77
+ },
60
78
  queue: {
61
79
  id: 'queue',
62
80
  label: 'Queue Worker',
@@ -4,11 +4,15 @@
4
4
 
5
5
  Define one repeatable fullstack pattern for Forgeon add-modules.
6
6
 
7
- Each feature module should be split into:
7
+ Most feature modules should be split into:
8
8
 
9
- 1. `@forgeon/<feature>-contracts`
10
- 2. `@forgeon/<feature>-api`
11
- 3. `@forgeon/<feature>-web`
9
+ 1. `@forgeon/<feature>-contracts`
10
+ 2. `@forgeon/<feature>-api`
11
+ 3. `@forgeon/<feature>-web`
12
+
13
+ Exception:
14
+
15
+ - backend-only infrastructure or security modules may use a single runtime package (`@forgeon/<feature>`) when shared contracts and a dedicated web package add no real value.
12
16
 
13
17
  ## 1) Contracts Package
14
18
 
@@ -0,0 +1 @@
1
+ # {{MODULE_LABEL}}
@@ -0,0 +1,6 @@
1
+ ## Overview
2
+
3
+ - Id: `{{MODULE_ID}}`
4
+ - Category: `{{MODULE_CATEGORY}}`
5
+ - Status: {{MODULE_STATUS}}
6
+ - Description: {{MODULE_DESCRIPTION}}
@@ -0,0 +1,9 @@
1
+ ## Idea / Why
2
+
3
+ This module adds a minimal authorization layer for backend routes.
4
+
5
+ It exists to answer one simple question consistently:
6
+
7
+ - does the current caller have the required role or permission for this endpoint?
8
+
9
+ The module intentionally stays small. It does not try to become a general policy engine. It provides a stable baseline for backend access checks and leaves more advanced patterns to separate modules if they are ever needed.
@@ -0,0 +1,11 @@
1
+ ## What It Adds
2
+
3
+ - `@forgeon/rbac` runtime package
4
+ - `@Roles(...)` decorator
5
+ - `@Permissions(...)` decorator
6
+ - `ForgeonRbacGuard`
7
+ - helper utilities for role and permission checks
8
+ - a protected probe route: `GET /api/health/rbac`
9
+ - a frontend probe button that sends a valid permission header
10
+
11
+ This module is backend-first. It does not include frontend route guards. If frontend access-control helpers are needed later, they should live in a separate module.
@@ -0,0 +1,20 @@
1
+ ## How It Works
2
+
3
+ Implementation details:
4
+
5
+ - decorators store required roles and permissions as Nest metadata
6
+ - `ForgeonRbacGuard` reads that metadata with `Reflector`
7
+ - the guard checks `request.user` first
8
+ - if no user payload is available, it can also read test headers:
9
+ - `x-forgeon-roles`
10
+ - `x-forgeon-permissions`
11
+
12
+ Decision rules in v1:
13
+
14
+ - roles: any required role is enough
15
+ - permissions: all required permissions must be present
16
+
17
+ Failure path:
18
+
19
+ - denied access throws `403`
20
+ - the existing Forgeon error envelope wraps it as `FORBIDDEN`
@@ -0,0 +1,19 @@
1
+ ## How To Use
2
+
3
+ Install:
4
+
5
+ ```bash
6
+ npx create-forgeon@latest add rbac
7
+ pnpm install
8
+ ```
9
+
10
+ Verify:
11
+
12
+ 1. start the project
13
+ 2. click `Check RBAC access` on the generated frontend
14
+ 3. the request should return `200`
15
+
16
+ Manual forbidden-path check:
17
+
18
+ 1. call `GET /api/health/rbac` without the `x-forgeon-permissions` header
19
+ 2. the request should return `403`
@@ -0,0 +1,9 @@
1
+ ## Configuration
2
+
3
+ This module has no dedicated environment variables in v1.
4
+
5
+ Behavior is controlled by:
6
+
7
+ - route decorators (`@Roles`, `@Permissions`)
8
+ - the active request payload (`request.user`)
9
+ - optional testing headers (`x-forgeon-roles`, `x-forgeon-permissions`)
@@ -0,0 +1,10 @@
1
+ ## Operational Notes
2
+
3
+ Current scope:
4
+
5
+ - no policy engine
6
+ - no database-backed role or permission store
7
+ - no admin UI
8
+ - no frontend route-guard package in this module
9
+
10
+ This is a stable baseline module, not a placeholder for a future “v2”. If access-control needs grow later, this module can be extended carefully or paired with a separate specialized module.
@@ -0,0 +1,3 @@
1
+ ## Status
2
+
3
+ Implemented in the current scaffold.
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@forgeon/rbac",
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/core": "^11.0.1",
13
+ "reflect-metadata": "^0.2.2"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^22.10.7",
17
+ "typescript": "^5.7.3"
18
+ }
19
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ CanActivate,
3
+ ExecutionContext,
4
+ ForbiddenException,
5
+ Injectable,
6
+ } from '@nestjs/common';
7
+ import { Reflector } from '@nestjs/core';
8
+ import {
9
+ RBAC_PERMISSIONS_HEADER,
10
+ RBAC_PERMISSIONS_KEY,
11
+ RBAC_ROLES_HEADER,
12
+ RBAC_ROLES_KEY,
13
+ } from './rbac.constants';
14
+ import { hasAllPermissions, hasAnyRole } from './rbac.helpers';
15
+ import { Permission, RbacPrincipal, Role } from './rbac.types';
16
+
17
+ type HeaderValue = string | string[] | undefined;
18
+
19
+ type HttpRequestLike = {
20
+ user?: unknown;
21
+ headers?: Record<string, HeaderValue>;
22
+ };
23
+
24
+ @Injectable()
25
+ export class ForgeonRbacGuard implements CanActivate {
26
+ constructor(private readonly reflector: Reflector) {}
27
+
28
+ canActivate(context: ExecutionContext): boolean {
29
+ const requiredRoles =
30
+ this.reflector.getAllAndOverride<Role[]>(RBAC_ROLES_KEY, [
31
+ context.getHandler(),
32
+ context.getClass(),
33
+ ]) ?? [];
34
+ const requiredPermissions =
35
+ this.reflector.getAllAndOverride<Permission[]>(RBAC_PERMISSIONS_KEY, [
36
+ context.getHandler(),
37
+ context.getClass(),
38
+ ]) ?? [];
39
+
40
+ if (requiredRoles.length === 0 && requiredPermissions.length === 0) {
41
+ return true;
42
+ }
43
+
44
+ const request = context.switchToHttp().getRequest<HttpRequestLike>();
45
+ const principal = this.resolvePrincipal(request);
46
+
47
+ if (!hasAnyRole(principal, requiredRoles) || !hasAllPermissions(principal, requiredPermissions)) {
48
+ throw new ForbiddenException({
49
+ message: 'Access denied',
50
+ details: {
51
+ feature: 'rbac',
52
+ requiredRoles,
53
+ requiredPermissions,
54
+ },
55
+ });
56
+ }
57
+
58
+ return true;
59
+ }
60
+
61
+ private resolvePrincipal(request: HttpRequestLike | undefined): RbacPrincipal {
62
+ const user = request?.user;
63
+ if (user && typeof user === 'object') {
64
+ const principal = user as RbacPrincipal;
65
+ if (Array.isArray(principal.roles) || Array.isArray(principal.permissions)) {
66
+ return {
67
+ roles: Array.isArray(principal.roles) ? principal.roles : [],
68
+ permissions: Array.isArray(principal.permissions) ? principal.permissions : [],
69
+ };
70
+ }
71
+ }
72
+
73
+ const headers = request?.headers;
74
+ return {
75
+ roles: this.parseHeaderList(headers?.[RBAC_ROLES_HEADER]),
76
+ permissions: this.parseHeaderList(headers?.[RBAC_PERMISSIONS_HEADER]),
77
+ };
78
+ }
79
+
80
+ private parseHeaderList(value: HeaderValue): string[] {
81
+ const source = Array.isArray(value) ? value.join(',') : value;
82
+ if (typeof source !== 'string' || source.trim().length === 0) {
83
+ return [];
84
+ }
85
+
86
+ return source
87
+ .split(',')
88
+ .map((item) => item.trim())
89
+ .filter(Boolean);
90
+ }
91
+ }
@@ -0,0 +1,8 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ForgeonRbacGuard } from './forgeon-rbac.guard';
3
+
4
+ @Module({
5
+ providers: [ForgeonRbacGuard],
6
+ exports: [ForgeonRbacGuard],
7
+ })
8
+ export class ForgeonRbacModule {}
@@ -0,0 +1,6 @@
1
+ export * from './forgeon-rbac.guard';
2
+ export * from './forgeon-rbac.module';
3
+ export * from './rbac.constants';
4
+ export * from './rbac.decorators';
5
+ export * from './rbac.helpers';
6
+ export * from './rbac.types';
@@ -0,0 +1,4 @@
1
+ export const RBAC_ROLES_KEY = 'forgeon:rbac:roles';
2
+ export const RBAC_PERMISSIONS_KEY = 'forgeon:rbac:permissions';
3
+ export const RBAC_ROLES_HEADER = 'x-forgeon-roles';
4
+ export const RBAC_PERMISSIONS_HEADER = 'x-forgeon-permissions';
@@ -0,0 +1,11 @@
1
+ import { SetMetadata } from '@nestjs/common';
2
+ import { RBAC_PERMISSIONS_KEY, RBAC_ROLES_KEY } from './rbac.constants';
3
+ import { Permission, Role } from './rbac.types';
4
+
5
+ export function Roles(...roles: Role[]) {
6
+ return SetMetadata(RBAC_ROLES_KEY, roles);
7
+ }
8
+
9
+ export function Permissions(...permissions: Permission[]) {
10
+ return SetMetadata(RBAC_PERMISSIONS_KEY, permissions);
11
+ }
@@ -0,0 +1,31 @@
1
+ import { Permission, RbacPrincipal, Role } from './rbac.types';
2
+
3
+ export function hasRole(principal: RbacPrincipal | null | undefined, role: Role): boolean {
4
+ const roles = principal?.roles ?? [];
5
+ return roles.includes(role);
6
+ }
7
+
8
+ export function hasPermission(
9
+ principal: RbacPrincipal | null | undefined,
10
+ permission: Permission,
11
+ ): boolean {
12
+ const permissions = principal?.permissions ?? [];
13
+ return permissions.includes(permission);
14
+ }
15
+
16
+ export function hasAnyRole(principal: RbacPrincipal | null | undefined, roles: Role[]): boolean {
17
+ if (roles.length === 0) {
18
+ return true;
19
+ }
20
+ return roles.some((role) => hasRole(principal, role));
21
+ }
22
+
23
+ export function hasAllPermissions(
24
+ principal: RbacPrincipal | null | undefined,
25
+ permissions: Permission[],
26
+ ): boolean {
27
+ if (permissions.length === 0) {
28
+ return true;
29
+ }
30
+ return permissions.every((permission) => hasPermission(principal, permission));
31
+ }
@@ -0,0 +1,7 @@
1
+ export type Role = string;
2
+ export type Permission = string;
3
+
4
+ export interface RbacPrincipal {
5
+ roles?: Role[];
6
+ permissions?: Permission[];
7
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.node.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }