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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/modules/accounts-communications.mjs +146 -0
  3. package/src/modules/accounts.mjs +17 -5
  4. package/src/modules/communications.mjs +4 -3
  5. package/src/modules/dependencies.test.mjs +37 -14
  6. package/src/modules/executor.mjs +2 -0
  7. package/src/modules/executor.test.mjs +105 -16
  8. package/src/modules/registry.mjs +24 -8
  9. package/templates/module-fragments/accounts/20_scope.md +4 -5
  10. package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
  11. package/templates/module-fragments/accounts-communications/00_title.md +1 -0
  12. package/templates/module-fragments/accounts-communications/10_overview.md +3 -0
  13. package/templates/module-fragments/accounts-communications/20_scope.md +24 -0
  14. package/templates/module-fragments/accounts-communications/90_status_implemented.md +3 -0
  15. package/templates/module-fragments/communications/20_scope.md +5 -2
  16. package/templates/module-presets/accounts/apps/api/prisma/migrations/0002_accounts_core/migration.sql +22 -1
  17. package/templates/module-presets/accounts/packages/accounts-api/package.json +0 -1
  18. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +122 -117
  19. package/templates/module-presets/accounts/packages/accounts-api/src/auth-pending-operations.ts +9 -0
  20. package/templates/module-presets/accounts/packages/accounts-api/src/auth.controller.ts +2 -21
  21. package/templates/module-presets/accounts/packages/accounts-api/src/auth.handlers.ts +45 -0
  22. package/templates/module-presets/accounts/packages/accounts-api/src/auth.service.ts +19 -18
  23. package/templates/module-presets/accounts/packages/accounts-api/src/auth.store.ts +87 -0
  24. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +29 -5
  25. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +2 -0
  26. package/templates/module-presets/accounts/packages/accounts-contracts/src/index.ts +37 -1
  27. package/templates/module-presets/accounts-communications/packages/accounts-communications/package.json +22 -0
  28. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.controller.ts +69 -0
  29. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.service.ts +221 -0
  30. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/confirmed-change-password.handler.ts +16 -0
  31. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-email.dto.ts +8 -0
  32. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-password.dto.ts +8 -0
  33. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-password-reset.dto.ts +12 -0
  34. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/index.ts +6 -0
  35. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-change-email.dto.ts +7 -0
  36. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-password-reset.dto.ts +7 -0
  37. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/verify-email.dto.ts +8 -0
  38. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/index.ts +5 -0
  39. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/pending-verification-register.handler.ts +13 -0
  40. package/templates/module-presets/accounts-communications/packages/accounts-communications/tsconfig.json +9 -0
  41. package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +2 -1
  42. package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +21 -5
  43. package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +52 -12
  44. package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -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
+ }
@@ -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
- const userModel = `model User {
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
- '- email verification and password-reset request flows send best-effort communication intents through `CommunicationsService`',
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=Forgeon <no-reply@example.com>',
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=Forgeon <no-reply@example.com>',
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' }, { type: 'capability', id: 'communications-runtime' }],
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: 'capability', id: 'files-runtime' }],
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: 'capability', id: 'files-runtime' }],
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: 'capability', id: 'files-runtime' }],
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', 'communications', 'accounts']);
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-runtime capability chain', async () => {
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-runtime capability chain', async () => {
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-runtime capability chain', async () => {
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
 
@@ -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, /CommunicationsService/);
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, /import type \{ RegisterRequest \} from '@forgeon\/accounts-contracts';/);
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.match(accountsApiPackage, /@forgeon\/communications/);
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.match(readme, /POST \/api\/auth\/password-reset\/request/);
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
 
@@ -67,10 +67,10 @@ const MODULE_PRESETS = {
67
67
  category: 'file-storage',
68
68
  implemented: true,
69
69
  description:
70
- 'Resource-level access policy module for files metadata/download/delete operations. Requires files-runtime capability.',
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: 'capability', id: 'files-runtime' }],
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 policy module for files. Requires files-runtime capability.',
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: 'capability', id: 'files-runtime' }],
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 module for files runtime (magic-bytes detect + sharp re-encode, metadata stripped by default). Requires files-runtime capability.',
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: 'capability', id: 'files-runtime' }],
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 communications integration.',
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' }, { type: 'capability', id: 'communications-runtime' }],
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',