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
@@ -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.
@@ -1,4 +1,4 @@
1
- ## Scope
1
+ ## Scope
2
2
 
3
3
  Implemented scope:
4
4
 
@@ -14,7 +14,10 @@ Implemented scope:
14
14
  - email channel with Gmail SMTP transport configuration
15
15
  - sms stub channel
16
16
  - push stub channel
17
- 5. Module checks:
17
+ 5. SMTP defaults:
18
+ - `COMMUNICATIONS_EMAIL_SMTP_SECURE=false` uses STARTTLS mode correctly on port `587`
19
+ - `COMMUNICATIONS_EMAIL_FROM` falls back to the authenticated SMTP user when left empty
20
+ 6. Module checks:
18
21
  - `GET /api/health/communications`
19
22
  - `POST /api/health/communications`
20
23
  - default web probe with email input + test send
@@ -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",
@@ -3,18 +3,22 @@ import {
3
3
  BadRequestException,
4
4
  ConflictException,
5
5
  Injectable,
6
- Logger,
6
+ NotFoundException,
7
7
  UnauthorizedException,
8
8
  } from '@nestjs/common';
9
- import { CommunicationsService } from '@forgeon/communications';
10
9
  import type {
11
10
  AuthSessionResponse,
11
+ JsonObject,
12
12
  RegisterRequest,
13
13
  UserRecordDto,
14
14
  } from '@forgeon/accounts-contracts';
15
15
  import { AuthJwtService } from './auth-jwt.service';
16
16
  import { AuthPasswordService } from './auth-password.service';
17
- import { AuthStore } from './auth.store';
17
+ import {
18
+ AuthStore,
19
+ type PasswordAccountRecord,
20
+ type PendingOperationRecord,
21
+ } from './auth.store';
18
22
  import type { AuthRefreshTokenPayload } from './auth.types';
19
23
  import { UsersService } from './users.service';
20
24
  import { toUserRecordDto } from './users.types';
@@ -25,21 +29,27 @@ const AUTH_ERROR_CODES = {
25
29
  tokenExpired: 'AUTH_TOKEN_EXPIRED',
26
30
  emailTaken: 'AUTH_EMAIL_TAKEN',
27
31
  accountDisabled: 'AUTH_ACCOUNT_DISABLED',
32
+ pendingOperationInvalid: 'AUTH_PENDING_OPERATION_INVALID',
28
33
  } as const;
29
34
 
35
+ const DEFAULT_PENDING_OPERATION_TTL_MS = 1000 * 60 * 30;
36
+
30
37
  @Injectable()
