create-forgeon 0.2.6 → 0.2.8

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/modules/executor.mjs +2 -0
  3. package/src/modules/executor.test.mjs +178 -1
  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/src/modules/sync-integrations.mjs +143 -0
  8. package/src/run-add-module.mjs +1 -1
  9. package/templates/base/README.md +1 -0
  10. package/templates/base/docs/AI/MODULE_SPEC.md +8 -4
  11. package/templates/base/scripts/forgeon-sync-integrations.mjs +118 -0
  12. package/templates/module-fragments/rbac/00_title.md +1 -0
  13. package/templates/module-fragments/rbac/10_overview.md +6 -0
  14. package/templates/module-fragments/rbac/20_idea.md +9 -0
  15. package/templates/module-fragments/rbac/30_what_it_adds.md +11 -0
  16. package/templates/module-fragments/rbac/40_how_it_works.md +20 -0
  17. package/templates/module-fragments/rbac/50_how_to_use.md +19 -0
  18. package/templates/module-fragments/rbac/60_configuration.md +9 -0
  19. package/templates/module-fragments/rbac/70_operational_notes.md +10 -0
  20. package/templates/module-fragments/rbac/90_status_implemented.md +3 -0
  21. package/templates/module-presets/rbac/packages/rbac/package.json +19 -0
  22. package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.guard.ts +91 -0
  23. package/templates/module-presets/rbac/packages/rbac/src/forgeon-rbac.module.ts +8 -0
  24. package/templates/module-presets/rbac/packages/rbac/src/index.ts +6 -0
  25. package/templates/module-presets/rbac/packages/rbac/src/rbac.constants.ts +4 -0
  26. package/templates/module-presets/rbac/packages/rbac/src/rbac.decorators.ts +11 -0
  27. package/templates/module-presets/rbac/packages/rbac/src/rbac.helpers.ts +31 -0
  28. package/templates/module-presets/rbac/packages/rbac/src/rbac.types.ts +7 -0
  29. 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.8",
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
 
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { addModule } from './executor.mjs';
8
- import { syncIntegrations } from './sync-integrations.mjs';
8
+ import { scanIntegrations, syncIntegrations } from './sync-integrations.mjs';
9
9
  import { scaffoldProject } from '../core/scaffold.mjs';
10
10
 
11
11
  function mkTmp(prefix) {
@@ -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');
@@ -1079,6 +1145,80 @@ describe('addModule', () => {
1079
1145
  }
1080
1146
  });
1081
1147
 
