@better-auth/core 1.7.1 → 1.7.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/dist/api/index.d.mts +3 -0
- package/dist/context/endpoint-context.d.mts +19 -5
- package/dist/context/endpoint-context.mjs +35 -16
- package/dist/context/global.mjs +5 -2
- package/dist/context/index.d.mts +2 -2
- package/dist/context/index.mjs +2 -2
- package/dist/context/transaction.mjs +3 -0
- package/dist/db/adapter/atomic-fallback.mjs +134 -0
- package/dist/db/adapter/factory.mjs +22 -4
- package/dist/db/adapter/index.d.mts +15 -11
- package/dist/db/get-tables.mjs +1 -9
- package/dist/db/index.d.mts +2 -2
- package/dist/db/index.mjs +2 -2
- package/dist/db/internal.d.mts +3 -1
- package/dist/db/internal.mjs +3 -1
- package/dist/db/schema/account.d.mts +2 -13
- package/dist/db/schema/account.mjs +1 -19
- package/dist/db/schema-check.d.mts +48 -0
- package/dist/db/schema-check.mjs +80 -0
- package/dist/db/schema-diff.d.mts +104 -0
- package/dist/db/schema-diff.mjs +154 -0
- package/dist/env/logger.mjs +16 -1
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/oauth-provider.d.mts +0 -10
- package/dist/oauth2/token-endpoint-auth.d.mts +26 -2
- package/dist/oauth2/token-endpoint-auth.mjs +11 -0
- package/dist/social-providers/apple.d.mts +0 -1
- package/dist/social-providers/apple.mjs +0 -1
- package/dist/social-providers/cloudflare.d.mts +132 -0
- package/dist/social-providers/cloudflare.mjs +85 -0
- package/dist/social-providers/cognito.d.mts +0 -1
- package/dist/social-providers/cognito.mjs +0 -1
- package/dist/social-providers/facebook.d.mts +0 -1
- package/dist/social-providers/facebook.mjs +0 -1
- package/dist/social-providers/google.d.mts +0 -1
- package/dist/social-providers/google.mjs +0 -1
- package/dist/social-providers/index.d.mts +53 -21
- package/dist/social-providers/index.mjs +3 -1
- package/dist/social-providers/line.d.mts +0 -1
- package/dist/social-providers/line.mjs +0 -1
- package/dist/social-providers/microsoft-entra-id.d.mts +0 -3
- package/dist/social-providers/microsoft-entra-id.mjs +0 -1
- package/dist/social-providers/paybin.d.mts +0 -1
- package/dist/social-providers/paybin.mjs +0 -1
- package/dist/social-providers/paypal.d.mts +3 -11
- package/dist/social-providers/paypal.mjs +20 -47
- package/dist/social-providers/reddit.mjs +22 -23
- package/dist/social-providers/roblox.mjs +5 -1
- package/dist/social-providers/tiktok.d.mts +1 -0
- package/dist/social-providers/tiktok.mjs +19 -10
- package/dist/social-providers/twitter.mjs +5 -1
- package/dist/social-providers/wechat.mjs +6 -1
- package/dist/types/context.d.mts +11 -0
- package/dist/types/init-options.d.mts +11 -0
- package/dist/utils/ip.mjs +11 -9
- package/dist/utils/url.d.mts +10 -1
- package/dist/utils/url.mjs +21 -1
- package/package.json +3 -3
- package/src/context/endpoint-context.ts +46 -21
- package/src/context/global.ts +7 -0
- package/src/context/index.ts +2 -0
- package/src/context/transaction.ts +5 -0
- package/src/db/adapter/atomic-fallback.ts +237 -0
- package/src/db/adapter/factory.ts +33 -17
- package/src/db/adapter/index.ts +15 -11
- package/src/db/get-tables.ts +1 -14
- package/src/db/index.ts +0 -2
- package/src/db/internal.ts +19 -0
- package/src/db/schema/account.ts +3 -22
- package/src/db/schema/user.ts +1 -1
- package/src/db/schema-check.ts +107 -0
- package/src/db/schema-diff.ts +270 -0
- package/src/env/logger.ts +22 -1
- package/src/oauth2/index.ts +2 -0
- package/src/oauth2/oauth-provider.ts +0 -10
- package/src/oauth2/token-endpoint-auth.ts +39 -6
- package/src/social-providers/apple.ts +0 -1
- package/src/social-providers/cloudflare.ts +221 -0
- package/src/social-providers/cognito.ts +0 -1
- package/src/social-providers/facebook.ts +0 -1
- package/src/social-providers/google.ts +0 -1
- package/src/social-providers/index.ts +3 -0
- package/src/social-providers/line.ts +0 -1
- package/src/social-providers/microsoft-entra-id.ts +0 -1
- package/src/social-providers/paybin.ts +0 -1
- package/src/social-providers/paypal.ts +30 -71
- package/src/social-providers/reddit.ts +34 -37
- package/src/social-providers/roblox.ts +5 -3
- package/src/social-providers/tiktok.ts +25 -14
- package/src/social-providers/twitter.ts +8 -2
- package/src/social-providers/wechat.ts +6 -6
- package/src/types/context.ts +11 -0
- package/src/types/init-options.ts +11 -0
- package/src/utils/ip.ts +13 -9
- package/src/utils/url.ts +43 -0
|
@@ -8,6 +8,7 @@ import { BetterAuthError } from "../../error";
|
|
|
8
8
|
import type { BetterAuthOptions } from "../../types";
|
|
9
9
|
import { safeJSONParse } from "../../utils/json";
|
|
10
10
|
import { getAuthTables } from "../get-tables";
|
|
11
|
+
import { createAtomicFallbacks } from "./atomic-fallback";
|
|
11
12
|
import { initGetDefaultFieldName } from "./get-default-field-name";
|
|
12
13
|
import { initGetDefaultModelName } from "./get-default-model-name";
|
|
13
14
|
import { initGetFieldAttributes } from "./get-field-attributes";
|
|
@@ -841,6 +842,16 @@ export const createAdapterFactory =
|
|
|
841
842
|
});
|
|
842
843
|
|
|
843
844
|
let lazyLoadTransaction: DBAdapter<Options>["transaction"] | null = null;
|
|
845
|
+
const atomicFallbacks = createAtomicFallbacks({
|
|
846
|
+
adapter: adapterInstance,
|
|
847
|
+
adapterId: config.adapterId,
|
|
848
|
+
mapKeysTransformInput: config.mapKeysTransformInput,
|
|
849
|
+
mapKeysTransformOutput: config.mapKeysTransformOutput,
|
|
850
|
+
getFieldName,
|
|
851
|
+
transformOutput,
|
|
852
|
+
transformWhereClause,
|
|
853
|
+
});
|
|
854
|
+
|
|
844
855
|
const adapter: DBAdapter<Options> = {
|
|
845
856
|
transaction: async (cb) => {
|
|
846
857
|
if (!lazyLoadTransaction) {
|
|
@@ -1366,18 +1377,20 @@ export const createAdapterFactory =
|
|
|
1366
1377
|
{ model, where },
|
|
1367
1378
|
);
|
|
1368
1379
|
|
|
1369
|
-
if (typeof adapterInstance.consumeOne !== "function") {
|
|
1370
|
-
throw new BetterAuthError(
|
|
1371
|
-
`Adapter "${config.adapterId}" must implement consumeOne for atomic single-use credential consumption.`,
|
|
1372
|
-
);
|
|
1373
|
-
}
|
|
1374
1380
|
const res = await withSpan(
|
|
1375
1381
|
`db consumeOne ${model}`,
|
|
1376
1382
|
{
|
|
1377
1383
|
[ATTR_DB_OPERATION_NAME]: "consumeOne",
|
|
1378
1384
|
[ATTR_DB_COLLECTION_NAME]: model,
|
|
1379
1385
|
},
|
|
1380
|
-
() =>
|
|
1386
|
+
() =>
|
|
1387
|
+
adapterInstance.consumeOne
|
|
1388
|
+
? adapterInstance.consumeOne<T>({ model, where })
|
|
1389
|
+
: atomicFallbacks.consumeOne({
|
|
1390
|
+
model,
|
|
1391
|
+
logicalModel: unsafeModel,
|
|
1392
|
+
where,
|
|
1393
|
+
}),
|
|
1381
1394
|
);
|
|
1382
1395
|
|
|
1383
1396
|
debugLog(
|
|
@@ -1440,11 +1453,6 @@ export const createAdapterFactory =
|
|
|
1440
1453
|
{ model, where, increment: unsafeIncrement, set: unsafeSet },
|
|
1441
1454
|
);
|
|
1442
1455
|
|
|
1443
|
-
if (typeof adapterInstance.incrementOne !== "function") {
|
|
1444
|
-
throw new BetterAuthError(
|
|
1445
|
-
`Adapter "${config.adapterId}" must implement incrementOne for atomic guarded counter updates.`,
|
|
1446
|
-
);
|
|
1447
|
-
}
|
|
1448
1456
|
const mappedKeys = config.mapKeysTransformInput ?? {};
|
|
1449
1457
|
const increment: Record<string, number> = {};
|
|
1450
1458
|
for (const [field, delta] of Object.entries(unsafeIncrement)) {
|
|
@@ -1473,12 +1481,20 @@ export const createAdapterFactory =
|
|
|
1473
1481
|
[ATTR_DB_COLLECTION_NAME]: model,
|
|
1474
1482
|
},
|
|
1475
1483
|
() =>
|
|
1476
|
-
adapterInstance.incrementOne
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1484
|
+
adapterInstance.incrementOne
|
|
1485
|
+
? adapterInstance.incrementOne<T>({
|
|
1486
|
+
model,
|
|
1487
|
+
where,
|
|
1488
|
+
increment,
|
|
1489
|
+
set,
|
|
1490
|
+
})
|
|
1491
|
+
: atomicFallbacks.incrementOne({
|
|
1492
|
+
model,
|
|
1493
|
+
logicalModel: unsafeModel,
|
|
1494
|
+
where,
|
|
1495
|
+
increment,
|
|
1496
|
+
set,
|
|
1497
|
+
}),
|
|
1482
1498
|
);
|
|
1483
1499
|
|
|
1484
1500
|
debugLog(
|
package/src/db/adapter/index.ts
CHANGED
|
@@ -466,9 +466,9 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
466
466
|
* race-safe primitive for consuming single-use credentials
|
|
467
467
|
* (verification tokens, authorization codes, one-time tokens).
|
|
468
468
|
*
|
|
469
|
-
* Always defined on the factory-wrapped adapter.
|
|
470
|
-
*
|
|
471
|
-
*
|
|
469
|
+
* Always defined on the factory-wrapped adapter. Without a native method,
|
|
470
|
+
* the factory uses a snapshot-guarded delete and requires an exact affected
|
|
471
|
+
* row count. The adapter must evaluate the condition and deletion atomically.
|
|
472
472
|
*/
|
|
473
473
|
consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
|
|
474
474
|
/**
|
|
@@ -490,9 +490,11 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
490
490
|
* primitive for guarded counter updates (e.g. decrementing a remaining-uses
|
|
491
491
|
* counter only while it is still positive).
|
|
492
492
|
*
|
|
493
|
-
* Always defined on the factory-wrapped adapter.
|
|
494
|
-
*
|
|
495
|
-
*
|
|
493
|
+
* Always defined on the factory-wrapped adapter. Without a native method,
|
|
494
|
+
* the factory uses bounded compare-and-swap retries. Contention exhaustion
|
|
495
|
+
* throws rather than returning null. Conditional writes must be atomic.
|
|
496
|
+
* A no-op may return the read snapshot without writing. A non-null result
|
|
497
|
+
* alone does not establish exclusive ownership of the row.
|
|
496
498
|
*/
|
|
497
499
|
incrementOne: <T>(data: {
|
|
498
500
|
model: string;
|
|
@@ -587,19 +589,21 @@ export interface CustomAdapter {
|
|
|
587
589
|
where: CleanedWhere[];
|
|
588
590
|
}) => Promise<number>;
|
|
589
591
|
/**
|
|
590
|
-
*
|
|
592
|
+
* Optional native atomic single-row consume.
|
|
593
|
+
*
|
|
591
594
|
* Implementing this method natively (e.g. `DELETE ... RETURNING *`,
|
|
592
595
|
* `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
|
|
593
596
|
* strongest race-safety guarantee. Implementations must delete at most
|
|
594
597
|
* one matching row.
|
|
595
598
|
*/
|
|
596
|
-
consumeOne
|
|
599
|
+
consumeOne?: <T>(data: {
|
|
597
600
|
model: string;
|
|
598
601
|
where: CleanedWhere[];
|
|
599
602
|
}) => Promise<T | null>;
|
|
600
603
|
/**
|
|
601
|
-
*
|
|
602
|
-
*
|
|
604
|
+
* Optional native atomic guarded counter mutation.
|
|
605
|
+
*
|
|
606
|
+
* Applies `field = field + delta` for each entry in `increment` (negative deltas
|
|
603
607
|
* decrement), with `where` acting as both selector and guard and `set`
|
|
604
608
|
* assigning absolute values in the same operation. Returns the updated row,
|
|
605
609
|
* or `null` when the guard matched no row.
|
|
@@ -608,7 +612,7 @@ export interface CustomAdapter {
|
|
|
608
612
|
* RETURNING *`) gives one round trip and the strongest race-safety
|
|
609
613
|
* guarantee.
|
|
610
614
|
*/
|
|
611
|
-
incrementOne
|
|
615
|
+
incrementOne?: <T>(data: {
|
|
612
616
|
model: string;
|
|
613
617
|
where: CleanedWhere[];
|
|
614
618
|
increment: Record<string, number>;
|
package/src/db/get-tables.ts
CHANGED
|
@@ -250,21 +250,8 @@ const buildAuthTables = (options: BetterAuthOptions): BetterAuthDBSchema => {
|
|
|
250
250
|
: {}),
|
|
251
251
|
account: {
|
|
252
252
|
modelName: options.account?.modelName || "account",
|
|
253
|
-
indexes:
|
|
254
|
-
[
|
|
255
|
-
{
|
|
256
|
-
fields: ["issuer", "accountId"],
|
|
257
|
-
unique: true,
|
|
258
|
-
},
|
|
259
|
-
],
|
|
260
|
-
account?.indexes,
|
|
261
|
-
),
|
|
253
|
+
indexes: account?.indexes,
|
|
262
254
|
fields: {
|
|
263
|
-
issuer: {
|
|
264
|
-
type: "string",
|
|
265
|
-
required: true,
|
|
266
|
-
fieldName: options.account?.fields?.issuer || "issuer",
|
|
267
|
-
},
|
|
268
255
|
accountId: {
|
|
269
256
|
type: "string",
|
|
270
257
|
required: true,
|
package/src/db/index.ts
CHANGED
package/src/db/internal.ts
CHANGED
|
@@ -10,3 +10,22 @@ export {
|
|
|
10
10
|
resolveDatabaseTableIndexes,
|
|
11
11
|
} from "./database-index";
|
|
12
12
|
export { getAuthTablesWithResolvedIndexes } from "./get-tables";
|
|
13
|
+
export {
|
|
14
|
+
checksSchema,
|
|
15
|
+
createSchemaCheck,
|
|
16
|
+
invalidateSchemaChecks,
|
|
17
|
+
registerSchemaCheck,
|
|
18
|
+
type SchemaCheck,
|
|
19
|
+
schemaCheckFor,
|
|
20
|
+
} from "./schema-check";
|
|
21
|
+
export {
|
|
22
|
+
diffSchema,
|
|
23
|
+
type ExpectedSchema,
|
|
24
|
+
formatSchemaFinding,
|
|
25
|
+
getExpectedSchema,
|
|
26
|
+
type IntrospectedColumn,
|
|
27
|
+
type IntrospectedTable,
|
|
28
|
+
type SchemaFinding,
|
|
29
|
+
SchemaMismatchError,
|
|
30
|
+
type SchemaSource,
|
|
31
|
+
} from "./schema-diff";
|
package/src/db/schema/account.ts
CHANGED
|
@@ -9,7 +9,6 @@ import { coreSchema } from "./shared";
|
|
|
9
9
|
|
|
10
10
|
export const accountSchema = coreSchema.extend({
|
|
11
11
|
providerId: z.string(),
|
|
12
|
-
issuer: z.string(),
|
|
13
12
|
accountId: z.string(),
|
|
14
13
|
userId: z.coerce.string(),
|
|
15
14
|
accessToken: z.string().nullish(),
|
|
@@ -39,27 +38,9 @@ export const accountSchema = coreSchema.extend({
|
|
|
39
38
|
export type BaseAccount = z.infer<typeof accountSchema>;
|
|
40
39
|
|
|
41
40
|
/** The stable provider-side key used to recognize an account. */
|
|
42
|
-
export type AccountKey = Readonly<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return encodeURIComponent(providerId);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Creates the synthetic issuer used by providers without an issuer of their own.
|
|
50
|
-
*/
|
|
51
|
-
export function createLocalAccountIssuer(providerId: string): string {
|
|
52
|
-
return `local:${encodeAccountIssuerProviderId(providerId)}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Creates the synthetic issuer used by OAuth providers without an issuer of
|
|
57
|
-
* their own. OAuth identities use a distinct namespace so a provider ID
|
|
58
|
-
* cannot collide with an internal local authentication method.
|
|
59
|
-
*/
|
|
60
|
-
export function createOAuthAccountIssuer(providerId: string): string {
|
|
61
|
-
return `local:oauth:${encodeAccountIssuerProviderId(providerId)}`;
|
|
62
|
-
}
|
|
41
|
+
export type AccountKey = Readonly<
|
|
42
|
+
Pick<BaseAccount, "providerId" | "accountId">
|
|
43
|
+
>;
|
|
63
44
|
|
|
64
45
|
/**
|
|
65
46
|
* Account schema type used by better-auth, note that it's possible that account could have additional fields
|
package/src/db/schema/user.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { coreSchema } from "./shared";
|
|
|
9
9
|
export const userSchema = coreSchema.extend({
|
|
10
10
|
// TODO(#9124): widen to nullish in v2. OAuth providers (Discord phone-only,
|
|
11
11
|
// Apple subsequent sign-ins, etc.) can legitimately omit email; identity
|
|
12
|
-
// must key on (
|
|
12
|
+
// must key on (providerId, accountId) per OpenID Connect Core §5.7.
|
|
13
13
|
email: z.string().transform((val) => val.toLowerCase()),
|
|
14
14
|
emailVerified: z.boolean().default(false),
|
|
15
15
|
name: z.string(),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal schema validation infrastructure for built-in adapters.
|
|
3
|
+
*
|
|
4
|
+
* Intended to become a public core extension point for community database
|
|
5
|
+
* adapter authors once the registration and lifecycle contracts are stabilized.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BetterAuthOptions } from "../types";
|
|
9
|
+
import type { SchemaFinding, SchemaSource } from "./schema-diff";
|
|
10
|
+
import { SchemaMismatchError } from "./schema-diff";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether the adapter validates its schema. Enabled in every environment
|
|
14
|
+
* unless explicitly disabled.
|
|
15
|
+
*/
|
|
16
|
+
export function checksSchema(options: BetterAuthOptions): boolean {
|
|
17
|
+
return options.advanced?.database?.validateSchema !== false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves when the schema can hold what Better Auth writes. Returns nothing
|
|
22
|
+
* once that is known and the database schema revision is unchanged.
|
|
23
|
+
*/
|
|
24
|
+
export type SchemaCheck = () => Promise<void> | undefined;
|
|
25
|
+
|
|
26
|
+
const schemaChecks = new WeakMap<object, SchemaCheck>();
|
|
27
|
+
const schemaRevisions = new WeakMap<object, { value: number }>();
|
|
28
|
+
|
|
29
|
+
/** Invalidates cached checks after Better Auth changes this database's schema. */
|
|
30
|
+
export function invalidateSchemaChecks(database: object): void {
|
|
31
|
+
const revision = schemaRevisions.get(database);
|
|
32
|
+
if (revision) revision.value++;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Attaches a check to the adapter it verifies. The adapter object itself is
|
|
37
|
+
* left untouched, so this works for adapters Better Auth does not own.
|
|
38
|
+
*/
|
|
39
|
+
export function registerSchemaCheck(adapter: object, check: SchemaCheck): void {
|
|
40
|
+
schemaChecks.set(adapter, check);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The check registered for an adapter, if its store is checked at all.
|
|
45
|
+
*/
|
|
46
|
+
export function schemaCheckFor(adapter: object): SchemaCheck | undefined {
|
|
47
|
+
return schemaChecks.get(adapter);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Turns a schema comparison into a check shared by one adapter instance.
|
|
52
|
+
*
|
|
53
|
+
* The first call runs `find` and every concurrent call shares that promise. A
|
|
54
|
+
* clean result is cached until invalidation. A mismatch is kept as one
|
|
55
|
+
* {@link SchemaMismatchError} and rethrown on every later call without asking
|
|
56
|
+
* the store again, until a migration invalidates it. When a database identity is supplied,
|
|
57
|
+
* checks for that identity share its schema revision. Pending callers follow
|
|
58
|
+
* the new check if their revision is invalidated. A failure to reach
|
|
59
|
+
* the store is not kept, so the next call asks again.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const checkSchema = createSchemaCheck(
|
|
64
|
+
* () => findSchemaProblems(db, "postgres", expected),
|
|
65
|
+
* "database",
|
|
66
|
+
* );
|
|
67
|
+
* const pending = checkSchema();
|
|
68
|
+
* if (pending) await pending;
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function createSchemaCheck(
|
|
72
|
+
find: () => Promise<SchemaFinding[]>,
|
|
73
|
+
source: SchemaSource,
|
|
74
|
+
database?: object,
|
|
75
|
+
): SchemaCheck {
|
|
76
|
+
let revision = database ? schemaRevisions.get(database) : undefined;
|
|
77
|
+
if (database && !revision) {
|
|
78
|
+
revision = { value: 0 };
|
|
79
|
+
schemaRevisions.set(database, revision);
|
|
80
|
+
}
|
|
81
|
+
let checkedRevision = revision?.value;
|
|
82
|
+
let clean = false;
|
|
83
|
+
let verdict: Promise<void> | undefined;
|
|
84
|
+
return function checkSchema(): Promise<void> | undefined {
|
|
85
|
+
const currentRevision = revision?.value;
|
|
86
|
+
if (checkedRevision !== currentRevision) {
|
|
87
|
+
checkedRevision = currentRevision;
|
|
88
|
+
clean = false;
|
|
89
|
+
verdict = undefined;
|
|
90
|
+
}
|
|
91
|
+
if (clean) return;
|
|
92
|
+
return (verdict ??= Promise.resolve()
|
|
93
|
+
.then(find)
|
|
94
|
+
.then(
|
|
95
|
+
(findings) => {
|
|
96
|
+
if (revision?.value !== currentRevision) return checkSchema();
|
|
97
|
+
if (findings.length) throw new SchemaMismatchError(findings, source);
|
|
98
|
+
if (checkedRevision === currentRevision) clean = true;
|
|
99
|
+
},
|
|
100
|
+
(error: unknown) => {
|
|
101
|
+
if (revision?.value !== currentRevision) return checkSchema();
|
|
102
|
+
if (checkedRevision === currentRevision) verdict = undefined;
|
|
103
|
+
throw error;
|
|
104
|
+
},
|
|
105
|
+
));
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { BetterAuthError } from "../error";
|
|
2
|
+
import type { BetterAuthOptions } from "../types";
|
|
3
|
+
import { getAuthTables } from "./get-tables";
|
|
4
|
+
import type { DBFieldAttribute } from "./type";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A column as the database, or an ORM schema definition, reports it.
|
|
8
|
+
*/
|
|
9
|
+
export interface IntrospectedColumn {
|
|
10
|
+
name: string;
|
|
11
|
+
nullable: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The store fills the column when an insert omits it.
|
|
14
|
+
*/
|
|
15
|
+
hasDefault: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A table as the database, or an ORM schema definition, reports it.
|
|
20
|
+
*/
|
|
21
|
+
export interface IntrospectedTable {
|
|
22
|
+
name: string;
|
|
23
|
+
/**
|
|
24
|
+
* The schema the table lives in, when the store has schemas.
|
|
25
|
+
*/
|
|
26
|
+
schema?: string | undefined;
|
|
27
|
+
columns: IntrospectedColumn[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The tables Better Auth writes, keyed the way the store addresses them:
|
|
32
|
+
* physical table name, then physical column name. A table that manages its
|
|
33
|
+
* own storage is excluded from migrations and from this comparison.
|
|
34
|
+
*/
|
|
35
|
+
export type ExpectedSchema = Record<
|
|
36
|
+
string,
|
|
37
|
+
{
|
|
38
|
+
fields: Record<string, DBFieldAttribute>;
|
|
39
|
+
idColumn?: string | undefined;
|
|
40
|
+
disableMigrations?: boolean | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The schema the table is addressed in. Unset when the store has no
|
|
43
|
+
* schemas or the table is found by name alone.
|
|
44
|
+
*/
|
|
45
|
+
schema?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The tables this configuration writes, keyed the way the adapter addresses
|
|
51
|
+
* them. Tables that share a physical name are merged into one entry.
|
|
52
|
+
*/
|
|
53
|
+
export function getExpectedSchema(
|
|
54
|
+
options: BetterAuthOptions,
|
|
55
|
+
{ usePlural = false }: { usePlural?: boolean | undefined } = {},
|
|
56
|
+
): ExpectedSchema {
|
|
57
|
+
const expected: ExpectedSchema = {};
|
|
58
|
+
for (const table of Object.values(getAuthTables(options))) {
|
|
59
|
+
const name = usePlural ? `${table.modelName}s` : table.modelName;
|
|
60
|
+
const entry = (expected[name] ??= { fields: {}, disableMigrations: true });
|
|
61
|
+
for (const [key, field] of Object.entries(table.fields)) {
|
|
62
|
+
entry.fields[field.fieldName || key] = field;
|
|
63
|
+
}
|
|
64
|
+
entry.disableMigrations =
|
|
65
|
+
entry.disableMigrations && !!table.disableMigrations;
|
|
66
|
+
}
|
|
67
|
+
return expected;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type SchemaFinding =
|
|
71
|
+
| { kind: "missing-table"; table: string }
|
|
72
|
+
| { kind: "missing-column"; table: string; column: string }
|
|
73
|
+
| { kind: "unexpected-required-column"; table: string; column: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How the schema reaches the store, which decides the fix each finding names.
|
|
77
|
+
*/
|
|
78
|
+
export type SchemaSource = "database" | "drizzle" | "prisma";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compares the tables Better Auth writes with what the store holds.
|
|
82
|
+
*
|
|
83
|
+
* A table or column Better Auth writes must exist. A column Better Auth does
|
|
84
|
+
* not write must accept an insert that omits it, so it is nullable or carries
|
|
85
|
+
* a default. Otherwise every insert into that table fails with a constraint
|
|
86
|
+
* error that says nothing about why the schema drifted.
|
|
87
|
+
*/
|
|
88
|
+
export function diffSchema(
|
|
89
|
+
expected: ExpectedSchema,
|
|
90
|
+
actual: readonly IntrospectedTable[],
|
|
91
|
+
): SchemaFinding[] {
|
|
92
|
+
const findings: SchemaFinding[] = [];
|
|
93
|
+
for (const [tableName, table] of Object.entries(expected)) {
|
|
94
|
+
if (table.disableMigrations) continue;
|
|
95
|
+
const actualTable = actual.find(
|
|
96
|
+
(candidate) =>
|
|
97
|
+
candidate.name === tableName &&
|
|
98
|
+
(table.schema === undefined || candidate.schema === table.schema),
|
|
99
|
+
);
|
|
100
|
+
if (!actualTable) {
|
|
101
|
+
findings.push({ kind: "missing-table", table: tableName });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const written = new Set([
|
|
105
|
+
table.idColumn ?? "id",
|
|
106
|
+
...Object.keys(table.fields),
|
|
107
|
+
]);
|
|
108
|
+
for (const column of written) {
|
|
109
|
+
if (!actualTable.columns.some((candidate) => candidate.name === column)) {
|
|
110
|
+
findings.push({ kind: "missing-column", table: tableName, column });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const column of actualTable.columns) {
|
|
114
|
+
if (written.has(column.name) || column.nullable || column.hasDefault) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
findings.push({
|
|
118
|
+
kind: "unexpected-required-column",
|
|
119
|
+
table: tableName,
|
|
120
|
+
column: column.name,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return findings;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const applyHint: Record<SchemaSource, string> = {
|
|
128
|
+
database: "Run `npx auth migrate` to add it.",
|
|
129
|
+
drizzle:
|
|
130
|
+
"Run `npx auth generate` to refresh the Drizzle schema, then apply it with your migration tool.",
|
|
131
|
+
prisma:
|
|
132
|
+
"Run `npx auth generate` to refresh the Prisma schema, then run `prisma migrate`.",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const relaxHint: Record<SchemaSource, string> = {
|
|
136
|
+
database: "Drop the column, make it nullable, or give it a database default.",
|
|
137
|
+
drizzle:
|
|
138
|
+
"Remove it from the Drizzle schema, make it nullable, or give it a default, then apply the change with your migration tool.",
|
|
139
|
+
prisma:
|
|
140
|
+
"Remove it from the Prisma schema, make it optional, or give it a default, then run `prisma migrate`.",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const sourceLabel: Record<SchemaSource, string> = {
|
|
144
|
+
database: "Database",
|
|
145
|
+
drizzle: "Drizzle",
|
|
146
|
+
prisma: "Prisma",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* One finding as a sentence that names the change resolving it.
|
|
151
|
+
*/
|
|
152
|
+
export function formatSchemaFinding(
|
|
153
|
+
finding: SchemaFinding,
|
|
154
|
+
source: SchemaSource,
|
|
155
|
+
): string {
|
|
156
|
+
switch (finding.kind) {
|
|
157
|
+
case "missing-table":
|
|
158
|
+
return `Table "${finding.table}" is missing. ${applyHint[source]}`;
|
|
159
|
+
case "missing-column":
|
|
160
|
+
return `Column "${finding.column}" is missing from table "${finding.table}". ${applyHint[source]}`;
|
|
161
|
+
case "unexpected-required-column": {
|
|
162
|
+
const issuer =
|
|
163
|
+
finding.column === "issuer"
|
|
164
|
+
? " If this column came from Better Auth 1.7.0 through 1.7.2, follow the upgrade guide before removing it: https://www.better-auth.com/docs/guides/1-7-upgrade-guide"
|
|
165
|
+
: "";
|
|
166
|
+
return `Column "${finding.column}" on table "${finding.table}" is required but Better Auth never writes it, so every insert into "${finding.table}" fails. ${relaxHint[source]}${issuer}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const repairHint: Record<SchemaSource, string> = {
|
|
172
|
+
database:
|
|
173
|
+
"Make the listed columns nullable, give them defaults, or remove them.",
|
|
174
|
+
drizzle:
|
|
175
|
+
"Make the listed columns nullable in your Drizzle schema, give them defaults, or remove them.",
|
|
176
|
+
prisma:
|
|
177
|
+
"Make the listed fields optional in your Prisma schema, give them defaults, or remove them.",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const migrationHint: Record<SchemaSource, string> = {
|
|
181
|
+
...applyHint,
|
|
182
|
+
database: "Run `npx auth migrate` to add the missing tables and columns.",
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
function formatSchemaMismatch(
|
|
186
|
+
findings: readonly SchemaFinding[],
|
|
187
|
+
source: SchemaSource,
|
|
188
|
+
): string {
|
|
189
|
+
const tables: string[] = [];
|
|
190
|
+
const columns: string[] = [];
|
|
191
|
+
const required: string[] = [];
|
|
192
|
+
const affectedTables = new Set<string>();
|
|
193
|
+
let hasIssuer = false;
|
|
194
|
+
for (const finding of findings) {
|
|
195
|
+
switch (finding.kind) {
|
|
196
|
+
case "missing-table":
|
|
197
|
+
tables.push(finding.table);
|
|
198
|
+
break;
|
|
199
|
+
case "missing-column":
|
|
200
|
+
columns.push(`${finding.table}.${finding.column}`);
|
|
201
|
+
break;
|
|
202
|
+
case "unexpected-required-column":
|
|
203
|
+
required.push(`${finding.table}.${finding.column}`);
|
|
204
|
+
affectedTables.add(finding.table);
|
|
205
|
+
hasIssuer ||= finding.column === "issuer";
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sections = [`${sourceLabel[source]} schema mismatch`];
|
|
211
|
+
if (tables.length)
|
|
212
|
+
sections.push(` Missing tables\n ${tables.join(", ")}`);
|
|
213
|
+
if (columns.length)
|
|
214
|
+
sections.push(` Missing columns\n ${columns.join("\n ")}`);
|
|
215
|
+
if (required.length) {
|
|
216
|
+
sections.push(
|
|
217
|
+
` Required columns Better Auth never writes\n ${required.join("\n ")}`,
|
|
218
|
+
);
|
|
219
|
+
sections.push(
|
|
220
|
+
` Inserts into ${[...affectedTables].join(", ")} will fail.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const help: string[] = [];
|
|
225
|
+
if (required.length) help.push(repairHint[source]);
|
|
226
|
+
if (
|
|
227
|
+
tables.length ||
|
|
228
|
+
columns.length ||
|
|
229
|
+
(required.length && source !== "database")
|
|
230
|
+
) {
|
|
231
|
+
help.push(migrationHint[source]);
|
|
232
|
+
}
|
|
233
|
+
if (help.length) sections.push(` help: ${help.join("\n ")}`);
|
|
234
|
+
if (hasIssuer) {
|
|
235
|
+
sections.push(
|
|
236
|
+
" note: If this column came from Better Auth 1.7.0 through 1.7.2,\n" +
|
|
237
|
+
" follow the upgrade guide before removing it:\n" +
|
|
238
|
+
" https://www.better-auth.com/docs/guides/1-7-upgrade-guide",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return sections.join("\n\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The store cannot hold what this configuration writes.
|
|
246
|
+
*
|
|
247
|
+
* `findings` carries every problem as data; `message` lists each one with the
|
|
248
|
+
* change that resolves it. Reported during initialization and thrown when
|
|
249
|
+
* requests await validation, in every environment. Also thrown by
|
|
250
|
+
* `auth migrate` before it changes anything.
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* ```ts
|
|
254
|
+
* try {
|
|
255
|
+
* await auth.api.getSession({ headers });
|
|
256
|
+
* } catch (error) {
|
|
257
|
+
* if (error instanceof SchemaMismatchError) console.error(error.findings);
|
|
258
|
+
* }
|
|
259
|
+
* ```
|
|
260
|
+
*/
|
|
261
|
+
export class SchemaMismatchError extends BetterAuthError {
|
|
262
|
+
readonly code = "SCHEMA_MISMATCH";
|
|
263
|
+
|
|
264
|
+
constructor(
|
|
265
|
+
readonly findings: readonly SchemaFinding[],
|
|
266
|
+
readonly source: SchemaSource,
|
|
267
|
+
) {
|
|
268
|
+
super(formatSchemaMismatch(findings, source));
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/env/logger.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AuthEndpointContext } from "../context/endpoint-context";
|
|
2
|
+
import { __getCurrentEndpointContext } from "../context/global";
|
|
1
3
|
import { getColorDepth } from "./color-depth";
|
|
2
4
|
|
|
3
5
|
export const TTY_COLORS = {
|
|
@@ -142,4 +144,23 @@ export const createLogger = (options?: Logger | undefined): InternalLogger => {
|
|
|
142
144
|
};
|
|
143
145
|
};
|
|
144
146
|
|
|
145
|
-
|
|
147
|
+
const defaultLogger = createLogger();
|
|
148
|
+
|
|
149
|
+
const getCurrentLogger = (): InternalLogger => {
|
|
150
|
+
const currentLogger =
|
|
151
|
+
__getCurrentEndpointContext<AuthEndpointContext>()?.context.logger;
|
|
152
|
+
return currentLogger && currentLogger !== logger
|
|
153
|
+
? currentLogger
|
|
154
|
+
: defaultLogger;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const logger: InternalLogger = {
|
|
158
|
+
debug: (...params) => getCurrentLogger().debug(...params),
|
|
159
|
+
info: (...params) => getCurrentLogger().info(...params),
|
|
160
|
+
success: (...params) => getCurrentLogger().success(...params),
|
|
161
|
+
warn: (...params) => getCurrentLogger().warn(...params),
|
|
162
|
+
error: (...params) => getCurrentLogger().error(...params),
|
|
163
|
+
get level() {
|
|
164
|
+
return getCurrentLogger().level;
|
|
165
|
+
},
|
|
166
|
+
};
|