@lenne.tech/nest-server 11.42.1 → 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 (55) hide show
  1. package/.claude/rules/configurable-features.md +3 -2
  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/tus/core-tus.controller.js +2 -2
  25. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  26. package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
  27. package/dist/core/modules/tus/core-tus.service.js +39 -0
  28. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  29. package/dist/core/modules/user/core-user.service.js +11 -6
  30. package/dist/core/modules/user/core-user.service.js.map +1 -1
  31. package/dist/core.module.d.ts +3 -0
  32. package/dist/core.module.js +100 -42
  33. package/dist/core.module.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/tsconfig.build.tsbuildinfo +1 -1
  38. package/docs/REQUEST-LIFECYCLE.md +1 -0
  39. package/migration-guides/11.42.1-to-11.42.2.md +53 -0
  40. package/migration-guides/11.42.2-to-11.42.3.md +145 -0
  41. package/package.json +1 -1
  42. package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
  43. package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
  44. package/src/core/common/interfaces/server-options.interface.ts +32 -2
  45. package/src/core/modules/auth/services/core-auth.service.ts +37 -0
  46. package/src/core/modules/better-auth/better-auth.config.ts +46 -1
  47. package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
  48. package/src/core/modules/tenant/README.md +8 -8
  49. package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
  50. package/src/core/modules/tus/README.md +15 -0
  51. package/src/core/modules/tus/core-tus.controller.ts +4 -2
  52. package/src/core/modules/tus/core-tus.service.ts +60 -0
  53. package/src/core/modules/user/core-user.service.ts +19 -6
  54. package/src/core.module.ts +166 -55
  55. package/src/index.ts +1 -0
@@ -208,6 +208,7 @@ JWT-based authentication for existing projects:
208
208
  | **Custom Scalars** | `Date`, `DateTime` (timestamp), `JSON`, `Any` |
209
209
  | **Subscriptions** | WebSocket support via `graphql-ws` with auth. The `PUB_SUB` provider is built from a factory: `CoreRedisPubSub` when `redis` is configured (delivery is then cluster-wide), the in-memory `PubSub` otherwise (delivery only to clients connected to the publishing replica). **Constraint once Redis is in play: every published payload must be JSON-serializable** — it crosses the wire as JSON, so `Date`, class instances, `Map`/`Set` and `undefined` do not survive the round trip. An in-process `PubSub` never had this constraint, so a payload that worked on one replica can silently lose fields on a cluster. Publish plain objects and ISO strings |
210
210
  | **Subscription request context** | Since 11.35.0 `CoreModule` installs a context-aware `execute` / `subscribe` pair (inside `subscriptions`, where `ApolloDriver` forwards it) on all three GraphQL driver builders, so every WebSocket operation runs inside a `RequestContext`. Before that a WS operation had **none** — no Express middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a subscription context — and `mongooseTenantPlugin` reads "no context" as "system operation, no filter", so a tenant-scoped read while delivering a subscription message returned EVERY tenant's rows. The tenant comes from the handshake's tenant header **validated against an active membership**, else from the subscriber's memberships; an unresolvable tenant leaves the safety net to refuse the read. The async iterator is wrapped too, not only the subscribe call: graphql-js runs `resolve` / `filter` / field resolvers inside its `next()`. See `.claude/rules/role-system.md` → "Tenant context on non-HTTP transports" |
211
+ | **Subscription re-check** | Since 11.42.3 that context is not trusted for the life of the socket. Every connection a `CoreModule` `onConnect` accepts is registered (`registerWsConnection()`) and checked again — at once on an invalidation (tenant guard, role change, password reset, Better-Auth deleting the session), otherwise before the next operation or event once `graphQl.subscriptionRevalidationMs` (30 s) has passed. The check re-runs the handshake's authentication and the tenant resolution; a connection whose rights changed is closed with 4403 (retried by the graphql-ws client, which then reconnects with its current rights) and the event that triggered the check is dropped. Before, a removed membership, a deactivated tenant, a withdrawn role or a revoked session kept receiving events on an open socket until the client reconnected. See `.claude/rules/role-system.md` → "Open WebSockets" |
211
212
  | **Complexity Analysis** | Query cost calculation to prevent DoS attacks |
