@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.
- package/.claude/rules/configurable-features.md +3 -2
- package/.claude/rules/role-system.md +31 -3
- package/.claude/rules/testing.md +4 -4
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
- package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
- package/dist/core/modules/auth/services/core-auth.service.js +19 -0
- package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
- package/dist/core/modules/better-auth/better-auth.config.js +27 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.js +2 -2
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
- package/dist/core/modules/tus/core-tus.service.js +39 -0
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +11 -6
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core.module.d.ts +3 -0
- package/dist/core.module.js +100 -42
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -0
- package/migration-guides/11.42.1-to-11.42.2.md +53 -0
- package/migration-guides/11.42.2-to-11.42.3.md +145 -0
- package/package.json +1 -1
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
- package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
- package/src/core/common/interfaces/server-options.interface.ts +32 -2
- package/src/core/modules/auth/services/core-auth.service.ts +37 -0
- package/src/core/modules/better-auth/better-auth.config.ts +46 -1
- package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
- package/src/core/modules/tenant/README.md +8 -8
- package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
- package/src/core/modules/tus/README.md +15 -0
- package/src/core/modules/tus/core-tus.controller.ts +4 -2
- package/src/core/modules/tus/core-tus.service.ts +60 -0
- package/src/core/modules/user/core-user.service.ts +19 -6
- package/src/core.module.ts +166 -55
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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)
|
|
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
|
|
1494
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
/**
|
|
@@ -526,6 +526,21 @@ from elsewhere (an API key, a signed form token), or to let a support role resum
|
|
|
526
526
|
2. Verify MongoDB connection is established
|
|
527
527
|
3. Check server logs for initialization errors
|
|
528
528
|
|
|
529
|
+
### Upload is stored but the client never sees success (fixed in 11.42.2)
|
|
530
|
+
|
|
531
|
+
**Symptom:** the file appears in the file store (`Upload … migrated to …` in the log), but the final
|
|
532
|
+
`PATCH` never answers (the request hangs: the headers are already sent, so no error response can
|
|
533
|
+
follow), `tus-js-client` never fires `onSuccess`, and the log shows
|
|
534
|
+
`ERR_INVALID_ARG_TYPE … Received function`.
|
|
535
|
+
|
|
536
|
+
**Cause:** `@tus/server` finishes its responses with `res.end(callback)`, and the `compression`
|
|
537
|
+
middleware patches `res.end(chunk, encoding)` and reads the callback as a chunk. The starter registers
|
|
538
|
+
`compression` with `filter: () => true` and `threshold: 0`, so it also handles the bodyless 204.
|
|
539
|
+
|
|
540
|
+
**Solution:** update to 11.42.2 — `CoreTusService` normalises `res.end(callback)` before the tus
|
|
541
|
+
server writes (`normalizeEndCallback()`), for the core controller and any project controller that
|
|
542
|
+
calls `getServer().handle()`. No change to `main.ts` is needed.
|
|
543
|
+
|
|
529
544
|
### Upload stalls or fails to resume
|
|
530
545
|
|
|
531
546
|
**Cause:** Upload expired or server restarted
|
|
@@ -105,7 +105,9 @@ export class CoreTusController {
|
|
|
105
105
|
try {
|
|
106
106
|
await server.handle(req, res);
|
|
107
107
|
} catch (error) {
|
|
108
|
-
|
|
108
|
+
// The stack, not just the message: a TypeError from a middleware's patched `res.end` reads
|
|
109
|
+
// like a tus failure without it, and that cost a project several rounds of diagnosis (11.42.2).
|
|
110
|
+
this.logger.error(`TUS request error: ${error.message}`, error?.stack);
|
|
109
111
|
if (!res.headersSent) {
|
|
110
112
|
res.status(500).json({ message: 'Upload error' });
|
|
111
113
|
}
|
|
@@ -131,7 +133,7 @@ export class CoreTusController {
|
|
|
131
133
|
try {
|
|
132
134
|
await server.handle(req, res);
|
|
133
135
|
} catch (error) {
|
|
134
|
-
this.logger.error(`TUS request error for upload ${req.params.id}: ${error.message}
|
|
136
|
+
this.logger.error(`TUS request error for upload ${req.params.id}: ${error.message}`, error?.stack);
|
|
135
137
|
if (!res.headersSent) {
|
|
136
138
|
res.status(500).json({ message: 'Upload error' });
|
|
137
139
|
}
|
|
@@ -54,6 +54,9 @@ export interface CoreTusServiceOptions {
|
|
|
54
54
|
s3Service?: CoreS3Service;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Marks a response whose `end()` was already normalised (see `normalizeEndCallback()`). */
|
|
58
|
+
const END_CALLBACK_NORMALIZED = Symbol('tusEndCallbackNormalized');
|
|
59
|
+
|
|
57
60
|
/**
|
|
58
61
|
* Core TUS Service
|
|
59
62
|
*
|
|
@@ -794,9 +797,66 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
794
797
|
respectForwardedHeaders: true,
|
|
795
798
|
});
|
|
796
799
|
|
|
800
|
+
// Every caller of getServer().handle() — the core controller and any project controller — gets
|
|
801
|
+
// the response normalised before @tus/server writes to it. See normalizeEndCallback().
|
|
802
|
+
const handle = server.handle.bind(server);
|
|
803
|
+
server.handle = (req, res) => {
|
|
804
|
+
this.normalizeEndCallback(res);
|
|
805
|
+
return handle(req, res);
|
|
806
|
+
};
|
|
807
|
+
|
|
797
808
|
return server;
|
|
798
809
|
}
|
|
799
810
|
|
|
811
|
+
/**
|
|
812
|
+
* Makes `res.end(callback)` safe for middlewares that patch `res.end` with the old two-argument form.
|
|
813
|
+
*
|
|
814
|
+
* @tus/server finishes its responses through srvx with `res.end(callback)` — a documented Node
|
|
815
|
+
* signature. `compression` (and any middleware written like it) patches `res.end(chunk, encoding)`
|
|
816
|
+
* and treats the first argument as a chunk unconditionally, so the callback reaches
|
|
817
|
+
* `Buffer.byteLength()` and throws `ERR_INVALID_ARG_TYPE ... Received function`. The starter
|
|
818
|
+
* registers `compression` with `filter: () => true, threshold: 0`, which removes the checks that
|
|
819
|
+
* would otherwise skip a bodyless 204, so every finished upload failed AFTER the file had been
|
|
820
|
+
* stored: the client never saw a 2xx and retried an upload that was already complete.
|
|
821
|
+
*
|
|
822
|
+
* The wrapper moves a function argument into the callback slot and runs it on `finish`, which is
|
|
823
|
+
* what Node itself does with it — or on `close`, so a connection dropped after `end()` resolves srvx
|
|
824
|
+
* too. A response destroyed before `end()` stays unresolved, exactly as with plain Node.
|
|
825
|
+
* Idempotent per response. Protected so a project can widen it.
|
|
826
|
+
*/
|
|
827
|
+
protected normalizeEndCallback(res: any): void {
|
|
828
|
+
if (!res || typeof res.end !== 'function' || res[END_CALLBACK_NORMALIZED]) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const end = res.end;
|
|
832
|
+
res.end = function normalizedEnd(this: any, chunk?: unknown, encoding?: unknown, callback?: unknown) {
|
|
833
|
+
if (typeof chunk === 'function') {
|
|
834
|
+
callback = chunk;
|
|
835
|
+
chunk = undefined;
|
|
836
|
+
encoding = undefined;
|
|
837
|
+
} else if (typeof encoding === 'function') {
|
|
838
|
+
callback = encoding;
|
|
839
|
+
encoding = undefined;
|
|
840
|
+
}
|
|
841
|
+
if (typeof callback === 'function') {
|
|
842
|
+
let called = false;
|
|
843
|
+
const done = () => {
|
|
844
|
+
if (!called) {
|
|
845
|
+
called = true;
|
|
846
|
+
(callback as () => void)();
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
this.once('finish', done);
|
|
850
|
+
this.once('close', done);
|
|
851
|
+
}
|
|
852
|
+
if (chunk === undefined) {
|
|
853
|
+
return end.call(this);
|
|
854
|
+
}
|
|
855
|
+
return encoding === undefined ? end.call(this, chunk) : end.call(this, chunk, encoding);
|
|
856
|
+
};
|
|
857
|
+
res[END_CALLBACK_NORMALIZED] = true;
|
|
858
|
+
}
|
|
859
|
+
|
|
800
860
|
/**
|
|
801
861
|
* Parse TUS metadata into object
|
|
802
862
|
* @tus/server v2 already parses metadata into an object
|