@tstdl/base 0.93.150 → 0.93.152

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.
@@ -30,6 +30,7 @@ export declare const authenticationApiDefinition: {
30
30
  readonly tenantId: string | undefined;
31
31
  readonly subject: string;
32
32
  readonly secret: string;
33
+ readonly remember: boolean;
33
34
  readonly data: undefined;
34
35
  }>;
35
36
  result: ObjectSchema<TokenPayload<import("type-fest").EmptyObject>>;
@@ -155,6 +156,7 @@ export declare function getAuthenticationApiDefinition<AdditionalTokenPayload ex
155
156
  readonly tenantId: string | undefined;
156
157
  readonly subject: string;
157
158
  readonly secret: string;
159
+ readonly remember: boolean;
158
160
  readonly data: AuthenticationData;
159
161
  }>;
160
162
  result: ObjectSchema<TokenPayload<AdditionalTokenPayload>>;
@@ -275,6 +277,7 @@ export declare function getAuthenticationApiEndpointsDefinition<AdditionalTokenP
275
277
  readonly tenantId: string | undefined;
276
278
  readonly subject: string;
277
279
  readonly secret: string;
280
+ readonly remember: boolean;
278
281
  readonly data: AuthenticationData;
279
282
  }>;
280
283
  result: ObjectSchema<TokenPayload<AdditionalTokenPayload>>;
@@ -1,5 +1,5 @@
1
1
  import { defineApi } from '../api/types.js';
2
- import { assign, emptyObjectSchema, explicitObject, literal, never, number, object, optional, string } from '../schema/index.js';
2
+ import { assign, boolean, defaulted, emptyObjectSchema, explicitObject, literal, never, number, object, optional, string } from '../schema/index.js';
3
3
  import { SecretCheckResult } from './models/secret-check-result.model.js';
4
4
  import { TokenPayloadBase } from './models/token-payload-base.model.js';
5
5
  /**
@@ -51,6 +51,7 @@ export function getAuthenticationApiEndpointsDefinition(additionalTokenPayloadSc
51
51
  tenantId: optional(string()),
52
52
  subject: string(),
53
53
  secret: string(),
54
+ remember: defaulted(boolean(), false),
54
55
  data: authenticationDataSchema,
55
56
  }),
56
57
  result: tokenResultSchema,
@@ -1,4 +1,3 @@
1
- import { type Observable } from 'rxjs';
2
1
  import type { AfterResolve } from '../../injector/index.js';
3
2
  import { afterResolve } from '../../injector/index.js';
4
3
  import type { Record } from '../../types/index.js';
@@ -36,7 +35,7 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
36
35
  * Observable for authentication errors.
37
36
  * Emits when a refresh fails.
38
37
  */
39
- readonly error$: Observable<Error>;
38
+ readonly error$: import("rxjs").Observable<Error>;
40
39
  /** Current token */
41
40
  readonly token: import("../../signals/api.js").WritableSignal<TokenPayload<AdditionalTokenPayload> | undefined>;
42
41
  /** Current raw token */
@@ -58,23 +57,23 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
58
57
  /** Whether the user is impersonated */
59
58
  readonly impersonated: import("../../signals/api.js").Signal<boolean>;
60
59
  /** Current token */
61
- readonly token$: Observable<TokenPayload<AdditionalTokenPayload> | undefined>;
60
+ readonly token$: import("rxjs").Observable<TokenPayload<AdditionalTokenPayload> | undefined>;
62
61
  /** Emits when token is available (not undefined) */
63
- readonly definedToken$: Observable<Exclude<TokenPayload<AdditionalTokenPayload>, void | undefined>>;
62
+ readonly definedToken$: import("rxjs").Observable<Exclude<TokenPayload<AdditionalTokenPayload>, void | undefined>>;
64
63
  /** Emits when a valid token is available (not undefined and not expired) */
65
- readonly validToken$: Observable<Exclude<TokenPayload<AdditionalTokenPayload>, void | undefined>>;
64
+ readonly validToken$: import("rxjs").Observable<Exclude<TokenPayload<AdditionalTokenPayload>, void | undefined>>;
66
65
  /** Current subject */
67
- readonly subjectId$: Observable<string | undefined>;
66
+ readonly subjectId$: import("rxjs").Observable<string | undefined>;
68
67
  /** Emits when subject is available */
69
- readonly definedSubjectId$: Observable<string>;
68
+ readonly definedSubjectId$: import("rxjs").Observable<string>;
70
69
  /** Current session id */
71
- readonly sessionId$: Observable<string | undefined>;
70
+ readonly sessionId$: import("rxjs").Observable<string | undefined>;
72
71
  /** Emits when session id is available */
73
- readonly definedSessionId$: Observable<string>;
72
+ readonly definedSessionId$: import("rxjs").Observable<string>;
74
73
  /** Whether the user is logged in */
75
- readonly isLoggedIn$: Observable<boolean>;
74
+ readonly isLoggedIn$: import("rxjs").Observable<boolean>;
76
75
  /** Emits when the user logs out */
77
- readonly loggedOut$: Observable<void>;
76
+ readonly loggedOut$: import("rxjs").Observable<void>;
78
77
  private get authenticationData();
79
78
  private set authenticationData(value);
80
79
  private get impersonatorAuthenticationData();
@@ -128,8 +127,9 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
128
127
  * @param subjectInput The subject to login with
129
128
  * @param secret The secret to login with
130
129
  * @param data Additional authentication data
130
+ * @param remember Whether to remember the session
131
131
  */
