create-forgeon 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -70,10 +70,13 @@ function assertRateLimitWiring(projectRoot) {
70
70
  path.join(projectRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'),
71
71
  'utf8',
72
72
  );
73
+ assert.match(healthController, /import \{ Header \} from '@nestjs\/common';/);
74
+ assert.match(healthController, /@Header\('Cache-Control', 'no-store, no-cache, must-revalidate'\)/);
73
75
  assert.match(healthController, /@Get\('rate-limit'\)/);
74
76
  assert.match(healthController, /TOO_MANY_REQUESTS/);
75
77
 
76
78
  const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
79
+ assert.match(appTsx, /cache: 'no-store'/);
77
80
  assert.match(appTsx, /Check rate limit \(click repeatedly\)/);
78
81
  assert.match(appTsx, /Rate limit probe response/);
79
82
 
@@ -569,10 +572,9 @@ describe('addModule', () => {
569
572
 
570
573
  const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
571
574
  assert.match(mainTs, /ForgeonLoggerService/);
572
- assert.match(mainTs, /ForgeonHttpLoggingInterceptor/);
573
575
  assert.match(mainTs, /bufferLogs: true/);
574
576
  assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
575
- assert.match(mainTs, /app\.useGlobalInterceptors\(app\.get\(ForgeonHttpLoggingInterceptor\)\);/);
577
+ assert.doesNotMatch(mainTs, /useGlobalInterceptors/);
576
578
 
577
579
  const apiDockerfile = fs.readFileSync(
578
580
  path.join(projectRoot, 'apps', 'api', 'Dockerfile'),
@@ -588,6 +590,13 @@ describe('addModule', () => {
588
590
  );
589
591
  assert.match(loggerTsconfig, /"extends": "\.\.\/\.\.\/tsconfig\.base\.node\.json"/);
590
592
 
593
+ const loggerModule = fs.readFileSync(
594
+ path.join(projectRoot, 'packages', 'logger', 'src', 'forgeon-logger.module.ts'),
595
+ 'utf8',
596
+ );
597
+ assert.match(loggerModule, /ForgeonHttpLoggingMiddleware/);
598
+ assert.match(loggerModule, /consumer\.apply\(RequestIdMiddleware, ForgeonHttpLoggingMiddleware\)\.forRoutes\('\*'\);/);
599
+
591
600
  const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
592
601
  assert.match(apiEnv, /LOGGER_LEVEL=log/);
593
602
  assert.match(apiEnv, /LOGGER_HTTP_ENABLED=true/);
@@ -954,7 +963,7 @@ describe('addModule', () => {
954
963
 
955
964
  const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
956
965
  assert.match(mainTs, /ForgeonLoggerService/);
957
- assert.match(mainTs, /ForgeonHttpLoggingInterceptor/);
966
+ assert.doesNotMatch(mainTs, /ForgeonHttpLoggingInterceptor/);
958
967
  } finally {
959
968
  fs.rmSync(targetRoot, { recursive: true, force: true });
960
969
  }
@@ -1055,7 +1064,7 @@ describe('addModule', () => {
1055
1064
  const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
1056
1065
  assert.match(mainTs, /setupSwagger\(app,\s*swaggerConfigService\)/);
1057
1066
  assert.match(mainTs, /app\.useLogger\(app\.get\(ForgeonLoggerService\)\);/);
1058
- assert.match(mainTs, /app\.useGlobalInterceptors\(app\.get\(ForgeonHttpLoggingInterceptor\)\);/);
1067
+ assert.doesNotMatch(mainTs, /useGlobalInterceptors/);
1059
1068
 
1060
1069
  const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
1061
1070
  assert.match(apiPackage, /@forgeon\/swagger/);
@@ -44,10 +44,14 @@ function patchMain(targetRoot) {
44
44
  }
45
45
 
46
46
  let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
47
+ content = content.replace(
48
+ "import { ForgeonHttpLoggingInterceptor, ForgeonLoggerService } from '@forgeon/logger';",
49
+ "import { ForgeonLoggerService } from '@forgeon/logger';",
50
+ );
47
51
  content = ensureLineBefore(
48
52
  content,
49
53
  "import { NestFactory } from '@nestjs/core';",
50
- "import { ForgeonHttpLoggingInterceptor, ForgeonLoggerService } from '@forgeon/logger';",
54
+ "import { ForgeonLoggerService } from '@forgeon/logger';",
51
55
  );
52
56
 
53
57
  content = content.replace(
@@ -55,12 +59,16 @@ function patchMain(targetRoot) {
55
59
  'const app = await NestFactory.create(AppModule, { bufferLogs: true });',
56
60
  );
57
61
 
62
+ content = content.replace(
63
+ /\n\s*app\.useGlobalInterceptors\(app\.get\(ForgeonHttpLoggingInterceptor\)\);\s*/g,
64
+ '\n',
65
+ );
66
+
58
67
  if (!content.includes('app.useLogger(app.get(ForgeonLoggerService));')) {
59
68
  content = content.replace(
60
69
  ' const coreConfigService = app.get(CoreConfigService);',
61
70
  ` const coreConfigService = app.get(CoreConfigService);
62
- app.useLogger(app.get(ForgeonLoggerService));
63
- app.useGlobalInterceptors(app.get(ForgeonHttpLoggingInterceptor));`,
71
+ app.useLogger(app.get(ForgeonLoggerService));`,
64
72
  );
65
73
  }
66
74
 
@@ -179,6 +187,7 @@ function patchReadme(targetRoot) {
179
187
  The logger add-module provides:
180
188
  - request id middleware (default header: \`x-request-id\`)
181
189
  - HTTP access logs with method/path/status/duration/ip/requestId
190
+ - HTTP access logs are emitted from middleware, so requests rejected by guards (for example 429 from rate-limit) are still logged
182
191
  - Nest logger integration via \`app.useLogger(...)\`
183
192
 
184
193
  It installs independently and intentionally does not add a dedicated API/web probe.
@@ -120,8 +120,10 @@ function patchHealthController(targetRoot) {
120
120
  }
121
121
 
122
122
  let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
123
+ content = ensureImportLine(content, "import { Header } from '@nestjs/common';");
123
124
  if (!content.includes("@Get('rate-limit')")) {
124
125
  const method = `
126
+ @Header('Cache-Control', 'no-store, no-cache, must-revalidate')
125
127
  @Get('rate-limit')
126
128
  getRateLimitProbe() {
127
129
  return {
@@ -24,6 +24,25 @@ const PRISMA_AUTH_MIGRATION_TEMPLATE = path.join(
24
24
  '0002_auth_refresh_token_hash',
25
25
  );
26
26
 
27
+ const AUTH_PERSISTENCE_STRATEGIES = [
28
+ {
29
+ id: 'db-prisma',
30
+ capability: 'db-adapter',
31
+ providerLabel: 'db-prisma',
32
+ participants: ['jwt-auth', 'db-adapter'],
33
+ relatedModules: ['jwt-auth', 'db-prisma'],
34
+ description: [
35
+ 'Patch AppModule to wire AUTH_REFRESH_TOKEN_STORE to the current db-adapter implementation (today: PrismaAuthRefreshTokenStore)',
36
+ 'Add apps/api/src/auth/prisma-auth-refresh-token.store.ts',
37
+ 'Extend Prisma User model with refreshTokenHash and add migration 0002_auth_refresh_token_hash',
38
+ 'Update JWT auth README note to reflect db-adapter-backed refresh-token persistence',
39
+ ],
40
+ isDetected: (detected) => detected.dbPrisma,
41
+ isPending: isAuthPersistencePending,
42
+ apply: syncJwtDbPrisma,
43
+ },
44
+ ];
45
+
27
46
  function ensureLineAfter(content, anchorLine, lineToInsert) {
28
47
  if (content.includes(lineToInsert)) {
29
48
  return content;
@@ -97,15 +116,10 @@ const INTEGRATION_GROUPS = [
97
116
  title: 'Auth Persistence Integration',
98
117
  participants: ['jwt-auth', 'db-adapter'],
99
118
  relatedModules: ['jwt-auth', 'db-prisma'],
100
- description: [
101
- 'Patch AppModule to wire AUTH_REFRESH_TOKEN_STORE to the current db-adapter implementation (today: PrismaAuthRefreshTokenStore)',
102
- 'Add apps/api/src/auth/prisma-auth-refresh-token.store.ts',
103
- 'Extend Prisma User model with refreshTokenHash and add migration 0002_auth_refresh_token_hash',
104
- 'Update JWT auth README note to reflect db-adapter-backed refresh-token persistence',
105
- ],
106
- isAvailable: (detected) => detected.jwtAuth && detected.dbPrisma,
107
- isPending: (rootDir) => isAuthPersistencePending(rootDir),
108
- apply: syncJwtDbPrisma,
119
+ description: (detected) => getAuthPersistenceDescription(detected),
120
+ isAvailable: (detected) => detected.jwtAuth && hasSingleAuthPersistenceStrategy(detected),
121
+ isPending: (rootDir, detected) => isAuthPersistencePendingForDetected(rootDir, detected),
122
+ apply: applyAuthPersistenceSync,
109
123
  },
110
124
  {
111
125
  id: 'auth-rbac-claims',
@@ -141,6 +155,52 @@ function detectModules(rootDir) {
141
155
  };
142
156
  }
143
157
 
158
+ function resolveAuthPersistenceStrategy(detected) {
159
+ const matched = AUTH_PERSISTENCE_STRATEGIES.filter((strategy) => strategy.isDetected(detected));
160
+ if (matched.length === 0) {
161
+ return { kind: 'none' };
162
+ }
163
+ if (matched.length > 1) {
164
+ return { kind: 'conflict', strategies: matched };
165
+ }
166
+ return { kind: 'single', strategy: matched[0] };
167
+ }
168
+
169
+ function hasSingleAuthPersistenceStrategy(detected) {
170
+ return resolveAuthPersistenceStrategy(detected).kind === 'single';
171
+ }
172
+
173
+ function getAuthPersistenceDescription(detected) {
174
+ const resolved = resolveAuthPersistenceStrategy(detected);
175
+ if (resolved.kind === 'single') {
176
+ return [...resolved.strategy.description];
177
+ }
178
+ return [
179
+ 'Use the current db-adapter provider strategy to wire refresh-token persistence.',
180
+ 'A supported db-adapter provider must be installed before this integration can apply.',
181
+ ];
182
+ }
183
+
184
+ function isAuthPersistencePendingForDetected(rootDir, detected) {
185
+ const resolved = resolveAuthPersistenceStrategy(detected);
186
+ if (resolved.kind !== 'single') {
187
+ return false;
188
+ }
189
+ return resolved.strategy.isPending(rootDir);
190
+ }
191
+
192
+ function applyAuthPersistenceSync({ rootDir, packageRoot, changedFiles }) {
193
+ const detected = detectModules(rootDir);
194
+ const resolved = resolveAuthPersistenceStrategy(detected);
195
+ if (resolved.kind === 'none') {
196
+ return { applied: false, reason: 'no supported db-adapter provider detected' };
197
+ }
198
+ if (resolved.kind === 'conflict') {
199
+ return { applied: false, reason: 'multiple db-adapter providers detected' };
200
+ }
201
+ return resolved.strategy.apply({ rootDir, packageRoot, changedFiles });
202
+ }
203
+
144
204
  function getGroupParticipants(group) {
145
205
  return Array.isArray(group.participants) && group.participants.length > 0
146
206
  ? group.participants
@@ -155,6 +215,13 @@ function getGroupRelatedModules(group) {
155
215
  : getGroupParticipants(group);
156
216
  }
157
217
 
218
+ function getGroupDescription(group, detected) {
219
+ if (typeof group.description === 'function') {
220
+ return group.description(detected);
221
+ }
222
+ return Array.isArray(group.description) ? group.description : [];
223
+ }
224
+
158
225
  function syncJwtDbPrisma({ rootDir, packageRoot, changedFiles }) {
159
226
  const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
160
227
  const schemaPath = path.join(rootDir, 'apps', 'api', 'prisma', 'schema.prisma');
@@ -381,7 +448,7 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
381
448
  const detected = detectModules(rootDir);
382
449
  const summary = [];
383
450
  const available = INTEGRATION_GROUPS.filter(
384
- (group) => group.isAvailable(detected) && group.isPending(rootDir),
451
+ (group) => group.isAvailable(detected) && group.isPending(rootDir, detected),
385
452
  );
386
453
  const selected = Array.isArray(groupIds)
387
454
  ? available.filter((group) => groupIds.includes(group.id))
@@ -391,7 +458,7 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
391
458
  summary.push({
392
459
  id: group.id,
393
460
  title: group.title,
394
- modules: group.modules,
461
+ modules: getGroupParticipants(group),
395
462
  result: group.apply({ rootDir, packageRoot, changedFiles }),
396
463
  });
397
464
  }
@@ -402,7 +469,7 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
402
469
  id: group.id,
403
470
  title: group.title,
404
471
  modules: [...getGroupParticipants(group)],
405
- description: [...group.description],
472
+ description: [...getGroupDescription(group, detected)],
406
473
  })),
407
474
  changedFiles: [...changedFiles].sort().map((filePath) => path.relative(rootDir, filePath)),
408
475
  };
@@ -414,7 +481,7 @@ export function scanIntegrations({ targetRoot, relatedModuleId = null }) {
414
481
  const available = INTEGRATION_GROUPS.filter(
415
482
  (group) =>
416
483
  group.isAvailable(detected) &&
417
- group.isPending(rootDir) &&
484
+ group.isPending(rootDir, detected) &&
418
485
  (!relatedModuleId || getGroupRelatedModules(group).includes(relatedModuleId)),
419
486
  );
420
487
  return {
@@ -422,7 +489,7 @@ export function scanIntegrations({ targetRoot, relatedModuleId = null }) {
422
489
  id: group.id,
423
490
  title: group.title,
424
491
  modules: [...getGroupParticipants(group)],
425
- description: [...group.description],
492
+ description: [...getGroupDescription(group, detected)],
426
493
  })),
427
494
  };
428
495
  }
@@ -13,7 +13,10 @@ export default function App() {
13
13
  const [networkError, setNetworkError] = useState<string | null>(null);
14
14
 
15
15
  const requestProbe = async (url: string, init?: RequestInit): Promise<ProbeResult> => {
16
- const response = await fetch(url, init);
16
+ const response = await fetch(url, {
17
+ ...(init ?? {}),
18
+ cache: 'no-store',
19
+ });
17
20
  let body: unknown = null;
18
21
 
19
22
  try {
@@ -45,6 +45,15 @@ ALTER TABLE "User"
45
45
  ADD COLUMN "refreshTokenHash" TEXT;
46
46
  `;
47
47
 
48
+ const AUTH_PERSISTENCE_STRATEGIES = [
49
+ {
50
+ id: 'db-prisma',
51
+ providerLabel: 'db-prisma',
52
+ isDetected: (detected) => detected.dbPrisma,
53
+ apply: syncJwtDbPrisma,
54
+ },
55
+ ];
56
+
48
57
  function detectModules(rootDir) {
49
58
  const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
50
59
  const appModuleText = fs.existsSync(appModulePath) ? fs.readFileSync(appModulePath, 'utf8') : '';
@@ -74,6 +83,17 @@ function ensureLineAfter(content, anchorLine, lineToInsert) {
74
83
  return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
75
84
  }
76
85
 
86
+ function resolveAuthPersistenceStrategy(detected) {
87
+ const matched = AUTH_PERSISTENCE_STRATEGIES.filter((strategy) => strategy.isDetected(detected));
88
+ if (matched.length === 0) {
89
+ return { kind: 'none' };
90
+ }
91
+ if (matched.length > 1) {
92
+ return { kind: 'conflict', strategies: matched };
93
+ }
94
+ return { kind: 'single', strategy: matched[0] };
95
+ }
96
+
77
97
  function syncJwtDbPrisma({ rootDir, changedFiles }) {
78
98
  const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
79
99
  const schemaPath = path.join(rootDir, 'apps', 'api', 'prisma', 'schema.prisma');
@@ -290,16 +310,21 @@ function run() {
290
310
  const changedFiles = new Set();
291
311
  const detected = detectModules(rootDir);
292
312
  const summary = [];
313
+ const authPersistence = resolveAuthPersistenceStrategy(detected);
293
314
 
294
- if (detected.jwtAuth && detected.dbPrisma) {
315
+ if (detected.jwtAuth && authPersistence.kind === 'single') {
295
316
  summary.push({
296
- feature: 'jwt-auth + db-adapter (current provider: db-prisma)',
297
- result: syncJwtDbPrisma({ rootDir, changedFiles }),
317
+ feature: `jwt-auth + db-adapter (current provider: ${authPersistence.strategy.providerLabel})`,
318
+ result: authPersistence.strategy.apply({ rootDir, changedFiles }),
298
319
  });
299
320
  } else {
321
+ const reason =
322
+ authPersistence.kind === 'conflict'
323
+ ? 'multiple db-adapter providers detected'
324
+ : 'required components are not both available';
300
325
  summary.push({
301
326
  feature: 'jwt-auth + db-adapter (current provider: db-prisma)',
302
- result: { applied: false, reason: 'required components are not both available' },
327
+ result: { applied: false, reason },
303
328
  });
304
329
  }
305
330
 
@@ -27,7 +27,8 @@ export default function App() {
27
27
 
28
28
  const requestProbe = async (path: string, init?: RequestInit): Promise<ProbeResult> => {
29
29
  const response = await fetch(`/api${path}${toLangQuery(locale)}`, {
30
- ...init,
30
+ ...(init ?? {}),
31
+ cache: 'no-store',
31
32
  headers: {
32
33
  ...(init?.headers ?? {}),
33
34
  'Accept-Language': locale,
@@ -1,17 +1,16 @@
1
1
  import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
2
- import { ForgeonHttpLoggingInterceptor } from './http-logging.interceptor';
2
+ import { ForgeonHttpLoggingMiddleware } from './http-logging.middleware';
3
3
  import { ForgeonLoggerService } from './forgeon-logger.service';
4
4
  import { LoggerConfigModule } from './logger-config.module';
5
5
  import { RequestIdMiddleware } from './request-id.middleware';
6
6
 
7
7
  @Module({
8
8
  imports: [LoggerConfigModule],
9
- providers: [RequestIdMiddleware, ForgeonLoggerService, ForgeonHttpLoggingInterceptor],
10
- exports: [LoggerConfigModule, ForgeonLoggerService, ForgeonHttpLoggingInterceptor],
9
+ providers: [RequestIdMiddleware, ForgeonHttpLoggingMiddleware, ForgeonLoggerService],
10
+ exports: [LoggerConfigModule, ForgeonLoggerService],
11
11
  })
12
12
  export class ForgeonLoggerModule implements NestModule {
13
13
  configure(consumer: MiddlewareConsumer): void {
14
- consumer.apply(RequestIdMiddleware).forRoutes('*');
14
+ consumer.apply(RequestIdMiddleware, ForgeonHttpLoggingMiddleware).forRoutes('*');
15
15
  }
16
16
  }
17
-
@@ -0,0 +1,74 @@
1
+ import { Injectable, NestMiddleware } from '@nestjs/common';
2
+ import { ForgeonLoggerService } from './forgeon-logger.service';
3
+ import { LoggerConfigService } from './logger-config.service';
4
+
5
+ type HeaderValue = string | string[] | undefined;
6
+ type HeadersRecord = Record<string, HeaderValue>;
7
+
8
+ interface RequestLike {
9
+ method?: string;
10
+ originalUrl?: string;
11
+ url?: string;
12
+ ip?: string;
13
+ requestId?: string;
14
+ headers?: HeadersRecord;
15
+ }
16
+
17
+ interface ResponseLike {
18
+ statusCode?: number;
19
+ once?: (event: string, listener: () => void) => void;
20
+ }
21
+
22
+ type NextFunction = () => void;
23
+
24
+ @Injectable()
25
+ export class ForgeonHttpLoggingMiddleware implements NestMiddleware {
26
+ constructor(
27
+ private readonly logger: ForgeonLoggerService,
28
+ private readonly loggerConfig: LoggerConfigService,
29
+ ) {}
30
+
31
+ use(request: RequestLike, response: ResponseLike, next: NextFunction): void {
32
+ if (!this.loggerConfig.httpEnabled) {
33
+ next();
34
+ return;
35
+ }
36
+
37
+ const method = request.method ?? 'UNKNOWN';
38
+ const path = request.originalUrl ?? request.url ?? '/';
39
+ const ip = request.ip;
40
+ const requestId =
41
+ request.requestId ?? this.readHeader(request.headers, this.loggerConfig.requestIdHeader);
42
+ const startedAt = Date.now();
43
+
44
+ if (typeof response.once === 'function') {
45
+ response.once('finish', () => {
46
+ this.logger.logHttpRequest({
47
+ method,
48
+ path,
49
+ statusCode: response.statusCode ?? 200,
50
+ durationMs: Date.now() - startedAt,
51
+ requestId,
52
+ ip,
53
+ });
54
+ });
55
+ }
56
+
57
+ next();
58
+ }
59
+
60
+ private readHeader(headers: HeadersRecord | undefined, name: string): string | undefined {
61
+ if (!headers) {
62
+ return undefined;
63
+ }
64
+
65
+ const value = headers[name.toLowerCase()];
66
+ if (typeof value === 'string' && value.trim().length > 0) {
67
+ return value;
68
+ }
69
+ if (Array.isArray(value) && typeof value[0] === 'string' && value[0].trim().length > 0) {
70
+ return value[0];
71
+ }
72
+ return undefined;
73
+ }
74
+ }
@@ -3,7 +3,7 @@ export * from './logger-config.loader';
3
3
  export * from './logger-config.service';
4
4
  export * from './logger-config.module';
5
5
  export * from './forgeon-logger.service';
6
+ export * from './http-logging.middleware';
6
7
  export * from './http-logging.interceptor';
7
8
  export * from './request-id.middleware';
8
9
  export * from './forgeon-logger.module';
9
-