@lenne.tech/nest-server 11.42.2 → 11.42.4

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 (62) hide show
  1. package/.claude/rules/configurable-features.md +3 -1
  2. package/.claude/rules/package-management.md +1 -1
  3. package/.claude/rules/role-system.md +31 -3
  4. package/.claude/rules/testing.md +4 -4
  5. package/CLAUDE.md +7 -1
  6. package/FRAMEWORK-API.md +2 -1
  7. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
  8. package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
  9. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
  10. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
  11. package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
  12. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
  13. package/dist/core/common/interfaces/server-options.interface.d.ts +9 -0
  14. package/dist/core/common/services/core-body-parser.initializer.d.ts +26 -0
  15. package/dist/core/common/services/core-body-parser.initializer.js +136 -0
  16. package/dist/core/common/services/core-body-parser.initializer.js.map +1 -0
  17. package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
  18. package/dist/core/modules/auth/services/core-auth.service.js +19 -0
  19. package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
  20. package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
  21. package/dist/core/modules/better-auth/better-auth.config.js +27 -1
  22. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  23. package/dist/core/modules/better-auth/core-better-auth.module.js +7 -3
  24. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  25. package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
  26. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  27. package/dist/core/modules/error-code/error-codes.d.ts +9 -0
  28. package/dist/core/modules/error-code/error-codes.js +8 -0
  29. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  30. package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
  31. package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
  32. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  33. package/dist/core/modules/user/core-user.service.js +11 -6
  34. package/dist/core/modules/user/core-user.service.js.map +1 -1
  35. package/dist/core.module.d.ts +3 -0
  36. package/dist/core.module.js +102 -42
  37. package/dist/core.module.js.map +1 -1
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/server/modules/error-code/error-codes.d.ts +1 -0
  42. package/dist/tsconfig.build.tsbuildinfo +1 -1
  43. package/docs/REQUEST-LIFECYCLE.md +29 -0
  44. package/docs/error-codes.md +16 -0
  45. package/docs/security-overrides.md +18 -2
  46. package/migration-guides/11.42.2-to-11.42.3.md +145 -0
  47. package/migration-guides/11.42.3-to-11.42.4.md +185 -0
  48. package/package.json +3 -3
  49. package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
  50. package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
  51. package/src/core/common/interfaces/server-options.interface.ts +121 -2
  52. package/src/core/common/services/core-body-parser.initializer.ts +232 -0
  53. package/src/core/modules/auth/services/core-auth.service.ts +37 -0
  54. package/src/core/modules/better-auth/better-auth.config.ts +46 -1
  55. package/src/core/modules/better-auth/core-better-auth.module.ts +24 -4
  56. package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
  57. package/src/core/modules/error-code/error-codes.ts +9 -0
  58. package/src/core/modules/tenant/README.md +8 -8
  59. package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
  60. package/src/core/modules/user/core-user.service.ts +19 -6
  61. package/src/core.module.ts +170 -55
  62. package/src/index.ts +1 -0