212
213
  | **Enum Registration** | `registerEnum()` helper for GraphQL enum types |
213
214
  | **Upload Support** | `graphqlUploadExpress()` for multipart file uploads |
@@ -0,0 +1,53 @@
1
+ # Migration Guide: 11.42.1 → 11.42.2
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | ------------------------------------------------------------------------ |
7
+ | **Bugfix** | Finished TUS uploads answer 204 behind the `compression` middleware (§1) |
8
+ | **Migration Effort** | None — `pnpm update` |
9
+
10
+ ## Quick Migration
11
+
12
+ ```bash
13
+ pnpm update @lenne.tech/nest-server
14
+ ```
15
+
16
+ Vendor-mode projects pick up the core update; nothing else to change.
17
+
18
+ ## 1. A finished TUS upload never got an answer although the file was stored
19
+
20
+ `@tus/server` finishes its responses with `res.end(callback)`, a documented Node signature. The
21
+ `compression` middleware patches `res.end(chunk, encoding)` and treats the first argument as a chunk,
22
+ so the callback reached `Buffer.byteLength()` and threw:
23
+
24
+ ```
25
+ TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer … Received function
26
+ ```
27
+
28
+ The starter's `main.ts` — and therefore every generated project — registers `compression` with
29
+ `filter: () => true` and `threshold: 0`, which removes the two checks that would otherwise skip a
30
+ bodyless 204. The failure hit the final `PATCH`, AFTER the upload had been migrated into the file
31
+ store: the request hung — the headers were already sent, so no error response could follow — the
32
+ client never saw a 2xx, `tus-js-client` never fired `onSuccess`, and no log named `compression`.
33
+
34
+ Since 11.42.2 `CoreTusService` normalises `res.end(callback)` before the tus server writes
35
+ (`normalizeEndCallback()`, wrapped around `getServer().handle()`), so it holds for the core controller
36
+ and for any project controller that calls the tus server. No change to `main.ts` is needed; a project
37
+ that worked around it by excluding `/tus` from the compression filter can keep or drop that filter.
38
+
39
+ ## 2. TUS request errors are logged with their stack
40
+
41
+ `CoreTusController` logged only `error.message` for a failed TUS request. A `TypeError` thrown by a
42
+ middleware's patched `res.end` then read like a tus failure, with nothing pointing at the middleware —
43
+ the defect in §1 took several rounds to find for exactly that reason. The stack is now logged too.
44
+
45
+ ## Troubleshooting
46
+
47
+ | Symptom | Cause | Fix |
48
+ | -------------------------------------------------------------------------------------- | ------------------------------------- | ----------------- |
49
+ | File stored, final PATCH never answers (request hangs), `Received function` in the log | 11.42.1 or older behind `compression` | Update to 11.42.2 |
50
+
51
+ ## Module Documentation
52
+
53
+ - [TUS README](../src/core/modules/tus/README.md) — Troubleshooting
@@ -0,0 +1,145 @@
1
+ # Migration Guide: 11.42.2 → 11.42.3
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
7
+ | **Security** | An open GraphQL WebSocket loses what its user lost — membership, tenant, role, session — instead of keeping it until reconnect (§1) |
8
+ | **Security** | IAM sign-out ends the session on the server, not only in the browser (§2) |
9
+ | **Bugfix** | The IAM WebSocket handshake accepts a valid bearer token (§3) |
10
+ | **Security** | The legacy WebSocket handshake verifies the token's signature and expiry, as HTTP does (§4) |
11
+ | **New option** | `graphQl.subscriptionRevalidationMs` (default 30000) (§1) |
12
+ | **Migration Effort** | None for most projects — `pnpm update`. Check §1 if you replaced `onConnect` yourself |
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ ```
19
+
20
+ Vendor-mode projects pick up the core update. No configuration change is required.
21
+
22
+ ## 1. Open WebSockets are re-checked, and closed when the rights behind them changed
23
+
24
+ A GraphQL WebSocket was authorized once: `onConnect` validated the token and recorded the user, the
25
+ subscribe wrapper resolved the tenant, and every event and operation on the socket ran on that
26
+ snapshot until the client reconnected. Removing someone from a tenant, switching a tenant off,
27
+ withdrawing a role or ending a session took effect on every new request and on none of the sockets
28
+ that were already open — for a browser tab, often hours.
29
+
30
+ Since 11.42.3 every connection `CoreModule`'s `onConnect` accepts is registered with the rights it was
31
+ authorized with, and checked again:
32
+
33
+ | Trigger | When |
34
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
35
+ | `CoreTenantGuard.invalidateUser()` / `invalidateTenant()` / `invalidateAll()`, `CoreUserService.setRoles()`, an `update()` of `roles` / `verified` / `emailVerified`, the legacy password reset | at once |
36
+ | Better-Auth deleting a session: sign-out, a revoked session, a reset with `revokeSessionsOnPasswordReset` | at once |
37
+ | A change nobody invalidated (a membership edited directly in the database, an expired session) | before the next event or operation, once `graphQl.subscriptionRevalidationMs` has passed |
38
+
39
+ A connection whose rights changed is closed with **4403**. The graphql-ws client retries that code: it
40
+ reconnects, resubscribes and continues with the rights the user holds now, or is refused at the
41
+ handshake when the session is gone. Nothing changes for a connection whose rights did not change.
42
+
43
+ ```typescript
44
+ // config.env.ts — optional, the default is 30000
45
+ graphQl: {
46
+ subscriptionRevalidationMs: 30000, // 0 = re-check on every event and operation, false = only on invalidation
47
+ },
48
+ ```
49
+
50
+ **If you replaced `onConnect`** through `graphQl.driver.subscriptions`, register what you accept, or
51
+ your connections keep the old "checked once" behaviour:
52
+
53
+ ```typescript
54
+ import { registerWsConnection } from '@lenne.tech/nest-server';
55
+
56
+ onConnect: async (context) => {
57
+ const user = await authenticate(context); // your own handshake
58
+ context.extra.user = user;
59
+ registerWsConnection({
60
+ carrier: context.extra,
61
+ reauthenticate: () => authenticate(context), // the user the token belongs to NOW, or null
62
+ socket: context.extra.socket,
63
+ user,
64
+ });
65
+ return context.extra;
66
+ },
67
+ ```
68
+
69
+ **If your client does not use graphql-ws** (or turned its retries off), make sure it reconnects after a
70
+ 4403 close; otherwise the user's live updates stop until the page is reloaded.
71
+
72
+ A dropped event is checked after graphql-js executed it and before it is sent: the reads made while
73
+ computing it happened, but nothing of it reaches the client. Subscription resolvers should not write.
74
+
75
+ ## 2. IAM sign-out ends the session on the server
76
+
77
+ `CoreBetterAuthService.revokeSession()` — used by `POST /iam/sign-out` and the `betterAuthSignOut`
78
+ mutation — passed the session token to Better-Auth's `signOut` only as an `Authorization` header.
79
+ Better-Auth reads that header only with its `bearer` plugin, which the framework does not install. The
80
+ sign-out therefore answered `{ success: true }`, cleared the cookies, and deleted nothing: the session
81
+ stayed valid server-side, and the same token kept authenticating requests until it expired.
82
+
83
+ The token is now passed as the signed session cookie Better-Auth reads, and the header is kept for
84
+ projects that added the bearer plugin themselves. **No action is needed.** Sessions signed out before
85
+ the update are still valid until they expire; if that matters for your deployment, delete them from the
86
+ `session` collection.
87
+
88
+ ## 3. The IAM WebSocket handshake accepts a valid bearer token
89
+
90
+ Both IAM driver builders authenticated the handshake through `getSession()` with an `Authorization`
91
+ header — the same header Better-Auth ignores without its `bearer` plugin. Every IAM WebSocket handshake
92
+ with a bearer token was refused as `Invalid or expired session`, so authenticated subscriptions did not
93
+ work in IAM-only projects at all.
94
+
95
+ The handshake now resolves the token the way the HTTP middleware does for the same header: a Better-Auth
96
+ JWT first, then the session store, and `getSession()` as the last resort. If you built a workaround
97
+ (for example a custom `onConnect`), you can remove it — and register the connection as shown in §1 if
98
+ you keep it.
99
+
100
+ ## 4. The legacy WebSocket handshake verifies the token
101
+
102
+ Legacy access tokens are verified on HTTP by passport-jwt: signature and expiry. The legacy WebSocket
103
+ handshake read them with `decodeJwt()` instead, which checks neither — only the payload's `tokenId` had
104
+ to match the device. An expired token, or a copy of one re-signed with any key, therefore kept opening
105
+ sockets for as long as the device's refresh token was not rotated.
106
+
107
+ The handshake, and the re-check from §1, now use `CoreAuthService.verifyJwt()`, which applies passport's
108
+ rule: `jwt.secretOrKeyProvider` when configured, else `jwt.secretOrPrivateKey || jwt.secret`. A token
109
+ that is not a JWT at all is refused as unauthorized instead of closing the socket with 4500 and a
110
+ TypeError as the reason.
111
+
112
+ **What this changes for a legacy WebSocket client:** a connection now ends when its token does — at
113
+ expiry, or when the client refreshes its tokens (a refresh rotates the device's `tokenId`, which already
114
+ invalidated the old token on HTTP). The socket is closed with 4403 and graphql-ws reconnects, so pass
115
+ the CURRENT token on every connect rather than the one the client had at start-up:
116
+
117
+ ```typescript
118
+ createClient({
119
+ url,
120
+ connectionParams: () => ({ Authorization: `Bearer ${getCurrentAccessToken()}` }), // a function, not an object
121
+ });
122
+ ```
123
+
124
+ The same holds for an IAM client that authenticates its socket with a Better-Auth JWT (§3): the JWT
125
+ expires after `betterAuth.jwt.expiresIn` (15 minutes by default). A session token lives as long as its
126
+ session.
127
+
128
+ An AuthService passed to `CoreModule.forRoot()` that does not extend `CoreAuthService` may lack
129
+ `verifyJwt()`; it keeps the unverified read and logs a warning once. Extend `CoreAuthService` to close it.
130
+
131
+ ## Troubleshooting
132
+
133
+ | Symptom | Cause | Fix |
134
+ | -------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------- |
135
+ | A client's WebSocket closes with 4403 after a role or membership change | Intended: the rights behind the connection changed | Let the client reconnect (graphql-ws does by default) |
136
+ | Live updates stop after a role change and never resume | The client does not reconnect after a 4403 close | Enable reconnects (graphql-ws `retryAttempts`) |
137
+ | Revoked access still receives events on a project with its own `onConnect` | The custom `onConnect` does not register the connection | Call `registerWsConnection()` (§1) |
138
+ | IAM subscriptions are refused with `Invalid or expired session` | 11.42.2 or older | Update to 11.42.3 |
139
+ | A WebSocket reconnects and is refused shortly after a token refresh or expiry | The client reconnects with the token it had at start-up | Pass `connectionParams` as a function (§4) |
140
+ | `has no verifyJwt()` warning at the first legacy WebSocket | The AuthService does not extend `CoreAuthService` | Extend `CoreAuthService` (§4) |
141
+
142
+ ## Module Documentation
143
+
144
+ - [Tenant README](../src/core/modules/tenant/README.md) — Deactivated tenants
145
+ - [Better-Auth README](../src/core/modules/better-auth/README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.42.1",
3
+ "version": "11.42.3",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -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
+ }