31
38
  export class AuthCoreService {
32
- private readonly logger = new Logger(AuthCoreService.name);
33
-
34
39
  constructor(
35
40
  private readonly authStore: AuthStore,
36
- private readonly communicationsService: CommunicationsService,
37
41
  private readonly authJwtService: AuthJwtService,
38
42
  private readonly authPasswordService: AuthPasswordService,
39
43
  private readonly usersService: UsersService,
40
44
  ) {}
41
45
 
42
- async registerWithPassword(input: RegisterRequest): Promise<AuthSessionResponse> {
46
+ async createPasswordAccount(
47
+ input: RegisterRequest,
48
+ options: {
49
+ status: string;
50
+ emailVerifiedAt: Date | null;
51
+ },
52
+ ): Promise<PasswordAccountRecord> {
43
53
  const email = input.email.trim().toLowerCase();
44
54
  const existing = await this.authStore.findPasswordAccountByEmail(email);
45
55
  if (existing) {
@@ -50,10 +60,11 @@ export class AuthCoreService {
50
60
  }
51
61
 
52
62
  const passwordHash = await this.authPasswordService.hash(input.password);
53
- const account = await this.authStore.createPasswordAccount({
63
+ return this.authStore.createPasswordAccount({
54
64
  email,
55
65
  passwordHash,
56
- status: 'active',
66
+ status: options.status,
67
+ emailVerifiedAt: options.emailVerifiedAt,
57
68
  userData: this.usersService.resolveUserData(input.user),
58
69
  profile: {
59
70
  name: this.readNullableString(input.profile, 'name'),
@@ -66,40 +77,6 @@ export class AuthCoreService {
66
77
  data: this.usersService.resolveSettingsData(this.readNestedObject(input.settings, 'data')),
67
78
  },
68
79
  });
69
-
70
- const accountDto = toUserRecordDto(account);
71
- const verificationToken = this.createStubToken('verify', account.id);
72
- await Promise.all([
73
- this.sendCommunicationSafely({
74
- kind: 'email_verification_code',
75
- channels: ['email'],
76
- recipient: { email },
77
- payload: {
78
- NAME: accountDto.profile?.name ?? 'there',
79
- CODE: verificationToken,
80
- },
81
- locale: accountDto.settings?.locale ?? undefined,
82
- metadata: {
83
- USER_ID: account.id,
84
- SOURCE: 'accounts.register',
85
- },
86
- }),
87
- this.sendCommunicationSafely({
88
- kind: 'welcome_email',
89
- channels: ['email'],
90
- recipient: { email },
91
- payload: {
92
- NAME: accountDto.profile?.name ?? 'there',
93
- },
94
- locale: accountDto.settings?.locale ?? undefined,
95
- metadata: {
96
- USER_ID: account.id,
97
- SOURCE: 'accounts.register',
98
- },
99
- }),
100
- ]);
101
-
102
- return this.issueSession(accountDto);
103
80
  }
104
81
 
105
82
  async loginWithPassword(emailInput: string, password: string): Promise<AuthSessionResponse> {
@@ -116,7 +93,7 @@ export class AuthCoreService {
116
93
  throw this.invalidCredentialsError();
117
94
  }
118
95
 
119
- return this.issueSession(toUserRecordDto(account));
96
+ return this.issueSessionForAccount(account);
120
97
  }
121
98
 
122
99
  async refreshTokens(refreshToken: string): Promise<AuthSessionResponse> {
@@ -152,17 +129,10 @@ export class AuthCoreService {
152
129
  });
153
130
  }
154
131
 
155
- const account = await this.authStore.findAccountByUserId(payload.sub);
156
- if (!account) {
157
- throw new UnauthorizedException({
158
- message: 'Refresh token is invalid or expired',
159
- details: { code: AUTH_ERROR_CODES.refreshInvalid },
160
- });
161
- }
162
-
132
+ const account = await this.findAccountByUserIdOrThrow(payload.sub);
163
133
  this.assertAccountActive(account);
164
134
  await this.authStore.revokeRefreshToken(record.id, new Date());
165
- return this.issueSession(toUserRecordDto(account));
135
+ return this.issueSessionForAccount(account);
166
136
  }
167
137
 
168
138
  async logout(userId: string, refreshToken: string): Promise<void> {
@@ -177,62 +147,104 @@ export class AuthCoreService {
177
147
  await this.authStore.revokeRefreshToken(payload.jti, new Date());
178
148
  }
179
149
 
180
- async changePassword(userId: string, newPassword: string): Promise<void> {
150
+ async changePasswordNow(userId: string, newPassword: string): Promise<void> {
181
151
  const passwordHash = await this.authPasswordService.hash(newPassword);
152
+ await this.applyPasswordHash(userId, passwordHash);
153
+ }
154
+
155
+ async applyPasswordHash(userId: string, passwordHash: string): Promise<void> {
182
156
  await this.authStore.updatePassword(userId, passwordHash);
183
157
  await this.authStore.revokeRefreshTokensForUser(userId, new Date());
184
158
  }
185
159
 
186
- async requestPasswordReset(emailInput: string) {
160
+ async markEmailVerified(userId: string): Promise<PasswordAccountRecord> {
161
+ await this.authStore.markEmailVerified(userId, new Date());
162
+ return this.findAccountByUserIdOrThrow(userId);
163
+ }
164
+
165
+ async updatePrimaryEmail(userId: string, emailInput: string): Promise<PasswordAccountRecord> {
187
166
  const email = emailInput.trim().toLowerCase();
188
- const account = await this.authStore.findPasswordAccountByEmail(email);
189
- if (account) {
190
- const user = toUserRecordDto(account);
191
- await this.sendCommunicationSafely({
192
- kind: 'password_reset',
193
- channels: ['email'],
194
- recipient: { email },
195
- payload: {
196
- NAME: user.profile?.name ?? 'there',
197
- TOKEN: this.createStubToken('reset', account.id),
198
- },
199
- locale: user.settings?.locale ?? undefined,
200
- metadata: {
201
- USER_ID: account.id,
202
- SOURCE: 'accounts.password-reset',
203
- },
167
+ const existing = await this.authStore.findPasswordAccountByEmail(email);
168
+ if (existing && existing.id !== userId) {
169
+ throw new ConflictException({
170
+ message: 'Email is already registered',
171
+ details: { code: AUTH_ERROR_CODES.emailTaken },
204
172
  });
205
173
  }
206
174
 
207
- return {
208
- status: 'accepted',
209
- delivery: 'communications',
210
- };
175
+ await this.authStore.updatePrimaryEmail(userId, email, new Date());
176
+ return this.findAccountByUserIdOrThrow(userId);
211
177
  }
212
178
 
213
- async resetPassword(token: string, newPassword: string) {
214
- if (token.trim().length < 8) {
215
- throw new BadRequestException('Reset token is invalid');
216
- }
179
+ async issuePendingOperation(input: {
180
+ userId: string;
181
+ type: string;
182
+ metadata?: JsonObject | null;
183
+ ttlMs?: number;
184
+ }): Promise<{ token: string; id: string; expiresAt: Date }> {
185
+ const id = crypto.randomUUID();
186
+ const secret = crypto.randomBytes(24).toString('hex');
187
+ const tokenHash = await this.authPasswordService.hash(secret);
188
+ const expiresAt = new Date(Date.now() + (input.ttlMs ?? DEFAULT_PENDING_OPERATION_TTL_MS));
189
+
190
+ await this.authStore.createPendingOperation({
191
+ id,
192
+ userId: input.userId,
193
+ type: input.type,
194
+ tokenHash,
195
+ metadata: input.metadata ?? null,
196
+ expiresAt,
197
+ });
217
198
 
218
199
  return {
219
- status: 'accepted',
220
- delivery: 'stub',
221
- nextAction: 'accounts-token-flow',
222
- passwordLength: newPassword.length,
200
+ id,
201
+ token: `${id}.${secret}`,
202
+ expiresAt,
223
203
  };
224
204
  }
225
205
 
226
- async verifyEmail(token: string) {
227
- if (token.trim().length < 8) {
228
- throw new BadRequestException('Verification token is invalid');
206
+ async readPendingOperation(token: string, expectedType: string): Promise<PendingOperationRecord> {
207
+ const [id, secret] = token.trim().split('.');
208
+ if (!id || !secret) {
209
+ throw new BadRequestException({
210
+ message: 'Pending operation token is invalid',
211
+ details: { code: AUTH_ERROR_CODES.pendingOperationInvalid },
212
+ });
229
213
  }
230
214
 
231
- return {
232
- status: 'accepted',
233
- delivery: 'stub',
234
- nextAction: 'accounts-token-flow',
235
- };
215
+ const operation = await this.authStore.findPendingOperationById(id);
216
+ if (!operation || operation.type !== expectedType || operation.consumedAt || operation.expiresAt <= new Date()) {
217
+ throw new BadRequestException({
218
+ message: 'Pending operation token is invalid',
219
+ details: { code: AUTH_ERROR_CODES.pendingOperationInvalid },
220
+ });
221
+ }
222
+
223
+ const matched = await this.authPasswordService.verify(secret, operation.tokenHash);
224
+ if (!matched) {
225
+ throw new BadRequestException({
226
+ message: 'Pending operation token is invalid',
227
+ details: { code: AUTH_ERROR_CODES.pendingOperationInvalid },
228
+ });
229
+ }
230
+
231
+ return operation;
232
+ }
233
+
234
+ async consumePendingOperation(operationId: string): Promise<void> {
235
+ await this.authStore.consumePendingOperation(operationId, new Date());
236
+ }
237
+
238
+ async findPasswordAccountByEmail(emailInput: string): Promise<PasswordAccountRecord | null> {
239
+ return this.authStore.findPasswordAccountByEmail(emailInput.trim().toLowerCase());
240
+ }
241
+
242
+ async findAccountByUserIdOrThrow(userId: string): Promise<PasswordAccountRecord> {
243
+ const account = await this.authStore.findAccountByUserId(userId);
244
+ if (!account) {
245
+ throw new NotFoundException('Account was not found');
246
+ }
247
+ return account;
236
248
  }
237
249
 
238
250
  async me(userId: string): Promise<{ user: UserRecordDto }> {
@@ -240,12 +252,16 @@ export class AuthCoreService {
240
252
  return { user };
241
253
  }
242
254
 
255
+ async issueSessionForAccount(account: PasswordAccountRecord): Promise<AuthSessionResponse> {
256
+ return this.issueSession(toUserRecordDto(account));
257
+ }
258
+
243
259
  getProbeStatus() {
244
260
  return {
245
261
  status: 'ok',
246
262
  feature: 'accounts',
247
263
  storage: 'db-prisma',
248
- emailDelivery: 'communications',
264
+ messagingExtension: 'accounts-communications (optional)',
249
265
  selfServiceRoutes: [
250
266
  '/api/users/:id',
251
267
  '/api/users/:id/profile',
@@ -288,16 +304,6 @@ export class AuthCoreService {
288
304
  };
289
305
  }
290
306
 
291
- private async sendCommunicationSafely(input: Parameters<CommunicationsService['send']>[0]): Promise<void> {
292
- try {
293
- await this.communicationsService.send(input);
294
- } catch (error) {
295
- this.logger.warn(
296
- `accounts.communication_failed kind=${input.kind} channel=${input.channels.join(',')} reason=${error instanceof Error ? error.message : 'unknown'}`,
297
- );
298
- }
299
- }
300
-
301
307
  private assertAccountActive(user: { status: string; deletedAt: Date | null }) {
302
308
  if (user.deletedAt || user.status !== 'active') {
303
309
  throw new UnauthorizedException({
@@ -314,10 +320,6 @@ export class AuthCoreService {
314
320
  });
315
321
  }
316
322
 
317
- private createStubToken(kind: string, userId: string): string {
318
- return `stub-${kind}-${userId}-${Date.now()}`;
319
- }
320
-
321
323
  private readNullableString(input: unknown, key: string): string | null {
322
324
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
323
325
  return null;
@@ -338,17 +340,20 @@ export class AuthCoreService {
338
340
  }
339
341
 
340
342
  private toExpiresAt(ttl: string): Date {
341
- const now = Date.now();
342
- const match = ttl.trim().match(/^(\d+)([smhd])$/i);
343
- if (!match) {
344
- const seconds = Number.parseInt(ttl, 10);
345
- return new Date(now + (Number.isFinite(seconds) ? seconds : 7 * 24 * 60 * 60) * 1000);
343
+ const value = ttl.trim();
344
+ const matched = value.match(/^(\d+)([smhd])$/);
345
+ if (!matched) {
346
+ return new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
346
347
  }
347
348
 
348
- const amount = Number.parseInt(match[1], 10);
349
- const unit = match[2].toLowerCase();
350
- const multiplier =
351
- unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
352
- return new Date(now + amount * multiplier);
349
+ const amount = Number(matched[1]);
350
+ const unit = matched[2];
351
+ const multipliers = {
352
+ s: 1000,
353
+ m: 1000 * 60,
354
+ h: 1000 * 60 * 60,
355
+ d: 1000 * 60 * 60 * 24,
356
+ } as const;
357
+ return new Date(Date.now() + amount * multipliers[unit]);
353
358
  }
354
- }
359
+ }
@@ -0,0 +1,9 @@
1
+ export const AUTH_PENDING_OPERATION_TYPES = {
2
+ emailVerification: 'email_verification',
3
+ passwordReset: 'password_reset',
4
+ passwordChange: 'password_change',
5
+ emailChange: 'email_change',
6
+ } as const;
7
+
8
+ export type AuthPendingOperationType =
9
+ (typeof AUTH_PENDING_OPERATION_TYPES)[keyof typeof AUTH_PENDING_OPERATION_TYPES];
@@ -9,12 +9,9 @@ import {
9
9
  import { AuthService } from './auth.service';
10
10
  import {
11
11
  ChangePasswordDto,
12
- ConfirmPasswordResetDto,
13
12
  LoginDto,
14
13
  RefreshDto,
15
14
  RegisterDto,
16
- RequestPasswordResetDto,
17
- VerifyEmailDto,
18
15
  } from './dto';
19
16
  import { JwtAuthGuard } from './access-token.guard';
20
17
  import type { AuthAccessTokenPayload } from './auth.types';
@@ -57,25 +54,9 @@ export class AuthController {
57
54
 
58
55
  @UseGuards(JwtAuthGuard)
59
56
  @Post('change-password')
60
- async changePassword(@Body() body: ChangePasswordDto, @Req() request: RequestWithUser) {
57
+ changePassword(@Body() body: ChangePasswordDto, @Req() request: RequestWithUser) {
61
58
  const user = this.getRequestUser(request);
62
- await this.authService.changePassword(user.sub, body.newPassword);
63
- return { status: 'ok' };
64
- }
65
-
66
- @Post('verify-email')
67
- verifyEmail(@Body() body: VerifyEmailDto) {
68
- return this.authService.verifyEmail(body.token);
69
- }
70
-
71
- @Post('password-reset/request')
72
- requestPasswordReset(@Body() body: RequestPasswordResetDto) {
73
- return this.authService.requestPasswordReset(body.email);
74
- }
75
-
76
- @Post('password-reset/confirm')
77
- confirmPasswordReset(@Body() body: ConfirmPasswordResetDto) {
78
- return this.authService.resetPassword(body.token, body.newPassword);
59
+ return this.authService.changePassword(user.sub, body);
79
60
  }
80
61
 
81
62
  private getRequestUser(request: RequestWithUser): AuthAccessTokenPayload {
@@ -0,0 +1,45 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type {
3
+ ChangePasswordRequest,
4
+ ChangePasswordResult,
5
+ RegisterRequest,
6
+ RegisterResult,
7
+ } from '@forgeon/accounts-contracts';
8
+ import { AuthCoreService } from './auth-core.service';
9
+
10
+ export const REGISTER_HANDLER = Symbol('REGISTER_HANDLER');
11
+ export const CHANGE_PASSWORD_HANDLER = Symbol('CHANGE_PASSWORD_HANDLER');
12
+
13
+ export interface RegisterHandler {
14
+ execute(input: RegisterRequest): Promise<RegisterResult>;
15
+ }
16
+
17
+ export interface ChangePasswordHandler {
18
+ execute(userId: string, input: ChangePasswordRequest): Promise<ChangePasswordResult>;
19
+ }
20
+
21
+ @Injectable()
22
+ export class DefaultRegisterHandler implements RegisterHandler {
23
+ constructor(private readonly authCoreService: AuthCoreService) {}
24
+
25
+ async execute(input: RegisterRequest): Promise<RegisterResult> {
26
+ const account = await this.authCoreService.createPasswordAccount(input, {
27
+ status: 'active',
28
+ emailVerifiedAt: new Date(),
29
+ });
30
+ return this.authCoreService.issueSessionForAccount(account);
31
+ }
32
+ }
33
+
34
+ @Injectable()
35
+ export class DefaultChangePasswordHandler implements ChangePasswordHandler {
36
+ constructor(private readonly authCoreService: AuthCoreService) {}
37
+
38
+ async execute(userId: string, input: ChangePasswordRequest): Promise<ChangePasswordResult> {
39
+ await this.authCoreService.changePasswordNow(userId, input.newPassword);
40
+ return {
41
+ status: 'completed',
42
+ message: 'Password changed successfully',
43
+ };
44
+ }
45
+ }
@@ -1,13 +1,26 @@
1
- import { Injectable } from '@nestjs/common';
2
- import type { RegisterRequest } from '@forgeon/accounts-contracts';
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import type {
3
+ ChangePasswordRequest,
4
+ RegisterRequest,
5
+ } from '@forgeon/accounts-contracts';
3
6
  import { AuthCoreService } from './auth-core.service';
7
+ import {
8
+ CHANGE_PASSWORD_HANDLER,
9
+ REGISTER_HANDLER,
10
+ type ChangePasswordHandler,
11
+ type RegisterHandler,
12
+ } from './auth.handlers';
4
13
 
5
14
  @Injectable()
6
15
  export class AuthService {
7
- constructor(private readonly authCoreService: AuthCoreService) {}
16
+ constructor(
17
+ private readonly authCoreService: AuthCoreService,
18
+ @Inject(REGISTER_HANDLER) private readonly registerHandler: RegisterHandler,
19
+ @Inject(CHANGE_PASSWORD_HANDLER) private readonly changePasswordHandler: ChangePasswordHandler,
20
+ ) {}
8
21
 
9
22
  register(input: RegisterRequest) {
10
- return this.authCoreService.registerWithPassword(input);
23
+ return this.registerHandler.execute(input);
11
24
  }
12
25
 
13
26
  login(input: { email: string; password: string }) {
@@ -22,20 +35,8 @@ export class AuthService {
22
35
  return this.authCoreService.logout(userId, refreshToken);
23
36
  }
24
37
 
25
- changePassword(userId: string, newPassword: string) {
26
- return this.authCoreService.changePassword(userId, newPassword);
27
- }
28
-
29
- requestPasswordReset(email: string) {
30
- return this.authCoreService.requestPasswordReset(email);
31
- }
32
-
33
- resetPassword(token: string, newPassword: string) {
34
- return this.authCoreService.resetPassword(token, newPassword);
35
- }
36
-
37
- verifyEmail(token: string) {
38
- return this.authCoreService.verifyEmail(token);
38
+ changePassword(userId: string, input: ChangePasswordRequest) {
39
+ return this.changePasswordHandler.execute(userId, input);
39
40
  }
40
41
 
41
42
  me(userId: string) {