@@ -0,0 +1,382 @@
1
+ import { Logger } from '@nestjs/common';
2
+
3
+ import { ConfigService } from '../services/config.service';
4
+ import { getTenantContextResolver, ResolvedTenantContext } from '../services/core-tenant-context.registry';
5
+
6
+ /**
7
+ * The authorization lifecycle of an OPEN GraphQL WebSocket connection.
8
+ *
9
+ * WHY THIS EXISTS: a WebSocket is authorized at the handshake. `onConnect` validates the session and
10
+ * records the user; the subscribe wrapper (`graphql-ws-context.helper.ts`) resolves the tenant. Before
11
+ * 11.42.3 nothing ever looked again — every delivered event and every further operation on the socket
12
+ * ran on that snapshot. Removing somebody from a tenant, switching a tenant off, withdrawing a role or
13
+ * ending a session therefore took effect on every NEW request and on none of the sockets that were
14
+ * already open, for as long as the client kept them open. For a browser tab that is hours.
15
+ *
16
+ * WHAT IT DOES: each connection is registered with the rights it was authorized with — the user's id,
17
+ * roles and verification state, and the tenant scope its operations resolved — plus a function that
18
+ * re-runs the handshake's authentication. Those rights are checked again
19
+ *
20
+ * - when an INVALIDATION names the connection (`revalidateWsConnectionsOf()` from a membership or
21
+ * role change, `revalidateWsConnectionsForTenant()` from a tenant switch, a password reset), at
22
+ * once and before any further event, and
23
+ * - before an event is delivered or an operation runs, once the last check is older than
24
+ * `graphQl.subscriptionRevalidationMs` (default 30 s) — the bound for a change nobody invalidated,
25
+ * such as a session that was signed out or a membership edited directly in the database.
26
+ *
27
+ * A connection whose rights CHANGED — narrowed or widened — is closed with 4403. It is not patched in
28
+ * place, deliberately: a subscription may have chosen its topic or its filter from the tenant at
29
+ * subscribe time, and only a fresh subscribe re-derives that. The graphql-ws client RETRIES 4403 (its
30
+ * own comment: "might grant access after retry"), so the user reconnects and continues with the
31
+ * rights they hold now, or is refused at `onConnect` if the session is gone.
32
+ *
33
+ * WHAT IT DOES NOT DO: an event is checked AFTER graphql-js executed it and BEFORE it is sent. Reads
34
+ * made while computing a dropped event still happened — under the old context — but nothing of them
35
+ * reaches the client. Subscription resolvers that WRITE are outside what this can take back.
36
+ */
37
+
38
+ /**
39
+ * Close code for a connection whose authorization changed: graphql-ws's `CloseCode.Forbidden`. Its
40
+ * client retries it, which is what makes the close a re-authorization rather than a hang-up.
41
+ */
42
+ export const WS_AUTHORIZATION_CHANGED_CLOSE_CODE = 4403;
43
+
44
+ /** Default for `graphQl.subscriptionRevalidationMs`. */
45
+ export const DEFAULT_SUBSCRIPTION_REVALIDATION_MS = 30_000;
46
+
47
+ /** The parts of a socket this helper needs: closing it, and noticing that it closed. */
48
+ export interface IWsConnectionSocket {
49
+ close(code: number, reason?: string): void;
50
+ once?(event: 'close', listener: (...args: any[]) => void): unknown;
51
+ }
52
+
53
+ export interface IWsConnectionRegistration {
54
+ /**
55
+ * The object every operation of the connection receives as its GraphQL context: `extra` for
56
+ * graphql-ws, the object `onConnect` returns for subscriptions-transport-ws (which copies it per
57
+ * operation — the registration survives that copy).
58
+ */
59
+ carrier: object;
60
+
61
+ /**
62
+ * Re-run the handshake's authentication. Resolves to the user the credential authenticates as NOW,
63
+ * or to a falsy value when it no longer does. A rejection counts as falsy. Without it only the
64
+ * tenant scope is re-checked.
65
+ */
66
+ reauthenticate?: () => any;
67
+
68
+ /** The raw socket — closed when the rights changed, and watched so a closed connection is forgotten. */
69
+ socket?: IWsConnectionSocket;
70
+
71
+ /** The user the handshake authenticated. */
72
+ user: any;
73
+ }
74
+
75
+ /** Who an invalidation is about. Any one field is enough; `userId` is the `users` document id. */
76
+ export interface IWsConnectionIdentity {
77
+ email?: string;
78
+ iamId?: string;
79
+ userId?: string;
80
+ }
81
+
82
+ /** @internal The registered state of one connection. */
83
+ export interface WsConnection {
84
+ readonly email?: string;
85
+ readonly iamId?: string;
86
+ readonly reauthenticate?: () => any;
87
+ readonly socket?: IWsConnectionSocket;
88
+ /** The tenant scope the connection's operations resolved, once one has. */
89
+ tenant?: ResolvedTenantContext;
90
+ /** The tenant header the client sent — constant for the life of the connection. */
91
+ readonly tenantHeader?: string;
92
+ terminated: boolean;
93
+ readonly user: any;
94
+ readonly userFingerprint: string;
95
+ readonly userId?: string;
96
+ /** @internal */
97
+ inflight?: Promise<boolean>;
98
+ /** @internal */
99
+ stale: boolean;
100
+ /** @internal */
101
+ tenantFingerprint?: string;
102
+ /** @internal */
103
+ validatedAt: number;
104
+ }
105
+
106
+ /**
107
+ * Module-private and not `Symbol.for()`: nothing a client sends — `connectionParams` are JSON — can
108
+ * produce it, so a registration cannot be forged or overwritten from outside. Enumerable on purpose:
109
+ * subscriptions-transport-ws hands each operation an `Object.assign` copy of the connection context,
110
+ * and only enumerable own properties survive that.
111
+ */
112
+ const WS_CONNECTION = Symbol('ltWsConnection');
113
+
114
+ /**
115
+ * Connections that can be found by an invalidation. Only those with a socket to watch: without a
116
+ * `close` event there is no moment to forget them, and a registry that only grows is a leak. A
117
+ * connection without one is still re-checked by the interval, through its carrier.
118
+ */
119
+ const liveConnections = new Set<WsConnection>();
120
+
121
+ const logger = new Logger('GraphQlWsConnection');
122
+
123
+ /**
124
+ * Resolve `graphQl.subscriptionRevalidationMs` to an interval, or `false` for "off".
125
+ *
126
+ * Only an explicit `false` switches the interval off. Anything else that is not a usable interval
127
+ * falls back to the DEFAULT, never to "off": this bounds how long revoked access keeps flowing, and a
128
+ * typo in an environment variable must not be the thing that removes the bound. `0` is a legitimate
129
+ * value meaning "on every operation and every event".
130
+ */
131
+ export function resolveSubscriptionRevalidationMs(value: unknown): false | number {
132
+ if (value === false || value === 'false') {
133
+ return false;
134
+ }
135
+ const numeric = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
136
+ if (typeof numeric === 'number' && Number.isFinite(numeric) && numeric >= 0) {
137
+ return numeric;
138
+ }
139
+ return DEFAULT_SUBSCRIPTION_REVALIDATION_MS;
140
+ }
141
+
142
+ function configuredRevalidationMs(): false | number {
143
+ const graphQl = ConfigService.configFastButReadOnly?.graphQl;
144
+ return resolveSubscriptionRevalidationMs(graphQl ? graphQl.subscriptionRevalidationMs : undefined);
145
+ }
146
+
147
+ /** What a connection's rights depend on, as a comparable string. Role ORDER does not count. */
148
+ function fingerprintUser(user: any): string {
149
+ if (!user) {
150
+ return '';
151
+ }
152
+ const roles = Array.isArray(user.roles) ? user.roles.map((role: unknown) => String(role)).sort() : [];
153
+ const verified = !!(user.verified || user.verifiedAt || user.emailVerified);
154
+ return JSON.stringify([String(user.id ?? user._id ?? ''), roles, verified]);
155
+ }
156
+
157
+ function fingerprintTenant(tenant: ResolvedTenantContext | undefined): string {
158
+ const tenantIds = Array.isArray(tenant?.tenantIds) ? tenant.tenantIds.map((id) => String(id)).sort() : null;
159
+ return JSON.stringify([tenant?.tenantId ?? null, tenantIds, tenant?.tenantRole ?? null, !!tenant?.isAdminBypass]);
160
+ }
161
+
162
+ /** Same lookup the context wrapper uses — see `readTenantHeader()` in graphql-ws-context.helper.ts. */
163
+ export function readWsTenantHeader(contextValue: any): string | undefined {
164
+ const headerName = (ConfigService.configFastButReadOnly?.multiTenancy?.headerName ?? 'x-tenant-id').toLowerCase();
165
+ const sources = [contextValue?.headers, contextValue?.connectionParams, contextValue?.request?.headers];
166
+ for (const source of sources) {
167
+ if (!source || typeof source !== 'object') {
168
+ continue;
169
+ }
170
+ for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
171
+ if (key.toLowerCase() === headerName && typeof value === 'string' && value) {
172
+ return value;
173
+ }
174
+ }
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /**
180
+ * Register a connection after a successful handshake. Call it from `onConnect`, with the same user
181
+ * the handshake recorded on the carrier.
182
+ */
183
+ export function registerWsConnection(registration: IWsConnectionRegistration): void {
184
+ const { carrier, reauthenticate, socket, user } = registration;
185
+ if (!carrier || typeof carrier !== 'object') {
186
+ return;
187
+ }
188
+ const userId = user?.id ?? user?._id;
189
+ const connection: WsConnection = {
190
+ email: typeof user?.email === 'string' ? user.email.toLowerCase() : undefined,
191
+ iamId: typeof user?.iamId === 'string' ? user.iamId : undefined,
192
+ reauthenticate,
193
+ socket,
194
+ stale: false,
195
+ tenantHeader: readWsTenantHeader(carrier),
196
+ terminated: false,
197
+ user,
198
+ userFingerprint: fingerprintUser(user),
199
+ userId: userId !== undefined && userId !== null ? String(userId) : undefined,
200
+ validatedAt: Date.now(),
201
+ };
202
+ Object.defineProperty(carrier, WS_CONNECTION, { configurable: true, enumerable: true, value: connection });
203
+
204
+ if (socket && typeof socket.once === 'function') {
205
+ liveConnections.add(connection);
206
+ socket.once('close', () => {
207
+ connection.terminated = true;
208
+ liveConnections.delete(connection);
209
+ });
210
+ }
211
+ }
212
+
213
+ /** The registered connection a GraphQL context belongs to, if any. */
214
+ export function getWsConnection(contextValue: any): undefined | WsConnection {
215
+ if (!contextValue || typeof contextValue !== 'object') {
216
+ return undefined;
217
+ }
218
+ const connection = contextValue[WS_CONNECTION];
219
+ return connection && typeof connection === 'object' ? connection : undefined;
220
+ }
221
+
222
+ /**
223
+ * Close a connection whose authorization no longer holds. Idempotent; always answers `false` so a
224
+ * caller can `return terminate(...)`.
225
+ */
226
+ function terminate(connection: WsConnection, reason: string): false {
227
+ if (connection.terminated) {
228
+ return false;
229
+ }
230
+ connection.terminated = true;
231
+ liveConnections.delete(connection);
232
+ logger.log(`Closing GraphQL WebSocket of user ${connection.userId ?? '(unknown)'}: ${reason}`);
233
+ try {
234
+ // A close reason is limited to 123 bytes on the wire; these are short and ASCII.
235
+ connection.socket?.close(WS_AUTHORIZATION_CHANGED_CLOSE_CODE, reason);
236
+ } catch {
237
+ // Already closing — the outcome is the same.
238
+ }
239
+ return false;
240
+ }
241
+
242
+ async function revalidate(connection: WsConnection): Promise<boolean> {
243
+ connection.stale = false;
244
+ try {
245
+ let user = connection.user;
246
+ if (connection.reauthenticate) {
247
+ user = await connection.reauthenticate();
248
+ if (!user) {
249
+ return terminate(connection, 'Session no longer valid');
250
+ }
251
+ }
252
+ if (fingerprintUser(user) !== connection.userFingerprint) {
253
+ return terminate(connection, 'User authorization changed');
254
+ }
255
+ if (connection.tenantFingerprint !== undefined) {
256
+ const resolver = getTenantContextResolver();
257
+ const tenant = resolver ? await resolver.resolve(user, connection.tenantHeader) : {};
258
+ if (fingerprintTenant(tenant) !== connection.tenantFingerprint) {
259
+ return terminate(connection, 'Tenant access changed');
260
+ }
261
+ }
262
+ if (connection.terminated) {
263
+ return false;
264
+ }
265
+ connection.validatedAt = Date.now();
266
+ return true;
267
+ } catch {
268
+ // Fail closed: a check that cannot be made is not a check that passed. The client reconnects,
269
+ // and the handshake decides again.
270
+ return terminate(connection, 'Authorization check failed');
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Is the connection still authorized as it was registered? Re-checks when an invalidation marked it,
276
+ * or when its last check is older than `graphQl.subscriptionRevalidationMs`; otherwise answers from
277
+ * the last check without any I/O. Concurrent callers share one re-check.
278
+ *
279
+ * `false` means the connection has been closed and nothing more may be delivered or executed on it.
280
+ */
281
+ export function ensureWsConnectionCurrent(connection: WsConnection): Promise<boolean> {
282
+ if (connection.terminated) {
283
+ return Promise.resolve(false);
284
+ }
285
+ const interval = configuredRevalidationMs();
286
+ if (!connection.stale && (interval === false || Date.now() - connection.validatedAt < interval)) {
287
+ return Promise.resolve(true);
288
+ }
289
+ if (!connection.inflight) {
290
+ connection.inflight = revalidate(connection).finally(() => {
291
+ connection.inflight = undefined;
292
+ // An invalidation that arrived while this check was running may have been answered from data
293
+ // that predates it — check once more rather than lose it.
294
+ if (connection.stale && !connection.terminated) {
295
+ void ensureWsConnectionCurrent(connection);
296
+ }
297
+ });
298
+ }
299
+ return connection.inflight;
300
+ }
301
+
302
+ /**
303
+ * Record the tenant scope an operation on this connection resolved.
304
+ *
305
+ * The first resolution becomes the connection's scope. A later operation resolving a DIFFERENT one
306
+ * means the rights changed underneath the subscriptions that are already running on the old scope —
307
+ * the connection is closed, and `false` returned.
308
+ */
309
+ export function noteWsConnectionTenant(connection: WsConnection, tenant: ResolvedTenantContext | undefined): boolean {
310
+ if (connection.terminated) {
311
+ return false;
312
+ }
313
+ const fingerprint = fingerprintTenant(tenant);
314
+ if (connection.tenantFingerprint === undefined) {
315
+ connection.tenant = tenant ? { ...tenant } : {};
316
+ connection.tenantFingerprint = fingerprint;
317
+ return true;
318
+ }
319
+ if (fingerprint !== connection.tenantFingerprint) {
320
+ return terminate(connection, 'Tenant access changed');
321
+ }
322
+ return true;
323
+ }
324
+
325
+ /** Mark a connection for an immediate re-check and start it. */
326
+ function markStale(connection: WsConnection): void {
327
+ connection.stale = true;
328
+ void ensureWsConnectionCurrent(connection);
329
+ }
330
+
331
+ /**
332
+ * Re-check, at once, every open connection of a user — after a membership, role or credential change.
333
+ * Answers the number of connections affected. Process-local: the tenant guard's Redis broadcast is
334
+ * what carries a membership invalidation to the other replicas.
335
+ */
336
+ export function revalidateWsConnectionsOf(identity: IWsConnectionIdentity): number {
337
+ const userId = identity?.userId !== undefined && identity?.userId !== null ? String(identity.userId) : undefined;
338
+ const iamId = identity?.iamId || undefined;
339
+ const email = typeof identity?.email === 'string' ? identity.email.toLowerCase() : undefined;
340
+ if (!userId && !iamId && !email) {
341
+ return 0;
342
+ }
343
+ let count = 0;
344
+ for (const connection of liveConnections) {
345
+ if (
346
+ (userId && connection.userId === userId) ||
347
+ (iamId && connection.iamId === iamId) ||
348
+ (email && connection.email === email)
349
+ ) {
350
+ markStale(connection);
351
+ count++;
352
+ }
353
+ }
354
+ return count;
355
+ }
356
+
357
+ /** Re-check, at once, every open connection scoped to a tenant — after the tenant was switched on or off. */
358
+ export function revalidateWsConnectionsForTenant(tenantId: string): number {
359
+ if (!tenantId) {
360
+ return 0;
361
+ }
362
+ const id = String(tenantId);
363
+ let count = 0;
364
+ for (const connection of liveConnections) {
365
+ const tenant = connection.tenant;
366
+ if (tenant && (tenant.tenantId === id || (Array.isArray(tenant.tenantIds) && tenant.tenantIds.includes(id)))) {
367
+ markStale(connection);
368
+ count++;
369
+ }
370
+ }
371
+ return count;
372
+ }
373
+
374
+ /** Re-check every open connection — after a change whose reach is unknown (`invalidateAll()`). */
375
+ export function revalidateAllWsConnections(): number {
376
+ let count = 0;
377
+ for (const connection of liveConnections) {
378
+ markStale(connection);
379
+ count++;
380
+ }
381
+ return count;
382
+ }
@@ -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