@lenne.tech/nest-server 11.42.2 → 11.42.3

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 (46) hide show
  1. package/.claude/rules/configurable-features.md +2 -1
  2. package/.claude/rules/role-system.md +31 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +1 -1
  6. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
  7. package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
  8. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
  9. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
  10. package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
  11. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
  12. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  13. package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
  14. package/dist/core/modules/auth/services/core-auth.service.js +19 -0
  15. package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
  16. package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
  17. package/dist/core/modules/better-auth/better-auth.config.js +27 -1
  18. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
  20. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  21. package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
  22. package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
  23. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  24. package/dist/core/modules/user/core-user.service.js +11 -6
  25. package/dist/core/modules/user/core-user.service.js.map +1 -1
  26. package/dist/core.module.d.ts +3 -0
  27. package/dist/core.module.js +100 -42
  28. package/dist/core.module.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/tsconfig.build.tsbuildinfo +1 -1
  33. package/docs/REQUEST-LIFECYCLE.md +1 -0
  34. package/migration-guides/11.42.2-to-11.42.3.md +145 -0
  35. package/package.json +1 -1
  36. package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
  37. package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
  38. package/src/core/common/interfaces/server-options.interface.ts +32 -2
  39. package/src/core/modules/auth/services/core-auth.service.ts +37 -0
  40. package/src/core/modules/better-auth/better-auth.config.ts +46 -1
  41. package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
  42. package/src/core/modules/tenant/README.md +8 -8
  43. package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
  44. package/src/core/modules/user/core-user.service.ts +19 -6
  45. package/src/core.module.ts +166 -55
  46. package/src/index.ts +1 -0
@@ -5,9 +5,15 @@
5
5
  // `graphql-upload` and `lodash` for the same reason.
6
6
  import graphql = require('graphql');
7
7
 
8
- import { getTenantContextResolver } from '../services/core-tenant-context.registry';
9
- import { ConfigService } from '../services/config.service';
8
+ import { getTenantContextResolver, ResolvedTenantContext } from '../services/core-tenant-context.registry';
10
9
  import { IRequestContext, RequestContext } from '../services/request-context.service';
10
+ import {
11
+ ensureWsConnectionCurrent,
12
+ getWsConnection,
13
+ noteWsConnectionTenant,
14
+ readWsTenantHeader,
15
+ WsConnection,
16
+ } from './graphql-ws-connection.helper';
11
17
 
12
18
  /**
13
19
  * A `RequestContext` for GraphQL operations that arrive over a WEBSOCKET.
@@ -39,6 +45,12 @@ import { IRequestContext, RequestContext } from '../services/request-context.ser
39
45
  * only around the `subscribe()` call therefore covers the initial subscribe and nothing else: the
40
46
  * store is gone by the time the first message is delivered. Wrapping `next()` (and `return`/`throw`)
41
47
  * is what puts every delivered message inside the context.
48
+ *
49
+ * AND THE CONTEXT IS RE-CHECKED (11.42.3). A context built here is a snapshot of the connection's
50
+ * rights. For a connection registered with `registerWsConnection()` — every connection `CoreModule`'s
51
+ * own `onConnect` accepts — each operation and each delivered event first asks
52
+ * `ensureWsConnectionCurrent()` whether that snapshot still holds, and a connection whose rights
53
+ * changed is closed instead of served. See `graphql-ws-connection.helper.ts`.
42
54
  */
43
55
 
44
56
  /** Minimal shape of what graphql-js `execute`/`subscribe` receive. */
@@ -58,21 +70,12 @@ type ExecuteFn = (args: GraphQlArgs) => any;
58
70
  * and the raw upgrade request is the fallback for a project with a custom `onConnect` that forwards
59
71
  * neither. Header names are matched case-insensitively — `connectionParams` are client-supplied JSON
60
72
  * and arrive with whatever casing the client used, unlike Node's lower-cased HTTP headers.
73
+ *
74
+ * Lives in `graphql-ws-connection.helper.ts`, because a connection's re-check has to resolve the
75
+ * tenant from exactly the same header this does.
61
76
  */
