create-forgeon 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/modules/executor.test.mjs +45 -0
- package/src/modules/rate-limit.mjs +2 -0
- package/src/modules/sync-integrations.mjs +102 -19
- package/templates/base/apps/web/src/App.tsx +4 -1
- package/templates/base/scripts/forgeon-sync-integrations.mjs +30 -5
- package/templates/module-presets/i18n/apps/web/src/App.tsx +2 -1
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -1261,6 +1264,48 @@ describe('addModule', () => {
|
|
|
1261
1264
|
}
|
|
1262
1265
|
});
|
|
1263
1266
|
|
|
1267
|
+
it('scans auth persistence as db-adapter participant while remaining triggerable from db-prisma install order', () => {
|
|
1268
|
+
const targetRoot = mkTmp('forgeon-module-jwt-db-scan-');
|
|
1269
|
+
const projectRoot = path.join(targetRoot, 'demo-jwt-db-scan');
|
|
1270
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
1271
|
+
|
|
1272
|
+
try {
|
|
1273
|
+
scaffoldProject({
|
|
1274
|
+
templateRoot,
|
|
1275
|
+
packageRoot,
|
|
1276
|
+
targetRoot: projectRoot,
|
|
1277
|
+
projectName: 'demo-jwt-db-scan',
|
|
1278
|
+
frontend: 'react',
|
|
1279
|
+
db: 'prisma',
|
|
1280
|
+
dbPrismaEnabled: false,
|
|
1281
|
+
i18nEnabled: false,
|
|
1282
|
+
proxy: 'caddy',
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
addModule({
|
|
1286
|
+
moduleId: 'jwt-auth',
|
|
1287
|
+
targetRoot: projectRoot,
|
|
1288
|
+
packageRoot,
|
|
1289
|
+
});
|
|
1290
|
+
addModule({
|
|
1291
|
+
moduleId: 'db-prisma',
|
|
1292
|
+
targetRoot: projectRoot,
|
|
1293
|
+
packageRoot,
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
const scan = scanIntegrations({
|
|
1297
|
+
targetRoot: projectRoot,
|
|
1298
|
+
relatedModuleId: 'db-prisma',
|
|
1299
|
+
});
|
|
1300
|
+
const persistenceGroup = scan.groups.find((group) => group.id === 'auth-persistence');
|
|
1301
|
+
|
|
1302
|
+
assert.ok(persistenceGroup);
|
|
1303
|
+
assert.deepEqual(persistenceGroup.modules, ['jwt-auth', 'db-adapter']);
|
|
1304
|
+
} finally {
|
|
1305
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1264
1309
|
it('applies logger then jwt-auth on db/i18n-disabled scaffold without breaking health controller syntax', () => {
|
|
1265
1310
|
const targetRoot = mkTmp('forgeon-module-jwt-nodb-noi18n-');
|
|
1266
1311
|
const projectRoot = path.join(targetRoot, 'demo-jwt-nodb-noi18n');
|
|
@@ -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;
|
|
@@ -95,21 +114,18 @@ const INTEGRATION_GROUPS = [
|
|
|
95
114
|
{
|
|
96
115
|
id: 'auth-persistence',
|
|
97
116
|
title: 'Auth Persistence Integration',
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
],
|
|
105
|
-
isAvailable: (detected) => detected.jwtAuth && detected.dbPrisma,
|
|
106
|
-
isPending: (rootDir) => isAuthPersistencePending(rootDir),
|
|
107
|
-
apply: syncJwtDbPrisma,
|
|
117
|
+
participants: ['jwt-auth', 'db-adapter'],
|
|
118
|
+
relatedModules: ['jwt-auth', 'db-prisma'],
|
|
119
|
+
description: (detected) => getAuthPersistenceDescription(detected),
|
|
120
|
+
isAvailable: (detected) => detected.jwtAuth && hasSingleAuthPersistenceStrategy(detected),
|
|
121
|
+
isPending: (rootDir, detected) => isAuthPersistencePendingForDetected(rootDir, detected),
|
|
122
|
+
apply: applyAuthPersistenceSync,
|
|
108
123
|
},
|
|
109
124
|
{
|
|
110
125
|
id: 'auth-rbac-claims',
|
|
111
126
|
title: 'Auth Claims Integration',
|
|
112
|
-
|
|
127
|
+
participants: ['jwt-auth', 'rbac'],
|
|
128
|
+
relatedModules: ['jwt-auth', 'rbac'],
|
|
113
129
|
description: [
|
|
114
130
|
'Extend AuthUser with optional permissions in @forgeon/auth-contracts',
|
|
115
131
|
'Add demo RBAC claims to jwt-auth login and token payloads',
|
|
@@ -139,6 +155,73 @@ function detectModules(rootDir) {
|
|
|
139
155
|
};
|
|
140
156
|
}
|
|
141
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
|
+
|
|
204
|
+
function getGroupParticipants(group) {
|
|
205
|
+
return Array.isArray(group.participants) && group.participants.length > 0
|
|
206
|
+
? group.participants
|
|
207
|
+
: Array.isArray(group.modules)
|
|
208
|
+
? group.modules
|
|
209
|
+
: [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getGroupRelatedModules(group) {
|
|
213
|
+
return Array.isArray(group.relatedModules) && group.relatedModules.length > 0
|
|
214
|
+
? group.relatedModules
|
|
215
|
+
: getGroupParticipants(group);
|
|
216
|
+
}
|
|
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
|
+
|
|
142
225
|
function syncJwtDbPrisma({ rootDir, packageRoot, changedFiles }) {
|
|
143
226
|
const appModulePath = path.join(rootDir, 'apps', 'api', 'src', 'app.module.ts');
|
|
144
227
|
const schemaPath = path.join(rootDir, 'apps', 'api', 'prisma', 'schema.prisma');
|
|
@@ -365,7 +448,7 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
|
|
|
365
448
|
const detected = detectModules(rootDir);
|
|
366
449
|
const summary = [];
|
|
367
450
|
const available = INTEGRATION_GROUPS.filter(
|
|
368
|
-
(group) => group.isAvailable(detected) && group.isPending(rootDir),
|
|
451
|
+
(group) => group.isAvailable(detected) && group.isPending(rootDir, detected),
|
|
369
452
|
);
|
|
370
453
|
const selected = Array.isArray(groupIds)
|
|
371
454
|
? available.filter((group) => groupIds.includes(group.id))
|
|
@@ -375,7 +458,7 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
|
|
|
375
458
|
summary.push({
|
|
376
459
|
id: group.id,
|
|
377
460
|
title: group.title,
|
|
378
|
-
modules: group
|
|
461
|
+
modules: getGroupParticipants(group),
|
|
379
462
|
result: group.apply({ rootDir, packageRoot, changedFiles }),
|
|
380
463
|
});
|
|
381
464
|
}
|
|
@@ -385,8 +468,8 @@ export function syncIntegrations({ targetRoot, packageRoot, groupIds = null }) {
|
|
|
385
468
|
availableGroups: available.map((group) => ({
|
|
386
469
|
id: group.id,
|
|
387
470
|
title: group.title,
|
|
388
|
-
modules: [...group
|
|
389
|
-
description: [...group
|
|
471
|
+
modules: [...getGroupParticipants(group)],
|
|
472
|
+
description: [...getGroupDescription(group, detected)],
|
|
390
473
|
})),
|
|
391
474
|
changedFiles: [...changedFiles].sort().map((filePath) => path.relative(rootDir, filePath)),
|
|
392
475
|
};
|
|
@@ -398,15 +481,15 @@ export function scanIntegrations({ targetRoot, relatedModuleId = null }) {
|
|
|
398
481
|
const available = INTEGRATION_GROUPS.filter(
|
|
399
482
|
(group) =>
|
|
400
483
|
group.isAvailable(detected) &&
|
|
401
|
-
group.isPending(rootDir) &&
|
|
402
|
-
(!relatedModuleId || group.
|
|
484
|
+
group.isPending(rootDir, detected) &&
|
|
485
|
+
(!relatedModuleId || getGroupRelatedModules(group).includes(relatedModuleId)),
|
|
403
486
|
);
|
|
404
487
|
return {
|
|
405
488
|
groups: available.map((group) => ({
|
|
406
489
|
id: group.id,
|
|
407
490
|
title: group.title,
|
|
408
|
-
modules: [...group
|
|
409
|
-
description: [...group
|
|
491
|
+
modules: [...getGroupParticipants(group)],
|
|
492
|
+
description: [...getGroupDescription(group, detected)],
|
|
410
493
|
})),
|
|
411
494
|
};
|
|
412
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,
|
|
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 &&
|
|
315
|
+
if (detected.jwtAuth && authPersistence.kind === 'single') {
|
|
295
316
|
summary.push({
|
|
296
|
-
feature:
|
|
297
|
-
result:
|
|
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
|
|
327
|
+
result: { applied: false, reason },
|
|
303
328
|
});
|
|
304
329
|
}
|
|
305
330
|
|
|
@@ -311,7 +336,7 @@ function run() {
|
|
|
311
336
|
} else {
|
|
312
337
|
summary.push({
|
|
313
338
|
feature: 'jwt-auth + rbac',
|
|
314
|
-
result: { applied: false, reason: 'required
|
|
339
|
+
result: { applied: false, reason: 'required components are not both available' },
|
|
315
340
|
});
|
|
316
341
|
}
|
|
317
342
|
|
|
@@ -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,
|