create-forgeon 0.3.19 → 0.3.21
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/cli/add-help.mjs +3 -2
- package/src/core/docs.test.mjs +1 -0
- package/src/core/scaffold.test.mjs +1 -0
- package/src/modules/accounts.mjs +9 -18
- package/src/modules/dependencies.mjs +153 -4
- package/src/modules/dependencies.test.mjs +58 -0
- package/src/modules/executor.test.mjs +544 -515
- package/src/modules/files-access.mjs +375 -375
- package/src/modules/files-image.mjs +512 -510
- package/src/modules/files-quotas.mjs +365 -365
- package/src/modules/files.mjs +5 -6
- package/src/modules/idempotency.test.mjs +3 -2
- package/src/modules/registry.mjs +20 -0
- package/src/modules/shared/files-runtime-wiring.mjs +13 -10
- package/src/run-add-module.mjs +39 -26
- package/src/run-add-module.test.mjs +228 -152
- package/src/run-scan-integrations.mjs +1 -0
- package/templates/base/package.json +1 -0
- package/templates/module-presets/accounts/packages/accounts-api/package.json +1 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +15 -19
- package/templates/module-presets/accounts/{apps/api/src/accounts/prisma-accounts-persistence.store.ts → packages/accounts-api/src/auth.store.ts} +44 -166
- package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +7 -1
- package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +3 -4
- package/templates/module-presets/accounts/packages/accounts-api/src/users.service.ts +10 -11
- package/templates/module-presets/accounts/packages/accounts-api/src/users.store.ts +113 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/users.types.ts +48 -0
- package/templates/module-presets/files/packages/files/package.json +1 -0
- package/templates/module-presets/files/packages/files/src/files.ports.ts +0 -95
- package/templates/module-presets/files/packages/files/src/files.service.ts +43 -36
- package/templates/module-presets/files/{apps/api/src/files/prisma-files-persistence.store.ts → packages/files/src/files.store.ts} +77 -13
- package/templates/module-presets/files/packages/files/src/forgeon-files.module.ts +7 -116
- package/templates/module-presets/files/packages/files/src/index.ts +1 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/package.json +20 -20
- package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts +118 -118
- package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +18 -18
- package/templates/module-presets/accounts/packages/accounts-api/src/accounts-persistence.port.ts +0 -67
- package/templates/module-presets/files/apps/api/src/files/forgeon-files-db-prisma.module.ts +0 -17
|
@@ -1,377 +1,377 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { copyRecursive, writeJson } from '../utils/fs.mjs';
|
|
4
|
-
import {
|
|
5
|
-
ensureBuildStepBefore,
|
|
6
|
-
ensureBuildSteps,
|
|
7
|
-
ensureClassMember,
|
|
8
|
-
ensureDependency,
|
|
9
|
-
ensureImportLine,
|
|
10
|
-
ensureLineAfter,
|
|
11
|
-
ensureLineBefore,
|
|
12
|
-
ensureNestCommonImport,
|
|
13
|
-
} from './shared/patch-utils.mjs';
|
|
14
|
-
import { ensureWebProbeDefinition, resolveProbeTargets } from './shared/probes.mjs';
|
|
15
|
-
|
|
16
|
-
function copyFromPreset(packageRoot, targetRoot, relativePath) {
|
|
17
|
-
const source = path.join(packageRoot, 'templates', 'module-presets', 'files-access', relativePath);
|
|
18
|
-
if (!fs.existsSync(source)) {
|
|
19
|
-
throw new Error(`Missing files-access preset template: ${source}`);
|
|
20
|
-
}
|
|
21
|
-
const destination = path.join(targetRoot, relativePath);
|
|
22
|
-
copyRecursive(source, destination);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function patchApiPackage(targetRoot) {
|
|
26
|
-
const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
|
|
27
|
-
if (!fs.existsSync(packagePath)) {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
32
|
-
ensureDependency(packageJson, '@forgeon/files-access', 'workspace:*');
|
|
33
|
-
ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/files-access build']);
|
|
34
|
-
ensureBuildStepBefore(
|
|
35
|
-
packageJson,
|
|
36
|
-
'predev',
|
|
37
|
-
'pnpm --filter @forgeon/files-access build',
|
|
38
|
-
'pnpm --filter @forgeon/files build',
|
|
39
|
-
);
|
|
40
|
-
writeJson(packagePath, packageJson);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function patchFilesPackage(targetRoot) {
|
|
44
|
-
const packagePath = path.join(targetRoot, 'packages', 'files', 'package.json');
|
|
45
|
-
if (!fs.existsSync(packagePath)) {
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
50
|
-
ensureDependency(packageJson, '@forgeon/files-access', 'workspace:*');
|
|
51
|
-
writeJson(packagePath, packageJson);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function patchAppModule(targetRoot) {
|
|
55
|
-
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
56
|
-
if (!fs.existsSync(filePath)) {
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
61
|
-
content = ensureImportLine(content, "import { ForgeonFilesAccessModule } from '@forgeon/files-access';");
|
|
62
|
-
|
|
63
|
-
if (!content.includes(' ForgeonFilesAccessModule,')) {
|
|
64
|
-
if (content.includes(' ForgeonFilesModule,')) {
|
|
65
|
-
content = ensureLineAfter(content, ' ForgeonFilesModule,', ' ForgeonFilesAccessModule,');
|
|
66
|
-
} else if (content.includes(' ForgeonI18nModule.register({')) {
|
|
67
|
-
content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonFilesAccessModule,');
|
|
68
|
-
} else if (content.includes(' ForgeonAccountsModule.register({')) {
|
|
69
|
-
content = ensureLineBefore(content, ' ForgeonAccountsModule.register({', ' ForgeonFilesAccessModule,');
|
|
70
|
-
} else if (content.includes(' ForgeonAccountsModule.register(),')) {
|
|
71
|
-
content = ensureLineBefore(content, ' ForgeonAccountsModule.register(),', ' ForgeonFilesAccessModule,');
|
|
72
|
-
} else if (content.includes(' DbPrismaModule,')) {
|
|
73
|
-
content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonFilesAccessModule,');
|
|
74
|
-
} else if (content.includes(' ForgeonLoggerModule,')) {
|
|
75
|
-
content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonFilesAccessModule,');
|
|
76
|
-
} else if (content.includes(' ForgeonSwaggerModule,')) {
|
|
77
|
-
content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonFilesAccessModule,');
|
|
78
|
-
} else {
|
|
79
|
-
content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonFilesAccessModule,');
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function patchFilesController(targetRoot) {
|
|
87
|
-
const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'files.controller.ts');
|
|
88
|
-
if (!fs.existsSync(filePath)) {
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
93
|
-
content = ensureNestCommonImport(content, 'Req');
|
|
94
|
-
content = ensureImportLine(
|
|
95
|
-
content,
|
|
96
|
-
"import { extractFilesAccessSubject, FilesAccessService } from '@forgeon/files-access';",
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
if (!content.includes('private readonly filesAccessService: FilesAccessService')) {
|
|
100
|
-
const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
|
|
101
|
-
if (constructorMatch) {
|
|
102
|
-
const original = constructorMatch[0];
|
|
103
|
-
const inner = constructorMatch[1].trimEnd();
|
|
104
|
-
const normalizedInner = inner.replace(/,\s*$/, '');
|
|
105
|
-
const separator = normalizedInner.length > 0 ? ',' : '';
|
|
106
|
-
const next = `constructor(${normalizedInner}${separator}
|
|
107
|
-
private readonly filesAccessService: FilesAccessService,
|
|
108
|
-
) {`;
|
|
109
|
-
content = content.replace(original, next);
|
|
110
|
-
} else {
|
|
111
|
-
const classAnchor = 'export class FilesController {';
|
|
112
|
-
if (content.includes(classAnchor)) {
|
|
113
|
-
content = content.replace(
|
|
114
|
-
classAnchor,
|
|
115
|
-
`${classAnchor}
|
|
116
|
-
constructor(
|
|
117
|
-
private readonly filesService: FilesService,
|
|
118
|
-
private readonly filesAccessService: FilesAccessService,
|
|
119
|
-
) {}
|
|
120
|
-
`,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!content.includes('filesAccessService.assertCanRead')) {
|
|
127
|
-
content = content.replace(
|
|
128
|
-
` async getMetadata(@Param('publicId') publicId: string) {
|
|
129
|
-
return this.filesService.getByPublicId(publicId);
|
|
130
|
-
}`,
|
|
131
|
-
` async getMetadata(@Param('publicId') publicId: string, @Req() req: any) {
|
|
132
|
-
const file = await this.filesService.getByPublicId(publicId);
|
|
133
|
-
this.filesAccessService.assertCanRead(file, extractFilesAccessSubject(req));
|
|
134
|
-
return file;
|
|
135
|
-
}`,
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
content = content.replace(
|
|
139
|
-
` async download(@Param('publicId') publicId: string, @Query('variant') variantQuery?: string) {
|
|
140
|
-
const variant = this.parseVariant(variantQuery);
|
|
141
|
-
const payload = await this.filesService.openDownload(publicId, variant);
|
|
142
|
-
return new StreamableFile(payload.stream, {
|
|
143
|
-
disposition: \`inline; filename="\${payload.fileName}"\`,
|
|
144
|
-
type: payload.mimeType,
|
|
145
|
-
});
|
|
146
|
-
}`,
|
|
147
|
-
` async download(
|
|
148
|
-
@Param('publicId') publicId: string,
|
|
149
|
-
@Query('variant') variantQuery?: string,
|
|
150
|
-
@Req() req: any,
|
|
151
|
-
) {
|
|
152
|
-
const variant = this.parseVariant(variantQuery);
|
|
153
|
-
const file = await this.filesService.getByPublicId(publicId);
|
|
154
|
-
this.filesAccessService.assertCanRead(file, extractFilesAccessSubject(req));
|
|
155
|
-
const payload = await this.filesService.openDownload(publicId, variant);
|
|
156
|
-
return new StreamableFile(payload.stream, {
|
|
157
|
-
disposition: \`inline; filename="\${payload.fileName}"\`,
|
|
158
|
-
type: payload.mimeType,
|
|
159
|
-
});
|
|
160
|
-
}`,
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
content = content.replace(
|
|
165
|
-
` async remove(@Param('publicId') publicId: string) {
|
|
166
|
-
return this.filesService.deleteByPublicId(publicId);
|
|
167
|
-
}`,
|
|
168
|
-
` async remove(@Param('publicId') publicId: string, @Req() req: any) {
|
|
169
|
-
const file = await this.filesService.getByPublicId(publicId);
|
|
170
|
-
this.filesAccessService.assertCanDelete(file, extractFilesAccessSubject(req));
|
|
171
|
-
return this.filesService.deleteByPublicId(publicId);
|
|
172
|
-
}`,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function patchHealthController(targetRoot, probeTargets) {
|
|
180
|
-
if (!probeTargets.allowApi) {
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
|
|
185
|
-
if (!fs.existsSync(filePath)) {
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
190
|
-
content = ensureNestCommonImport(content, 'Req');
|
|
191
|
-
content = ensureImportLine(
|
|
192
|
-
content,
|
|
193
|
-
"import { extractFilesAccessSubject, FilesAccessService } from '@forgeon/files-access';",
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
if (!content.includes('private readonly filesAccessService: FilesAccessService')) {
|
|
197
|
-
const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
|
|
198
|
-
if (constructorMatch) {
|
|
199
|
-
const original = constructorMatch[0];
|
|
200
|
-
const inner = constructorMatch[1].trimEnd();
|
|
201
|
-
const normalizedInner = inner.replace(/,\s*$/, '');
|
|
202
|
-
const separator = normalizedInner.length > 0 ? ',' : '';
|
|
203
|
-
const next = `constructor(${normalizedInner}${separator}
|
|
204
|
-
private readonly filesAccessService: FilesAccessService,
|
|
205
|
-
) {`;
|
|
206
|
-
content = content.replace(original, next);
|
|
207
|
-
} else {
|
|
208
|
-
const classAnchor = 'export class HealthController {';
|
|
209
|
-
if (content.includes(classAnchor)) {
|
|
210
|
-
content = content.replace(
|
|
211
|
-
classAnchor,
|
|
212
|
-
`${classAnchor}
|
|
213
|
-
constructor(private readonly filesAccessService: FilesAccessService) {}
|
|
214
|
-
`,
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (!content.includes("@Get('files-access')")) {
|
|
221
|
-
const method = `
|
|
222
|
-
@Get('files-access')
|
|
223
|
-
getFilesAccessProbe(@Req() req: any) {
|
|
224
|
-
const subject = extractFilesAccessSubject(req);
|
|
225
|
-
const sample = {
|
|
226
|
-
ownerType: 'user',
|
|
227
|
-
ownerId: 'probe-owner',
|
|
228
|
-
visibility: 'private',
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
status: 'ok',
|
|
233
|
-
feature: 'files-access',
|
|
234
|
-
canRead: this.filesAccessService.canRead(sample, subject),
|
|
235
|
-
canDelete: this.filesAccessService.canDelete(sample, subject),
|
|
236
|
-
hint: 'Send x-forgeon-user-id: probe-owner or x-forgeon-permissions: files.manage',
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
`;
|
|
240
|
-
content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function registerWebProbe(targetRoot, probeTargets) {
|
|
247
|
-
ensureWebProbeDefinition({
|
|
248
|
-
targetRoot,
|
|
249
|
-
probeTargets,
|
|
250
|
-
definition: {
|
|
251
|
-
id: 'files-access',
|
|
252
|
-
title: 'Files Access',
|
|
253
|
-
buttonLabel: 'Check files access',
|
|
254
|
-
resultTitle: 'Files access probe response',
|
|
255
|
-
path: '/health/files-access',
|
|
256
|
-
request: {
|
|
257
|
-
headers: {
|
|
258
|
-
'x-forgeon-user-id': 'probe-owner',
|
|
259
|
-
},
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function patchApiDockerfile(targetRoot) {
|
|
266
|
-
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
267
|
-
if (!fs.existsSync(dockerfilePath)) {
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
272
|
-
|
|
273
|
-
const packageAnchors = [
|
|
274
|
-
'COPY packages/files/package.json packages/files/package.json',
|
|
275
|
-
'COPY packages/files-local/package.json packages/files-local/package.json',
|
|
276
|
-
'COPY packages/files-s3/package.json packages/files-s3/package.json',
|
|
277
|
-
'COPY packages/accounts-api/package.json packages/accounts-api/package.json',
|
|
278
|
-
'COPY packages/rbac/package.json packages/rbac/package.json',
|
|
279
|
-
'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
|
|
280
|
-
'COPY packages/logger/package.json packages/logger/package.json',
|
|
281
|
-
'COPY packages/swagger/package.json packages/swagger/package.json',
|
|
282
|
-
'COPY packages/i18n/package.json packages/i18n/package.json',
|
|
283
|
-
'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
|
|
284
|
-
'COPY packages/core/package.json packages/core/package.json',
|
|
285
|
-
];
|
|
286
|
-
const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
|
|
287
|
-
content = ensureLineAfter(
|
|
288
|
-
content,
|
|
289
|
-
packageAnchor,
|
|
290
|
-
'COPY packages/files-access/package.json packages/files-access/package.json',
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
const sourceAnchors = [
|
|
294
|
-
'COPY packages/files packages/files',
|
|
295
|
-
'COPY packages/files-local packages/files-local',
|
|
296
|
-
'COPY packages/files-s3 packages/files-s3',
|
|
297
|
-
'COPY packages/accounts-api packages/accounts-api',
|
|
298
|
-
'COPY packages/rbac packages/rbac',
|
|
299
|
-
'COPY packages/rate-limit packages/rate-limit',
|
|
300
|
-
'COPY packages/logger packages/logger',
|
|
301
|
-
'COPY packages/swagger packages/swagger',
|
|
302
|
-
'COPY packages/i18n packages/i18n',
|
|
303
|
-
'COPY packages/db-prisma packages/db-prisma',
|
|
304
|
-
'COPY packages/core packages/core',
|
|
305
|
-
];
|
|
306
|
-
const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
|
|
307
|
-
content = ensureLineAfter(content, sourceAnchor, 'COPY packages/files-access packages/files-access');
|
|
308
|
-
|
|
309
|
-
content = content.replace(/^RUN pnpm --filter @forgeon\/files-access build\r?\n?/gm, '');
|
|
310
|
-
const buildAnchor = content.includes('RUN pnpm --filter @forgeon/files build')
|
|
311
|
-
? 'RUN pnpm --filter @forgeon/files build'
|
|
312
|
-
: content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
|
|
313
|
-
? 'RUN pnpm --filter @forgeon/api prisma:generate'
|
|
314
|
-
: 'RUN pnpm --filter @forgeon/api build';
|
|
315
|
-
content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/files-access build');
|
|
316
|
-
|
|
317
|
-
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function patchReadme(targetRoot) {
|
|
321
|
-
const readmePath = path.join(targetRoot, 'README.md');
|
|
322
|
-
if (!fs.existsSync(readmePath)) {
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const marker = '## Files Access Module';
|
|
327
|
-
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
328
|
-
if (content.includes(marker)) {
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const section = `## Files Access Module
|
|
333
|
-
|
|
334
|
-
The files-access module adds resource-level authorization checks for file metadata, download, and delete operations.
|
|
335
|
-
|
|
336
|
-
What it adds:
|
|
337
|
-
- \`@forgeon/files-access\` package
|
|
338
|
-
- policy service with owner/public/permission checks
|
|
339
|
-
- files controller enforcement for:
|
|
340
|
-
- \`GET /api/files/:publicId\`
|
|
341
|
-
- \`GET /api/files/:publicId/download\`
|
|
342
|
-
- \`DELETE /api/files/:publicId\`
|
|
343
|
-
- probe endpoint: \`GET /api/health/files-access\`
|
|
344
|
-
|
|
345
|
-
Current policy rules:
|
|
346
|
-
- allow if permission \`files.manage\` is present
|
|
347
|
-
- allow owner (when \`ownerType=user\` and \`ownerId\` matches actor)
|
|
348
|
-
- allow read for \`visibility=public\`
|
|
349
|
-
|
|
350
|
-
Actor context for probe/testing:
|
|
351
|
-
- \`x-forgeon-user-id\`
|
|
352
|
-
- \`x-forgeon-permissions\` (comma-separated)`;
|
|
353
|
-
|
|
354
|
-
if (content.includes('## Prisma In Docker Start')) {
|
|
355
|
-
content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
|
|
356
|
-
} else {
|
|
357
|
-
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export function applyFilesAccessModule({ packageRoot, targetRoot }) {
|
|
364
|
-
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files-access'));
|
|
365
|
-
const probeTargets = resolveProbeTargets({ targetRoot, moduleId: 'files-access' });
|
|
366
|
-
|
|
367
|
-
patchApiPackage(targetRoot);
|
|
368
|
-
patchFilesPackage(targetRoot);
|
|
369
|
-
patchAppModule(targetRoot);
|
|
370
|
-
patchFilesController(targetRoot);
|
|
371
|
-
patchHealthController(targetRoot, probeTargets);
|
|
372
|
-
registerWebProbe(targetRoot, probeTargets);
|
|
373
|
-
patchApiDockerfile(targetRoot);
|
|
374
|
-
patchReadme(targetRoot);
|
|
375
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { copyRecursive, writeJson } from '../utils/fs.mjs';
|
|
4
|
+
import {
|
|
5
|
+
ensureBuildStepBefore,
|
|
6
|
+
ensureBuildSteps,
|
|
7
|
+
ensureClassMember,
|
|
8
|
+
ensureDependency,
|
|
9
|
+
ensureImportLine,
|
|
10
|
+
ensureLineAfter,
|
|
11
|
+
ensureLineBefore,
|
|
12
|
+
ensureNestCommonImport,
|
|
13
|
+
} from './shared/patch-utils.mjs';
|
|
14
|
+
import { ensureWebProbeDefinition, resolveProbeTargets } from './shared/probes.mjs';
|
|
15
|
+
|
|
16
|
+
function copyFromPreset(packageRoot, targetRoot, relativePath) {
|
|
17
|
+
const source = path.join(packageRoot, 'templates', 'module-presets', 'files-access', relativePath);
|
|
18
|
+
if (!fs.existsSync(source)) {
|
|
19
|
+
throw new Error(`Missing files-access preset template: ${source}`);
|
|
20
|
+
}
|
|
21
|
+
const destination = path.join(targetRoot, relativePath);
|
|
22
|
+
copyRecursive(source, destination);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function patchApiPackage(targetRoot) {
|
|
26
|
+
const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
|
|
27
|
+
if (!fs.existsSync(packagePath)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
32
|
+
ensureDependency(packageJson, '@forgeon/files-access', 'workspace:*');
|
|
33
|
+
ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/files-access build']);
|
|
34
|
+
ensureBuildStepBefore(
|
|
35
|
+
packageJson,
|
|
36
|
+
'predev',
|
|
37
|
+
'pnpm --filter @forgeon/files-access build',
|
|
38
|
+
'pnpm --filter @forgeon/files build',
|
|
39
|
+
);
|
|
40
|
+
writeJson(packagePath, packageJson);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function patchFilesPackage(targetRoot) {
|
|
44
|
+
const packagePath = path.join(targetRoot, 'packages', 'files', 'package.json');
|
|
45
|
+
if (!fs.existsSync(packagePath)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
50
|
+
ensureDependency(packageJson, '@forgeon/files-access', 'workspace:*');
|
|
51
|
+
writeJson(packagePath, packageJson);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function patchAppModule(targetRoot) {
|
|
55
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
56
|
+
if (!fs.existsSync(filePath)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
61
|
+
content = ensureImportLine(content, "import { ForgeonFilesAccessModule } from '@forgeon/files-access';");
|
|
62
|
+
|
|
63
|
+
if (!content.includes(' ForgeonFilesAccessModule,')) {
|
|
64
|
+
if (content.includes(' ForgeonFilesModule,')) {
|
|
65
|
+
content = ensureLineAfter(content, ' ForgeonFilesModule,', ' ForgeonFilesAccessModule,');
|
|
66
|
+
} else if (content.includes(' ForgeonI18nModule.register({')) {
|
|
67
|
+
content = ensureLineBefore(content, ' ForgeonI18nModule.register({', ' ForgeonFilesAccessModule,');
|
|
68
|
+
} else if (content.includes(' ForgeonAccountsModule.register({')) {
|
|
69
|
+
content = ensureLineBefore(content, ' ForgeonAccountsModule.register({', ' ForgeonFilesAccessModule,');
|
|
70
|
+
} else if (content.includes(' ForgeonAccountsModule.register(),')) {
|
|
71
|
+
content = ensureLineBefore(content, ' ForgeonAccountsModule.register(),', ' ForgeonFilesAccessModule,');
|
|
72
|
+
} else if (content.includes(' DbPrismaModule,')) {
|
|
73
|
+
content = ensureLineAfter(content, ' DbPrismaModule,', ' ForgeonFilesAccessModule,');
|
|
74
|
+
} else if (content.includes(' ForgeonLoggerModule,')) {
|
|
75
|
+
content = ensureLineAfter(content, ' ForgeonLoggerModule,', ' ForgeonFilesAccessModule,');
|
|
76
|
+
} else if (content.includes(' ForgeonSwaggerModule,')) {
|
|
77
|
+
content = ensureLineAfter(content, ' ForgeonSwaggerModule,', ' ForgeonFilesAccessModule,');
|
|
78
|
+
} else {
|
|
79
|
+
content = ensureLineAfter(content, ' CoreErrorsModule,', ' ForgeonFilesAccessModule,');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function patchFilesController(targetRoot) {
|
|
87
|
+
const filePath = path.join(targetRoot, 'packages', 'files', 'src', 'files.controller.ts');
|
|
88
|
+
if (!fs.existsSync(filePath)) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
93
|
+
content = ensureNestCommonImport(content, 'Req');
|
|
94
|
+
content = ensureImportLine(
|
|
95
|
+
content,
|
|
96
|
+
"import { extractFilesAccessSubject, FilesAccessService } from '@forgeon/files-access';",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (!content.includes('private readonly filesAccessService: FilesAccessService')) {
|
|
100
|
+
const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
|
|
101
|
+
if (constructorMatch) {
|
|
102
|
+
const original = constructorMatch[0];
|
|
103
|
+
const inner = constructorMatch[1].trimEnd();
|
|
104
|
+
const normalizedInner = inner.replace(/,\s*$/, '');
|
|
105
|
+
const separator = normalizedInner.length > 0 ? ',' : '';
|
|
106
|
+
const next = `constructor(${normalizedInner}${separator}
|
|
107
|
+
private readonly filesAccessService: FilesAccessService,
|
|
108
|
+
) {`;
|
|
109
|
+
content = content.replace(original, next);
|
|
110
|
+
} else {
|
|
111
|
+
const classAnchor = 'export class FilesController {';
|
|
112
|
+
if (content.includes(classAnchor)) {
|
|
113
|
+
content = content.replace(
|
|
114
|
+
classAnchor,
|
|
115
|
+
`${classAnchor}
|
|
116
|
+
constructor(
|
|
117
|
+
private readonly filesService: FilesService,
|
|
118
|
+
private readonly filesAccessService: FilesAccessService,
|
|
119
|
+
) {}
|
|
120
|
+
`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!content.includes('filesAccessService.assertCanRead')) {
|
|
127
|
+
content = content.replace(
|
|
128
|
+
` async getMetadata(@Param('publicId') publicId: string) {
|
|
129
|
+
return this.filesService.getByPublicId(publicId);
|
|
130
|
+
}`,
|
|
131
|
+
` async getMetadata(@Param('publicId') publicId: string, @Req() req: any) {
|
|
132
|
+
const file = await this.filesService.getByPublicId(publicId);
|
|
133
|
+
this.filesAccessService.assertCanRead(file, extractFilesAccessSubject(req));
|
|
134
|
+
return file;
|
|
135
|
+
}`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
content = content.replace(
|
|
139
|
+
` async download(@Param('publicId') publicId: string, @Query('variant') variantQuery?: string) {
|
|
140
|
+
const variant = this.parseVariant(variantQuery);
|
|
141
|
+
const payload = await this.filesService.openDownload(publicId, variant);
|
|
142
|
+
return new StreamableFile(payload.stream, {
|
|
143
|
+
disposition: \`inline; filename="\${payload.fileName}"\`,
|
|
144
|
+
type: payload.mimeType,
|
|
145
|
+
});
|
|
146
|
+
}`,
|
|
147
|
+
` async download(
|
|
148
|
+
@Param('publicId') publicId: string,
|
|
149
|
+
@Query('variant') variantQuery?: string,
|
|
150
|
+
@Req() req: any,
|
|
151
|
+
) {
|
|
152
|
+
const variant = this.parseVariant(variantQuery);
|
|
153
|
+
const file = await this.filesService.getByPublicId(publicId);
|
|
154
|
+
this.filesAccessService.assertCanRead(file, extractFilesAccessSubject(req));
|
|
155
|
+
const payload = await this.filesService.openDownload(publicId, variant);
|
|
156
|
+
return new StreamableFile(payload.stream, {
|
|
157
|
+
disposition: \`inline; filename="\${payload.fileName}"\`,
|
|
158
|
+
type: payload.mimeType,
|
|
159
|
+
});
|
|
160
|
+
}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
content = content.replace(
|
|
165
|
+
` async remove(@Param('publicId') publicId: string) {
|
|
166
|
+
return this.filesService.deleteByPublicId(publicId);
|
|
167
|
+
}`,
|
|
168
|
+
` async remove(@Param('publicId') publicId: string, @Req() req: any) {
|
|
169
|
+
const file = await this.filesService.getByPublicId(publicId);
|
|
170
|
+
this.filesAccessService.assertCanDelete(file, extractFilesAccessSubject(req));
|
|
171
|
+
return this.filesService.deleteByPublicId(publicId);
|
|
172
|
+
}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function patchHealthController(targetRoot, probeTargets) {
|
|
180
|
+
if (!probeTargets.allowApi) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts');
|
|
185
|
+
if (!fs.existsSync(filePath)) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
190
|
+
content = ensureNestCommonImport(content, 'Req');
|
|
191
|
+
content = ensureImportLine(
|
|
192
|
+
content,
|
|
193
|
+
"import { extractFilesAccessSubject, FilesAccessService } from '@forgeon/files-access';",
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!content.includes('private readonly filesAccessService: FilesAccessService')) {
|
|
197
|
+
const constructorMatch = content.match(/constructor\(([\s\S]*?)\)\s*\{/m);
|
|
198
|
+
if (constructorMatch) {
|
|
199
|
+
const original = constructorMatch[0];
|
|
200
|
+
const inner = constructorMatch[1].trimEnd();
|
|
201
|
+
const normalizedInner = inner.replace(/,\s*$/, '');
|
|
202
|
+
const separator = normalizedInner.length > 0 ? ',' : '';
|
|
203
|
+
const next = `constructor(${normalizedInner}${separator}
|
|
204
|
+
private readonly filesAccessService: FilesAccessService,
|
|
205
|
+
) {`;
|
|
206
|
+
content = content.replace(original, next);
|
|
207
|
+
} else {
|
|
208
|
+
const classAnchor = 'export class HealthController {';
|
|
209
|
+
if (content.includes(classAnchor)) {
|
|
210
|
+
content = content.replace(
|
|
211
|
+
classAnchor,
|
|
212
|
+
`${classAnchor}
|
|
213
|
+
constructor(private readonly filesAccessService: FilesAccessService) {}
|
|
214
|
+
`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!content.includes("@Get('files-access')")) {
|
|
221
|
+
const method = `
|
|
222
|
+
@Get('files-access')
|
|
223
|
+
getFilesAccessProbe(@Req() req: any) {
|
|
224
|
+
const subject = extractFilesAccessSubject(req);
|
|
225
|
+
const sample = {
|
|
226
|
+
ownerType: 'user',
|
|
227
|
+
ownerId: 'probe-owner',
|
|
228
|
+
visibility: 'private',
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
status: 'ok',
|
|
233
|
+
feature: 'files-access',
|
|
234
|
+
canRead: this.filesAccessService.canRead(sample, subject),
|
|
235
|
+
canDelete: this.filesAccessService.canDelete(sample, subject),
|
|
236
|
+
hint: 'Send x-forgeon-user-id: probe-owner or x-forgeon-permissions: files.manage',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
`;
|
|
240
|
+
content = ensureClassMember(content, 'HealthController', method, { beforeNeedle: 'private translate(' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function registerWebProbe(targetRoot, probeTargets) {
|
|
247
|
+
ensureWebProbeDefinition({
|
|
248
|
+
targetRoot,
|
|
249
|
+
probeTargets,
|
|
250
|
+
definition: {
|
|
251
|
+
id: 'files-access',
|
|
252
|
+
title: 'Files Access',
|
|
253
|
+
buttonLabel: 'Check files access',
|
|
254
|
+
resultTitle: 'Files access probe response',
|
|
255
|
+
path: '/health/files-access',
|
|
256
|
+
request: {
|
|
257
|
+
headers: {
|
|
258
|
+
'x-forgeon-user-id': 'probe-owner',
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function patchApiDockerfile(targetRoot) {
|
|
266
|
+
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
267
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
272
|
+
|
|
273
|
+
const packageAnchors = [
|
|
274
|
+
'COPY packages/files/package.json packages/files/package.json',
|
|
275
|
+
'COPY packages/files-local/package.json packages/files-local/package.json',
|
|
276
|
+
'COPY packages/files-s3/package.json packages/files-s3/package.json',
|
|
277
|
+
'COPY packages/accounts-api/package.json packages/accounts-api/package.json',
|
|
278
|
+
'COPY packages/rbac/package.json packages/rbac/package.json',
|
|
279
|
+
'COPY packages/rate-limit/package.json packages/rate-limit/package.json',
|
|
280
|
+
'COPY packages/logger/package.json packages/logger/package.json',
|
|
281
|
+
'COPY packages/swagger/package.json packages/swagger/package.json',
|
|
282
|
+
'COPY packages/i18n/package.json packages/i18n/package.json',
|
|
283
|
+
'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
|
|
284
|
+
'COPY packages/core/package.json packages/core/package.json',
|
|
285
|
+
];
|
|
286
|
+
const packageAnchor = packageAnchors.find((line) => content.includes(line)) ?? packageAnchors.at(-1);
|
|
287
|
+
content = ensureLineAfter(
|
|
288
|
+
content,
|
|
289
|
+
packageAnchor,
|
|
290
|
+
'COPY packages/files-access/package.json packages/files-access/package.json',
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const sourceAnchors = [
|
|
294
|
+
'COPY packages/files packages/files',
|
|
295
|
+
'COPY packages/files-local packages/files-local',
|
|
296
|
+
'COPY packages/files-s3 packages/files-s3',
|
|
297
|
+
'COPY packages/accounts-api packages/accounts-api',
|
|
298
|
+
'COPY packages/rbac packages/rbac',
|
|
299
|
+
'COPY packages/rate-limit packages/rate-limit',
|
|
300
|
+
'COPY packages/logger packages/logger',
|
|
301
|
+
'COPY packages/swagger packages/swagger',
|
|
302
|
+
'COPY packages/i18n packages/i18n',
|
|
303
|
+
'COPY packages/db-prisma packages/db-prisma',
|
|
304
|
+
'COPY packages/core packages/core',
|
|
305
|
+
];
|
|
306
|
+
const sourceAnchor = sourceAnchors.find((line) => content.includes(line)) ?? sourceAnchors.at(-1);
|
|
307
|
+
content = ensureLineAfter(content, sourceAnchor, 'COPY packages/files-access packages/files-access');
|
|
308
|
+
|
|
309
|
+
content = content.replace(/^RUN pnpm --filter @forgeon\/files-access build\r?\n?/gm, '');
|
|
310
|
+
const buildAnchor = content.includes('RUN pnpm --filter @forgeon/files build')
|
|
311
|
+
? 'RUN pnpm --filter @forgeon/files build'
|
|
312
|
+
: content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
|
|
313
|
+
? 'RUN pnpm --filter @forgeon/api prisma:generate'
|
|
314
|
+
: 'RUN pnpm --filter @forgeon/api build';
|
|
315
|
+
content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/files-access build');
|
|
316
|
+
|
|
317
|
+
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function patchReadme(targetRoot) {
|
|
321
|
+
const readmePath = path.join(targetRoot, 'README.md');
|
|
322
|
+
if (!fs.existsSync(readmePath)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const marker = '## Files Access Module';
|
|
327
|
+
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
328
|
+
if (content.includes(marker)) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const section = `## Files Access Module
|
|
333
|
+
|
|
334
|
+
The files-access module adds resource-level authorization checks for file metadata, download, and delete operations.
|
|
335
|
+
|
|
336
|
+
What it adds:
|
|
337
|
+
- \`@forgeon/files-access\` package
|
|
338
|
+
- policy service with owner/public/permission checks
|
|
339
|
+
- files controller enforcement for:
|
|
340
|
+
- \`GET /api/files/:publicId\`
|
|
341
|
+
- \`GET /api/files/:publicId/download\`
|
|
342
|
+
- \`DELETE /api/files/:publicId\`
|
|
343
|
+
- probe endpoint: \`GET /api/health/files-access\`
|
|
344
|
+
|
|
345
|
+
Current policy rules:
|
|
346
|
+
- allow if permission \`files.manage\` is present
|
|
347
|
+
- allow owner (when \`ownerType=user\` and \`ownerId\` matches actor)
|
|
348
|
+
- allow read for \`visibility=public\`
|
|
349
|
+
|
|
350
|
+
Actor context for probe/testing:
|
|
351
|
+
- \`x-forgeon-user-id\`
|
|
352
|
+
- \`x-forgeon-permissions\` (comma-separated)`;
|
|
353
|
+
|
|
354
|
+
if (content.includes('## Prisma In Docker Start')) {
|
|
355
|
+
content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
|
|
356
|
+
} else {
|
|
357
|
+
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function applyFilesAccessModule({ packageRoot, targetRoot }) {
|
|
364
|
+
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'files-access'));
|
|
365
|
+
const probeTargets = resolveProbeTargets({ targetRoot, moduleId: 'files-access' });
|
|
366
|
+
|
|
367
|
+
patchApiPackage(targetRoot);
|
|
368
|
+
patchFilesPackage(targetRoot);
|
|
369
|
+
patchAppModule(targetRoot);
|
|
370
|
+
patchFilesController(targetRoot);
|
|
371
|
+
patchHealthController(targetRoot, probeTargets);
|
|
372
|
+
registerWebProbe(targetRoot, probeTargets);
|
|
373
|
+
patchApiDockerfile(targetRoot);
|
|
374
|
+
patchReadme(targetRoot);
|
|
375
|
+
}
|
|
376
376
|
|
|
377
377
|
|