62
77
  function readTenantHeader(contextValue: any): string | undefined {
63
- const headerName = (ConfigService.configFastButReadOnly?.multiTenancy?.headerName ?? 'x-tenant-id').toLowerCase();
64
- const sources = [contextValue?.headers, contextValue?.connectionParams, contextValue?.request?.headers];
65
- for (const source of sources) {
66
- if (!source || typeof source !== 'object') {
67
- continue;
68
- }
69
- for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
70
- if (key.toLowerCase() === headerName && typeof value === 'string' && value) {
71
- return value;
72
- }
73
- }
74
- }
75
- return undefined;
78
+ return readWsTenantHeader(contextValue);
76
79
  }
77
80
 
78
81
  /** The user the connection authenticated as, as `CoreModule`'s `onConnect` recorded it. */
@@ -109,28 +112,83 @@ export async function buildWsRequestContext(contextValue: any): Promise<IRequest
109
112
  // A failure here must NOT be answered with an unscoped context — that is the leak this file
110
113
  // exists to close. Leaving the tenant fields unset makes the plugin's safety net refuse
111
114
  // tenant-scoped reads, which is the safe direction.
115
+ let resolved: ResolvedTenantContext = {};
112
116
  try {
113
- Object.assign(context, await resolver.resolve(user, readTenantHeader(contextValue)));
117
+ resolved = (await resolver.resolve(user, readTenantHeader(contextValue))) ?? {};
118
+ Object.assign(context, resolved);
114
119
  } catch {
115
120
  // Intentionally swallowed: see above. Non-tenant operations keep working.
116
121
  }
122
+
123
+ // The first operation fixes the connection's tenant scope; a later one that resolves a different
124
+ // scope closes the connection, because subscriptions already running on it still carry the old
125
+ // one (see noteWsConnectionTenant()).
126
+ const connection = getWsConnection(contextValue);
127
+ if (connection) {
128
+ noteWsConnectionTenant(connection, resolved);
129
+ }
117
130
  }
118
131
 
119
132
  return context;
120
133
  }
121
134
 