1148
+ it('detects and applies jwt-auth + rbac claims integration explicitly', () => {
1149
+ const targetRoot = mkTmp('forgeon-module-jwt-rbac-');
1150
+ const projectRoot = path.join(targetRoot, 'demo-jwt-rbac');
1151
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1152
+
1153
+ try {
1154
+ scaffoldProject({
1155
+ templateRoot,
1156
+ packageRoot,
1157
+ targetRoot: projectRoot,
1158
+ projectName: 'demo-jwt-rbac',
1159
+ frontend: 'react',
1160
+ db: 'prisma',
1161
+ dbPrismaEnabled: false,
1162
+ i18nEnabled: false,
1163
+ proxy: 'caddy',
1164
+ });
1165
+
1166
+ addModule({
1167
+ moduleId: 'rbac',
1168
+ targetRoot: projectRoot,
1169
+ packageRoot,
1170
+ });
1171
+ addModule({
1172
+ moduleId: 'jwt-auth',
1173
+ targetRoot: projectRoot,
1174
+ packageRoot,
1175
+ });
1176
+
1177
+ const scan = scanIntegrations({
1178
+ targetRoot: projectRoot,
1179
+ relatedModuleId: 'jwt-auth',
1180
+ });
1181
+ assert.equal(scan.groups.some((group) => group.id === 'auth-rbac-claims'), true);
1182
+
1183
+ const syncResult = syncIntegrations({
1184
+ targetRoot: projectRoot,
1185
+ packageRoot,
1186
+ groupIds: ['auth-rbac-claims'],
1187
+ });
1188
+ const claimsPair = syncResult.summary.find((item) => item.id === 'auth-rbac-claims');
1189
+ assert.ok(claimsPair);
1190
+ assert.equal(claimsPair.result.applied, true);
1191
+
1192
+ const authContracts = fs.readFileSync(
1193
+ path.join(projectRoot, 'packages', 'auth-contracts', 'src', 'index.ts'),
1194
+ 'utf8',
1195
+ );
1196
+ assert.match(authContracts, /permissions\?: string\[\];/);
1197
+
1198
+ const authService = fs.readFileSync(
1199
+ path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.service.ts'),
1200
+ 'utf8',
1201
+ );
1202
+ assert.match(authService, /permissions: \['health\.rbac'\]/);
1203
+ assert.match(authService, /permissions: user\.permissions,/);
1204
+ assert.match(
1205
+ authService,
1206
+ /permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
1207
+ );
1208
+
1209
+ const authController = fs.readFileSync(
1210
+ path.join(projectRoot, 'packages', 'auth-api', 'src', 'auth.controller.ts'),
1211
+ 'utf8',
1212
+ );
1213
+ assert.match(
1214
+ authController,
1215
+ /permissions: Array\.isArray\(payload\.permissions\) \? payload\.permissions : \[\],/,
1216
+ );
1217
+ } finally {
1218
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1219
+ }
1220
+ });
1221
+
1082
1222
  it('applies logger then jwt-auth on db/i18n-disabled scaffold without breaking health controller syntax', () => {
1083
1223
  const targetRoot = mkTmp('forgeon-module-jwt-nodb-noi18n-');
1084
1224
  const projectRoot = path.join(targetRoot, 'demo-jwt-nodb-noi18n');
@@ -1255,6 +1395,43 @@ describe('addModule', () => {
1255
1395
  }
1256
1396
  });
1257
1397
 
1398
+ it('keeps rbac wiring valid after mixed module installation order', () => {
1399
+ const targetRoot = mkTmp('forgeon-module-rbac-order-');
1400
+ const projectRoot = path.join(targetRoot, 'demo-rbac-order');
1401
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
1402
+
1403
+ try {
1404
+ scaffoldProject({
1405
+ templateRoot,
1406
+ packageRoot,
1407
+ targetRoot: projectRoot,
1408
+ projectName: 'demo-rbac-order',
1409
+ frontend: 'react',
1410
+ db: 'prisma',
1411
+ dbPrismaEnabled: false,
1412
+ i18nEnabled: false,
1413
+ proxy: 'caddy',
1414
+ });
1415
+
1416
+ for (const moduleId of ['jwt-auth', 'logger', 'rate-limit', 'rbac', 'swagger', 'i18n', 'db-prisma']) {
1417
+ addModule({ moduleId, targetRoot: projectRoot, packageRoot });
1418
+ }
1419
+
1420
+ assertRbacWiring(projectRoot);
1421
+
1422
+ const healthController = fs.readFileSync(
1423
+ path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
1424
+ 'utf8',
1425
+ );
1426
+ const classStart = healthController.indexOf('export class HealthController {');
1427
+ const classEnd = healthController.lastIndexOf('\n}');
1428
+ const rbacProbe = healthController.indexOf("@Get('rbac')");
1429
+ assert.equal(rbacProbe > classStart && rbacProbe < classEnd, true);
1430
+ } finally {
1431
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1432
+ }
1433
+ });
1434
+
1258
1435
  it('keeps db-prisma wiring across module installation orders', () => {
1259
1436
  const sequences = [
1260
1437
  ['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',
@@ -68,6 +68,29 @@ function isAuthPersistencePending(rootDir) {
68
68
  return !(hasModuleWiring && hasSchema && hasStoreFile && hasMigration);
69
69
  }
70
70
 
71
+ function isAuthRbacPending(rootDir) {
72
+ const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
73
+ const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
74
+ const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
75
+
76
+ if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
77
+ return false;
78
+ }
79
+
80
+ const authContracts = fs.readFileSync(authContractsPath, 'utf8');
81
+ const authService = fs.readFileSync(authServicePath, 'utf8');
82
+ const authController = fs.readFileSync(authControllerPath, 'utf8');
83
+
84
+ const hasContracts = authContracts.includes('permissions?: string[];');
85
+ const hasDemoClaims = authService.includes("permissions: ['health.rbac']");
86
+ const hasPayloadClaims = authService.includes('permissions: user.permissions,');
87
+ const hasRefreshClaims = authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],');
88
+ const hasControllerClaims =
89
+ authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],');
90
+
91
+ return !(hasContracts && hasDemoClaims && hasPayloadClaims && hasRefreshClaims && hasControllerClaims);
92
+ }
93
+
71
94
  const INTEGRATION_GROUPS = [
72
95
  {
73
96
  id: 'auth-persistence',
@@ -83,6 +106,20 @@ const INTEGRATION_GROUPS = [
83
106
  isPending: (rootDir) => isAuthPersistencePending(rootDir),
84
107
  apply: syncJwtDbPrisma,
85
108
  },
109
+ {
110
+ id: 'auth-rbac-claims',
111
+ title: 'Auth Claims Integration',
112
+ modules: ['jwt-auth', 'rbac'],
113
+ description: [
114
+ 'Extend AuthUser with optional permissions in @forgeon/auth-contracts',
115
+ 'Add demo RBAC claims to jwt-auth login and token payloads',
116
+ 'Expose permissions in auth refresh and /me responses',
117
+ 'Update JWT auth README note about RBAC demo claims',
118
+ ],
119
+ isAvailable: (detected) => detected.jwtAuth && detected.rbac,
120
+ isPending: (rootDir) => isAuthRbacPending(rootDir),
121
+ apply: syncJwtRbacClaims,
122
+ },
86
123
  ];
87
124
 
88
125
  function detectModules(rootDir) {
@@ -93,6 +130,9 @@ function detectModules(rootDir) {
93
130
  jwtAuth:
94
131
  fs.existsSync(path.join(rootDir, 'packages', 'auth-api', 'package.json')) ||
95
132
  appModuleText.includes("from '@forgeon/auth-api'"),
133
+ rbac:
134
+ fs.existsSync(path.join(rootDir, 'packages', 'rbac', 'package.json')) ||
135
+ appModuleText.includes("from '@forgeon/rbac'"),
96
136
  dbPrisma:
97
137
  fs.existsSync(path.join(rootDir, 'packages', 'db-prisma', 'package.json')) ||
98
138
  appModuleText.includes("from '@forgeon/db-prisma'"),
@@ -216,6 +256,109 @@ function syncJwtDbPrisma({ rootDir, packageRoot, changedFiles }) {
216
256
  return { applied: true };
217
257
  }
218
258
 
259
+ function syncJwtRbacClaims({ rootDir, changedFiles }) {
260
+ const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
261
+ const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
262
+ const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
263
+ const readmePath = path.join(rootDir, 'README.md');
264
+
265
+ if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
266
+ return { applied: false, reason: 'auth package files are missing' };
267
+ }
268
+
269
+ let touched = false;
270
+
271
+ let authContracts = fs.readFileSync(authContractsPath, 'utf8').replace(/\r\n/g, '\n');
272
+ const originalAuthContracts = authContracts;
273
+ if (!authContracts.includes('permissions?: string[];')) {
274
+ authContracts = authContracts.replace(
275
+ ' roles: string[];',
276
+ ` roles: string[];
277
+ permissions?: string[];`,
278
+ );
279
+ }
280
+ if (authContracts !== originalAuthContracts) {
281
+ fs.writeFileSync(authContractsPath, `${authContracts.trimEnd()}\n`, 'utf8');
282
+ changedFiles.add(authContractsPath);
283
+ touched = true;
284
+ }
285
+
286
+ let authService = fs.readFileSync(authServicePath, 'utf8').replace(/\r\n/g, '\n');
287
+ const originalAuthService = authService;
288
+ authService = authService.replace(
289
+ /roles: \['user'\],/g,
290
+ `roles: ['admin'],
291
+ permissions: ['health.rbac'],`,
292
+ );
293
+ if (!authService.includes('permissions: user.permissions,')) {
294
+ authService = authService.replace(
295
+ ' roles: user.roles,',
296
+ ` roles: user.roles,
297
+ permissions: user.permissions,`,
298
+ );
299
+ }
300
+ if (!authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
301
+ authService = authService.replace(
302
+ " roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
303
+ ` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
304
+ permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
305
+ );
306
+ }
307
+ if (!authService.includes('demoPermissions: [')) {
308
+ authService = authService.replace(
309
+ " demoEmail: this.configService.demoEmail,",
310
+ ` demoEmail: this.configService.demoEmail,
311
+ demoPermissions: ['health.rbac'],`,
312
+ );
313
+ }
314
+ if (authService !== originalAuthService) {
315
+ fs.writeFileSync(authServicePath, `${authService.trimEnd()}\n`, 'utf8');
316
+ changedFiles.add(authServicePath);
317
+ touched = true;
318
+ }
319
+
320
+ let authController = fs.readFileSync(authControllerPath, 'utf8').replace(/\r\n/g, '\n');
321
+ const originalAuthController = authController;
322
+ if (!authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
323
+ authController = authController.replace(
324
+ " roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
325
+ ` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
326
+ permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
327
+ );
328
+ }
329
+ if (authController !== originalAuthController) {
330
+ fs.writeFileSync(authControllerPath, `${authController.trimEnd()}\n`, 'utf8');
331
+ changedFiles.add(authControllerPath);
332
+ touched = true;
333
+ }
334
+
335
+ if (fs.existsSync(readmePath)) {
336
+ let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
337
+ const originalReadme = readme;
338
+ if (!readme.includes('- RBAC integration: demo auth tokens include `health.rbac` permission')) {
339
+ const marker = 'Default demo credentials:';
340
+ if (readme.includes(marker)) {
341
+ readme = readme.replace(
342
+ marker,
343
+ `- RBAC integration: demo auth tokens include \`health.rbac\` permission
344
+
345
+ Default demo credentials:`,
346
+ );
347
+ }
348
+ }
349
+ if (readme !== originalReadme) {
350
+ fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
351
+ changedFiles.add(readmePath);
352
+ touched = true;
353
+ }
354
+ }
355
+
356
+ if (!touched) {
357
+ return { applied: false, reason: 'already synced' };
358
+ }
359
+ return { applied: true };
360
+ }
361
+
219
362
  export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
220
363
  const rootDir = path.resolve(targetRoot);
221
364
  const changedFiles = new Set();
@@ -94,7 +94,7 @@ function ensureSyncTooling({ packageRoot, targetRoot }) {
94
94
  );
95
95
  const targetScript = path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs');
96
96
 
97
- if (fs.existsSync(sourceScript) && !fs.existsSync(targetScript)) {
97
+ if (fs.existsSync(sourceScript)) {
98
98
  fs.mkdirSync(path.dirname(targetScript), { recursive: true });
99
99
  fs.copyFileSync(sourceScript, targetScript);
100
100
  }
@@ -37,6 +37,7 @@ pnpm forgeon:sync-integrations
37
37
  ```
38
38
 
39
39
  Current sync coverage:
40
+ - `jwt-auth + rbac`: extends demo auth tokens with the `health.rbac` permission.
40
41
  - `jwt-auth + db-prisma`: wires persistent refresh-token storage for auth.
41
42
 
42
43
  `create-forgeon add <module>` scans for relevant integration groups and can apply them immediately.
@@ -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
 
@@ -53,6 +53,9 @@ function detectModules(rootDir) {
53
53
  jwtAuth:
54
54
  fs.existsSync(path.join(rootDir, 'packages', 'auth-api', 'package.json')) ||
55
55
  appModuleText.includes("from '@forgeon/auth-api'"),
56
+ rbac:
57
+ fs.existsSync(path.join(rootDir, 'packages', 'rbac', 'package.json')) ||
58
+ appModuleText.includes("from '@forgeon/rbac'"),
56
59
  dbPrisma:
57
60
  fs.existsSync(path.join(rootDir, 'packages', 'db-prisma', 'package.json')) ||
58
61
  appModuleText.includes("from '@forgeon/db-prisma'"),
@@ -179,6 +182,109 @@ function syncJwtDbPrisma({ rootDir, changedFiles }) {
179
182
  return { applied: true };
180
183
  }
181
184
 
185
+ function syncJwtRbacClaims({ rootDir, changedFiles }) {
186
+ const authContractsPath = path.join(rootDir, 'packages', 'auth-contracts', 'src', 'index.ts');
187
+ const authServicePath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.service.ts');
188
+ const authControllerPath = path.join(rootDir, 'packages', 'auth-api', 'src', 'auth.controller.ts');
189
+ const readmePath = path.join(rootDir, 'README.md');
190
+
191
+ if (!fs.existsSync(authContractsPath) || !fs.existsSync(authServicePath) || !fs.existsSync(authControllerPath)) {
192
+ return { applied: false, reason: 'auth package files are missing' };
193
+ }
194
+
195
+ let touched = false;
196
+
197
+ let authContracts = fs.readFileSync(authContractsPath, 'utf8').replace(/\r\n/g, '\n');
198
+ const originalAuthContracts = authContracts;
199
+ if (!authContracts.includes('permissions?: string[];')) {
200
+ authContracts = authContracts.replace(
201
+ ' roles: string[];',
202
+ ` roles: string[];
203
+ permissions?: string[];`,
204
+ );
205
+ }
206
+ if (authContracts !== originalAuthContracts) {
207
+ fs.writeFileSync(authContractsPath, `${authContracts.trimEnd()}\n`, 'utf8');
208
+ changedFiles.add(authContractsPath);
209
+ touched = true;
210
+ }
211
+
212
+ let authService = fs.readFileSync(authServicePath, 'utf8').replace(/\r\n/g, '\n');
213
+ const originalAuthService = authService;
214
+ authService = authService.replace(
215
+ /roles: \['user'\],/g,
216
+ `roles: ['admin'],
217
+ permissions: ['health.rbac'],`,
218
+ );
219
+ if (!authService.includes('permissions: user.permissions,')) {
220
+ authService = authService.replace(
221
+ ' roles: user.roles,',
222
+ ` roles: user.roles,
223
+ permissions: user.permissions,`,
224
+ );
225
+ }
226
+ if (!authService.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
227
+ authService = authService.replace(
228
+ " roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
229
+ ` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
230
+ permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
231
+ );
232
+ }
233
+ if (!authService.includes('demoPermissions: [')) {
234
+ authService = authService.replace(
235
+ " demoEmail: this.configService.demoEmail,",
236
+ ` demoEmail: this.configService.demoEmail,
237
+ demoPermissions: ['health.rbac'],`,
238
+ );
239
+ }
240
+ if (authService !== originalAuthService) {
241
+ fs.writeFileSync(authServicePath, `${authService.trimEnd()}\n`, 'utf8');
242
+ changedFiles.add(authServicePath);
243
+ touched = true;
244
+ }
245
+
246
+ let authController = fs.readFileSync(authControllerPath, 'utf8').replace(/\r\n/g, '\n');
247
+ const originalAuthController = authController;
248
+ if (!authController.includes('permissions: Array.isArray(payload.permissions) ? payload.permissions : [],')) {
249
+ authController = authController.replace(
250
+ " roles: Array.isArray(payload.roles) ? payload.roles : ['user'],",
251
+ ` roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
252
+ permissions: Array.isArray(payload.permissions) ? payload.permissions : [],`,
253
+ );
254
+ }
255
+ if (authController !== originalAuthController) {
256
+ fs.writeFileSync(authControllerPath, `${authController.trimEnd()}\n`, 'utf8');
257
+ changedFiles.add(authControllerPath);
258
+ touched = true;
259
+ }
260
+
261
+ if (fs.existsSync(readmePath)) {
262
+ let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
263
+ const originalReadme = readme;
264
+ if (!readme.includes('- RBAC integration: demo auth tokens include `health.rbac` permission')) {
265
+ const marker = 'Default demo credentials:';
266
+ if (readme.includes(marker)) {
267
+ readme = readme.replace(
268
+ marker,
269
+ `- RBAC integration: demo auth tokens include \`health.rbac\` permission
270
+
271
+ Default demo credentials:`,
272
+ );
273
+ }
274
+ }
275
+ if (readme !== originalReadme) {
276
+ fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
277
+ changedFiles.add(readmePath);
278
+ touched = true;
279
+ }
280
+ }
281
+
282
+ if (!touched) {
283
+ return { applied: false, reason: 'already synced' };
284
+ }
285
+ return { applied: true };
286
+ }
287
+
182
288
  function run() {
183
289
  const rootDir = process.cwd();
184
290
  const changedFiles = new Set();
@@ -197,6 +303,18 @@ function run() {
197
303
  });
198
304
  }
199
305
 
306
+ if (detected.jwtAuth && detected.rbac) {
307
+ summary.push({
308
+ feature: 'jwt-auth + rbac',
309
+ result: syncJwtRbacClaims({ rootDir, changedFiles }),
310
+ });
311
+ } else {
312
+ summary.push({
313
+ feature: 'jwt-auth + rbac',
314
+ result: { applied: false, reason: 'required modules are not both installed' },
315
+ });
316
+ }
317
+
200
318
  console.log('[forgeon:sync-integrations] done');
201
319
  for (const item of summary) {
202
320
  if (item.result.applied) {
@@ -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
+ }