@lenne.tech/nest-server 12.3.1 → 12.4.0
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/.claude/rules/configurable-features.md +2 -1
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +2 -2
- package/dist/core/common/helpers/swagger.helper.js +6 -0
- package/dist/core/common/helpers/swagger.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +6 -0
- package/dist/core/common/services/email.service.d.ts +3 -0
- package/dist/core/common/services/email.service.js +20 -0
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +18 -0
- package/dist/core/modules/error-code/error-codes.js +16 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.controller.d.ts +2 -2
- package/dist/core/modules/system-setup/core-system-setup.controller.js +17 -7
- package/dist/core/modules/system-setup/core-system-setup.controller.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +18 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +118 -15
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/system-setup/system-setup-http-init.helper.d.ts +11 -0
- package/dist/core/modules/system-setup/system-setup-http-init.helper.js +45 -0
- package/dist/core/modules/system-setup/system-setup-http-init.helper.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +2 -0
- package/migration-guides/11.13.x-to-11.14.0.md +5 -5
- package/migration-guides/12.3.1-to-12.3.2.md +52 -0
- package/migration-guides/12.3.x-to-12.4.x.md +169 -0
- package/package.json +1 -1
- package/src/core/common/helpers/swagger.helper.ts +7 -0
- package/src/core/common/interfaces/server-options.interface.ts +70 -5
- package/src/core/common/services/email.service.ts +33 -1
- package/src/core/modules/error-code/error-codes.ts +27 -0
- package/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md +51 -16
- package/src/core/modules/system-setup/README.md +144 -28
- package/src/core/modules/system-setup/core-system-setup.controller.ts +30 -8
- package/src/core/modules/system-setup/core-system-setup.service.ts +246 -23
- package/src/core/modules/system-setup/system-setup-http-init.helper.ts +130 -0
- package/src/index.ts +1 -0
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { Body, Controller, Get, Post } from '@nestjs/common';
|
|
2
|
-
import { ApiBody, ApiOperation, ApiProperty, ApiResponse, ApiTags } from '@nestjs/swagger';
|
|
1
|
+
import { Body, Controller, Get, Headers, Post } from '@nestjs/common';
|
|
2
|
+
import { ApiBody, ApiHeader, ApiOperation, ApiProperty, ApiResponse, ApiTags } from '@nestjs/swagger';
|
|
3
3
|
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
|
4
4
|
|
|
5
5
|
import { Roles } from '../../common/decorators/roles.decorator.js';
|
|
6
6
|
import { RoleEnum } from '../../common/enums/role.enum.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CoreSystemSetupService,
|
|
9
|
+
type SystemSetupInitResult,
|
|
10
|
+
type SystemSetupStatus,
|
|
11
|
+
} from './core-system-setup.service.js';
|
|
12
|
+
import { SYSTEM_SETUP_TOKEN_HEADER } from './system-setup-http-init.helper.js';
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* DTO for system setup init request
|
|
@@ -31,8 +36,12 @@ export class SystemSetupInitDto {
|
|
|
31
36
|
* CoreSystemSetupController provides REST endpoints for initial system setup.
|
|
32
37
|
*
|
|
33
38
|
* These endpoints are public (S_EVERYONE) to allow first-time admin creation
|
|
34
|
-
* on a fresh system with zero users. The service enforces security
|
|
35
|
-
*
|
|
39
|
+
* on a fresh system with zero users. The service enforces security: the init is
|
|
40
|
+
* refused where `systemSetup.httpInit` does not open it (by default every
|
|
41
|
+
* environment except `local`, `ci` and `e2e`), and once any user exists.
|
|
42
|
+
*
|
|
43
|
+
* A subclass that overrides `createInitialAdmin()` must call
|
|
44
|
+
* `systemSetupService.assertHttpInitAllowed(token)` itself.
|
|
36
45
|
*/
|
|
37
46
|
@ApiTags('System Setup')
|
|
38
47
|
@Controller('system-setup')
|
|
@@ -55,18 +64,31 @@ export class CoreSystemSetupController {
|
|
|
55
64
|
* Create the initial admin user (only works when zero users exist)
|
|
56
65
|
*/
|
|
57
66
|
@ApiBody({ type: SystemSetupInitDto })
|
|
67
|
+
@ApiHeader({
|
|
68
|
+
description: 'The setup token, required when systemSetup.httpInit configures one (see GET /system-setup/status)',
|
|
69
|
+
name: SYSTEM_SETUP_TOKEN_HEADER,
|
|
70
|
+
required: false,
|
|
71
|
+
})
|
|
58
72
|
@ApiOperation({
|
|
59
|
-
description:
|
|
73
|
+
description:
|
|
74
|
+
'Creates the initial admin user. Only works when zero users exist in the database, and only where ' +
|
|
75
|
+
'systemSetup.httpInit opens it (by default in the local, ci and e2e environments).',
|
|
60
76
|
summary: 'Create initial admin',
|
|
61
77
|
})
|
|
62
78
|
@ApiResponse({ description: 'Initial admin created', status: 201 })
|
|
63
79
|
@ApiResponse({
|
|
64
|
-
description:
|
|
80
|
+
description:
|
|
81
|
+
'LTNS_0053: HTTP init not enabled in this environment; LTNS_0054: setup token missing or invalid; ' +
|
|
82
|
+
'LTNS_0050: users already exist, or the initial-admin setup is claimed',
|
|
65
83
|
status: 403,
|
|
66
84
|
})
|
|
67
85
|
@Post('init')
|
|
68
86
|
@Roles(RoleEnum.S_EVERYONE)
|
|
69
|
-
async createInitialAdmin(
|
|
87
|
+
async createInitialAdmin(
|
|
88
|
+
@Body() input: SystemSetupInitDto,
|
|
89
|
+
@Headers(SYSTEM_SETUP_TOKEN_HEADER) token?: string,
|
|
90
|
+
): Promise<SystemSetupInitResult> {
|
|
91
|
+
this.systemSetupService.assertHttpInitAllowed(token);
|
|
70
92
|
return this.systemSetupService.createInitialAdmin(input);
|
|
71
93
|
}
|
|
72
94
|
}
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
systemSetupMongoLocks,
|
|
16
16
|
} from '../../common/persistence/infrastructure-locks.js';
|
|
17
17
|
import type { LockStore } from '../../common/persistence/lock-store.interface.js';
|
|
18
|
+
import { isLocalEnv } from '../../common/helpers/cookies.helper.js';
|
|
19
|
+
import type { ISystemSetupInitialAdmin } from '../../common/interfaces/server-options.interface.js';
|
|
18
20
|
import { ConfigService } from '../../common/services/config.service.js';
|
|
19
21
|
import { legacyCredentialAccountIssuer } from '../better-auth/core-better-auth-account-issuer.helper.js';
|
|
20
22
|
import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.mapper.js';
|
|
@@ -22,6 +24,12 @@ import { CoreBetterAuthService } from '../better-auth/core-better-auth.service.j
|
|
|
22
24
|
import { ErrorCode } from '../error-code/error-codes.js';
|
|
23
25
|
import { getPersistenceRuntime } from '../../common/persistence/persistence-runtime.registry.js';
|
|
24
26
|
import { getBetterAuthSqlStore } from '../better-auth/core-better-auth-sql.registry.js';
|
|
27
|
+
import {
|
|
28
|
+
type ISystemSetupHttpInitPolicy,
|
|
29
|
+
isSystemSetupTokenValid,
|
|
30
|
+
resolveSystemSetupHttpInit,
|
|
31
|
+
SYSTEM_SETUP_TOKEN_HEADER,
|
|
32
|
+
} from './system-setup-http-init.helper.js';
|
|
25
33
|
|
|
26
34
|
/**
|
|
27
35
|
* Collection holding the bootstrap claim marker on MongoDB — the 12.0.0 document (`{ _id, claimedAt }`), kept so
|
|
@@ -63,6 +71,23 @@ export interface SystemSetupInitResult {
|
|
|
63
71
|
*/
|
|
64
72
|
export interface SystemSetupStatus {
|
|
65
73
|
betterAuthEnabled: boolean;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether `POST /system-setup/init` is open in this environment (`systemSetup.httpInit`; by default only in
|
|
77
|
+
* `local`, `ci` and `e2e`). Independent of `needsSetup`: a frontend offers the setup form only when both are true.
|
|
78
|
+
*
|
|
79
|
+
* @since 12.4.0
|
|
80
|
+
*/
|
|
81
|
+
httpInitEnabled: boolean;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether the init must carry the setup token in the `X-System-Setup-Token` header. Only ever true together with
|
|
85
|
+
* `httpInitEnabled`.
|
|
86
|
+
*
|
|
87
|
+
* @since 12.4.0
|
|
88
|
+
*/
|
|
89
|
+
httpInitTokenRequired: boolean;
|
|
90
|
+
|
|
66
91
|
needsSetup: boolean;
|
|
67
92
|
}
|
|
68
93
|
|
|
@@ -74,9 +99,12 @@ export interface SystemSetupStatus {
|
|
|
74
99
|
* which is the same approach used by Better-Auth's own admin plugin.
|
|
75
100
|
*
|
|
76
101
|
* Security:
|
|
102
|
+
* - The anonymous HTTP init is open only where `systemSetup.httpInit` allows it — by default only in `local`, `ci`
|
|
103
|
+
* and `e2e` ({@link assertHttpInitAllowed}); a deployment provisions its first admin via `initialAdmin` instead
|
|
77
104
|
* - Only works when zero users exist in the database
|
|
78
105
|
* - Once any user exists, the init endpoint is permanently locked
|
|
79
|
-
* - Race conditions handled by
|
|
106
|
+
* - Race conditions handled by an atomic claim shared by both entry points
|
|
107
|
+
* - A creation that fails after the user was created removes that user again, so `needsSetup` stays true
|
|
80
108
|
*/
|
|
81
109
|
@Injectable()
|
|
82
110
|
export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
@@ -100,32 +128,21 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
100
128
|
* is fully initialized before attempting user creation.
|
|
101
129
|
*/
|
|
102
130
|
async onApplicationBootstrap(): Promise<void> {
|
|
103
|
-
|
|
131
|
+
await this.createInitialAdminFromConfig();
|
|
132
|
+
await this.reportSetupReachability();
|
|
133
|
+
}
|
|
104
134
|
|
|
105
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Create the initial admin from `systemSetup.initialAdmin` when zero users exist (the boot half of
|
|
137
|
+
* {@link onApplicationBootstrap}). Not gated by `systemSetup.httpInit`: it is the deployment's own configuration,
|
|
138
|
+
* not an anonymous request.
|
|
139
|
+
*/
|
|
140
|
+
protected async createInitialAdminFromConfig(): Promise<void> {
|
|
141
|
+
const initialAdmin = this.usableInitialAdmin({ warn: true });
|
|
106
142
|
if (!initialAdmin) {
|
|
107
143
|
return;
|
|
108
144
|
}
|
|
109
145
|
|
|
110
|
-
// Partial credentials → warn and skip
|
|
111
|
-
if (!initialAdmin.email || !initialAdmin.password) {
|
|
112
|
-
const missing = [!initialAdmin.email && 'email', !initialAdmin.password && 'password'].filter(Boolean).join(', ');
|
|
113
|
-
this.logger.warn(`Incomplete initialAdmin config - missing: ${missing}. Auto-creation skipped.`);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Validate email format (same validator as @IsEmail() decorator)
|
|
118
|
-
if (!isEmail(initialAdmin.email)) {
|
|
119
|
-
this.logger.warn(`Invalid initialAdmin email format: "${initialAdmin.email}". Auto-creation skipped.`);
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Validate password is not empty/whitespace
|
|
124
|
-
if (!initialAdmin.password.trim()) {
|
|
125
|
-
this.logger.warn('Empty initialAdmin password. Auto-creation skipped.');
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
146
|
const status = await this.getSetupStatus();
|
|
130
147
|
if (!status.needsSetup) {
|
|
131
148
|
return;
|
|
@@ -161,6 +178,169 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
161
178
|
}
|
|
162
179
|
}
|
|
163
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The configured `systemSetup.initialAdmin`, or `undefined` when it is absent or cannot be used.
|
|
183
|
+
*
|
|
184
|
+
* With `warn`, each reason it cannot be used is logged (the boot path); without, it only answers.
|
|
185
|
+
*/
|
|
186
|
+
protected usableInitialAdmin(options?: { warn?: boolean }): ISystemSetupInitialAdmin | undefined {
|
|
187
|
+
const initialAdmin = this.configService.configFastButReadOnly?.systemSetup?.initialAdmin;
|
|
188
|
+
const skip = (message: string): undefined => {
|
|
189
|
+
if (options?.warn) {
|
|
190
|
+
this.logger.warn(message);
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// No initialAdmin config at all → nothing to report here
|
|
196
|
+
if (!initialAdmin) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Partial credentials → skip
|
|
201
|
+
if (!initialAdmin.email || !initialAdmin.password) {
|
|
202
|
+
const missing = [!initialAdmin.email && 'email', !initialAdmin.password && 'password'].filter(Boolean).join(', ');
|
|
203
|
+
return skip(`Incomplete initialAdmin config - missing: ${missing}. Auto-creation skipped.`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate email format (same validator as @IsEmail() decorator)
|
|
207
|
+
if (!isEmail(initialAdmin.email)) {
|
|
208
|
+
return skip(`Invalid initialAdmin email format: "${initialAdmin.email}". Auto-creation skipped.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// An all-digit NSC__SYSTEM_SETUP__INITIAL_ADMIN__PASSWORD arrives as a NUMBER (getEnvironmentObject() converts
|
|
212
|
+
// numeric strings), and `.trim()` on it used to throw at boot. Name the cause instead.
|
|
213
|
+
if (typeof initialAdmin.password !== 'string') {
|
|
214
|
+
return skip(
|
|
215
|
+
`initialAdmin password is a ${typeof initialAdmin.password}, not a string (an all-digit ENV value is ` +
|
|
216
|
+
'converted to a number). Auto-creation skipped.',
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Validate password is not empty/whitespace
|
|
221
|
+
if (!initialAdmin.password.trim()) {
|
|
222
|
+
return skip('Empty initialAdmin password. Auto-creation skipped.');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return initialAdmin;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The environment the HTTP-init policy is decided for: the configured `env`, else `NODE_ENV`
|
|
230
|
+
*/
|
|
231
|
+
protected currentEnv(): string | undefined {
|
|
232
|
+
return this.configService.configFastButReadOnly?.env ?? process.env.NODE_ENV;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether the anonymous `POST /system-setup/init` is open here, and whether it needs the setup token
|
|
237
|
+
* (`systemSetup.httpInit`, see {@link resolveSystemSetupHttpInit}).
|
|
238
|
+
*/
|
|
239
|
+
getHttpInitPolicy(): ISystemSetupHttpInitPolicy {
|
|
240
|
+
return resolveSystemSetupHttpInit(
|
|
241
|
+
this.configService.configFastButReadOnly?.systemSetup?.httpInit,
|
|
242
|
+
this.currentEnv(),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Refuse an HTTP init the configuration does not allow — call it from every HTTP entry point before
|
|
248
|
+
* {@link createInitialAdmin}, which does not check it (the boot path must not be gated).
|
|
249
|
+
*
|
|
250
|
+
* @param token the value of the `X-System-Setup-Token` header
|
|
251
|
+
* @throws ForbiddenException `SYSTEM_SETUP_HTTP_INIT_DISABLED` when the HTTP init is closed in this environment,
|
|
252
|
+
* `SYSTEM_SETUP_TOKEN_INVALID` when it needs a token and `token` is missing or wrong
|
|
253
|
+
*/
|
|
254
|
+
assertHttpInitAllowed(token?: unknown): void {
|
|
255
|
+
const policy = this.getHttpInitPolicy();
|
|
256
|
+
if (!policy.enabled) {
|
|
257
|
+
throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_HTTP_INIT_DISABLED);
|
|
258
|
+
}
|
|
259
|
+
if (policy.tokenRequired && !isSystemSetupTokenValid(policy.token, token)) {
|
|
260
|
+
throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_TOKEN_INVALID);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Say at boot when a deployment has no way to its first admin, or an open one (the second half of
|
|
266
|
+
* {@link onApplicationBootstrap}).
|
|
267
|
+
*
|
|
268
|
+
* A deployed environment with zero users, no usable `initialAdmin` and a closed HTTP init cannot be set up at all:
|
|
269
|
+
* nobody notices until somebody tries to sign in. With the HTTP init open WITHOUT a token, whoever reaches the API
|
|
270
|
+
* first becomes the administrator. Both are logged; neither stops the boot. Local environments are left alone,
|
|
271
|
+
* and an instance with a usable `initialAdmin` reports through the auto-creation instead — reporting here as well
|
|
272
|
+
* would raise a false alarm on every replica that lost the creation race while the winner is still creating.
|
|
273
|
+
*/
|
|
274
|
+
protected async reportSetupReachability(): Promise<void> {
|
|
275
|
+
const policy = this.getHttpInitPolicy();
|
|
276
|
+
if (policy.problem) {
|
|
277
|
+
this.logger.error(
|
|
278
|
+
`POST /system-setup/init stays closed: ${policy.problem}. Set a token of at least 16 characters ` +
|
|
279
|
+
'(NSC__SYSTEM_SETUP__HTTP_INIT__TOKEN), or systemSetup.httpInit: true to open it without one.',
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
this.warnAboutIgnoredEnvironmentNames();
|
|
283
|
+
|
|
284
|
+
if (isLocalEnv(this.currentEnv()) || this.usableInitialAdmin() || (policy.enabled && policy.tokenRequired)) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let needsSetup: boolean;
|
|
289
|
+
try {
|
|
290
|
+
needsSetup = (await this.getSetupStatus()).needsSetup;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
// A diagnostic must never be the reason a boot fails
|
|
293
|
+
this.logger.warn(
|
|
294
|
+
`Could not check whether initial setup is needed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
295
|
+
);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (!needsSetup) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const env = this.currentEnv() ?? '(not set)';
|
|
303
|
+
if (!policy.enabled) {
|
|
304
|
+
this.logger.error(
|
|
305
|
+
`No users exist and this instance cannot be set up: environment "${env}" closes POST /system-setup/init, ` +
|
|
306
|
+
'and no initialAdmin is configured. Set NSC__SYSTEM_SETUP__INITIAL_ADMIN__EMAIL and ' +
|
|
307
|
+
'NSC__SYSTEM_SETUP__INITIAL_ADMIN__PASSWORD (created on the next start), or open the HTTP init explicitly ' +
|
|
308
|
+
'with NSC__SYSTEM_SETUP__HTTP_INIT__TOKEN=<secret of at least 16 characters> ' +
|
|
309
|
+
`(sent as the "${SYSTEM_SETUP_TOKEN_HEADER}" header).`,
|
|
310
|
+
);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
this.logger.warn(
|
|
314
|
+
`No users exist and POST /system-setup/init is open without a token in environment "${env}": whoever reaches ` +
|
|
315
|
+
'the API first becomes the administrator. Prefer NSC__SYSTEM_SETUP__INITIAL_ADMIN__EMAIL / __PASSWORD, or ' +
|
|
316
|
+
'protect the init with NSC__SYSTEM_SETUP__HTTP_INIT__TOKEN.',
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Warn about `NSC__` variables for `systemSetup` that carry a camelCase segment (`NSC__systemSetup__initialAdmin__email`).
|
|
322
|
+
*
|
|
323
|
+
* `getEnvironmentObject()` lowercases every segment it does not split at `_`, so such a variable lands under
|
|
324
|
+
* `systemsetup.initialadmin` — a key nothing reads — and the auto-creation silently never happens. The framework's
|
|
325
|
+
* own documentation spelled the variables that way until 12.4.0. Only the variable NAMES are logged, never values.
|
|
326
|
+
*/
|
|
327
|
+
protected warnAboutIgnoredEnvironmentNames(): void {
|
|
328
|
+
for (const key of Object.keys(process.env)) {
|
|
329
|
+
if (!key.startsWith('NSC__')) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const segments = key.slice('NSC__'.length).split('__');
|
|
333
|
+
if (
|
|
334
|
+
segments[0]?.replace(/_/g, '').toLowerCase() !== 'systemsetup' ||
|
|
335
|
+
!segments.some((s) => /[a-z][A-Z]/.test(s))
|
|
336
|
+
) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const corrected = `NSC__${segments.map((s) => s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase()).join('__')}`;
|
|
340
|
+
this.logger.warn(`Environment variable ${key} is not read (camelCase segment). Use ${corrected} instead.`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
164
344
|
/**
|
|
165
345
|
* Try to claim the initial-admin creation for this instance
|
|
166
346
|
*
|
|
@@ -193,6 +373,31 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
193
373
|
}
|
|
194
374
|
}
|
|
195
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Remove the user a failed {@link createInitialAdmin} had already created: Better-Auth's sessions and accounts and
|
|
378
|
+
* the `users` row, which Better-Auth and the user model share. Without it the deployment keeps a user without the
|
|
379
|
+
* admin role, `needsSetup` turns false and init stays refused for good.
|
|
380
|
+
*
|
|
381
|
+
* Never throws: the failure that triggered the rollback is the one the caller must see. A rollback that fails is
|
|
382
|
+
* logged with what to remove by hand.
|
|
383
|
+
*/
|
|
384
|
+
protected async rollbackInitialAdmin(
|
|
385
|
+
context: { internalAdapter: { deleteUser(userId: string): Promise<unknown> } },
|
|
386
|
+
iamUserId: string,
|
|
387
|
+
email: string,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
try {
|
|
390
|
+
await context.internalAdapter.deleteUser(iamUserId);
|
|
391
|
+
this.logger.warn(`Initial admin creation failed; the partially created user ${email} was removed again`);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
this.logger.error(
|
|
394
|
+
`Initial admin creation failed and the partially created user ${email} could not be removed ` +
|
|
395
|
+
`(${error instanceof Error ? error.message : 'Unknown error'}). Remove that user and its accounts from ` +
|
|
396
|
+
'"users" and "account", otherwise initial setup stays refused because a user exists.',
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
196
401
|
/**
|
|
197
402
|
* Where the claim lives: the lease table when the default data source is a SQL database, else the MongoDB marker
|
|
198
403
|
*/
|
|
@@ -229,8 +434,11 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
229
434
|
*/
|
|
230
435
|
async getSetupStatus(): Promise<SystemSetupStatus> {
|
|
231
436
|
const userCount = await this.countUsers();
|
|
437
|
+
const httpInit = this.getHttpInitPolicy();
|
|
232
438
|
return {
|
|
233
439
|
betterAuthEnabled: this.betterAuthService.isEnabled(),
|
|
440
|
+
httpInitEnabled: httpInit.enabled,
|
|
441
|
+
httpInitTokenRequired: httpInit.enabled && httpInit.tokenRequired,
|
|
234
442
|
needsSetup: userCount === 0,
|
|
235
443
|
};
|
|
236
444
|
}
|
|
@@ -240,6 +448,9 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
240
448
|
*
|
|
241
449
|
* Uses BetterAuth's internalAdapter to bypass disableSignUp,
|
|
242
450
|
* then syncs to nest-server users collection with admin role.
|
|
451
|
+
*
|
|
452
|
+
* Does NOT check `systemSetup.httpInit`: the boot path calls it too. An HTTP entry point calls
|
|
453
|
+
* {@link assertHttpInitAllowed} first, as `CoreSystemSetupController` does.
|
|
243
454
|
*/
|
|
244
455
|
async createInitialAdmin(input: SystemSetupInitInput): Promise<SystemSetupInitResult> {
|
|
245
456
|
// Pre-check: only allow when zero users exist
|
|
@@ -291,9 +502,12 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
291
502
|
throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE);
|
|
292
503
|
}
|
|
293
504
|
|
|
505
|
+
// Set once the IAM user exists: every failure after that point removes the user again (see the catch below)
|
|
506
|
+
let createdIamUserId: string | undefined;
|
|
507
|
+
let context: Awaited<typeof authInstance.$context> | undefined;
|
|
294
508
|
try {
|
|
295
509
|
// Access BetterAuth internal context (same pattern as core-better-auth-api.middleware.ts)
|
|
296
|
-
|
|
510
|
+
context = await authInstance.$context;
|
|
297
511
|
|
|
298
512
|
// Normalize password for IAM (SHA256 if plain text)
|
|
299
513
|
const normalizedPassword = this.userMapper.normalizePasswordForIam(input.password);
|
|
@@ -329,6 +543,7 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
329
543
|
if (!iamUser) {
|
|
330
544
|
throw new Error('Failed to create IAM user');
|
|
331
545
|
}
|
|
546
|
+
createdIamUserId = iamUser.id;
|
|
332
547
|
|
|
333
548
|
// Hash password and create credential account
|
|
334
549
|
const hashedPassword = await context.password.hash(normalizedPassword);
|
|
@@ -382,6 +597,14 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
382
597
|
success: true,
|
|
383
598
|
};
|
|
384
599
|
} catch (error) {
|
|
600
|
+
// Remove the user this call created BEFORE releasing the claim. A failure after createUser (linkAccount,
|
|
601
|
+
// the sync, the role update, the legacy password) otherwise left a user without the admin role — and from then
|
|
602
|
+
// on `needsSetup` is false and init refused, so the deployment had users but no administrator and no way back
|
|
603
|
+
// short of editing the database. Seen on a deployed stage when linkAccount threw.
|
|
604
|
+
if (createdIamUserId && context) {
|
|
605
|
+
await this.rollbackInitialAdmin(context, createdIamUserId, input.email);
|
|
606
|
+
}
|
|
607
|
+
|
|
385
608
|
// Release the claim we took above: a caller that failed mid-creation must not
|
|
386
609
|
// leave a deployment with zero users and no way in. The marker is additionally
|
|
387
610
|
// stale-expiring, so this is belt AND braces for the crash-before-release case.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { isLocalEnv } from '../../common/helpers/cookies.helper.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The request header `POST /system-setup/init` reads the setup token from (`systemSetup.httpInit.token`).
|
|
7
|
+
*
|
|
8
|
+
* A header, never a body field: the body is the admin's credentials and is shaped by the DTO, while the token is a
|
|
9
|
+
* deployment secret the operator hands out separately. Header names are case-insensitive; Express lowercases them.
|
|
10
|
+
*
|
|
11
|
+
* @since 12.4.0
|
|
12
|
+
*/
|
|
13
|
+
export const SYSTEM_SETUP_TOKEN_HEADER = 'x-system-setup-token';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimum length of a configured setup token.
|
|
17
|
+
*
|
|
18
|
+
* The init route is anonymous and not rate-limited, so the token is all that stands between a caller and an
|
|
19
|
+
* administrator account. A shorter value is refused as a misconfiguration (the HTTP init stays closed), never
|
|
20
|
+
* accepted as a weak gate.
|
|
21
|
+
*
|
|
22
|
+
* @since 12.4.0
|
|
23
|
+
*/
|
|
24
|
+
export const SYSTEM_SETUP_TOKEN_MIN_LENGTH = 16;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether `POST /system-setup/init` may run, as decided from `systemSetup.httpInit` and the environment.
|
|
28
|
+
*
|
|
29
|
+
* @since 12.4.0
|
|
30
|
+
*/
|
|
31
|
+
export interface ISystemSetupHttpInitPolicy {
|
|
32
|
+
/** The HTTP init is open (with or without a token) */
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What is wrong with the configuration when it is unusable — a token that is missing, empty, too short or not a
|
|
37
|
+
* string. Set only when the configuration asked for a token-protected init and could not get one; the init then
|
|
38
|
+
* stays CLOSED. Meant for the boot log.
|
|
39
|
+
*/
|
|
40
|
+
problem?: string;
|
|
41
|
+
|
|
42
|
+
/** Why the decision came out this way: the environment default, or an explicit setting */
|
|
43
|
+
source: 'config' | 'default';
|
|
44
|
+
|
|
45
|
+
/** The configured token, when the init is open and token-protected */
|
|
46
|
+
token?: string;
|
|
47
|
+
|
|
48
|
+
/** A token must be sent in {@link SYSTEM_SETUP_TOKEN_HEADER} */
|
|
49
|
+
tokenRequired: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decide whether the anonymous `POST /system-setup/init` is open, from `systemSetup.httpInit` and the environment.
|
|
54
|
+
*
|
|
55
|
+
* Fail-closed in every branch that is not an explicit, well-formed opt-in:
|
|
56
|
+
*
|
|
57
|
+
* | `httpInit` | `local` / `ci` / `e2e` | every other environment |
|
|
58
|
+
* |---------------------------------------|------------------------|-------------------------|
|
|
59
|
+
* | not set (or not a boolean / object) | open, no token | **closed** |
|
|
60
|
+
* | `true` | open, no token | open, no token |
|
|
61
|
+
* | `false` / `{ enabled: false, … }` | closed | closed |
|
|
62
|
+
* | `{ token: '<≥ 16 characters>' }` | open, token required | open, token required |
|
|
63
|
+
* | an object without a usable token | closed + `problem` | closed + `problem` |
|
|
64
|
+
*
|
|
65
|
+
* The environment test is {@link isLocalEnv}: the deployed `develop` and `test` stages of a starter-based project,
|
|
66
|
+
* `production`, `staging` and an unknown or missing name all count as deployed.
|
|
67
|
+
*
|
|
68
|
+
* Why an object without a usable token CLOSES rather than opening without one: the natural way to write the option is
|
|
69
|
+
* `httpInit: { token: process.env.SETUP_TOKEN }`, and with the variable missing that is `{ token: undefined }`. Reading
|
|
70
|
+
* it as "open, no token" would turn a forgotten secret into an anonymous administrator endpoint — the incident this
|
|
71
|
+
* exists for. A value from `NSC__SYSTEM_SETUP__HTTP_INIT__TOKEN` that is empty or all digits arrives as a NUMBER
|
|
72
|
+
* (`getEnvironmentObject()` converts numeric strings, `''` included, and a long digit string loses digits on the way),
|
|
73
|
+
* so a non-string token is refused too. And a non-boolean `httpInit` (the string `'true'` from `NEST_SERVER_CONFIG`)
|
|
74
|
+
* reads as "not set", so a string cannot open the route by truthiness.
|
|
75
|
+
*
|
|
76
|
+
* @since 12.4.0
|
|
77
|
+
*/
|
|
78
|
+
export function resolveSystemSetupHttpInit(httpInit: unknown, env: string | undefined): ISystemSetupHttpInitPolicy {
|
|
79
|
+
if (httpInit === true) {
|
|
80
|
+
return { enabled: true, source: 'config', tokenRequired: false };
|
|
81
|
+
}
|
|
82
|
+
if (httpInit === false) {
|
|
83
|
+
return { enabled: false, source: 'config', tokenRequired: false };
|
|
84
|
+
}
|
|
85
|
+
if (httpInit && typeof httpInit === 'object' && !Array.isArray(httpInit)) {
|
|
86
|
+
const { enabled, token } = httpInit as { enabled?: unknown; token?: unknown };
|
|
87
|
+
if (enabled === false) {
|
|
88
|
+
return { enabled: false, source: 'config', tokenRequired: false };
|
|
89
|
+
}
|
|
90
|
+
const problem = tokenProblem(token);
|
|
91
|
+
if (problem) {
|
|
92
|
+
return { enabled: false, problem, source: 'config', tokenRequired: false };
|
|
93
|
+
}
|
|
94
|
+
return { enabled: true, source: 'config', token: token as string, tokenRequired: true };
|
|
95
|
+
}
|
|
96
|
+
return { enabled: isLocalEnv(env), source: 'default', tokenRequired: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether `provided` (the header value) matches the configured `expected` token.
|
|
101
|
+
*
|
|
102
|
+
* Both sides are hashed to the same length before `timingSafeEqual`, so neither the content nor the LENGTH of the
|
|
103
|
+
* configured token leaks through the response time. A missing, empty or non-string value never matches.
|
|
104
|
+
*
|
|
105
|
+
* @since 12.4.0
|
|
106
|
+
*/
|
|
107
|
+
export function isSystemSetupTokenValid(expected: string | undefined, provided: unknown): boolean {
|
|
108
|
+
if (typeof expected !== 'string' || !expected || typeof provided !== 'string' || !provided) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const digest = (value: string) => createHash('sha256').update(value, 'utf8').digest();
|
|
112
|
+
return timingSafeEqual(digest(expected), digest(provided));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Why a configured token cannot be used, or `undefined` when it can */
|
|
116
|
+
function tokenProblem(token: unknown): string | undefined {
|
|
117
|
+
if (token === undefined || token === null) {
|
|
118
|
+
return 'systemSetup.httpInit is an object without a token';
|
|
119
|
+
}
|
|
120
|
+
if (typeof token !== 'string') {
|
|
121
|
+
return (
|
|
122
|
+
`systemSetup.httpInit.token is a ${typeof token}, not a string — an empty or all-digit ` +
|
|
123
|
+
'NSC__SYSTEM_SETUP__HTTP_INIT__TOKEN is converted to a number; use a token with letters'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (token.trim().length < SYSTEM_SETUP_TOKEN_MIN_LENGTH) {
|
|
127
|
+
return `systemSetup.httpInit.token is shorter than ${SYSTEM_SETUP_TOKEN_MIN_LENGTH} characters`;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -277,6 +277,7 @@ export * from './core/modules/permissions/permissions-scanner.js';
|
|
|
277
277
|
export * from './core/modules/system-setup/core-system-setup.controller.js';
|
|
278
278
|
export * from './core/modules/system-setup/core-system-setup.module.js';
|
|
279
279
|
export * from './core/modules/system-setup/core-system-setup.service.js';
|
|
280
|
+
export * from './core/modules/system-setup/system-setup-http-init.helper.js';
|
|
280
281
|
|
|
281
282
|
// =====================================================================================================================
|
|
282
283
|
// Core - Modules - Sync (offline sync)
|