create-forgeon 0.3.24 → 0.3.26
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/accounts-communications.mjs +146 -0
- package/src/modules/accounts.mjs +17 -5
- package/src/modules/communications.mjs +4 -3
- package/src/modules/dependencies.test.mjs +37 -14
- package/src/modules/executor.mjs +2 -0
- package/src/modules/executor.test.mjs +105 -16
- package/src/modules/registry.mjs +24 -8
- package/templates/module-fragments/accounts/20_scope.md +4 -5
- package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
- package/templates/module-fragments/accounts-communications/00_title.md +1 -0
- package/templates/module-fragments/accounts-communications/10_overview.md +3 -0
- package/templates/module-fragments/accounts-communications/20_scope.md +24 -0
- package/templates/module-fragments/accounts-communications/90_status_implemented.md +3 -0
- package/templates/module-fragments/communications/20_scope.md +5 -2
- package/templates/module-presets/accounts/apps/api/prisma/migrations/0002_accounts_core/migration.sql +22 -1
- package/templates/module-presets/accounts/packages/accounts-api/package.json +0 -1
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +122 -117
- package/templates/module-presets/accounts/packages/accounts-api/src/auth-pending-operations.ts +9 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.controller.ts +2 -21
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.handlers.ts +45 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.service.ts +19 -18
- package/templates/module-presets/accounts/packages/accounts-api/src/auth.store.ts +87 -0
- package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +29 -5
- package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +2 -0
- package/templates/module-presets/accounts/packages/accounts-contracts/src/index.ts +37 -1
- package/templates/module-presets/accounts-communications/packages/accounts-communications/package.json +22 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.controller.ts +69 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.service.ts +221 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/confirmed-change-password.handler.ts +16 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-email.dto.ts +8 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-password.dto.ts +8 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-password-reset.dto.ts +12 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/index.ts +6 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-change-email.dto.ts +7 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-password-reset.dto.ts +7 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/verify-email.dto.ts +8 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/index.ts +5 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/src/pending-verification-register.handler.ts +13 -0
- package/templates/module-presets/accounts-communications/packages/accounts-communications/tsconfig.json +9 -0
- package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +2 -1
- package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +21 -5
- package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +52 -12
- package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +2 -1
package/package.json
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
ensureDependency,
|
|
7
|
+
ensureImportLine,
|
|
8
|
+
ensureLineAfter,
|
|
9
|
+
ensureLineBefore,
|
|
10
|
+
} from './shared/patch-utils.mjs';
|
|
11
|
+
|
|
12
|
+
function copyFromPreset(packageRoot, targetRoot, relativePath) {
|
|
13
|
+
const source = path.join(packageRoot, 'templates', 'module-presets', 'accounts-communications', relativePath);
|
|
14
|
+
if (!fs.existsSync(source)) {
|
|
15
|
+
throw new Error(`Missing accounts-communications preset template: ${source}`);
|
|
16
|
+
}
|
|
17
|
+
const destination = path.join(targetRoot, relativePath);
|
|
18
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
19
|
+
copyRecursive(source, destination);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function patchApiPackage(targetRoot) {
|
|
23
|
+
const packagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
|
|
24
|
+
if (!fs.existsSync(packagePath)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
29
|
+
ensureDependency(packageJson, '@forgeon/accounts-communications', 'workspace:*');
|
|
30
|
+
ensureBuildSteps(packageJson, 'predev', ['pnpm --filter @forgeon/accounts-communications build']);
|
|
31
|
+
writeJson(packagePath, packageJson);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function patchAppModule(targetRoot) {
|
|
35
|
+
const filePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
36
|
+
if (!fs.existsSync(filePath)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
|
41
|
+
content = ensureImportLine(
|
|
42
|
+
content,
|
|
43
|
+
"import { AuthCommunicationsController, AuthCommunicationsService, ConfirmedChangePasswordHandler, PendingVerificationRegisterHandler } from '@forgeon/accounts-communications';",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const accountsModuleLine = ` ForgeonAccountsModule.register({
|
|
47
|
+
users: UsersModule.register({}),
|
|
48
|
+
controllers: [AuthCommunicationsController],
|
|
49
|
+
providers: [
|
|
50
|
+
AuthCommunicationsService,
|
|
51
|
+
PendingVerificationRegisterHandler,
|
|
52
|
+
ConfirmedChangePasswordHandler,
|
|
53
|
+
],
|
|
54
|
+
handlers: {
|
|
55
|
+
register: PendingVerificationRegisterHandler,
|
|
56
|
+
changePassword: ConfirmedChangePasswordHandler,
|
|
57
|
+
},
|
|
58
|
+
}),`;
|
|
59
|
+
|
|
60
|
+
if (content.includes(' ForgeonAccountsModule.register({')) {
|
|
61
|
+
content = content.replace(
|
|
62
|
+
/ {4}ForgeonAccountsModule\.register\([\s\S]*? {4}\}\),/m,
|
|
63
|
+
accountsModuleLine,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function patchApiDockerfile(targetRoot) {
|
|
71
|
+
const dockerfilePath = path.join(targetRoot, 'apps', 'api', 'Dockerfile');
|
|
72
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let content = fs.readFileSync(dockerfilePath, 'utf8').replace(/\r\n/g, '\n');
|
|
77
|
+
content = ensureLineAfter(
|
|
78
|
+
content,
|
|
79
|
+
'COPY packages/accounts-api/package.json packages/accounts-api/package.json',
|
|
80
|
+
'COPY packages/accounts-communications/package.json packages/accounts-communications/package.json',
|
|
81
|
+
);
|
|
82
|
+
content = ensureLineAfter(
|
|
83
|
+
content,
|
|
84
|
+
'COPY packages/accounts-api packages/accounts-api',
|
|
85
|
+
'COPY packages/accounts-communications packages/accounts-communications',
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
content = content.replace(/^RUN pnpm --filter @forgeon\/accounts-communications build\r?\n?/gm, '');
|
|
89
|
+
const buildAnchor = content.includes('RUN pnpm --filter @forgeon/api prisma:generate')
|
|
90
|
+
? 'RUN pnpm --filter @forgeon/api prisma:generate'
|
|
91
|
+
: 'RUN pnpm --filter @forgeon/api build';
|
|
92
|
+
content = ensureLineBefore(content, buildAnchor, 'RUN pnpm --filter @forgeon/accounts-communications build');
|
|
93
|
+
|
|
94
|
+
fs.writeFileSync(dockerfilePath, `${content.trimEnd()}\n`, 'utf8');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function patchReadme(targetRoot) {
|
|
98
|
+
const readmePath = path.join(targetRoot, 'README.md');
|
|
99
|
+
if (!fs.existsSync(readmePath)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const section = [
|
|
104
|
+
'## Accounts Communications Module',
|
|
105
|
+
'',
|
|
106
|
+
'The accounts-communications add-module extends the base accounts runtime with communications-backed auth/account flows.',
|
|
107
|
+
'',
|
|
108
|
+
'What it adds:',
|
|
109
|
+
'- `@forgeon/accounts-communications` extension runtime for messaging-based auth/account operations',
|
|
110
|
+
'- pending-verification registration mode',
|
|
111
|
+
'- confirmable password changes and password reset flows',
|
|
112
|
+
'- email change confirmation routes under the same `/api/auth/*` namespace',
|
|
113
|
+
'',
|
|
114
|
+
'Current boundaries:',
|
|
115
|
+
'- requires both `accounts` and `communications`',
|
|
116
|
+
'- rebinds `register` and `change-password` handler implementations through the accounts composition point',
|
|
117
|
+
'- keeps base account state and pending-operation records inside `accounts`',
|
|
118
|
+
].join('\n');
|
|
119
|
+
|
|
120
|
+
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
121
|
+
const sectionHeading = '## Accounts Communications Module';
|
|
122
|
+
if (content.includes(sectionHeading)) {
|
|
123
|
+
const start = content.indexOf(sectionHeading);
|
|
124
|
+
const tail = content.slice(start + sectionHeading.length);
|
|
125
|
+
const nextHeadingMatch = tail.match(/\n##\s+/);
|
|
126
|
+
const end =
|
|
127
|
+
nextHeadingMatch && nextHeadingMatch.index !== undefined
|
|
128
|
+
? start + sectionHeading.length + nextHeadingMatch.index + 1
|
|
129
|
+
: content.length;
|
|
130
|
+
content = `${content.slice(0, start)}${section}\n\n${content.slice(end).replace(/^\n+/, '')}`;
|
|
131
|
+
} else if (content.includes('## Prisma In Docker Start')) {
|
|
132
|
+
content = content.replace('## Prisma In Docker Start', `${section}\n\n## Prisma In Docker Start`);
|
|
133
|
+
} else {
|
|
134
|
+
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fs.writeFileSync(readmePath, `${content.trimEnd()}\n`, 'utf8');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function applyAccountsCommunicationsModule({ packageRoot, targetRoot }) {
|
|
141
|
+
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'accounts-communications'));
|
|
142
|
+
patchApiPackage(targetRoot);
|
|
143
|
+
patchAppModule(targetRoot);
|
|
144
|
+
patchApiDockerfile(targetRoot);
|
|
145
|
+
patchReadme(targetRoot);
|
|
146
|
+
}
|
package/src/modules/accounts.mjs
CHANGED
|
@@ -57,10 +57,11 @@ function patchPrismaSchema(targetRoot) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
let content = fs.readFileSync(schemaPath, 'utf8').replace(/\r\n/g, '\n');
|
|
60
|
-
|
|
60
|
+
const userModel = `model User {
|
|
61
61
|
id String @id @default(cuid())
|
|
62
62
|
status String @default("active")
|
|
63
63
|
data Json?
|
|
64
|
+
emailVerifiedAt DateTime?
|
|
64
65
|
createdAt DateTime @default(now())
|
|
65
66
|
updatedAt DateTime @updatedAt
|
|
66
67
|
deletedAt DateTime?
|
|
@@ -69,6 +70,7 @@ function patchPrismaSchema(targetRoot) {
|
|
|
69
70
|
authIdentities AuthIdentity[]
|
|
70
71
|
authCredential AuthCredential?
|
|
71
72
|
authRefreshTokens AuthRefreshToken[]
|
|
73
|
+
authPendingOperations AuthPendingOperation[]
|
|
72
74
|
}`;
|
|
73
75
|
|
|
74
76
|
if (/model User \{[\s\S]*?\n\}/m.test(content)) {
|
|
@@ -119,6 +121,19 @@ function patchPrismaSchema(targetRoot) {
|
|
|
119
121
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
120
122
|
|
|
121
123
|
@@index([userId, createdAt])
|
|
124
|
+
}`,
|
|
125
|
+
`model AuthPendingOperation {
|
|
126
|
+
id String @id @default(cuid())
|
|
127
|
+
userId String
|
|
128
|
+
type String
|
|
129
|
+
tokenHash String
|
|
130
|
+
metadata Json?
|
|
131
|
+
expiresAt DateTime
|
|
132
|
+
consumedAt DateTime?
|
|
133
|
+
createdAt DateTime @default(now())
|
|
134
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
135
|
+
|
|
136
|
+
@@index([userId, type, createdAt])
|
|
122
137
|
}`,
|
|
123
138
|
];
|
|
124
139
|
|
|
@@ -336,7 +351,7 @@ function patchReadme(targetRoot) {
|
|
|
336
351
|
'',
|
|
337
352
|
'Current boundaries:',
|
|
338
353
|
'- `UsersModule.register({ user, profile, settings })` controls runtime defaults for JSON-backed extension fields',
|
|
339
|
-
'-
|
|
354
|
+
'- base accounts runtime works without `communications`; delivery-assisted auth/account flows belong to the optional `accounts-communications` extension',
|
|
340
355
|
'- base accounts schema does not include RBAC storage',
|
|
341
356
|
ACCOUNTS_RBAC_MARKERS.start,
|
|
342
357
|
ACCOUNTS_DEFAULT_RBAC_BLOCK,
|
|
@@ -349,9 +364,6 @@ function patchReadme(targetRoot) {
|
|
|
349
364
|
'- `POST /api/auth/logout`',
|
|
350
365
|
'- `GET /api/auth/me`',
|
|
351
366
|
'- `POST /api/auth/change-password`',
|
|
352
|
-
'- `POST /api/auth/verify-email` (stub)',
|
|
353
|
-
'- `POST /api/auth/password-reset/request` (stub)',
|
|
354
|
-
'- `POST /api/auth/password-reset/confirm` (stub)',
|
|
355
367
|
].join('\n');
|
|
356
368
|
|
|
357
369
|
let content = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
1
|
+
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { copyRecursive, writeJson } from '../utils/fs.mjs';
|
|
4
4
|
import {
|
|
@@ -165,6 +165,7 @@ function patchReadme(targetRoot) {
|
|
|
165
165
|
'Example env keys:',
|
|
166
166
|
'- `COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp`',
|
|
167
167
|
'- `COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com`',
|
|
168
|
+
'- `COMMUNICATIONS_EMAIL_FROM=` (falls back to the SMTP user when left empty)',
|
|
168
169
|
'- `COMMUNICATIONS_EMAIL_SMTP_USER=`',
|
|
169
170
|
'- `COMMUNICATIONS_EMAIL_SMTP_PASS=`',
|
|
170
171
|
].join('\n');
|
|
@@ -203,7 +204,7 @@ export function applyCommunicationsModule({ packageRoot, targetRoot }) {
|
|
|
203
204
|
upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
|
|
204
205
|
'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
|
|
205
206
|
'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
|
|
206
|
-
'COMMUNICATIONS_EMAIL_FROM=
|
|
207
|
+
'COMMUNICATIONS_EMAIL_FROM=',
|
|
207
208
|
'COMMUNICATIONS_EMAIL_REPLY_TO=',
|
|
208
209
|
'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
|
|
209
210
|
'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
|
|
@@ -218,7 +219,7 @@ export function applyCommunicationsModule({ packageRoot, targetRoot }) {
|
|
|
218
219
|
upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
|
|
219
220
|
'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
|
|
220
221
|
'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
|
|
221
|
-
'COMMUNICATIONS_EMAIL_FROM=
|
|
222
|
+
'COMMUNICATIONS_EMAIL_FROM=',
|
|
222
223
|
'COMMUNICATIONS_EMAIL_REPLY_TO=',
|
|
223
224
|
'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
|
|
224
225
|
'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
|
|
@@ -41,7 +41,7 @@ const TEST_PRESETS = [
|
|
|
41
41
|
implemented: true,
|
|
42
42
|
detectionPaths: ['packages/accounts-api/package.json'],
|
|
43
43
|
provides: ['accounts-runtime'],
|
|
44
|
-
requires: [{ type: 'capability', id: 'db-adapter' }
|
|
44
|
+
requires: [{ type: 'capability', id: 'db-adapter' }],
|
|
45
45
|
optionalIntegrations: [
|
|
46
46
|
{
|
|
47
47
|
id: 'accounts-rbac',
|
|
@@ -56,6 +56,15 @@ const TEST_PRESETS = [
|
|
|
56
56
|
},
|
|
57
57
|
],
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
id: 'accounts-communications',
|
|
61
|
+
label: 'Accounts Communications',
|
|
62
|
+
implemented: true,
|
|
63
|
+
detectionPaths: ['packages/accounts-communications/package.json'],
|
|
64
|
+
provides: ['accounts-communications-runtime'],
|
|
65
|
+
requires: [{ type: 'module', id: 'accounts' }, { type: 'module', id: 'communications' }],
|
|
66
|
+
optionalIntegrations: [],
|
|
67
|
+
},
|
|
59
68
|
{
|
|
60
69
|
id: 'rbac',
|
|
61
70
|
label: 'RBAC',
|
|
@@ -119,7 +128,7 @@ const TEST_PRESETS = [
|
|
|
119
128
|
implemented: true,
|
|
120
129
|
detectionPaths: ['packages/files-access/package.json'],
|
|
121
130
|
provides: ['files-access-runtime'],
|
|
122
|
-
requires: [{ type: '
|
|
131
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
123
132
|
optionalIntegrations: [],
|
|
124
133
|
},
|
|
125
134
|
{
|
|
@@ -128,7 +137,7 @@ const TEST_PRESETS = [
|
|
|
128
137
|
implemented: true,
|
|
129
138
|
detectionPaths: ['packages/files-quotas/package.json'],
|
|
130
139
|
provides: ['files-quotas-runtime'],
|
|
131
|
-
requires: [{ type: '
|
|
140
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
132
141
|
optionalIntegrations: [],
|
|
133
142
|
},
|
|
134
143
|
{
|
|
@@ -137,7 +146,7 @@ const TEST_PRESETS = [
|
|
|
137
146
|
implemented: true,
|
|
138
147
|
detectionPaths: ['packages/files-image/package.json'],
|
|
139
148
|
provides: ['files-image-runtime'],
|
|
140
|
-
requires: [{ type: '
|
|
149
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
141
150
|
optionalIntegrations: [],
|
|
142
151
|
},
|
|
143
152
|
{
|
|
@@ -213,10 +222,9 @@ describe('module dependency helpers', () => {
|
|
|
213
222
|
});
|
|
214
223
|
|
|
215
224
|
assert.equal(result.cancelled, false);
|
|
216
|
-
assert.deepEqual(result.moduleSequence, ['db-prisma', '
|
|
225
|
+
assert.deepEqual(result.moduleSequence, ['db-prisma', 'accounts']);
|
|
217
226
|
assert.deepEqual(result.selectedProviders, {
|
|
218
227
|
'db-adapter': 'db-prisma',
|
|
219
|
-
'communications-runtime': 'communications',
|
|
220
228
|
});
|
|
221
229
|
} finally {
|
|
222
230
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
@@ -269,7 +277,7 @@ describe('module dependency helpers', () => {
|
|
|
269
277
|
}
|
|
270
278
|
});
|
|
271
279
|
|
|
272
|
-
it('resolves files-access plan through files
|
|
280
|
+
it('resolves files-access plan through files core module dependency', async () => {
|
|
273
281
|
const targetRoot = mkTmp('forgeon-deps-files-access-plan-');
|
|
274
282
|
|
|
275
283
|
try {
|
|
@@ -289,14 +297,13 @@ describe('module dependency helpers', () => {
|
|
|
289
297
|
assert.deepEqual(result.selectedProviders, {
|
|
290
298
|
'db-adapter': 'db-prisma',
|
|
291
299
|
'files-storage-adapter': 'files-local',
|
|
292
|
-
'files-runtime': 'files',
|
|
293
300
|
});
|
|
294
301
|
} finally {
|
|
295
302
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
296
303
|
}
|
|
297
304
|
});
|
|
298
305
|
|
|
299
|
-
it('resolves files-quotas plan through files
|
|
306
|
+
it('resolves files-quotas plan through files core module dependency', async () => {
|
|
300
307
|
const targetRoot = mkTmp('forgeon-deps-files-quotas-plan-');
|
|
301
308
|
|
|
302
309
|
try {
|
|
@@ -316,14 +323,13 @@ describe('module dependency helpers', () => {
|
|
|
316
323
|
assert.deepEqual(result.selectedProviders, {
|
|
317
324
|
'db-adapter': 'db-prisma',
|
|
318
325
|
'files-storage-adapter': 'files-local',
|
|
319
|
-
'files-runtime': 'files',
|
|
320
326
|
});
|
|
321
327
|
} finally {
|
|
322
328
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
323
329
|
}
|
|
324
330
|
});
|
|
325
331
|
|
|
326
|
-
it('resolves files-image plan through files
|
|
332
|
+
it('resolves files-image plan through files core module dependency', async () => {
|
|
327
333
|
const targetRoot = mkTmp('forgeon-deps-files-image-plan-');
|
|
328
334
|
|
|
329
335
|
try {
|
|
@@ -343,7 +349,6 @@ describe('module dependency helpers', () => {
|
|
|
343
349
|
assert.deepEqual(result.selectedProviders, {
|
|
344
350
|
'db-adapter': 'db-prisma',
|
|
345
351
|
'files-storage-adapter': 'files-local',
|
|
346
|
-
'files-runtime': 'files',
|
|
347
352
|
});
|
|
348
353
|
} finally {
|
|
349
354
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
@@ -387,6 +392,7 @@ describe('module dependency helpers', () => {
|
|
|
387
392
|
'db-prisma',
|
|
388
393
|
'communications',
|
|
389
394
|
'accounts',
|
|
395
|
+
'accounts-communications',
|
|
390
396
|
'rbac',
|
|
391
397
|
'files-local',
|
|
392
398
|
'files',
|
|
@@ -399,8 +405,6 @@ describe('module dependency helpers', () => {
|
|
|
399
405
|
assert.deepEqual(result.selectedProviders, {
|
|
400
406
|
'files-storage-adapter': 'files-local',
|
|
401
407
|
'db-adapter': 'db-prisma',
|
|
402
|
-
'communications-runtime': 'communications',
|
|
403
|
-
'files-runtime': 'files',
|
|
404
408
|
'queue-runtime': 'queue',
|
|
405
409
|
});
|
|
406
410
|
assert.equal(result.rootModuleIds.includes('files-s3'), false);
|
|
@@ -451,6 +455,25 @@ describe('module dependency helpers', () => {
|
|
|
451
455
|
}
|
|
452
456
|
});
|
|
453
457
|
|
|
458
|
+
it('builds a concrete install plan for accounts-communications with required parent modules', async () => {
|
|
459
|
+
const targetRoot = mkTmp('forgeon-deps-accounts-comms-');
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const result = await resolveModuleInstallPlan({
|
|
463
|
+
moduleId: 'accounts-communications',
|
|
464
|
+
targetRoot,
|
|
465
|
+
presets: TEST_PRESETS,
|
|
466
|
+
withRequired: true,
|
|
467
|
+
isInteractive: false,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
assert.equal(result.cancelled, false);
|
|
471
|
+
assert.deepEqual(result.moduleSequence, ['db-prisma', 'accounts', 'communications', 'accounts-communications']);
|
|
472
|
+
} finally {
|
|
473
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
454
477
|
it('keeps the requested module in the plan even when it is already installed', async () => {
|
|
455
478
|
const targetRoot = mkTmp('forgeon-deps-reapply-');
|
|
456
479
|
|
package/src/modules/executor.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { applyFilesLocalModule } from './files-local.mjs';
|
|
|
11
11
|
import { applyFilesS3Module } from './files-s3.mjs';
|
|
12
12
|
import { applyI18nModule } from './i18n.mjs';
|
|
13
13
|
import { applyAccountsModule } from './accounts.mjs';
|
|
14
|
+
import { applyAccountsCommunicationsModule } from './accounts-communications.mjs';
|
|
14
15
|
import { applyCommunicationsModule } from './communications.mjs';
|
|
15
16
|
import { applyLoggerModule } from './logger.mjs';
|
|
16
17
|
import { applyRateLimitModule } from './rate-limit.mjs';
|
|
@@ -45,6 +46,7 @@ const MODULE_APPLIERS = {
|
|
|
45
46
|
'files-s3': applyFilesS3Module,
|
|
46
47
|
i18n: applyI18nModule,
|
|
47
48
|
accounts: applyAccountsModule,
|
|
49
|
+
'accounts-communications': applyAccountsCommunicationsModule,
|
|
48
50
|
communications: applyCommunicationsModule,
|
|
49
51
|
logger: applyLoggerModule,
|
|
50
52
|
queue: applyQueueModule,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -793,16 +793,17 @@ function assertAccountsWiring(projectRoot) {
|
|
|
793
793
|
assert.match(compose, /JWT_REFRESH_SECRET: \$\{JWT_REFRESH_SECRET\}/);
|
|
794
794
|
assert.match(compose, /AUTH_ARGON2_MEMORY_COST: \$\{AUTH_ARGON2_MEMORY_COST\}/);
|
|
795
795
|
|
|
796
|
-
const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
|
|
797
|
-
assert.match(readme, /## Accounts Module/);
|
|
798
|
-
assert.match(readme, /owner-scoped user routes/);
|
|
799
|
-
assert.match(readme, /
|
|
796
|
+
const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
|
|
797
|
+
assert.match(readme, /## Accounts Module/);
|
|
798
|
+
assert.match(readme, /owner-scoped user routes/);
|
|
799
|
+
assert.match(readme, /works without `communications`/);
|
|
800
800
|
|
|
801
|
-
const authServiceSource = fs.readFileSync(
|
|
802
|
-
path.join(projectRoot, 'packages', 'accounts-api', 'src', 'auth.service.ts'),
|
|
803
|
-
'utf8',
|
|
804
|
-
);
|
|
805
|
-
assert.match(authServiceSource, /
|
|
801
|
+
const authServiceSource = fs.readFileSync(
|
|
802
|
+
path.join(projectRoot, 'packages', 'accounts-api', 'src', 'auth.service.ts'),
|
|
803
|
+
'utf8',
|
|
804
|
+
);
|
|
805
|
+
assert.match(authServiceSource, /RegisterRequest/);
|
|
806
|
+
assert.match(authServiceSource, /REGISTER_HANDLER/);
|
|
806
807
|
|
|
807
808
|
const authCoreSource = fs.readFileSync(
|
|
808
809
|
path.join(projectRoot, 'packages', 'accounts-api', 'src', 'auth-core.service.ts'),
|
|
@@ -811,12 +812,12 @@ function assertAccountsWiring(projectRoot) {
|
|
|
811
812
|
assert.match(authCoreSource, /AuthStore/);
|
|
812
813
|
assert.doesNotMatch(authCoreSource, /ACCOUNTS_PERSISTENCE_PORT/);
|
|
813
814
|
|
|
814
|
-
const accountsApiPackage = fs.readFileSync(
|
|
815
|
-
path.join(projectRoot, 'packages', 'accounts-api', 'package.json'),
|
|
816
|
-
'utf8',
|
|
817
|
-
);
|
|
815
|
+
const accountsApiPackage = fs.readFileSync(
|
|
816
|
+
path.join(projectRoot, 'packages', 'accounts-api', 'package.json'),
|
|
817
|
+
'utf8',
|
|
818
|
+
);
|
|
818
819
|
assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
|
|
819
|
-
assert.
|
|
820
|
+
assert.doesNotMatch(accountsApiPackage, /@forgeon\/communications/);
|
|
820
821
|
|
|
821
822
|
const authStoreSource = fs.readFileSync(
|
|
822
823
|
path.join(projectRoot, 'packages', 'accounts-api', 'src', 'auth.store.ts'),
|
|
@@ -2228,6 +2229,89 @@ describe('addModule', () => {
|
|
|
2228
2229
|
}
|
|
2229
2230
|
});
|
|
2230
2231
|
|
|
2232
|
+
it('applies communications on top of scaffold and wires SMTP-backed probe flow', () => {
|
|
2233
|
+
const targetRoot = mkTmp('forgeon-module-communications-');
|
|
2234
|
+
const projectRoot = path.join(targetRoot, 'demo-communications');
|
|
2235
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
2236
|
+
|
|
2237
|
+
try {
|
|
2238
|
+
scaffoldProject({
|
|
2239
|
+
templateRoot,
|
|
2240
|
+
packageRoot,
|
|
2241
|
+
targetRoot: projectRoot,
|
|
2242
|
+
projectName: 'demo-communications',
|
|
2243
|
+
frontend: 'react',
|
|
2244
|
+
db: 'prisma',
|
|
2245
|
+
dbPrismaEnabled: true,
|
|
2246
|
+
i18nEnabled: true,
|
|
2247
|
+
proxy: 'caddy',
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
const result = addModule({
|
|
2251
|
+
moduleId: 'communications',
|
|
2252
|
+
targetRoot: projectRoot,
|
|
2253
|
+
packageRoot,
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
assert.equal(result.applied, true);
|
|
2257
|
+
|
|
2258
|
+
const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
|
|
2259
|
+
assert.match(appModule, /communicationsConfig/);
|
|
2260
|
+
assert.match(appModule, /communicationsEnvSchema/);
|
|
2261
|
+
assert.match(appModule, /ForgeonCommunicationsModule\.register\(\)/);
|
|
2262
|
+
|
|
2263
|
+
const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
|
|
2264
|
+
assert.match(apiPackage, /@forgeon\/communications/);
|
|
2265
|
+
assert.match(apiPackage, /pnpm --filter @forgeon\/communications build/);
|
|
2266
|
+
|
|
2267
|
+
const communicationsEnv = fs.readFileSync(
|
|
2268
|
+
path.join(projectRoot, 'packages', 'communications', 'src', 'communications-env.schema.ts'),
|
|
2269
|
+
'utf8',
|
|
2270
|
+
);
|
|
2271
|
+
assert.match(communicationsEnv, /normalizeEnvBoolean/);
|
|
2272
|
+
assert.doesNotMatch(communicationsEnv, /COMMUNICATIONS_EMAIL_SMTP_SECURE: z\.coerce\.boolean/);
|
|
2273
|
+
|
|
2274
|
+
const communicationsConfig = fs.readFileSync(
|
|
2275
|
+
path.join(projectRoot, 'packages', 'communications', 'src', 'communications-config.loader.ts'),
|
|
2276
|
+
'utf8',
|
|
2277
|
+
);
|
|
2278
|
+
assert.match(communicationsConfig, /const derivedFrom = env\.COMMUNICATIONS_EMAIL_FROM \|\| env\.COMMUNICATIONS_EMAIL_SMTP_USER/);
|
|
2279
|
+
|
|
2280
|
+
const providerSource = fs.readFileSync(
|
|
2281
|
+
path.join(projectRoot, 'packages', 'communications', 'src', 'email', 'providers', 'gmail-smtp-email.provider.ts'),
|
|
2282
|
+
'utf8',
|
|
2283
|
+
);
|
|
2284
|
+
assert.match(providerSource, /COMMUNICATIONS_EMAIL_PROVIDER_SEND_FAILED/);
|
|
2285
|
+
assert.match(providerSource, /extractErrorDetails/);
|
|
2286
|
+
|
|
2287
|
+
const communicationsModuleSource = fs.readFileSync(
|
|
2288
|
+
path.join(projectRoot, 'packages', 'communications', 'src', 'forgeon-communications.module.ts'),
|
|
2289
|
+
'utf8',
|
|
2290
|
+
);
|
|
2291
|
+
assert.match(communicationsModuleSource, /@Global\(\)/);
|
|
2292
|
+
|
|
2293
|
+
const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
|
|
2294
|
+
assert.match(apiEnv, /COMMUNICATIONS_EMAIL_SMTP_SECURE=false/);
|
|
2295
|
+
assert.match(apiEnv, /^COMMUNICATIONS_EMAIL_FROM=$/m);
|
|
2296
|
+
|
|
2297
|
+
const dockerEnv = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', '.env.example'), 'utf8');
|
|
2298
|
+
assert.match(dockerEnv, /^COMMUNICATIONS_EMAIL_FROM=$/m);
|
|
2299
|
+
|
|
2300
|
+
const probesTs = readWebProbes(projectRoot);
|
|
2301
|
+
assert.match(probesTs, /"id": "communications"/);
|
|
2302
|
+
assert.match(probesTs, /\$INPUT\.email\$/);
|
|
2303
|
+
|
|
2304
|
+
const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
|
|
2305
|
+
assert.match(readme, /## Communications Module/);
|
|
2306
|
+
assert.match(readme, /falls back to the SMTP user when left empty/);
|
|
2307
|
+
|
|
2308
|
+
const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
|
|
2309
|
+
assert.match(moduleDoc, /Status: implemented/);
|
|
2310
|
+
assert.match(moduleDoc, /STARTTLS/);
|
|
2311
|
+
} finally {
|
|
2312
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
2313
|
+
}
|
|
2314
|
+
});
|
|
2231
2315
|
it('applies accounts with db-prisma and wires the DB-backed runtime immediately', () => {
|
|
2232
2316
|
const targetRoot = mkTmp('forgeon-module-accounts-db-');
|
|
2233
2317
|
const projectRoot = path.join(targetRoot, 'demo-accounts-db');
|
|
@@ -2281,7 +2365,7 @@ describe('addModule', () => {
|
|
|
2281
2365
|
|
|
2282
2366
|
const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
|
|
2283
2367
|
assert.match(readme, /POST \/api\/auth\/register/);
|
|
2284
|
-
assert.
|
|
2368
|
+
assert.doesNotMatch(readme, /POST \/api\/auth\/password-reset\/request/);
|
|
2285
2369
|
assert.match(readme, /\/api\/users\/:id\/settings/);
|
|
2286
2370
|
|
|
2287
2371
|
const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
|
|
@@ -2706,6 +2790,11 @@ describe('addModule', () => {
|
|
|
2706
2790
|
|
|
2707
2791
|
|
|
2708
2792
|
|
|
2793
|
+
|
|
2794
|
+
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
|
|
2709
2798
|
|
|
2710
2799
|
|
|
2711
2800
|
|
package/src/modules/registry.mjs
CHANGED
|
@@ -67,10 +67,10 @@ const MODULE_PRESETS = {
|
|
|
67
67
|
category: 'file-storage',
|
|
68
68
|
implemented: true,
|
|
69
69
|
description:
|
|
70
|
-
'Resource-level access policy
|
|
70
|
+
'Resource-level access policy extension for the files core module. Requires files.',
|
|
71
71
|
detectionPaths: ['packages/files-access/package.json'],
|
|
72
72
|
provides: ['files-access-runtime'],
|
|
73
|
-
requires: [{ type: '
|
|
73
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
74
74
|
optionalIntegrations: [],
|
|
75
75
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
76
76
|
},
|
|
@@ -80,10 +80,10 @@ const MODULE_PRESETS = {
|
|
|
80
80
|
category: 'file-storage',
|
|
81
81
|
implemented: true,
|
|
82
82
|
description:
|
|
83
|
-
'Owner-level upload quota
|
|
83
|
+
'Owner-level upload quota extension for the files core module. Requires files.',
|
|
84
84
|
detectionPaths: ['packages/files-quotas/package.json'],
|
|
85
85
|
provides: ['files-quotas-runtime'],
|
|
86
|
-
requires: [{ type: '
|
|
86
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
87
87
|
optionalIntegrations: [],
|
|
88
88
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
89
89
|
},
|
|
@@ -93,10 +93,10 @@ const MODULE_PRESETS = {
|
|
|
93
93
|
category: 'file-storage',
|
|
94
94
|
implemented: true,
|
|
95
95
|
description:
|
|
96
|
-
'Image sanitation
|
|
96
|
+
'Image sanitation extension for the files core module (magic-bytes detect + sharp re-encode, metadata stripped by default). Requires files.',
|
|
97
97
|
detectionPaths: ['packages/files-image/package.json'],
|
|
98
98
|
provides: ['files-image-runtime'],
|
|
99
|
-
requires: [{ type: '
|
|
99
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
100
100
|
optionalIntegrations: [],
|
|
101
101
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
102
102
|
},
|
|
@@ -158,10 +158,10 @@ const MODULE_PRESETS = {
|
|
|
158
158
|
category: 'auth-security',
|
|
159
159
|
implemented: true,
|
|
160
160
|
description:
|
|
161
|
-
'Accounts umbrella module with DB-backed users/auth runtime, argon2 passwords, JWT access+refresh rotation, owner-scoped self-service routes, and
|
|
161
|
+
'Accounts umbrella module with standalone DB-backed users/auth runtime, argon2 passwords, JWT access+refresh rotation, owner-scoped self-service routes, and handler-based extension seams.',
|
|
162
162
|
detectionPaths: ['packages/accounts-api/package.json'],
|
|
163
163
|
provides: ['accounts-runtime'],
|
|
164
|
-
requires: [{ type: 'capability', id: 'db-adapter' }
|
|
164
|
+
requires: [{ type: 'capability', id: 'db-adapter' }],
|
|
165
165
|
optionalIntegrations: [
|
|
166
166
|
{
|
|
167
167
|
id: 'accounts-rbac',
|
|
@@ -180,6 +180,22 @@ const MODULE_PRESETS = {
|
|
|
180
180
|
],
|
|
181
181
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
182
182
|
},
|
|
183
|
+
'accounts-communications': {
|
|
184
|
+
id: 'accounts-communications',
|
|
185
|
+
label: 'Accounts Communications',
|
|
186
|
+
category: 'auth-security',
|
|
187
|
+
implemented: true,
|
|
188
|
+
description:
|
|
189
|
+
'Optional bridge module that extends accounts with communications-backed registration verification, password reset, password change confirmation, and email change confirmation flows.',
|
|
190
|
+
detectionPaths: ['packages/accounts-communications/package.json'],
|
|
191
|
+
provides: ['accounts-communications-runtime'],
|
|
192
|
+
requires: [
|
|
193
|
+
{ type: 'module', id: 'accounts' },
|
|
194
|
+
{ type: 'module', id: 'communications' },
|
|
195
|
+
],
|
|
196
|
+
optionalIntegrations: [],
|
|
197
|
+
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
198
|
+
},
|
|
183
199
|
'rate-limit': {
|
|
184
200
|
id: 'rate-limit',
|
|
185
201
|
label: 'Rate Limit',
|