create-forgeon 0.3.25 → 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 (38) 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/dependencies.test.mjs +31 -5
  5. package/src/modules/executor.mjs +2 -0
  6. package/src/modules/executor.test.mjs +18 -17
  7. package/src/modules/registry.mjs +18 -2
  8. package/templates/module-fragments/accounts/20_scope.md +4 -5
  9. package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
  10. package/templates/module-fragments/accounts-communications/00_title.md +1 -0
  11. package/templates/module-fragments/accounts-communications/10_overview.md +3 -0
  12. package/templates/module-fragments/accounts-communications/20_scope.md +24 -0
  13. package/templates/module-fragments/accounts-communications/90_status_implemented.md +3 -0
  14. package/templates/module-presets/accounts/apps/api/prisma/migrations/0002_accounts_core/migration.sql +22 -1
  15. package/templates/module-presets/accounts/packages/accounts-api/package.json +0 -1
  16. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +122 -117
  17. package/templates/module-presets/accounts/packages/accounts-api/src/auth-pending-operations.ts +9 -0
  18. package/templates/module-presets/accounts/packages/accounts-api/src/auth.controller.ts +2 -21
  19. package/templates/module-presets/accounts/packages/accounts-api/src/auth.handlers.ts +45 -0
  20. package/templates/module-presets/accounts/packages/accounts-api/src/auth.service.ts +19 -18
  21. package/templates/module-presets/accounts/packages/accounts-api/src/auth.store.ts +87 -0
  22. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +30 -4
  23. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +2 -0
  24. package/templates/module-presets/accounts/packages/accounts-contracts/src/index.ts +37 -1
  25. package/templates/module-presets/accounts-communications/packages/accounts-communications/package.json +22 -0
  26. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.controller.ts +69 -0
  27. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.service.ts +221 -0
  28. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/confirmed-change-password.handler.ts +16 -0
  29. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-email.dto.ts +8 -0
  30. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-password.dto.ts +8 -0
  31. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-password-reset.dto.ts +12 -0
  32. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/index.ts +6 -0
  33. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-change-email.dto.ts +7 -0
  34. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-password-reset.dto.ts +7 -0
  35. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/verify-email.dto.ts +8 -0
  36. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/index.ts +5 -0
  37. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/pending-verification-register.handler.ts +13 -0
  38. package/templates/module-presets/accounts-communications/packages/accounts-communications/tsconfig.json +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.3.25",
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');
@@ -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',
@@ -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 });
@@ -384,6 +392,7 @@ describe('module dependency helpers', () => {
384
392
  'db-prisma',
385
393
  'communications',
386
394
  'accounts',
395
+ 'accounts-communications',
387
396
  'rbac',
388
397
  'files-local',
389
398
  'files',
@@ -396,8 +405,6 @@ describe('module dependency helpers', () => {
396
405
  assert.deepEqual(result.selectedProviders, {
397
406
  'files-storage-adapter': 'files-local',
398
407
  'db-adapter': 'db-prisma',
399
- 'communications-runtime': 'communications',
400
- 'files-runtime': 'files',
401
408
  'queue-runtime': 'queue',
402
409
  });
403
410
  assert.equal(result.rootModuleIds.includes('files-s3'), false);
@@ -448,6 +455,25 @@ describe('module dependency helpers', () => {
448
455
  }
449
456
  });
450
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
+
451
477
  it('keeps the requested module in the plan even when it is already installed', async () => {
452
478
  const targetRoot = mkTmp('forgeon-deps-reapply-');
453
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,
@@ -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/);
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';/);
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
+
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
- );
818
- assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
819
- assert.match(accountsApiPackage, /@forgeon\/communications/);
815
+ const accountsApiPackage = fs.readFileSync(
816
+ path.join(projectRoot, 'packages', 'accounts-api', 'package.json'),
817
+ 'utf8',
818
+ );
819
+ assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
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'),
@@ -2364,7 +2365,7 @@ describe('addModule', () => {
2364
2365
 
2365
2366
  const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
2366
2367
  assert.match(readme, /POST \/api\/auth\/register/);
2367
- assert.match(readme, /POST \/api\/auth\/password-reset\/request/);
2368
+ assert.doesNotMatch(readme, /POST \/api\/auth\/password-reset\/request/);
2368
2369
  assert.match(readme, /\/api\/users\/:id\/settings/);
2369
2370
 
2370
2371
  const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
@@ -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',
@@ -5,11 +5,10 @@ Implemented scope:
5
5
  1. Public installer surface:
6
6
  - single umbrella add-module: `accounts`
7
7
  - requires `db-adapter`
8
- - requires `communications-runtime`
9
8
  2. Internal runtime split:
10
9
  - `@forgeon/accounts-contracts`
11
10
  - `@forgeon/accounts-api`
12
- - users core, auth core, auth-jwt, auth-password
11
+ - users core, auth core, handlers, auth-jwt, auth-password
13
12
  3. API runtime:
14
13
  - `POST /api/auth/register`
15
14
  - `POST /api/auth/login`
@@ -17,14 +16,14 @@ Implemented scope:
17
16
  - `POST /api/auth/logout`
18
17
  - `GET /api/auth/me`
19
18
  - `POST /api/auth/change-password`
20
- - stub endpoints for verify-email and password reset confirmation
21
19
  4. Users surface:
22
20
  - owner-scoped routes under `/api/users/:id`, `/api/users/:id/profile`, `/api/users/:id/settings`
23
21
  - `/users/me` is resolved through the same owner-scoped route surface
24
22
  5. Persistence and security:
25
- - DB-backed `User`, `UserProfile`, `UserSettings`, `AuthIdentity`, `AuthCredential`, `AuthRefreshToken`
23
+ - DB-backed `User`, `UserProfile`, `UserSettings`, `AuthIdentity`, `AuthCredential`, `AuthRefreshToken`, `AuthPendingOperation`
26
24
  - argon2 for password and refresh-token hashing
27
25
  - refresh token rotation + revoke with per-token storage rows
26
+ - pending operation records for delayed confirmation and recovery flows
28
27
  6. Module checks:
29
28
  - API probe endpoint: `GET /api/health/auth`
30
- - default web probe button + result block
29
+ - default web probe button + result block
@@ -3,6 +3,6 @@
3
3
  Status: implemented.
4
4
 
5
5
  Notes:
6
- - `accounts` is a hard consumer of the `db-adapter` and `communications-runtime` capabilities.
6
+ - `accounts` is a hard consumer of the `db-adapter` capability only.
7
7
  - The base accounts schema does not store RBAC roles or permissions.
8
- - Registration and password-reset request flows send best-effort communication intents through `CommunicationsService`.
8
+ - Delivery-assisted auth/account flows belong to the optional `accounts-communications` extension.
@@ -0,0 +1 @@
1
+ # {{MODULE_LABEL}}
@@ -0,0 +1,3 @@
1
+ ## Overview
2
+
3
+ {{MODULE_DESCRIPTION}}
@@ -0,0 +1,24 @@
1
+ ## Scope
2
+
3
+ Implemented scope:
4
+
5
+ 1. Public installer surface:
6
+ - single add-module: `accounts-communications`
7
+ - requires `accounts`
8
+ - requires `communications`
9
+ 2. Runtime package:
10
+ - `@forgeon/accounts-communications`
11
+ 3. Handler rebinding:
12
+ - pending-verification `register`
13
+ - confirmable `change-password`
14
+ 4. Extension routes:
15
+ - `POST /api/auth/verify-email`
16
+ - `POST /api/auth/password-reset/request`
17
+ - `POST /api/auth/password-reset/confirm`
18
+ - `POST /api/auth/change-password/confirm`
19
+ - `POST /api/auth/change-email/request`
20
+ - `POST /api/auth/change-email/confirm`
21
+ 5. Runtime boundaries:
22
+ - one `AuthCommunicationsController`
23
+ - one `AuthCommunicationsService`
24
+ - base account/auth state remains owned by `accounts`
@@ -0,0 +1,3 @@
1
+ ## Status
2
+
3
+ Current status: implemented.
@@ -6,6 +6,7 @@ ALTER TABLE "User"
6
6
  DROP COLUMN IF EXISTS "email",
7
7
  ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'active',
8
8
  ADD COLUMN IF NOT EXISTS "data" JSONB,
9
+ ADD COLUMN IF NOT EXISTS "emailVerifiedAt" TIMESTAMP(3),
9
10
  ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3),
10
11
  ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
11
12
 
@@ -57,10 +58,24 @@ CREATE TABLE IF NOT EXISTS "AuthRefreshToken" (
57
58
  CONSTRAINT "AuthRefreshToken_pkey" PRIMARY KEY ("id")
58
59
  );
59
60
 
61
+ -- CreateTable
62
+ CREATE TABLE IF NOT EXISTS "AuthPendingOperation" (
63
+ "id" TEXT NOT NULL,
64
+ "userId" TEXT NOT NULL,
65
+ "type" TEXT NOT NULL,
66
+ "tokenHash" TEXT NOT NULL,
67
+ "metadata" JSONB,
68
+ "expiresAt" TIMESTAMP(3) NOT NULL,
69
+ "consumedAt" TIMESTAMP(3),
70
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
71
+ CONSTRAINT "AuthPendingOperation_pkey" PRIMARY KEY ("id")
72
+ );
73
+
60
74
  -- Indexes
61
75
  CREATE UNIQUE INDEX IF NOT EXISTS "AuthIdentity_provider_providerId_key" ON "AuthIdentity"("provider", "providerId");
62
76
  CREATE UNIQUE INDEX IF NOT EXISTS "AuthCredential_userId_key" ON "AuthCredential"("userId");
63
77
  CREATE INDEX IF NOT EXISTS "AuthRefreshToken_userId_createdAt_idx" ON "AuthRefreshToken"("userId", "createdAt");
78
+ CREATE INDEX IF NOT EXISTS "AuthPendingOperation_userId_type_createdAt_idx" ON "AuthPendingOperation"("userId", "type", "createdAt");
64
79
 
65
80
  -- Foreign keys
66
81
  DO $$
@@ -94,4 +109,10 @@ BEGIN
94
109
  ADD CONSTRAINT "AuthRefreshToken_userId_fkey"
95
110
  FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
96
111
  END IF;
97
- END $$;
112
+
113
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'AuthPendingOperation_userId_fkey') THEN
114
+ ALTER TABLE "AuthPendingOperation"
115
+ ADD CONSTRAINT "AuthPendingOperation_userId_fkey"
116
+ FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
117
+ END IF;
118
+ END $$;
@@ -9,7 +9,6 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@forgeon/accounts-contracts": "workspace:*",
12
- "@forgeon/communications": "workspace:*",
13
12
  "@forgeon/db-prisma": "workspace:*",
14
13
  "@nestjs/common": "^11.0.1",
15
14
  "@nestjs/config": "^4.0.2",