132
- login(subjectInput: SubjectInput, secret: string, data?: AuthenticationData): Promise<void>;
132
+ login(subjectInput: SubjectInput, secret: string, data?: AuthenticationData, remember?: boolean): Promise<void>;
133
133
  /**
134
134
  * Logout from the current session.
135
135
  * This will attempt to end the session on the server and then clear local credentials.
@@ -7,7 +7,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
- import { Subject, filter, firstValueFrom, from, map, race, skip, takeUntil, timer } from 'rxjs';
10
+ import { Subject, filter, firstValueFrom, from, race, skip, takeUntil, timer } from 'rxjs';
11
11
  import { CancellationSignal } from '../../cancellation/token.js';
12
12
  import { BadRequestError } from '../../errors/bad-request.error.js';
13
13
  import { ForbiddenError } from '../../errors/forbidden.error.js';
@@ -220,13 +220,14 @@ let AuthenticationClientService = class AuthenticationClientService {
220
220
  * @param subjectInput The subject to login with
221
221
  * @param secret The secret to login with
222
222
  * @param data Additional authentication data
223
+ * @param remember Whether to remember the session
223
224
  */
224
- async login(subjectInput, secret, data) {
225
+ async login(subjectInput, secret, data, remember = false) {
225
226
  if (isDefined(data)) {
226
227
  this.setAdditionalData(data);
227
228
  }
228
229
  const [token] = await Promise.all([
229
- this.client.login({ tenantId: subjectInput.tenantId, subject: subjectInput.subject, secret, data: this.authenticationData }),
230
+ this.client.login({ tenantId: subjectInput.tenantId, subject: subjectInput.subject, secret, remember, data: this.authenticationData }),
230
231
  this.syncClock(),
231
232
  ]);
232
233
  this.setNewToken(token);
@@ -398,76 +399,60 @@ let AuthenticationClientService = class AuthenticationClientService {
398
399
  if (this.isLoggedIn()) {
399
400
  await this.syncClock();
400
401
  }
402
+ // Helper to sleep until the delay passes OR a vital state change occurs
403
+ const waitForNextAction = async (delayMs, referenceExp) => await firstValueFrom(race([
404
+ timer(Math.max(10, delayMs)),
405
+ from(this.disposeSignal),
406
+ this.token$.pipe(filter((t) => t?.exp !== referenceExp)),
407
+ this.forceRefreshRequested$.pipe(filter(Boolean),
408
+ // Skip the current value if already true to prevent infinite tight loops
409
+ skip(this.forceRefreshRequested() ? 1 : 0)),
410
+ ]), { defaultValue: undefined });
401
411
  while (this.disposeSignal.isUnset) {
402
- const iterationToken = this.token();
412
+ const token = this.token();
413
+ // 1. Wait for login/token if none is present
414
+ if (isUndefined(token)) {
415
+ await firstValueFrom(race([this.definedToken$, from(this.disposeSignal)]), { defaultValue: undefined });
416
+ continue;
417
+ }
403
418
  try {
404
- const token = iterationToken;
405
- if (isUndefined(token)) {
406
- // Wait for login or dispose.
407
- // We ignore forceRefreshToken here because we can't refresh without a token.
408
- await firstValueFrom(race([this.definedToken$, from(this.disposeSignal)]), { defaultValue: undefined });
409
- continue;
410
- }
411
419
  const now = this.estimatedServerTimestampSeconds();
412
- const forceRefresh = this.forceRefreshRequested();
413
- const refreshBufferSeconds = calculateRefreshBufferSeconds(token);
414
- const needsRefresh = forceRefresh || (now >= (token.exp - refreshBufferSeconds));
420
+ const buffer = calculateRefreshBufferSeconds(token);
421
+ const needsRefresh = this.forceRefreshRequested() || (now >= token.exp - buffer);
422
+ // 2. Handle token refresh
415
423
  if (needsRefresh) {
416
424
  const lockResult = await this.lock.tryUse(undefined, async () => {
417
425
  const currentToken = this.token();
426
+ if (isUndefined(currentToken)) {
427
+ return;
428
+ }
429
+ // Passive Sync: Verify if refresh is still needed once lock is acquired
418
430
  const currentNow = this.estimatedServerTimestampSeconds();
419
- const currentRefreshBufferSeconds = isDefined(currentToken) ? calculateRefreshBufferSeconds(currentToken) : 0;
420
- // Passive Sync: Check if another tab refreshed the token while we were waiting for the lock (or trying to get it)
421
- const stillNeedsRefresh = isDefined(currentToken) && (this.forceRefreshRequested() || (currentNow >= (currentToken.exp - currentRefreshBufferSeconds)));
431
+ const currentBuffer = calculateRefreshBufferSeconds(currentToken);
432
+ const stillNeedsRefresh = this.forceRefreshRequested() || (currentNow >= currentToken.exp - currentBuffer);
422
433
  if (stillNeedsRefresh) {
423
434
  await this.refresh();
424
435
  }
425
- if (this.forceRefreshRequested() && (this.token()?.jti != currentToken?.jti)) {
426
- this.forceRefreshRequested.set(false);
427
- }
436
+ this.forceRefreshRequested.set(false);
428
437
  });
438
+ // If lock is held by another instance/tab, wait briefly for it to finish (passive sync)
429
439
  if (!lockResult.success) {
430
- // Lock held by another instance, wait 5 seconds or until token changes (Passive Sync)
431
- const changeReason = await firstValueFrom(race([
432
- timer(5000).pipe(map(() => 'timer')),
433
- this.token$.pipe(filter((t) => t?.jti != token.jti), map(() => 'token')),
434
- from(this.disposeSignal),
435
- ]), { defaultValue: undefined });
436
- if (changeReason == 'token') {
440
+ await waitForNextAction(5000, token.exp);
441
+ // If another tab successfully refreshed, the expiration will have changed
442
+ if (this.token()?.exp !== token.exp) {
437
443
  this.forceRefreshRequested.set(false);
438
444
  }
439
- continue;
440
445
  }
446
+ continue; // Re-evaluate the loop with the newly refreshed (or synced) token
441
447
  }
442
- const currentToken = this.token();
443
- if (isUndefined(currentToken)) {
444
- continue;
445
- }
446
- const currentRefreshBufferSeconds = calculateRefreshBufferSeconds(currentToken);
447
- const delay = clamp((currentToken.exp - this.estimatedServerTimestampSeconds() - currentRefreshBufferSeconds) * millisecondsPerSecond, minRefreshDelay, maxRefreshDelay);
448
- const wakeUpSignals = [
449
- from(this.disposeSignal),
450
- this.token$.pipe(filter((t) => t?.jti != currentToken.jti)),
451
- ];
452
- if (!forceRefresh) {
453
- wakeUpSignals.push(this.forceRefreshRequested$.pipe(filter((requested) => requested)));
454
- }
455
- if (delay > 0) {
456
- await firstValueFrom(race([timer(delay), ...wakeUpSignals]), { defaultValue: undefined });
457
- }
458
- else {
459
- await firstValueFrom(race([timer(2500), ...wakeUpSignals]), { defaultValue: undefined });
460
- }
448
+ // 3. Calculate delay and sleep until the next scheduled refresh window
449
+ const timeUntilRefreshMs = (token.exp - this.estimatedServerTimestampSeconds() - buffer) * millisecondsPerSecond;
450
+ const delay = clamp(timeUntilRefreshMs, minRefreshDelay, maxRefreshDelay);
451
+ await waitForNextAction(delay, token.exp);
461
452
  }
462
453
  catch (error) {
463
454
  this.logger.error(error);
464
- const currentToken = this.token();
465
- await firstValueFrom(race([
466
- timer(2500),
467
- from(this.disposeSignal),
468
- this.token$.pipe(filter((t) => t?.jti != currentToken?.jti)),
469
- this.forceRefreshRequested$.pipe(filter((requested) => requested), skip(this.forceRefreshRequested() ? 1 : 0)),
470
- ]), { defaultValue: undefined });
455
+ await waitForNextAction(2500, token.exp);
471
456
  }
472
457
  }
473
458
  }
@@ -26,6 +26,8 @@ export type RefreshToken = JwtToken<{
26
26
  impersonator?: string;
27
27
  /** The id of the session. */
28
28
  session: string;
29
+ /** Whether to remember the session. */
30
+ remember: boolean;
29
31
  /** The secret to use for refreshing the token. */
30
32
  secret: string;
31
33
  }>;
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
1
2
  import type { ApiController, ApiRequestContext, ApiServerResult } from '../../api/types.js';
2
3
  import { HttpServerResponse } from '../../http/server/index.js';
3
4
  import type { ObjectSchemaOrType, SchemaTestable } from '../../schema/index.js';
@@ -71,7 +72,7 @@ export declare class AuthenticationApiController<AdditionalTokenPayload extends
71
72
  * @returns The current server timestamp in seconds.
72
73
  */
73
74
  timestamp(): ApiServerResult<AuthenticationApiDefinition<AdditionalTokenPayload, AuthenticationData, AdditionalInitSecretResetData>, 'timestamp'>;
74
- protected getTokenResponse({ token, jsonToken, refreshToken, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }: TokenResult<AdditionalTokenPayload>): HttpServerResponse;
75
+ protected getTokenResponse({ token, jsonToken, refreshToken, remember, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }: TokenResult<AdditionalTokenPayload>): HttpServerResponse;
75
76
  }
76
77
  /**
77
78
  * Get an authentication API controller.
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
1
2
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -8,7 +9,7 @@ import { apiController } from '../../api/server/index.js';
8
9
  import { HttpServerResponse } from '../../http/server/index.js';
9
10
  import { inject } from '../../injector/index.js';
10
11
  import { currentTimestampSeconds } from '../../utils/date-time.js';
11
- import { assertDefinedPass, isDefined } from '../../utils/type-guards.js';
12
+ import { isDefined } from '../../utils/type-guards.js';
12
13
  import { authenticationApiDefinition, getAuthenticationApiDefinition } from '../authentication.api.js';
13
14
  import { AuthenticationService } from './authentication.service.js';
14
15
  import { tryGetAuthorizationTokenStringFromRequest } from './helper.js';
@@ -29,7 +30,7 @@ let AuthenticationApiController = class AuthenticationApiController {
29
30
  * @returns The token result.
30
31
  */
31
32
  async login({ parameters, getAuditor }) {
32
- const result = await this.authenticationService.login({ tenantId: parameters.tenantId, subject: parameters.subject }, parameters.secret, parameters.data, await getAuditor());
33
+ const result = await this.authenticationService.login({ tenantId: parameters.tenantId, subject: parameters.subject }, parameters.secret, parameters.data, await getAuditor(), parameters.remember);
33
34
  return this.getTokenResponse(result);
34
35
  }
35
36
  /**
@@ -140,7 +141,7 @@ let AuthenticationApiController = class AuthenticationApiController {
140
141
  timestamp() {
141
142
  return currentTimestampSeconds();
142
143
  }
143
- getTokenResponse({ token, jsonToken, refreshToken, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }) {
144
+ getTokenResponse({ token, jsonToken, refreshToken, remember, omitImpersonatorRefreshToken, impersonatorRefreshToken, impersonatorRefreshTokenExpiration }) {
144
145
  const result = jsonToken.payload;
145
146
  const options = {
146
147
  headers: {
@@ -151,12 +152,12 @@ let AuthenticationApiController = class AuthenticationApiController {
151
152
  authorization: {
152
153
  value: `Bearer ${token}`,
153
154
  ...cookieBaseOptions,
154
- expires: jsonToken.payload.exp * 1000,
155
+ expires: remember ? (jsonToken.payload.exp * 1000) : undefined,
155
156
  },
156
157
  refreshToken: {
157
158
  value: `Bearer ${refreshToken}`,
158
159
  ...cookieBaseOptions,
159
- expires: jsonToken.payload.refreshTokenExp * 1000,
160
+ expires: remember ? (jsonToken.payload.refreshTokenExp * 1000) : undefined,
160
161
  },
161
162
  },
162
163
  body: {
@@ -168,7 +169,7 @@ let AuthenticationApiController = class AuthenticationApiController {
168
169
  options.cookies['impersonatorRefreshToken'] = {
169
170
  value: `Bearer ${impersonatorRefreshToken}`,
170
171
  ...cookieBaseOptions,
171
- expires: assertDefinedPass(impersonatorRefreshTokenExpiration) * 1000,
172
+ expires: (remember && isDefined(impersonatorRefreshTokenExpiration)) ? (impersonatorRefreshTokenExpiration * 1000) : undefined,
172
173
  };
173
174
  }
174
175
  if (omitImpersonatorRefreshToken == true) {
@@ -2,6 +2,7 @@ import type { SubjectInput } from '../types.js';
2
2
  export type AuthenticationAuditEvents = {
3
3
  'login-success': {
4
4
  sessionId: string;
5
+ remember: boolean;
5
6
  };
6
7
  'login-failure': {
7
8
  subjectInput: SubjectInput;
@@ -12,6 +13,7 @@ export type AuthenticationAuditEvents = {
12
13
  };
13
14
  'refresh-success': {
14
15
  sessionId: string;
16
+ remember: boolean;
15
17
  };
16
18
  'refresh-failure': {
17
19
  reason: string;
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
1
2
  import { Auditor } from '../../audit/index.js';
2
3
  import { afterResolve, type AfterResolve } from '../../injector/index.js';
3
4
  import type { BinaryData, Record } from '../../types/index.js';
@@ -59,6 +60,12 @@ export declare class AuthenticationServiceOptions {
59
60
  * @default 5 days
60
61
  */
61
62
  refreshTokenTimeToLive?: number;
63
+ /**
64
+ * How long a refresh token is valid in milliseconds if "remember" is checked.
65
+ *
66
+ * @default 30 days
67
+ */
68
+ rememberRefreshTokenTimeToLive?: number;
62
69
  /**
63
70
  * How long a secret reset token is valid in milliseconds.
64
71
  *
@@ -97,6 +104,7 @@ export type TokenResult<AdditionalTokenPayload extends Record> = {
97
104
  token: string;
98
105
  jsonToken: Token<AdditionalTokenPayload>;
99
106
  refreshToken: string;
107
+ remember: boolean;
100
108
  omitImpersonatorRefreshToken?: boolean;
101
109
  impersonatorRefreshToken?: string;
102
110
  impersonatorRefreshTokenExpiration?: number;
@@ -161,6 +169,7 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
161
169
  private readonly tokenVersion;
162
170
  private readonly tokenTimeToLive;
163
171
  private readonly refreshTokenTimeToLive;
172
+ private readonly rememberRefreshTokenTimeToLive;
164
173
  private readonly secretResetTokenTimeToLive;
165
174
  private derivedTokenSigningSecret;
166
175
  private derivedRefreshTokenSigningSecret;
@@ -196,8 +205,9 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
196
205
  * @param options Options for getting the token.
197
206
  * @returns The token result.
198
207
  */
199
- getToken(subject: Subject, authenticationData: AuthenticationData, { impersonator }?: {
208
+ getToken(subject: Subject, authenticationData: AuthenticationData, { impersonator, remember }?: {
200
209
  impersonator?: string;
210
+ remember?: boolean;
201
211
  }): Promise<TokenResult<AdditionalTokenPayload>>;
202
212
  /**
203
213
  * Logs in a subject.
@@ -205,9 +215,10 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
205
215
  * @param secret The secret to log in with.
206
216
  * @param data Additional authentication data.
207
217
  * @param auditor Auditor for auditing.
218
+ * @param remember Whether to remember the session.
208
219
  * @returns Token
209
220
  */
210
- login(subjectInput: SubjectInput, secret: string, data: AuthenticationData, auditor: Auditor): Promise<TokenResult<AdditionalTokenPayload>>;
221
+ login(subjectInput: SubjectInput, secret: string, data: AuthenticationData, auditor: Auditor, remember?: boolean): Promise<TokenResult<AdditionalTokenPayload>>;
211
222
  /**
212
223
  * Ends a session.
213
224
  * @param sessionId The id of the session to end.
@@ -366,6 +377,7 @@ export declare class AuthenticationService<AdditionalTokenPayload extends Record
366
377
  */
367
378
  createRefreshToken(subject: Subject, sessionId: string, expirationTimestamp: number, options?: {
368
379
  impersonator?: string;
380
+ remember?: boolean;
369
381
  }): Promise<CreateRefreshTokenResult>;
370
382
  defaultResolveSubjects({ tenantId, subject }: {
371
383
  tenantId?: string;
@@ -1,3 +1,4 @@
1
+ /** biome-ignore-all lint/nursery/noExcessiveClassesPerFile: <explanation> */
1
2
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -57,6 +58,12 @@ export class AuthenticationServiceOptions {
57
58
  * @default 5 days
58
59
  */
59
60
  refreshTokenTimeToLive;
61
+ /**
62
+ * How long a refresh token is valid in milliseconds if "remember" is checked.
63
+ *
64
+ * @default 30 days
65
+ */
66
+ rememberRefreshTokenTimeToLive;
60
67
  /**
61
68
  * How long a secret reset token is valid in milliseconds.
62
69
  *
@@ -120,6 +127,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
120
127
  tokenVersion = this.#options.version ?? 1;
121
128
  tokenTimeToLive = this.#options.tokenTimeToLive ?? (5 * millisecondsPerMinute);
122
129
  refreshTokenTimeToLive = this.#options.refreshTokenTimeToLive ?? (5 * millisecondsPerDay);
130
+ rememberRefreshTokenTimeToLive = this.#options.rememberRefreshTokenTimeToLive ?? (30 * millisecondsPerDay);
123
131
  secretResetTokenTimeToLive = this.#options.secretResetTokenTimeToLive ?? (10 * millisecondsPerMinute);
124
132
  derivedTokenSigningSecret;
125
133
  derivedRefreshTokenSigningSecret;
@@ -201,9 +209,10 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
201
209
  * @param options Options for getting the token.
202
210
  * @returns The token result.
203
211
  */
204
- async getToken(subject, authenticationData, { impersonator } = {}) {
212
+ async getToken(subject, authenticationData, { impersonator, remember = false } = {}) {
205
213
  const now = currentTimestamp();
206
- const end = now + this.refreshTokenTimeToLive;
214
+ const ttl = remember ? this.rememberRefreshTokenTimeToLive : this.refreshTokenTimeToLive;
215
+ const end = now + ttl;
207
216
  return await this.#sessionRepository.transaction(async (tx) => {
208
217
  const session = await this.#sessionRepository.withTransaction(tx).insert({
209
218
  tenantId: subject.tenantId,
@@ -216,14 +225,14 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
216
225
  });
217
226
  const tokenPayload = await this.#authenticationAncillaryService?.getTokenPayload(subject, authenticationData, { action: GetTokenPayloadContextAction.GetToken });
218
227
  const { token, jsonToken } = await this.createToken({ additionalTokenPayload: tokenPayload, subject, impersonator, sessionId: session.id, refreshTokenExpiration: end, timestamp: now });
219
- const refreshToken = await this.createRefreshToken(subject, session.id, end, { impersonator });
228
+ const refreshToken = await this.createRefreshToken(subject, session.id, end, { impersonator, remember });
220
229
  await this.#sessionRepository.withTransaction(tx).update(session.id, {
221
230
  end,
222
231
  refreshTokenHashVersion: 1,
223
232
  refreshTokenSalt: refreshToken.salt,
224
233
  refreshTokenHash: refreshToken.hash,
225
234
  });
226
- return { token, jsonToken, refreshToken: refreshToken.token };
235
+ return { token, jsonToken, refreshToken: refreshToken.token, remember };
227
236
  });
228
237
  }
229
238
  /**
@@ -232,9 +241,10 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
232
241
  * @param secret The secret to log in with.
233
242
  * @param data Additional authentication data.
234
243
  * @param auditor Auditor for auditing.
244
+ * @param remember Whether to remember the session.
235
245
  * @returns Token
236
246
  */
237
- async login(subjectInput, secret, data, auditor) {
247
+ async login(subjectInput, secret, data, auditor, remember = false) {
238
248
  const authAuditor = auditor.fork(AuthenticationService_1.name);
239
249
  const authenticationResult = await this.authenticate(subjectInput, secret);
240
250
  if (!authenticationResult.success) {
@@ -249,7 +259,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
249
259
  throw new InvalidCredentialsError();
250
260
  }
251
261
  await this.hooks.beforeLogin.trigger({ subject: authenticationResult.subject });
252
- const token = await this.getToken(authenticationResult.subject, data);
262
+ const token = await this.getToken(authenticationResult.subject, data, { remember });
253
263
  await this.hooks.afterLogin.trigger({ subject: authenticationResult.subject });
254
264
  const sessionId = token.jsonToken.payload.session;
255
265
  await authAuditor.info('login-success', {
@@ -259,7 +269,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
259
269
  targetId: authenticationResult.subject.id,
260
270
  targetType: 'User',
261
271
  network: { sessionId },
262
- details: { sessionId },
272
+ details: { sessionId, remember },
263
273
  });
264
274
  return token;
265
275
  }
@@ -361,11 +371,13 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
361
371
  }
362
372
  const now = currentTimestamp();
363
373
  const impersonator = (options.omitImpersonator == true) ? undefined : validatedRefreshToken.payload.impersonator;
364
- const newEnd = now + this.refreshTokenTimeToLive;
374
+ const remember = validatedRefreshToken.payload.remember;
375
+ const ttl = remember ? this.rememberRefreshTokenTimeToLive : this.refreshTokenTimeToLive;
376
+ const newEnd = now + ttl;
365
377
  const subject = await this.#subjectRepository.loadByQuery({ tenantId: session.tenantId, id: session.subjectId });
366
378
  const tokenPayload = await this.#authenticationAncillaryService?.getTokenPayload(subject, authenticationData, { action: GetTokenPayloadContextAction.Refresh });
367
379
  const { token, jsonToken } = await this.createToken({ additionalTokenPayload: tokenPayload, subject, sessionId, refreshTokenExpiration: newEnd, impersonator, timestamp: now });
368
- const newRefreshToken = await this.createRefreshToken(subject, sessionId, newEnd, { impersonator });
380
+ const newRefreshToken = await this.createRefreshToken(subject, sessionId, newEnd, { impersonator, remember });
369
381
  await this.#sessionRepository.update(sessionId, {
370
382
  end: newEnd,
371
383
  refreshTokenHashVersion: 1,
@@ -378,9 +390,9 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
378
390
  actorType: ActorType.Subject,
379
391
  targetId: session.subjectId,
380
392
  targetType: 'User',
381
- details: { sessionId },
393
+ details: { sessionId, remember },
382
394
  });