135
+ /**
136
+ * What an operation on a connection whose authorization no longer holds receives instead of being
137
+ * executed. The socket is closed at the same moment, so a client normally sees the close first; this
138
+ * matters for a connection without a socket to close.
139
+ */
140
+ function refusedResult(): { errors: InstanceType<typeof graphql.GraphQLError>[] } {
141
+ return {
142
+ errors: [
143
+ new graphql.GraphQLError('The authorization of this connection changed — reconnect.', {
144
+ extensions: { code: 'FORBIDDEN' },
145
+ }),
146
+ ],
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Re-check a registered connection before an operation runs on it. `true` for an unregistered
152
+ * connection — a project's own `onConnect` that does not register keeps the previous behaviour.
153
+ */
154
+ async function connectionStillAuthorized(contextValue: any): Promise<boolean> {
155
+ const connection = getWsConnection(contextValue);
156
+ return connection ? ensureWsConnectionCurrent(connection) : true;
157
+ }
158
+
122
159
  /**
123
160
  * Run an AsyncIterator's pulls inside `context`.
124
161
  *
125
162
  * `next()` is where graphql-js executes the per-event selection set, so this is what carries the
126
163
  * context into every delivered message rather than only into the initial subscribe.
164
+ *
165
+ * With a `connection`, every executed event is held back until `ensureWsConnectionCurrent()` confirms
166
+ * the connection's rights still hold. One that no longer holds is DROPPED and the stream ends — the
167
+ * event was computed under the old context, so delivering it would hand out exactly what was revoked.
168
+ * The check comes after the execution because the event only exists once graphql-js produced it; a
169
+ * check at pull time would be hours early for a quiet subscription.
127
170
  */
128
171
  export function withRequestContextAsyncIterator<T>(
129
172
  iterator: AsyncIterator<T> & { [Symbol.asyncIterator]?: () => AsyncIterator<T> },
130
173
  context: IRequestContext,
174
+ connection?: WsConnection,
131
175
  ): AsyncIterableIterator<T> {
176
+ const next = (...args: [] | [undefined]) => RequestContext.run(context, () => iterator.next(...(args as [])));
132
177
  const wrapped: AsyncIterableIterator<T> = {
133
- next: (...args: [] | [undefined]) => RequestContext.run(context, () => iterator.next(...(args as []))),
178
+ next: connection
179
+ ? async (...args: [] | [undefined]) => {
180
+ const result = await next(...args);
181
+ if (result.done || (await ensureWsConnectionCurrent(connection))) {
182
+ return result;
183
+ }
184
+ try {
185
+ await RequestContext.run(context, () => iterator.return?.());
186
+ } catch {
187
+ // The source is being torn down anyway; nothing to report.
188
+ }
189
+ return { done: true, value: undefined } as IteratorResult<T>;
190
+ }
191
+ : next,
134
192
  [Symbol.asyncIterator]() {
135
193
  return wrapped;
136
194
  },
@@ -156,10 +214,16 @@ function isAsyncIterable(value: any): boolean {
156
214
  */
157
215
  export function createRequestContextAwareExecute(execute: ExecuteFn): ExecuteFn {
158
216
  return async (args: GraphQlArgs) => {
217
+ if (!(await connectionStillAuthorized(args?.contextValue))) {
218
+ return refusedResult();
219
+ }
159
220
  const context = await buildWsRequestContext(args?.contextValue);
160
221
  if (!context) {
161
222
  return execute(args);
162
223
  }
224
+ if (getWsConnection(args?.contextValue)?.terminated) {
225
+ return refusedResult();
226
+ }
163
227
  return RequestContext.run(context, () => execute(args));
164
228
  };
165
229
  }
@@ -167,12 +231,21 @@ export function createRequestContextAwareExecute(execute: ExecuteFn): ExecuteFn
167
231
  /** Wrap graphql-js `subscribe` so both the subscribe AND every delivered message run in a context. */
168
232
  export function createRequestContextAwareSubscribe(subscribe: ExecuteFn): ExecuteFn {
169
233
  return async (args: GraphQlArgs) => {
234
+ if (!(await connectionStillAuthorized(args?.contextValue))) {
235
+ return refusedResult();
236
+ }
170
237
  const context = await buildWsRequestContext(args?.contextValue);
171
238
  if (!context) {
172
239
  return subscribe(args);
173
240
  }
241
+ const connection = getWsConnection(args?.contextValue);
242
+ if (connection?.terminated) {
243
+ return refusedResult();
244
+ }
174
245
  const result = await RequestContext.run(context, () => subscribe(args));
175
- return isAsyncIterable(result) ? withRequestContextAsyncIterator(result as AsyncIterator<any>, context) : result;
246
+ return isAsyncIterable(result)
247
+ ? withRequestContextAsyncIterator(result as AsyncIterator<any>, context, connection)
248
+ : result;
176
249
  };
177
250
  }
178
251
 
@@ -1490,8 +1490,9 @@ export interface IMultiTenancy {
1490
1490
  * - tenant API tokens: **403**
1491
1491
  * - public (`S_EVERYONE`) routes: not blocked, but no tenant context is set
1492
1492
  * - no header: the tenant is dropped from the user's tenant list, so its data stays out of reach
1493
- * - GraphQL over WebSocket: no tenant is resolved for a NEW subscription; one already open keeps
1494
- * its tenant until the client reconnects (as it does after a removed membership)
1493
+ * - GraphQL over WebSocket: no tenant is resolved for a NEW subscription; one already open is
1494
+ * closed (4403) once `invalidateTenant()` is called, or at its next event after
1495
+ * `graphQl.subscriptionRevalidationMs` without it (since 11.42.3)
1495
1496
  *
1496
1497
  * A platform administrator under `adminBypass` still reaches the tenant — somebody has to be able to
1497
1498
  * look into it and switch it back on.
@@ -2567,6 +2568,35 @@ export interface IServerOptions {
2567
2568
  * Module options (forRootAsync)
2568
2569
  */
2569
2570
  options?: GqlModuleAsyncOptions;
2571
+
2572
+ /**
2573
+ * How long an open GraphQL WebSocket connection may go without re-checking its authorization,
2574
+ * in milliseconds.
2575
+ *
2576
+ * A WebSocket is authorized at the handshake. Before 11.42.3 that was also the last check: a
2577
+ * removed membership, a deactivated tenant, a withdrawn role or a revoked session took effect
2578
+ * on every new request and on none of the sockets already open. Now a connection is re-checked
2579
+ * at once when an invalidation names it (`CoreTenantGuard.invalidateUser()` /
2580
+ * `invalidateTenant()` / `invalidateAll()`, a role change through `CoreUserService`, a
2581
+ * password reset), and before an event is delivered or an operation runs once its last check
2582
+ * is older than this value — the bound for a change nobody invalidated, such as a sign-out or a
2583
+ * membership edited directly in the database. A connection whose rights changed is closed with
2584
+ * 4403, which the graphql-ws client retries: it reconnects and continues with the current
2585
+ * rights, or is refused at the handshake.
2586
+ *
2587
+ * - `undefined` → 30000
2588
+ * - `0` → re-check on every operation and every event (one session lookup each)
2589
+ * - `false` → no time-based re-check; invalidations still apply
2590
+ * - anything else that is not a non-negative number → 30000, never "off"
2591
+ *
2592
+ * Applies to connections registered by `CoreModule`'s own `onConnect`. A project that replaces
2593
+ * `onConnect` through `graphQl.driver.subscriptions` registers its connections with
2594
+ * `registerWsConnection()` to keep this.
2595
+ *
2596
+ * @default 30000
2597
+ * @since 11.42.3
2598
+ */
2599
+ subscriptionRevalidationMs?: false | number;
2570
2600
  };
2571
2601
 
2572
2602
  /**
@@ -69,11 +69,48 @@ export class CoreAuthService {
69
69
 
70
70
  /**
71
71
  * Decode JWT
72
+ *
73
+ * Reads the payload WITHOUT checking signature or expiry — never decide access on its result.
74
+ * Use `verifyJwt()` for that.
72
75
  */
73
76
  decodeJwt(token: string): JwtPayload {
74
77
  return this.jwtService.decode(token) as JwtPayload;
75
78
  }
76
79
 
80
+ /**
81
+ * Verify an access token's signature and expiry and answer its payload, or `null` when it does not
82
+ * verify.
83
+ *
84
+ * The same check passport-jwt applies on the HTTP path (`JwtStrategy`), for transports that have no
85
+ * passport in front of them — today the GraphQL WebSocket. The key is resolved in passport's order:
86
+ * `jwt.secretOrKeyProvider` when configured, else `jwt.secretOrPrivateKey || jwt.secret`. Until
87
+ * 11.42.3 the WebSocket handshake used `decodeJwt()`, so an expired token, or a copy re-signed with
88
+ * any key, was accepted there as long as its `tokenId` still matched the device.
89
+ *
90
+ * @since 11.42.3
91
+ */
92
+ async verifyJwt(token: string): Promise<JwtPayload | null> {
93
+ if (!token || typeof token !== 'string') {
94
+ return null;
95
+ }
96
+ try {
97
+ const provider = this.configService.getFastButReadOnly('jwt.secretOrKeyProvider');
98
+ const secret =
99
+ typeof provider === 'function'
100
+ ? await new Promise<string>((resolve, reject) =>
101
+ provider({}, token, (error: unknown, key: string) => (error ? reject(error) : resolve(key))),
102
+ )
103
+ : this.configService.getFastButReadOnly('jwt.secretOrPrivateKey') ||
104
+ this.configService.getFastButReadOnly('jwt.secret');
105
+ if (!secret) {
106
+ return null;
107
+ }
108
+ return (await this.jwtService.verifyAsync(token, { secret })) as JwtPayload;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
77
114
  /**
78
115
  * Logout user (from device)
79
116
  */
@@ -8,6 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
 
10
10
  import { resolveServerUrls, strippedApiHostname } from '../../common/helpers/cookies.helper';
11
+ import { revalidateWsConnectionsOf } from '../../common/helpers/graphql-ws-connection.helper';
11
12
  import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
12
13
  import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
13
14
 
@@ -753,6 +754,48 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
753
754
  };
754
755
  }
755
756
 
757
+ /**
758
+ * Ends the GraphQL WebSocket connections that rode on a session the moment the session is deleted —
759
+ * a sign-out, a revoked session, a password reset with `revokeSessionsOnPasswordReset`, a deleted user.
760
+ *
761
+ * Without it such a connection stays open until `graphQl.subscriptionRevalidationMs` (30 s by
762
+ * default) catches it on its next event. Better-Auth's `onPasswordReset` cannot do this job: all
763
+ * three reset routes call it BEFORE they delete the sessions, so a re-check from there would still
764
+ * find them valid.
765
+ *
766
+ * A plugin rather than `databaseHooks` in the options, because Better-Auth collects a plugin's
767
+ * `init().options.databaseHooks` as a SEPARATE source: a project's own `options.databaseHooks`
768
+ * neither replaces this one nor is replaced by it — unlike everything spread from `options`.
769
+ *
770
+ * It only asks for a re-check of the user's connections. The re-check resolves each connection's
771
+ * own token again, so a connection on a DIFFERENT, still-valid session of the same user stays open.
772
+ * It never throws: a failure here must not fail the sign-out.
773
+ */
774
+ export function wsSessionRevocationPlugin(): BetterAuthPlugin {
775
+ return {
776
+ id: 'lt-ws-session-revocation',
777
+ init: () => ({
778
+ options: {
779
+ databaseHooks: {
780
+ session: {
781
+ delete: {
782
+ after: async (session: { userId?: unknown }) => {
783
+ try {
784
+ if (session?.userId) {
785
+ revalidateWsConnectionsOf({ iamId: String(session.userId) });
786
+ }
787
+ } catch {
788
+ // Never fail the deletion over a re-check; the revalidation interval still applies.
789
+ }
790
+ },
791
+ },
792
+ },
793
+ },
794
+ },
795
+ }),
796
+ } as BetterAuthPlugin;
797
+ }
798
+
756
799
  /**
757
800
  * Formats a package name to a human-readable display name.
758
801
  * Converts kebab-case and snake_case to Title Case.
@@ -873,7 +916,9 @@ function buildPlugins(
873
916
  options: { passkeyNormalization: PasskeyNormalizationResult; serverEnv?: string },
874
917
  ): BetterAuthPlugin[] {
875
918
  const { passkeyNormalization, serverEnv } = options;
876
- const plugins: BetterAuthPlugin[] = [];
919
+ // Always first and always on: it changes nothing about authentication itself, it only ends the
920
+ // WebSocket connections that rode on a session once the session is gone.
921
+ const plugins: BetterAuthPlugin[] = [wsSessionRevocationPlugin()];
877
922
 
878
923
  // JWT Plugin for API client compatibility
879
924
  // JWT is enabled by default unless explicitly disabled (jwt: false or jwt: { enabled: false })
@@ -804,9 +804,17 @@ export class CoreBetterAuthService implements OnModuleInit {
804
804
  }
805
805
 
806
806
  try {
807
- // Create headers with the session token
807
+ // Better-Auth's signOut finds the session through its SIGNED session cookie. An `Authorization`
808
+ // header is read only by its `bearer` plugin, which the framework does not install — so until
809
+ // 11.42.3 this call deleted nothing: the caller got `true`, the controller cleared the cookies,
810
+ // and the session stayed valid server-side, authenticating the same token until it expired.
811
+ // The header stays for projects that added the bearer plugin themselves.
808
812
  const headers = new Headers();
809
813
  headers.set('Authorization', `Bearer ${sessionToken}`);
814
+ const secret = this.config?.secret;
815
+ if (secret) {
816
+ headers.set('cookie', `${this.getSessionCookieName()}=${signCookieValueIfNeeded(sessionToken, secret, true)}`);
817
+ }
810
818
 
811
819
  // Call Better-Auth's signOut endpoint
812
820
  await api.signOut({ headers });
@@ -236,14 +236,14 @@ multiTenancy: {
236
236
  }
237
237
  ```
238
238
 
239
- | Path | Deactivated tenant |
240
- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
241
- | Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `Tenant is inactive` |
242
- | Tenant API token | **403** |
243
- | `S_EVERYONE` with a header | not blocked, but no tenant context is set |
244
- | No header | dropped from the user's tenant list — its data stays out of reach |
245
- | GraphQL over WebSocket | no tenant resolved for a new subscription — an open one keeps its tenant until it reconnects (as after a removed membership) |
246
- | Admin under `adminBypass` | still allowed — somebody has to be able to switch it back on |
239
+ | Path | Deactivated tenant |
240
+ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
241
+ | Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `Tenant is inactive` |
242
+ | Tenant API token | **403** |
243
+ | `S_EVERYONE` with a header | not blocked, but no tenant context is set |
244
+ | No header | dropped from the user's tenant list — its data stays out of reach |
245
+ | GraphQL over WebSocket | no tenant resolved for a new subscription; an open one is closed (4403) on `invalidateTenant()`, or at its next event once `graphQl.subscriptionRevalidationMs` has passed (since 11.42.3) |
246
+ | Admin under `adminBypass` | still allowed — somebody has to be able to switch it back on |
247
247
 
248
248
  The hook is asked **after** the membership check, so a non-member learns nothing about a tenant's
249
249
  state. Return `false` for inactive; anything else counts as active. Answers are cached for
@@ -16,6 +16,11 @@ import { Model } from 'mongoose';
16
16
 
17
17
  import { RoleEnum } from '../../common/enums/role.enum';
18
18
  import { resolveGuardRequest } from '../../common/helpers/execution-context-request.helper';
19
+ import {
20
+ revalidateAllWsConnections,
21
+ revalidateWsConnectionsForTenant,
22
+ revalidateWsConnectionsOf,
23
+ } from '../../common/helpers/graphql-ws-connection.helper';
19
24
  import { ConfigService } from '../../common/services/config.service';
20
25
  import { ResolvedTenantContext, setTenantContextResolver } from '../../common/services/core-tenant-context.registry';
21
26
  import { CoreRedisService } from '../../common/services/core-redis.service';
@@ -208,6 +213,9 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
208
213
  if (!config || config.enabled === false) {
209
214
  return {};
210
215
  }
216
+ // The WebSocket path reaches this without passing canActivate(), so it has to pick up a changed
217
+ // `cacheTtlMs` itself — otherwise an API whose traffic is all WebSocket keeps the 30 s default.
218
+ this.syncConfig(config);
211
219
 
212
220
  const trimmed =
213
221
  headerTenantId && typeof headerTenantId === 'string' && headerTenantId.length <= 128
@@ -301,7 +309,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
301
309
  * `cacheTtlMs`. Broadcast to every replica when Redis is enabled.
302
310
  */
303
311
  invalidateTenant(tenantId: string): void {
304
- this.tenantActiveCache.delete(tenantId);
312
+ this.clearTenant(tenantId);
305
313
  this.publishInvalidation({ scope: 'tenant', tenantId });
306
314
  }
307
315
 
@@ -339,16 +347,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
339
347
  return true;
340
348
  }
341
349
 
342
- // Detect config changes (e.g., roleHierarchy modified in tests) and flush caches
343
- if (this.lastSeenConfig !== config) {
344
- this.lastSeenConfig = config;
345
- // Default 30s in production, 0 (disabled) in test environments to avoid stale data between test cases
346
- const isTestEnv =
347
- process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
348
- this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
349
- // Local-only flush: every replica detects the config change itself, no broadcast needed
350
- this.clearAll();
351
- }
350
+ this.syncConfig(config);
352
351
 
353
352
  const request = this.getRequest(context);
354
353
  if (!request) {
@@ -711,7 +710,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
711
710
  } else if (parsed?.scope === 'user' && parsed.userId) {
712
711
  this.clearUser(parsed.userId);
713
712
  } else if (parsed?.scope === 'tenant' && parsed.tenantId) {
714
- this.tenantActiveCache.delete(parsed.tenantId);
713
+ this.clearTenant(parsed.tenantId);
715
714
  }
716
715
  }
717
716
 
@@ -722,6 +721,17 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
722
721
  this.membershipCache.clear();
723
722
  this.tenantIdsCache.clear();
724
723
  this.tenantActiveCache.clear();
724
+ // Open WebSocket connections were authorized against what was just thrown away.
725
+ revalidateAllWsConnections();
726
+ }
727
+
728
+ /**
729
+ * Forget a tenant's cached `isTenantActive` answer locally (no broadcast), and re-check the open
730
+ * WebSocket connections scoped to it.
731
+ */
732
+ protected clearTenant(tenantId: string): void {
733
+ this.tenantActiveCache.delete(tenantId);
734
+ revalidateWsConnectionsForTenant(tenantId);
725
735
  }
726
736
 
727
737
  /**
@@ -738,6 +748,25 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
738
748
  this.tenantIdsCache.delete(key);
739
749
  }
740
750
  }
751
+ // A membership change reaches the user's OPEN WebSocket connections too, not just new requests.
752
+ // After the caches above, so the re-check reads the membership as it is now.
753
+ revalidateWsConnectionsOf({ userId });
754
+ }
755
+
756
+ /**
757
+ * Pick up a changed `multiTenancy` config: the cache TTL, and a flush of everything cached under
758
+ * the old one. Local-only — every replica detects the change itself, no broadcast needed.
759
+ */
760
+ private syncConfig(config: object & { cacheTtlMs?: number }): void {
761
+ if (this.lastSeenConfig === config) {
762
+ return;
763
+ }
764
+ this.lastSeenConfig = config;
765
+ // Default 30s in production, 0 (disabled) in test environments to avoid stale data between test cases
766
+ const isTestEnv =
767
+ process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
768
+ this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
769
+ this.clearAll();
741
770
  }
742
771
 
743
772
  /**
@@ -7,6 +7,7 @@ import { Document, Model } from 'mongoose';
7
7
  import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
8
8
  import { accessDeniedException } from '../../common/exceptions/access-denied.exception';
9
9
  import { resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
10
+ import { revalidateWsConnectionsOf } from '../../common/helpers/graphql-ws-connection.helper';
10
11
  import { maskEmail } from '../../common/helpers/logging.helper';
11
12
  import { assignPlain, isQueryableString, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
12
13
  import { ServiceOptions } from '../../common/interfaces/service-options.interface';
@@ -377,6 +378,10 @@ export abstract class CoreUserService<
377
378
  );
378
379
  }
379
380
 
381
+ // The same holds for the user's open GraphQL WebSockets: their re-check finds the device's
382
+ // refresh token gone and closes them, instead of letting them run until the next interval.
383
+ revalidateWsConnectionsOf({ email: dbObject.email, userId: dbObject.id });
384
+
380
385
  return updatedUser;
381
386
  },
382
387
  { dbObject, serviceOptions },
@@ -648,6 +653,10 @@ export abstract class CoreUserService<
648
653
  this.options.betterAuthUserMapper.invalidateUserCache((user as any).iamId);
649
654
  }
650
655
 
656
+ // ... including on the user's OPEN GraphQL WebSockets, which were authorized with the old
657
+ // roles. After the cache above, so the re-check reads the new ones.
658
+ revalidateWsConnectionsOf({ userId: String(userId) });
659
+
651
660
  return user;
652
661
  },
653
662
  { serviceOptions },
@@ -730,12 +739,16 @@ export abstract class CoreUserService<
730
739
  }
731
740
 
732
741
  // Invalidate BetterAuth user cache when roles or verified status may have changed
733
- if (this.options?.betterAuthUserMapper && (oldUser as any)?.iamId) {
734
- const rolesChanged = 'roles' in (input as any);
735
- const verifiedChanged = 'verified' in (input as any) || 'emailVerified' in (input as any);
736
- if (rolesChanged || verifiedChanged) {
737
- this.options.betterAuthUserMapper.invalidateUserCache((oldUser as any).iamId);
738
- }
742
+ const rightsMayHaveChanged =
743
+ !!input &&
744
+ typeof input === 'object' &&
745
+ ('roles' in (input as any) || 'verified' in (input as any) || 'emailVerified' in (input as any));
746
+ if (rightsMayHaveChanged && this.options?.betterAuthUserMapper && (oldUser as any)?.iamId) {
747
+ this.options.betterAuthUserMapper.invalidateUserCache((oldUser as any).iamId);
748
+ }
749
+ // ... and re-check the user's OPEN GraphQL WebSockets, which were authorized with the old values.
750
+ if (rightsMayHaveChanged) {
751
+ revalidateWsConnectionsOf({ userId: String(id) });
739
752
  }
740
753
 
741
754
  return updatedUser;