@lenne.tech/nest-server 11.41.6 → 11.41.8
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/better-auth.md +26 -22
- package/.claude/rules/configurable-features.md +1 -1
- package/.claude/rules/package-management.md +8 -0
- package/.claude/rules/testing.md +13 -10
- package/CLAUDE.md +6 -4
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/services/core-s3.service.d.ts +8 -0
- package/dist/core/common/services/core-s3.service.js +44 -3
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +10 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js +13 -0
- package/dist/core/modules/better-auth/core-better-auth-account-issuer.helper.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +3 -2
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth.constants.js +2 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js +46 -3
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/file/core-file.service.js +3 -2
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +1 -0
- package/dist/core/modules/file/s3-file.helper.d.ts +1 -0
- package/dist/core/modules/file/s3-file.helper.js +10 -4
- package/dist/core/modules/file/s3-file.helper.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js +3 -2
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/docs/security-overrides.md +1 -1
- package/migration-guides/11.41.6-to-11.41.7.md +192 -0
- package/migration-guides/11.41.7-to-11.41.8.md +135 -0
- package/package.json +24 -22
- package/src/core/common/helpers/file.helper.ts +4 -0
- package/src/core/common/helpers/gridfs.helper.ts +1 -1
- package/src/core/common/services/core-s3.service.ts +125 -7
- package/src/core/modules/ai/README.md +8 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +33 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +2 -2
- package/src/core/modules/better-auth/README.md +20 -6
- package/src/core/modules/better-auth/core-better-auth-account-issuer.helper.ts +25 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -7
- package/src/core/modules/better-auth/core-better-auth.constants.ts +20 -11
- package/src/core/modules/better-auth/core-better-auth.service.ts +87 -3
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +39 -11
- package/src/core/modules/file/README.md +30 -5
- package/src/core/modules/file/core-file.service.ts +15 -6
- package/src/core/modules/file/interfaces/file-upload.interface.ts +18 -0
- package/src/core/modules/file/s3-file.helper.ts +28 -9
- package/src/core/modules/system-setup/core-system-setup.service.ts +12 -8
|
@@ -74,6 +74,16 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
74
74
|
*/
|
|
75
75
|
protected presigner?: typeof import('@aws-sdk/s3-request-presigner');
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Lazy-imported `@aws-sdk/lib-storage`, used to upload a stream whose length is unknown.
|
|
79
|
+
*
|
|
80
|
+
* Three states, and the third is the point: `undefined` means "not tried yet", a module means
|
|
81
|
+
* "available", and `null` means "tried and absent". Without the `null` the import would be
|
|
82
|
+
* retried on every single upload, and since the absence is permanent that is a resolver miss per
|
|
83
|
+
* request plus a repeated warning in the log.
|
|
84
|
+
*/
|
|
85
|
+
protected multipartUploader?: null | typeof import('@aws-sdk/lib-storage');
|
|
86
|
+
|
|
77
87
|
constructor(protected readonly configService: ConfigService) {
|
|
78
88
|
const raw = this.configService.getFastButReadOnly<IS3Config | undefined>('s3');
|
|
79
89
|
if (!raw || raw.enabled === false) {
|
|
@@ -189,6 +199,10 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
189
199
|
...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}),
|
|
190
200
|
});
|
|
191
201
|
|
|
202
|
+
// Resolved at boot rather than on the first upload, so a missing optional peer is reported
|
|
203
|
+
// where operators look — the startup log — instead of during the first large upload.
|
|
204
|
+
await this.loadMultipartUploader();
|
|
205
|
+
|
|
192
206
|
await this.verifyBuckets();
|
|
193
207
|
}
|
|
194
208
|
|
|
@@ -263,10 +277,26 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
263
277
|
/**
|
|
264
278
|
* Upload an object to the main bucket.
|
|
265
279
|
*
|
|
266
|
-
* A
|
|
267
|
-
* encoding and then fails on a missing `x-amz-decoded-content-length
|
|
268
|
-
*
|
|
269
|
-
*
|
|
280
|
+
* A `PutObject` needs the body's length: without `Content-Length` the SDK switches to
|
|
281
|
+
* aws-chunked encoding and then fails on a missing `x-amz-decoded-content-length`. So pass
|
|
282
|
+
* `contentLength` whenever the size is known — that streams the body straight through as a
|
|
283
|
+
* single request.
|
|
284
|
+
*
|
|
285
|
+
* A stream of UNKNOWN length goes through a multipart upload instead (`@aws-sdk/lib-storage`),
|
|
286
|
+
* which is what that package exists for: it reads the stream in parts and needs no total up
|
|
287
|
+
* front. It used to be read into memory instead, which made the memory cost of an upload equal
|
|
288
|
+
* to its size — a multi-GB object then either threw "Array buffer allocation failed" or got the
|
|
289
|
+
* container OOM-killed at 100 % progress, taking every other in-flight request with it. That is
|
|
290
|
+
* not a hypothetical shape: any streamed HTTP upload has it, because a multipart request's
|
|
291
|
+
* `Content-Length` covers the whole envelope and therefore cannot be used as the object length.
|
|
292
|
+
*
|
|
293
|
+
* A KNOWN length above {@link CoreS3Service.MAX_SINGLE_PUT_BYTES} goes through the multipart
|
|
294
|
+
* upload as well: AWS S3 refuses a single PutObject that large with `EntityTooLarge`, which for a
|
|
295
|
+
* finished TUS upload means after the client has sent every byte.
|
|
296
|
+
*
|
|
297
|
+
* Without the optional peer dependency both cases behave exactly as before: an unknown length is
|
|
298
|
+
* read into memory, a known one goes up as one PutObject whatever its size (MinIO and RustFS
|
|
299
|
+
* accept that; AWS S3 does not above 5 GiB).
|
|
270
300
|
*/
|
|
271
301
|
async putObject(
|
|
272
302
|
key: string,
|
|
@@ -278,6 +308,15 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
278
308
|
|
|
279
309
|
let payload: Buffer | Readable | string = body;
|
|
280
310
|
let length = contentLength;
|
|
311
|
+
// Multipart for a stream of UNKNOWN length — nothing else carries it without buffering — and for
|
|
312
|
+
// one whose known length exceeds what S3 accepts in a single request.
|
|
313
|
+
if (this.isStream(body) && (length === undefined || length > CoreS3Service.MAX_SINGLE_PUT_BYTES)) {
|
|
314
|
+
const uploader = await this.loadMultipartUploader();
|
|
315
|
+
if (uploader) {
|
|
316
|
+
await this.putObjectMultipart(uploader, key, body, contentType, length);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
281
320
|
if (this.isStream(body) && length === undefined) {
|
|
282
321
|
payload = await this.collect(body);
|
|
283
322
|
length = payload.length;
|
|
@@ -318,6 +357,69 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
318
357
|
await send();
|
|
319
358
|
}
|
|
320
359
|
|
|
360
|
+
/**
|
|
361
|
+
* The multipart uploader, or `undefined` when the optional peer dependency is absent.
|
|
362
|
+
*
|
|
363
|
+
* Absence is NOT an error here, unlike the missing `@aws-sdk/client-s3` in `onModuleInit`:
|
|
364
|
+
* multipart is an optimisation of a path that already works by buffering, so a project that
|
|
365
|
+
* never installs it keeps the previous behaviour instead of failing to boot. The cost of that
|
|
366
|
+
* choice is memory, which is why the caller's doc states it.
|
|
367
|
+
*/
|
|
368
|
+
protected async loadMultipartUploader(): Promise<typeof import('@aws-sdk/lib-storage') | undefined> {
|
|
369
|
+
if (this.multipartUploader !== undefined) {
|
|
370
|
+
return this.multipartUploader ?? undefined;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
this.multipartUploader = await import('@aws-sdk/lib-storage');
|
|
374
|
+
} catch {
|
|
375
|
+
// `null`, not `undefined`: the field doubles as the "already tried" marker, and a failed
|
|
376
|
+
// import must not be retried on every upload.
|
|
377
|
+
this.multipartUploader = null;
|
|
378
|
+
this.logger.warn(
|
|
379
|
+
'Streaming an S3 upload of unknown length falls back to buffering it in memory because the ' +
|
|
380
|
+
'optional peer dependency "@aws-sdk/lib-storage" is not installed. Run: pnpm add @aws-sdk/lib-storage',
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return this.multipartUploader ?? undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Upload a stream of unknown length as a multipart upload.
|
|
388
|
+
*
|
|
389
|
+
* Memory stays bounded by the uploader's defaults — parts of 5 MiB, at most four in flight —
|
|
390
|
+
* whatever the object's size. A stream that ends within the first part is sent as one ordinary
|
|
391
|
+
* PutObject by `Upload` itself, so a small file costs no multipart round trips.
|
|
392
|
+
*
|
|
393
|
+
* No `guardBodyStream` here, and that is deliberate: the guard exists because the SDK pipes a
|
|
394
|
+
* `PutObject` body into its request without listening for the source's `error`, so a failed
|
|
395
|
+
* source surfaces as a successful upload of a truncated object. `Upload` does listen — a source
|
|
396
|
+
* error rejects `done()` and the already-uploaded parts are aborted — so wrapping it would add a
|
|
397
|
+
* second error path without adding a guarantee.
|
|
398
|
+
*/
|
|
399
|
+
protected async putObjectMultipart(
|
|
400
|
+
uploader: typeof import('@aws-sdk/lib-storage'),
|
|
401
|
+
key: string,
|
|
402
|
+
body: Readable,
|
|
403
|
+
contentType?: string,
|
|
404
|
+
contentLength?: number,
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
const { client, config } = this.requireInit();
|
|
407
|
+
const upload = new uploader.Upload({
|
|
408
|
+
client,
|
|
409
|
+
params: {
|
|
410
|
+
Body: body,
|
|
411
|
+
Bucket: config.bucket,
|
|
412
|
+
// A known total lets `Upload` size its parts (`ceil(total / 10 000)`, at least 5 MiB) so the
|
|
413
|
+
// 10 000-part limit holds, and refuse a stream that yields a different NUMBER of parts than
|
|
414
|
+
// that total implies (it checks the part count, not the byte count). Not sent per part.
|
|
415
|
+
...(contentLength === undefined ? {} : { ContentLength: contentLength }),
|
|
416
|
+
...(contentType ? { ContentType: contentType } : {}),
|
|
417
|
+
Key: key,
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
await upload.done();
|
|
421
|
+
}
|
|
422
|
+
|
|
321
423
|
/**
|
|
322
424
|
* Shield the SDK from a REQUEST-BODY stream that can fail, and keep the cause.
|
|
323
425
|
*
|
|
@@ -397,6 +499,12 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
397
499
|
*/
|
|
398
500
|
static readonly MAX_COPY_OBJECT_BYTES = 5 * 1024 * 1024 * 1024;
|
|
399
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Largest object a single `PutObject` can carry (AWS S3 hard limit). {@link CoreS3Service.putObject}
|
|
504
|
+
* sends a known length above it as a multipart upload instead.
|
|
505
|
+
*/
|
|
506
|
+
static readonly MAX_SINGLE_PUT_BYTES = 5 * 1024 * 1024 * 1024;
|
|
507
|
+
|
|
400
508
|
/**
|
|
401
509
|
* Build the `CopySource` value for {@link CoreS3Service.copyObject}
|
|
402
510
|
*/
|
|
@@ -453,13 +561,23 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
453
561
|
* Whether an object exists in the main bucket
|
|
454
562
|
*/
|
|
455
563
|
async objectExists(key: string): Promise<boolean> {
|
|
564
|
+
return (await this.statObject(key)) !== null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Size and content type of an object in the main bucket, or `null` when it does not exist.
|
|
569
|
+
*
|
|
570
|
+
* One HEAD request. This is how the length of an object uploaded WITHOUT a known length is
|
|
571
|
+
* learned afterwards: the stored object is the authority on its own size.
|
|
572
|
+
*/
|
|
573
|
+
async statObject(key: string): Promise<null | { contentLength?: number; contentType?: string }> {
|
|
456
574
|
const { client, config, sdk } = this.requireInit();
|
|
457
575
|
try {
|
|
458
|
-
await client.send(new sdk.HeadObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
459
|
-
return
|
|
576
|
+
const result = await client.send(new sdk.HeadObjectCommand({ Bucket: config.bucket, Key: key }));
|
|
577
|
+
return { contentLength: result.ContentLength, contentType: result.ContentType };
|
|
460
578
|
} catch (error: any) {
|
|
461
579
|
if (error?.$metadata?.httpStatusCode === 404 || error?.name === 'NotFound') {
|
|
462
|
-
return
|
|
580
|
+
return null;
|
|
463
581
|
}
|
|
464
582
|
throw error;
|
|
465
583
|
}
|
|
@@ -621,6 +621,14 @@ the server log; the response carries only a stable `#LTNS_0901` code.
|
|
|
621
621
|
framework's existing auth) — the MCP session is bound to that user, and
|
|
622
622
|
`tools/list` / `tools/call` are filtered to and executed with their permissions.
|
|
623
623
|
- Unauthenticated requests get `401` with a `WWW-Authenticate` header.
|
|
624
|
+
- A session id the server does not know — which every restart produces, because the
|
|
625
|
+
transport map is process memory — gets **`404`**, and so does another user's id. 404
|
|
626
|
+
is the status the MCP spec has a client react to by sending a fresh `initialize`, so
|
|
627
|
+
clients recover from a deploy on their own rather than sitting dead until someone
|
|
628
|
+
reconnects by hand. A request with **no** session id and no `initialize` body still
|
|
629
|
+
gets `400`: there is nothing to recover, and a 404 would start a reconnect loop.
|
|
630
|
+
- Answering a foreign id `404` rather than `403` is deliberate — a 403 would confirm
|
|
631
|
+
that the id exists, and the id travels in a response header and through client logs.
|
|
624
632
|
|
|
625
633
|
### Multi-replica / sticky sessions
|
|
626
634
|
|
|
@@ -121,6 +121,27 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
121
121
|
this.foreignSession(res, owner);
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
// A session id the client still holds that no replica knows — the state every restart
|
|
126
|
+
// produces, because the transport map is process memory. 404 is the only status a client
|
|
127
|
+
// reacts to by starting a fresh session itself (the spec requires it to), and it is what the
|
|
128
|
+
// SDK's own server returns for a mismatched id.
|
|
129
|
+
//
|
|
130
|
+
// Falling through to a new transport instead, as this did until now, looked like a silent
|
|
131
|
+
// recovery and was not one: after a restart the client's next request is a `tools/call` or
|
|
132
|
+
// `tools/list`, never an `initialize`, so the fresh transport's `validateSession()` answered
|
|
133
|
+
// `400 Bad Request: Server not initialized` — the same dead session, one layer down, and a
|
|
134
|
+
// 400 is a status no client recovers from. Found in a consumer project whose own older copy
|
|
135
|
+
// of this controller had the identical defect: after every deploy, every connected client
|
|
136
|
+
// stayed dead until somebody reconnected by hand.
|
|
137
|
+
//
|
|
138
|
+
// An `initialize` carrying a stale id still gets a new session rather than a 404: a client
|
|
139
|
+
// that keeps sending the old id would otherwise loop. No SDK client does that — it skips
|
|
140
|
+
// `initialize` entirely once its transport has an id — but the case costs one check.
|
|
141
|
+
if (!this.isInitializeRequest(req.body)) {
|
|
142
|
+
res.status(404).json({ error: 'Unknown or expired MCP session' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
if (!entry) {
|
|
@@ -216,6 +237,18 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
216
237
|
return null;
|
|
217
238
|
}
|
|
218
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Whether the body is an `initialize` — the one request that legitimately starts a session.
|
|
242
|
+
*
|
|
243
|
+
* A batch counts when any member is an initialize, matching how the SDK transport reads it.
|
|
244
|
+
*/
|
|
245
|
+
protected isInitializeRequest(body: any): boolean {
|
|
246
|
+
if (Array.isArray(body)) {
|
|
247
|
+
return body.some((message: any) => message?.method === 'initialize');
|
|
248
|
+
}
|
|
249
|
+
return body?.method === 'initialize';
|
|
250
|
+
}
|
|
251
|
+
|
|
219
252
|
/**
|
|
220
253
|
* Whether the OAuth 2.1 layer is enabled via `ai.mcp.oauth`.
|
|
221
254
|
*/
|
|
@@ -18,14 +18,14 @@ From `@lenne.tech/nest-server` 11.37.0, better-auth is **not** a dependency of t
|
|
|
18
18
|
non-optional **peer dependency**, so your project declares and owns the version:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
pnpm add better-auth@1.7.
|
|
21
|
+
pnpm add better-auth@1.7.7 @better-auth/passkey@1.7.7 @better-auth/core@1.7.7
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
**Pin all three to the same exact version** — no `^`, no `~`. They are one release train, and a
|
|
25
25
|
mixed set fails in ways the type checker does not catch.
|
|
26
26
|
|
|
27
27
|
> **Do not rely on `pnpm install` to remind you.** On pnpm's default `autoInstallPeers: true` the
|
|
28
|
-
> packages are installed silently at whatever version satisfies `>=1.7.
|
|
28
|
+
> packages are installed silently at whatever version satisfies `>=1.7.7 <1.8.0` — your build stays
|
|
29
29
|
> green while the version is unpinned and free to drift. Verify with `pnpm why better-auth`.
|
|
30
30
|
|
|
31
31
|
**Why peers rather than dependencies:** in a fullstack project the frontend talks to better-auth
|
|
@@ -1028,12 +1028,26 @@ Idempotent indices on `session`, `users`, `account` and `verification`, includin
|
|
|
1028
1028
|
expires unconsumed verification and password-reset documents. Failures are logged at `warn` and do
|
|
1029
1029
|
not block the boot: these help speed, not correctness.
|
|
1030
1030
|
|
|
1031
|
-
### 2.
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1031
|
+
### 2. The account issuer — correctness (depends on the INSTALLED better-auth)
|
|
1032
|
+
|
|
1033
|
+
better-auth 1.7.0–1.7.2 keyed accounts by `(issuer, accountId)`; 1.7.3 restored the 1.6 schema,
|
|
1034
|
+
`(providerId, accountId)`. The boot runs ONE of two steps, decided by what is installed
|
|
1035
|
+
(`core-better-auth-account-issuer.helper.ts`):
|
|
1036
|
+
|
|
1037
|
+
- **better-auth 1.7.3+ (11.41.8+ requires it): `dropLegacyAccountIssuerIndex()`.** Databases that ran
|
|
1038
|
+
1.7.0–1.7.2 (nest-server 11.37–11.41.7) carry a unique `(issuer, accountId)` index. With the field
|
|
1039
|
+
no longer written it refuses a second provider with the same account ID, so the boot drops it —
|
|
1040
|
+
recognised by shape, so a renamed collection or field is covered. Existing `issuer` values stay;
|
|
1041
|
+
they are inert. The backfill marker is deleted, so a rollback to a better-auth that needs the
|
|
1042
|
+
issuer runs the backfill again. Log line: `Dropped the unique index "…" on account(issuer,
|
|
1043
|
+
accountId)`. A failure is logged at `error` and the boot continues.
|
|
1044
|
+
- **better-auth 1.7.0–1.7.2: `backfillAccountIssuers()`, described below.** Kept for a project that
|
|
1045
|
+
has not raised its better-auth pin yet.
|
|
1046
|
+
|
|
1047
|
+
From better-auth 1.7.0 to 1.7.2 an account is keyed by `(issuer, accountId)` and the sign-in route
|
|
1048
|
+
filters on it verbatim. Rows written by better-auth 1.6 have no `issuer` field, so **every existing
|
|
1049
|
+
password user would be locked out by the upgrade**, with a bare 401 and nothing in the log to
|
|
1050
|
+
explain it. The backfill repairs those rows on the first boot after the upgrade.
|
|
1037
1051
|
|
|
1038
1052
|
| Property | Behaviour |
|
|
1039
1053
|
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as betterAuthCoreDb from '@better-auth/core/db';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The synthetic issuer better-auth 1.7.0–1.7.2 stores on credential accounts — or `undefined` when
|
|
5
|
+
* the installed better-auth no longer uses one.
|
|
6
|
+
*
|
|
7
|
+
* better-auth 1.7.0 keyed accounts by `(issuer, accountId)` and exported `createLocalAccountIssuer`
|
|
8
|
+
* to derive the credential issuer. 1.7.3 restored the 1.6 schema — accounts keyed by
|
|
9
|
+
* `(providerId, accountId)` — and removed both. This framework works with either line: its peer
|
|
10
|
+
* range starts at 1.7.7, but a project that has not raised its pin yet must keep signing users in.
|
|
11
|
+
* So every place that writes or repairs an account asks THIS function instead of importing the
|
|
12
|
+
* helper: a static import does not even compile against 1.7.3+, and an issuer written there would
|
|
13
|
+
* be dead data.
|
|
14
|
+
*
|
|
15
|
+
* Resolved at call time from the module namespace, never cached at load, so a test can swap it.
|
|
16
|
+
*/
|
|
17
|
+
export function legacyCredentialAccountIssuer(): string | undefined {
|
|
18
|
+
const create = (betterAuthCoreDb as unknown as Record<string, unknown>).createLocalAccountIssuer;
|
|
19
|
+
return typeof create === 'function' ? (create as (providerId: string) => string)('credential') : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether the installed better-auth keys accounts by `(issuer, accountId)` — 1.7.0 to 1.7.2. */
|
|
23
|
+
export function usesLegacyAccountIssuer(): boolean {
|
|
24
|
+
return legacyCredentialAccountIssuer() !== undefined;
|
|
25
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createLocalAccountIssuer } from '@better-auth/core/db';
|
|
2
1
|
import { Injectable, Logger, Optional } from '@nestjs/common';
|
|
3
2
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
4
3
|
import * as bcrypt from 'bcrypt';
|
|
@@ -19,6 +18,7 @@ const scryptPromise = (password: string, salt: string, keylen: number, options:
|
|
|
19
18
|
|
|
20
19
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
21
20
|
import { maskEmail } from '../../common/helpers/logging.helper';
|
|
21
|
+
import { legacyCredentialAccountIssuer } from './core-better-auth-account-issuer.helper';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Interface for Better-Auth session user
|
|
@@ -531,16 +531,16 @@ export class CoreBetterAuthUserMapper {
|
|
|
531
531
|
// Store account matching Better-Auth's format:
|
|
532
532
|
// - userId: ObjectId referencing users._id
|
|
533
533
|
// - accountId: string version of users._id
|
|
534
|
-
// - issuer:
|
|
535
|
-
// accountId), and
|
|
536
|
-
// the
|
|
537
|
-
//
|
|
538
|
-
|
|
534
|
+
// - issuer: ONLY while the installed better-auth is 1.7.0–1.7.2, which key accounts by
|
|
535
|
+
// (issuer, accountId) — a row without it is not found, and the migrated user gets a 401 on
|
|
536
|
+
// the very sign-in that triggered the migration. 1.7.3+ went back to (providerId,
|
|
537
|
+
// accountId) and writes no issuer. Always derived from the helper, never hand-written.
|
|
538
|
+
const credentialIssuer = legacyCredentialAccountIssuer();
|
|
539
539
|
await accountsCollection.insertOne({
|
|
540
540
|
accountId: userIdHex,
|
|
541
541
|
createdAt: now,
|
|
542
542
|
id: this.generateId(),
|
|
543
|
-
issuer:
|
|
543
|
+
...(credentialIssuer ? { issuer: credentialIssuer } : {}),
|
|
544
544
|
password: passwordHash,
|
|
545
545
|
providerId: 'credential',
|
|
546
546
|
updatedAt: now,
|
|
@@ -73,27 +73,36 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
|
|
|
73
73
|
export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* better-auth's default
|
|
76
|
+
* better-auth's default names for the account collection and for fields inside it.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
78
|
+
* All are overridable by a consumer through `betterAuth.options.account.modelName` / `.fields.*`,
|
|
79
|
+
* which `better-auth.config.ts` spreads onto the resolved config verbatim. Any framework code
|
|
80
|
+
* touching that collection directly MUST resolve the real names from the running instance and fall
|
|
81
|
+
* back to these — a hardcoded name silently addresses a collection or an index better-auth does not
|
|
82
|
+
* use, and the operation then reports success by saying nothing.
|
|
83
|
+
*
|
|
84
|
+
* `issuer` is only used by better-auth 1.7.0–1.7.2; 1.7.3 restored the 1.6 schema (accounts keyed by
|
|
85
|
+
* `(providerId, accountId)` again). Its name is still needed on both lines: for the backfill while a
|
|
86
|
+
* project runs 1.7.0–1.7.2, and afterwards to recognise the unique index those versions left behind —
|
|
87
|
+
* see `CoreBetterAuthService.dropLegacyAccountIssuerIndex()`.
|
|
84
88
|
*/
|
|
85
89
|
export const DEFAULT_ACCOUNT_MODEL_NAME = 'account';
|
|
86
90
|
export const DEFAULT_ACCOUNT_ISSUER_FIELD = 'issuer';
|
|
91
|
+
export const DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD = 'accountId';
|
|
87
92
|
|
|
88
93
|
/**
|
|
89
94
|
* Collection holding one-shot completion markers for boot-time data migrations, and the marker id
|
|
90
|
-
* of the `account.issuer` backfill
|
|
95
|
+
* of the `account.issuer` backfill.
|
|
96
|
+
*
|
|
97
|
+
* The backfill only runs while the installed better-auth still keys accounts by issuer (1.7.0–1.7.2,
|
|
98
|
+
* see `core-better-auth-account-issuer.helper.ts`). On 1.7.3+ the boot DELETES this marker instead:
|
|
99
|
+
* should the project roll back to a better-auth that needs the issuer, the backfill then runs again
|
|
100
|
+
* and repairs the accounts written in the meantime, rather than being skipped by a stale marker.
|
|
91
101
|
*
|
|
92
102
|
* The marker exists for cost, not for correctness: the backfill is idempotent and safe to repeat,
|
|
93
103
|
* but its filters (`$exists: false`, `$ne`) cannot use an index, so without a marker every boot of
|
|
94
|
-
* every replica pays a full pass over the account collection
|
|
95
|
-
*
|
|
96
|
-
* again without clearing this one.
|
|
104
|
+
* every replica pays a full pass over the account collection. Versioned in the id so a future
|
|
105
|
+
* backfill of the same field can run again without clearing this one.
|
|
97
106
|
*/
|
|
98
107
|
export const BACKFILL_MARKER_COLLECTION = 'better-auth-backfills';
|
|
99
108
|
export const ACCOUNT_ISSUER_BACKFILL_ID = 'account-issuer-backfill-v1';
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createLocalAccountIssuer } from '@better-auth/core/db';
|
|
2
1
|
import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
|
|
3
2
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
4
3
|
import { Request } from 'express';
|
|
@@ -12,6 +11,7 @@ import { ConfigService } from '../../common/services/config.service';
|
|
|
12
11
|
import { ErrorCode } from '../error-code/error-codes';
|
|
13
12
|
import { resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
|
|
14
13
|
import { BetterAuthInstance } from './better-auth.config';
|
|
14
|
+
import { legacyCredentialAccountIssuer, usesLegacyAccountIssuer } from './core-better-auth-account-issuer.helper';
|
|
15
15
|
import { isJwtShaped } from './core-better-auth-token.helper';
|
|
16
16
|
import { BetterAuthSessionUser } from './core-better-auth-user.mapper';
|
|
17
17
|
import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper';
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
BETTER_AUTH_CONFIG,
|
|
22
22
|
BETTER_AUTH_COOKIE_DOMAIN,
|
|
23
23
|
BETTER_AUTH_INSTANCE,
|
|
24
|
+
DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD,
|
|
24
25
|
DEFAULT_ACCOUNT_ISSUER_FIELD,
|
|
25
26
|
DEFAULT_ACCOUNT_MODEL_NAME,
|
|
26
27
|
} from './core-better-auth.constants';
|
|
@@ -113,12 +114,21 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
113
114
|
*
|
|
114
115
|
* Order matters: the backfill's update can use the `{ providerId: 1, userId: 1 }` index created
|
|
115
116
|
* below, so indices come first.
|
|
117
|
+
*
|
|
118
|
+
* Which account step runs depends on the INSTALLED better-auth, not on this package's version:
|
|
119
|
+
* 1.7.0–1.7.2 key accounts by issuer and need the backfill; 1.7.3+ dropped the issuer and need the
|
|
120
|
+
* unique index those versions left behind removed instead. A project that has not raised its
|
|
121
|
+
* better-auth pin yet therefore keeps working exactly as before.
|
|
116
122
|
*/
|
|
117
123
|
async onModuleInit(): Promise<void> {
|
|
118
124
|
if (!this.isEnabled() || !this.connection?.db) return;
|
|
119
125
|
|
|
120
126
|
await this.ensureIndices();
|
|
121
|
-
|
|
127
|
+
if (usesLegacyAccountIssuer()) {
|
|
128
|
+
await this.backfillAccountIssuers();
|
|
129
|
+
} else {
|
|
130
|
+
await this.dropLegacyAccountIssuerIndex();
|
|
131
|
+
}
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
/**
|
|
@@ -195,6 +205,11 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
195
205
|
protected async backfillAccountIssuers(): Promise<void> {
|
|
196
206
|
if (!this.isEnabled() || !this.connection?.db) return;
|
|
197
207
|
|
|
208
|
+
// Only better-auth 1.7.0–1.7.2 key accounts by issuer. On 1.7.3+ there is nothing to backfill —
|
|
209
|
+
// and nothing to derive the value from.
|
|
210
|
+
const credentialIssuer = legacyCredentialAccountIssuer();
|
|
211
|
+
if (!credentialIssuer) return;
|
|
212
|
+
|
|
198
213
|
const db = this.connection.db;
|
|
199
214
|
|
|
200
215
|
// The consumer can rename both of these through `betterAuth.options.account`, which
|
|
@@ -243,7 +258,7 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
243
258
|
pending.map((doc) => ({
|
|
244
259
|
updateOne: {
|
|
245
260
|
filter: { _id: doc._id },
|
|
246
|
-
update: { $set: { [issuerField]:
|
|
261
|
+
update: { $set: { [issuerField]: credentialIssuer } },
|
|
247
262
|
},
|
|
248
263
|
})),
|
|
249
264
|
{ ordered: false },
|
|
@@ -306,6 +321,75 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
306
321
|
}
|
|
307
322
|
}
|
|
308
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Drops the unique `(issuer, accountId)` index better-auth 1.7.0–1.7.2 created on the account
|
|
326
|
+
* collection, and forgets the issuer-backfill marker. Runs on better-auth 1.7.3+ only.
|
|
327
|
+
*
|
|
328
|
+
* better-auth 1.7.0 keyed accounts by `(issuer, accountId)`, and its MongoDB adapter created that
|
|
329
|
+
* index lazily. 1.7.3 restored the 1.6 schema — `(providerId, accountId)` — and no longer writes
|
|
330
|
+
* `issuer`. The index stays behind in every database that ran those versions (this framework
|
|
331
|
+
* required them from 11.37.0 to 11.41.7), and with the field gone it treats every new account as
|
|
332
|
+
* having the same, missing issuer: a user who holds the same account ID at two providers can no
|
|
333
|
+
* longer link the second one. MongoDB has no `NOT NULL`, so dropping the index is the whole
|
|
334
|
+
* cleanup — upstream's 1.7 upgrade guide, "Account identity keeps the provider key". Existing
|
|
335
|
+
* `issuer` values are inert and stay, which also keeps a rollback possible.
|
|
336
|
+
*
|
|
337
|
+
* The marker is deleted for the same rollback: back on a better-auth that needs the issuer, the
|
|
338
|
+
* backfill must run again and repair the accounts written in the meantime instead of being
|
|
339
|
+
* skipped as "already completed".
|
|
340
|
+
*
|
|
341
|
+
* The index is recognised by its SHAPE — unique, keyed exactly on the issuer and account-id fields
|
|
342
|
+
* — not by name: better-auth derives the name from the collection and field names (by default
|
|
343
|
+
* `account_issuer_accountId_uidx`), and a consumer can rename both through
|
|
344
|
+
* `betterAuth.options.account`.
|
|
345
|
+
*
|
|
346
|
+
* Idempotent and cheap: one `listIndexes` and one `deleteOne` per boot. Never fatal — a failure is
|
|
347
|
+
* logged as an error and the server still starts.
|
|
348
|
+
*/
|
|
349
|
+
protected async dropLegacyAccountIssuerIndex(): Promise<void> {
|
|
350
|
+
if (!this.isEnabled() || !this.connection?.db) return;
|
|
351
|
+
|
|
352
|
+
const db = this.connection.db;
|
|
353
|
+
const accountOptions = (this.authInstance as any)?.options?.account;
|
|
354
|
+
const modelName: string = accountOptions?.modelName ?? DEFAULT_ACCOUNT_MODEL_NAME;
|
|
355
|
+
const issuerField: string = accountOptions?.fields?.issuer ?? DEFAULT_ACCOUNT_ISSUER_FIELD;
|
|
356
|
+
const accountIdField: string = accountOptions?.fields?.accountId ?? DEFAULT_ACCOUNT_ACCOUNT_ID_FIELD;
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
await db.collection(BACKFILL_MARKER_COLLECTION).deleteOne({ _id: ACCOUNT_ISSUER_BACKFILL_ID as any });
|
|
360
|
+
|
|
361
|
+
const accounts = db.collection(modelName);
|
|
362
|
+
let indexes: { key?: Record<string, unknown>; name?: string; unique?: boolean }[];
|
|
363
|
+
try {
|
|
364
|
+
indexes = await accounts.indexes();
|
|
365
|
+
} catch (error) {
|
|
366
|
+
// A collection that does not exist yet has no index to drop (NamespaceNotFound).
|
|
367
|
+
if ((error as { code?: number })?.code === 26) return;
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const legacy = indexes.filter(
|
|
372
|
+
(index) =>
|
|
373
|
+
index.unique === true &&
|
|
374
|
+
!!index.name &&
|
|
375
|
+
Object.keys(index.key ?? {}).join(',') === `${issuerField},${accountIdField}`,
|
|
376
|
+
);
|
|
377
|
+
for (const index of legacy) {
|
|
378
|
+
await accounts.dropIndex(index.name as string);
|
|
379
|
+
this.logger.log(
|
|
380
|
+
`Dropped the unique index "${index.name}" on ${modelName}(${issuerField}, ${accountIdField}): ` +
|
|
381
|
+
'better-auth >= 1.7.3 no longer writes the issuer, and the index would refuse new accounts.',
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
} catch (error) {
|
|
385
|
+
this.logger.error(
|
|
386
|
+
`Could not drop the legacy unique (${issuerField}, ${accountIdField}) index on "${modelName}": ` +
|
|
387
|
+
`${error instanceof Error ? error.message : 'unknown'}. Linking an account whose ID another ` +
|
|
388
|
+
'provider already uses will fail until it is dropped by hand.',
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
309
393
|
/**
|
|
310
394
|
* Checks if better-auth is enabled and initialized
|
|
311
395
|
* Returns true only if:
|
|
@@ -88,6 +88,31 @@ If some files are genuinely public and others are not, do not solve it with a ro
|
|
|
88
88
|
record a visibility flag in the metadata and branch in `checkRights()`, or expose a separate public
|
|
89
89
|
route for exactly the public files and leave the core routes gated.
|
|
90
90
|
|
|
91
|
+
### 6. Switching to the S3 driver — in this order
|
|
92
|
+
|
|
93
|
+
Each step fails on its own if skipped, and only the first one fails loudly:
|
|
94
|
+
|
|
95
|
+
1. **Forward the services to `super()`.** `FileService` must call
|
|
96
|
+
`super(connection, 'fs', { configService, s3Service })`, as the reference implementation does. With
|
|
97
|
+
`super(connection)` the S3 store counts as unavailable and `file.storage: 's3'` fails the boot
|
|
98
|
+
with "that storage is not available".
|
|
99
|
+
2. **Install the S3 packages as `dependencies`, not `devDependencies`:** `@aws-sdk/client-s3` and
|
|
100
|
+
`@aws-sdk/lib-storage` (same version), plus `@aws-sdk/s3-request-presigner` for
|
|
101
|
+
`s3.presignedDownloads`. A production image installed with `--prod` otherwise boots without them.
|
|
102
|
+
Vendor mode has them installed already (the vendored core references their types) — check which
|
|
103
|
+
section they are in.
|
|
104
|
+
3. **Count stored files across all three metadata collections.** `fs.files` is GridFS only; the
|
|
105
|
+
filesystem driver records in `filesystem-files` and S3 in `s3-files`. Project code that
|
|
106
|
+
aggregates sizes or counts directly — a quota, a statistic — must cover all three, or it reads 0
|
|
107
|
+
after the switch and the quota never refuses anything. `findFileInfo()` already merges every
|
|
108
|
+
store.
|
|
109
|
+
4. **Then switch the driver** — `file.storage: 's3'`, or leave it unset and let `s3.bucket` derive
|
|
110
|
+
it. Files written before stay readable: reads consult every store.
|
|
111
|
+
|
|
112
|
+
An `s3` block with a `bucket` turns `CoreS3Service` on whatever `file.storage` says (TUS, where enabled, stages
|
|
113
|
+
uploads in S3 as soon as it is on), so it needs `@aws-sdk/client-s3` at boot. To prepare the block
|
|
114
|
+
without activating it, set `enabled: false`.
|
|
115
|
+
|
|
91
116
|
## Pick your project class first — the whole model is one dial with five settings
|
|
92
117
|
|
|
93
118
|
`file.access` is the per-file rule as a DECLARATION instead of code. Until 11.35.0 the framework shipped
|
|
@@ -201,14 +226,17 @@ personal or medical, a per-file rule is not optional.
|
|
|
201
226
|
|
|
202
227
|
## Common Mistakes
|
|
203
228
|
|
|
204
|
-
| Mistake
|
|
205
|
-
|
|
|
206
|
-
| Overriding `getFileById` to add Swagger decorators
|
|
207
|
-
| Overriding without re-declaring `@Get(...)`
|
|
208
|
-
| `downloadRoles: []`
|
|
209
|
-
| Expecting `metadata` back from `getFileInfo()`
|
|
210
|
-
| `downloadRoles: ['member']` with multiTenancy
|
|
211
|
-
| Signed-in user can upload via TUS but not download
|
|
212
|
-
| Rule narrows only `'id'` / `'filename'`
|
|
213
|
-
| Treating the file id as unguessable
|
|
214
|
-
| Approving the caller's `filterArgs` as "narrowed"
|
|
229
|
+
| Mistake | Symptom | Fix |
|
|
230
|
+
| -------------------------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
231
|
+
| Overriding `getFileById` to add Swagger decorators | `file.downloadRoles` silently ignored | Don't override; document at class level |
|
|
232
|
+
| Overriding without re-declaring `@Get(...)` | Route 404s for everyone, no error logged | Don't override |
|
|
233
|
+
| `downloadRoles: []` | Warning logged, default applied | Use a non-empty array; `[]` would read as "no roles required" and open the route |
|
|
234
|
+
| Expecting `metadata` back from `getFileInfo()` | `undefined` | Use `getRawFileInfo()` inside `checkRights()` |
|
|
235
|
+
| `downloadRoles: ['member']` with multiTenancy | Works from code, fails from `<img>` | Both file classes carry `@SkipTenantCheck()`; roles resolve against `user.roles`. Use `checkRights()` for tenant rules |
|
|
236
|
+
| Signed-in user can upload via TUS but not download | 403 on their own file | `tus.roles` and `file.downloadRoles` are separate. Add an owner to the metadata and authorize per file |
|
|
237
|
+
| Rule narrows only `'id'` / `'filename'` | `findFileInfo()` returns every file | Refuse `'filterArgs'`; force a per-user filter server-side with `{ force: true }` |
|
|
238
|
+
| Treating the file id as unguessable | Enumerable inventory | Ids share a per-process random part and an incrementing counter — authorize every read, do not rely on the id |
|
|
239
|
+
| Approving the caller's `filterArgs` as "narrowed" | Bypass via a different filter shape | `filterArgs` is client-controlled. Override the filter; never approve it |
|
|
240
|
+
| `super(connection)` in `FileService` with `file.storage: 's3'` | Boot fails: "that storage is not available" | Forward `{ configService, s3Service }` — step 6 |
|
|
241
|
+
| S3 packages in `devDependencies` | Production image cannot load S3 | Move them to `dependencies` — step 6 |
|
|
242
|
+
| A quota aggregating only `fs.files` | Reads 0 on the S3 / filesystem driver | Aggregate `fs.files`, `filesystem-files` and `s3-files` — step 6 |
|