383
- return { token, jsonToken, refreshToken: newRefreshToken.token, omitImpersonatorRefreshToken: options.omitImpersonator };
395
+ return { token, jsonToken, refreshToken: newRefreshToken.token, remember, omitImpersonatorRefreshToken: options.omitImpersonator };
384
396
  }
385
397
  catch (error) {
386
398
  await authAuditor.warn('refresh-failure', {
@@ -715,6 +727,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
715
727
  tenant: subject.tenantId,
716
728
  impersonator: options?.impersonator,
717
729
  session: sessionId,
730
+ remember: options?.remember ?? false,
718
731
  secret,
719
732
  },
720
733
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
2
+ import { Auditor } from '../../audit/index.js';
3
+ import { HttpHeaders } from '../../http/index.js';
4
+ import { HttpServerResponse } from '../../http/server/index.js';
5
+ import { clearTenantData, setupIntegrationTest } from '../../testing/index.js';
6
+ import { toArray } from '../../utils/array/array.js';
7
+ import { AuthenticationApiController } from '../server/authentication.api-controller.js';
8
+ import { AuthenticationService } from '../server/authentication.service.js';
9
+ import { SubjectService } from '../server/subject.service.js';
10
+ import { DefaultAuthenticationAncillaryService } from './authentication.test-ancillary-service.js';
11
+ describe('AuthenticationApiController Remember Functionality', () => {
12
+ let injector;
13
+ let database;
14
+ let controller;
15
+ let authenticationService;
16
+ let subjectService;
17
+ let auditor;
18
+ const schema = 'authentication';
19
+ const tenantId = crypto.randomUUID();
20
+ beforeAll(async () => {
21
+ ({ injector, database } = await setupIntegrationTest({
22
+ modules: { authentication: true, audit: true, keyValueStore: true },
23
+ authenticationAncillaryService: DefaultAuthenticationAncillaryService,
24
+ }));
25
+ authenticationService = await injector.resolveAsync(AuthenticationService);
26
+ subjectService = await injector.resolveAsync(SubjectService);
27
+ auditor = injector.resolve(Auditor);
28
+ controller = injector.resolve(AuthenticationApiController);
29
+ });
30
+ afterAll(async () => {
31
+ await injector?.dispose();
32
+ });
33
+ beforeEach(async () => {
34
+ await clearTenantData(database, schema, ['credentials', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
35
+ });
36
+ test('login with remember: true should have Expires in cookies', async () => {
37
+ const user = await subjectService.createUser({ tenantId, email: 'api-rem@example.com', firstName: 'A', lastName: 'L' });
38
+ await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
39
+ const context = {
40
+ parameters: { tenantId, subject: user.id, secret: 'Pass-R3m3mb3r-2026!', remember: true, data: undefined },
41
+ getAuditor: async () => auditor,
42
+ };
43
+ const response = await controller.login(context);
44
+ expect(response).toBeInstanceOf(HttpServerResponse);
45
+ const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
46
+ expect(setCookieHeaders).toHaveLength(2); // authorization and refreshToken
47
+ for (const cookie of setCookieHeaders) {
48
+ expect(cookie).toMatch(/Expires=/);
49
+ }
50
+ });
51
+ test('login with remember: false should NOT have Expires in cookies', async () => {
52
+ const user = await subjectService.createUser({ tenantId, email: 'api-no-rem@example.com', firstName: 'A', lastName: 'L' });
53
+ await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
54
+ const context = {
55
+ parameters: { tenantId, subject: user.id, secret: 'Pass-R3m3mb3r-2026!', remember: false, data: undefined },
56
+ getAuditor: async () => auditor,
57
+ };
58
+ const response = await controller.login(context);
59
+ const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
60
+ for (const cookie of setCookieHeaders) {
61
+ expect(cookie).not.toMatch(/Expires=/);
62
+ expect(cookie).not.toMatch(/Max-Age=/);
63
+ }
64
+ });
65
+ test('refresh should propagate remember status to cookies', async () => {
66
+ const user = await subjectService.createUser({ tenantId, email: 'api-refresh-rem@example.com', firstName: 'A', lastName: 'L' });
67
+ await authenticationService.setCredentials(user, 'Pass-R3m3mb3r-2026!');
68
+ // 1. Login with remember: true
69
+ const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Pass-R3m3mb3r-2026!', undefined, auditor, true);
70
+ // 2. Refresh
71
+ const context = {
72
+ request: {
73
+ headers: new HttpHeaders({
74
+ 'X-Refresh-Token': `Bearer ${loginResult.refreshToken}`
75
+ }),
76
+ cookies: {
77
+ tryGet: () => undefined
78
+ }
79
+ },
80
+ parameters: { data: undefined },
81
+ getAuditor: async () => auditor,
82
+ };
83
+ const response = await controller.refresh(context);
84
+ const setCookieHeaders = toArray(response.headers.tryGet('Set-Cookie') ?? []);
85
+ for (const cookie of setCookieHeaders) {
86
+ expect(cookie).toMatch(/Expires=/);
87
+ }
88
+ // 3. Login with remember: false
89
+ const loginResultNoRem = await authenticationService.login({ tenantId, subject: user.id }, 'Pass-R3m3mb3r-2026!', undefined, auditor, false);
90
+ // 4. Refresh
91
+ const contextNoRem = {
92
+ request: {
93
+ headers: new HttpHeaders({
94
+ 'X-Refresh-Token': `Bearer ${loginResultNoRem.refreshToken}`
95
+ }),
96
+ cookies: {
97
+ tryGet: () => undefined
98
+ }
99
+ },
100
+ parameters: { data: undefined },
101
+ getAuditor: async () => auditor,
102
+ };
103
+ const responseNoRem = await controller.refresh(contextNoRem);
104
+ const setCookieHeadersNoRem = toArray(responseNoRem.headers.tryGet('Set-Cookie') ?? []);
105
+ for (const cookie of setCookieHeadersNoRem) {
106
+ expect(cookie).not.toMatch(/Expires=/);
107
+ }
108
+ });
109
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
2
+ import { Auditor } from '../../audit/index.js';
3
+ import { clearTenantData, setupIntegrationTest } from '../../testing/index.js';
4
+ import { AuthenticationService } from '../server/authentication.service.js';
5
+ import { getRefreshTokenFromString } from '../server/helper.js';
6
+ import { SubjectService } from '../server/subject.service.js';
7
+ import { DefaultAuthenticationAncillaryService } from './authentication.test-ancillary-service.js';
8
+ describe('AuthenticationService Remember Functionality', () => {
9
+ let injector;
10
+ let database;
11
+ let authenticationService;
12
+ let subjectService;
13
+ let auditor;
14
+ const schema = 'authentication';
15
+ const tenantId = crypto.randomUUID();
16
+ beforeAll(async () => {
17
+ ({ injector, database } = await setupIntegrationTest({
18
+ modules: { authentication: true },
19
+ authenticationAncillaryService: DefaultAuthenticationAncillaryService,
20
+ }));
21
+ authenticationService = await injector.resolveAsync(AuthenticationService);
22
+ subjectService = await injector.resolveAsync(SubjectService);
23
+ auditor = injector.resolve(Auditor);
24
+ });
25
+ afterAll(async () => {
26
+ await injector?.dispose();
27
+ });
28
+ beforeEach(async () => {
29
+ await clearTenantData(database, schema, ['credentials', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
30
+ });
31
+ test('RefreshToken should contain remember flag', async () => {
32
+ const user = await subjectService.createUser({
33
+ tenantId,
34
+ email: 'remember@example.com',
35
+ firstName: 'Rem',
36
+ lastName: 'Ember',
37
+ });
38
+ const tokenResult = await authenticationService.getToken(user, undefined, { remember: true });
39
+ const refreshToken = await getRefreshTokenFromString(tokenResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
40
+ expect(refreshToken.payload).toHaveProperty('remember', true);
41
+ expect(tokenResult.remember).toBe(true);
42
+ });
43
+ test('RefreshToken should respect remember flag for expiration', async () => {
44
+ const user = await subjectService.createUser({ tenantId, email: 'ttl@example.com', firstName: 'T', lastName: 'L' });
45
+ const normalResult = await authenticationService.getToken(user, undefined, { remember: false });
46
+ const rememberResult = await authenticationService.getToken(user, undefined, { remember: true });
47
+ const normalToken = await getRefreshTokenFromString(normalResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
48
+ const rememberToken = await getRefreshTokenFromString(rememberResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
49
+ expect(rememberToken.payload.exp).toBeGreaterThan(normalToken.payload.exp);
50
+ });
51
+ test('login should pass remember flag to getToken', async () => {
52
+ const user = await subjectService.createUser({ tenantId, email: 'login-rem@example.com', firstName: 'L', lastName: 'R' });
53
+ await authenticationService.setCredentials(user, 'Strong-Password-2026!');
54
+ const result = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, true);
55
+ expect(result.remember).toBe(true);
56
+ const refreshToken = await getRefreshTokenFromString(result.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
57
+ expect(refreshToken.payload.remember).toBe(true);
58
+ });
59
+ test('refresh should propagate remember flag', async () => {
60
+ const user = await subjectService.createUser({ tenantId, email: 'refresh-rem@example.com', firstName: 'R', lastName: 'R' });
61
+ await authenticationService.setCredentials(user, 'Strong-Password-2026!');
62
+ const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, true);
63
+ expect(loginResult.remember).toBe(true);
64
+ const refreshResult = await authenticationService.refresh(loginResult.refreshToken, undefined, {}, auditor);
65
+ expect(refreshResult.remember).toBe(true);
66
+ const newRefreshToken = await getRefreshTokenFromString(refreshResult.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
67
+ expect(newRefreshToken.payload.remember).toBe(true);
68
+ // Verify it also works when not remembered
69
+ const loginResultNoRem = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor, false);
70
+ expect(loginResultNoRem.remember).toBe(false);
71
+ const refreshResultNoRem = await authenticationService.refresh(loginResultNoRem.refreshToken, undefined, {}, auditor);
72
+ expect(refreshResultNoRem.remember).toBe(false);
73
+ const newRefreshTokenNoRem = await getRefreshTokenFromString(refreshResultNoRem.refreshToken, authenticationService.derivedRefreshTokenSigningSecret);
74
+ expect(newRefreshTokenNoRem.payload.remember).toBe(false);
75
+ });
76
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.150",
3
+ "version": "0.93.152",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,7 +1,7 @@
1
1
  import { NotSupportedError } from '../../errors/not-supported.error.js';
2
2
  import { filterUndefinedObjectProperties, fromEntries, hasOwnProperty, objectEntries } from '../../utils/object/object.js';
3
3
  import { isDefined, isNotNull, isNumber, isString } from '../../utils/type-guards.js';
4
- import { ArraySchema, BooleanSchema, DateSchema, DefaultSchema, EnumerationSchema, LiteralSchema, nullable, NullableSchema, NumberSchema, ObjectSchema, OptionalSchema, StringSchema, TransformSchema, Uint8ArraySchema, UnionSchema } from '../schemas/index.js';
4
+ import { AnySchema, ArraySchema, BooleanSchema, DateSchema, DefaultSchema, EnumerationSchema, LiteralSchema, NullableSchema, NumberSchema, ObjectSchema, OptionalSchema, StringSchema, TransformSchema, Uint8ArraySchema, UnionSchema, UnknownSchema } from '../schemas/index.js';
5
5
  import { schemaTestableToSchema } from '../testable.js';
6
6
  export function convertToOpenApiSchema(testable) {
7
7
  const schema = schemaTestableToSchema(testable);
@@ -17,7 +17,7 @@ export function convertToOpenApiSchema(testable) {
17
17
  function convertToOpenApiSchemaBase(schema) {
18
18
  if (schema instanceof ObjectSchema) {
19
19
  const entries = objectEntries(schema.properties);
20
- const convertedEntries = entries.map(([property, propertySchema]) => [property, convertToOpenApiSchema(stripOptional(propertySchema))]);
20
+ const convertedEntries = entries.map(([property, propertySchema]) => [property, convertToOpenApiSchema(propertySchema)]);
21
21
  const required = entries
22
22
  .filter(([, propertySchema]) => !(propertySchema instanceof OptionalSchema) && !((propertySchema instanceof NullableSchema) && (propertySchema.schema instanceof OptionalSchema)))
23
23
  .map(([property]) => property);
@@ -29,13 +29,13 @@ function convertToOpenApiSchemaBase(schema) {
29
29
  maxProperties: isNumber(schema.maximumPropertiesCount) ? schema.maximumPropertiesCount : undefined,
30
30
  });
31
31
  }
32
- if (schema instanceof DefaultSchema) { // You'd need to import DefaultedSchema
32
+ if (schema instanceof DefaultSchema) {
33
33
  return {
34
34
  ...convertToOpenApiSchema(schema.schema),
35
35
  default: schema.defaultValue,
36
36
  };
37
37
  }
38
- if (schema instanceof TransformSchema) { // You'd need to import TransformSchema
38
+ if (schema instanceof TransformSchema) {
39
39
  return convertToOpenApiSchema(schema.schema);
40
40
  }
41
41
  if (schema instanceof StringSchema) {
@@ -114,19 +114,16 @@ function convertToOpenApiSchemaBase(schema) {
114
114
  nullable: true,
115
115
  };
116
116
  }
117
+ if (schema instanceof OptionalSchema) {
118
+ return convertToOpenApiSchema(schema.schema);
119
+ }
117
120
  if (schema instanceof UnionSchema) {
118
121
  return {
119
122
  anyOf: schema.schemas.map((innerSchema) => convertToOpenApiSchema(innerSchema)),
120
123
  };
121
124
  }
122
- throw new NotSupportedError(`Schema "${schema.name}" not supported.`);
123
- }
124
- function stripOptional(schema) {
125
- if ((schema instanceof OptionalSchema)) {
126
- return schema.schema;
127
- }
128
- if ((schema instanceof NullableSchema) && (schema.schema instanceof OptionalSchema)) {
129
- return nullable(schema.schema.schema);
125
+ if ((schema instanceof AnySchema) || (schema instanceof UnknownSchema)) {
126
+ return {};
130
127
  }
131
- return schema;
128
+ throw new NotSupportedError(`Schema "${schema.name}" not supported.`);
132
129
  }
package/testing/README.md CHANGED
@@ -80,37 +80,48 @@ The `setupIntegrationTest` utility (found in `source/testing/integration-setup.t
80
80
  4. **Schema Isolation**: Automatically creates and uses PostgreSQL schemas for isolation.
81
81
  5. **Modules**: Optional configuration for `taskQueue`, `authentication`, `objectStorage`, `notification`, etc.
82
82
 
83
- Integration tests typically require an injection context to resolve services and repositories. You can use the `testInInjector` (or `itInInjector`) helper to automatically wrap your test body.
83
+ ### Injection Context: `testInInjector`
84
+
85
+ Integration tests may require an injection context to resolve services and repositories. You can use the `testInInjector` (or `itInInjector`) helper to automatically wrap your test body.
84
86
 
85
87
  > [!IMPORTANT]
86
- > `testInInjector` is designed for test files where a **single injector** is shared across all tests (typically initialized in `beforeAll`). It accepts a `ValueOrProvider<Injector>`, allowing you to pass a getter function (e.g., `() => injector`) if the injector variable is initialized late.
88
+ > `testInInjector` is **only required** for code that uses `inject()` or `injectRepository()` outside of a class constructor/initializer (e.g. ad-hoc repository injection in a test body).
87
89
  >
88
- > If your tests require a fresh injector per test case (e.g. `setupIntegrationTest` in `beforeEach`), you can still use `testInInjector` by passing a provider.
90
+ > If you resolve your services in `beforeAll` or `beforeEach`, calling their methods usually does **not** require `testInInjector`.
91
+
92
+ > [!TIP]
93
+ > **Performance & Reusability**: For most integration tests, resolve services once in `beforeAll` and reuse the variables in your tests.
89
94
 
90
95
  ```typescript
91
- import { beforeAll, describe, expect } from 'vitest';
96
+ import { beforeAll, describe, expect, test } from 'vitest';
92
97
  import { setupIntegrationTest, testInInjector } from '#/testing/index.js';
93
98
  import { MyService } from '../my-service.js';
94
99
 
95
100
  describe('MyService Integration', () => {
96
- let injector;
101
+ let injector: Injector;
102
+ let myService: MyService;
97
103
 
98
104
  beforeAll(async () => {
99
- // Setup with database and specific modules
100
105
  ({ injector } = await setupIntegrationTest({
101
106
  modules: { taskQueue: true },
102
- orm: { schema: 'test_my_service' },
103
107
  }));
108
+
109
+ // Resolve reused services once
110
+ myService = injector.resolve(MyService);
104
111
  });
105
112
 
106
- testInInjector('should process data through the database', injector, async () => {
107
- const service = injector.resolve(MyService);
108
- const result = await service.process();
113
+ // Calling methods on resolved services does NOT require testInInjector
114
+ test('should process data', async () => {
115
+ const result = await myService.process();
109
116
  expect(result.success).toBe(true);
110
117
  });
111
118
 
112
- // Using a provider function to defer resolution of the 'injector' variable when using `beforeEach` for injector setup
113
- testInInjector('should process data through the database', () => injector, async () => { ... });
119
+ // testInInjector is ONLY needed if you use inject() directly in the test body
120
+ testInInjector('should work with ad-hoc injection', () => injector, async () => {
121
+ const repo = injectRepository(SomeEntity);
122
+ // ...
123
+ },
124
+ );
114
125
  });
115
126
  ```
116
127