Merge branch 'canary' into fix/openapi-required-id-field

This commit is contained in:
Akshay
2025-12-04 13:10:30 +05:30
committed by GitHub
49 changed files with 2499 additions and 2527 deletions
+1 -4
View File
@@ -19,8 +19,6 @@
"@libsql/client": "^0.8.1",
"@libsql/kysely-libsql": "^0.4.1",
"@number-flow/react": "^0.5.10",
"@prisma/adapter-libsql": "^5.22.0",
"@prisma/client": "^5.22.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -67,7 +65,7 @@
"lucide-react": "^0.542.0",
"mini-svg-data-uri": "^1.4.4",
"mysql2": "^3.14.4",
"next": "16.0.1",
"next": "16.0.7",
"next-themes": "^0.4.6",
"react": "catalog:react19",
"react-day-picker": "9.9.0",
@@ -92,7 +90,6 @@
"@types/react-dom": "catalog:react19",
"@types/ua-parser-js": "^0.7.39",
"postcss": "^8.5.6",
"prisma": "^5.22.0",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.3.8"
}
+1 -1
View File
@@ -11,7 +11,7 @@
},
"dependencies": {
"better-auth": "workspace:*",
"next": "16.0.1",
"next": "16.0.7",
"react": "catalog:react19",
"react-dom": "catalog:react19"
},
+1 -1
View File
@@ -999,7 +999,7 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
),
},
{
title: "Tiktok",
title: "TikTok",
href: "/docs/authentication/tiktok",
icon: () => (
<svg
@@ -59,3 +59,44 @@ const signIn = async () => {
})
}
```
### Middleware
You can use TanStack Start's middleware to protect routes that require authentication. Create a middleware that checks for a valid session and redirects unauthenticated users to the login page.
```ts title="src/middleware/auth.ts"
import { redirect } from "@tanstack/react-router";
import { createMiddleware } from "@tanstack/react-start";
import { auth } from "./auth";
export const authMiddleware = createMiddleware().server(
async ({ next, request }) => {
const session = await auth.api.getSession({ headers: request.headers })
if (!session) {
throw redirect({ to: "/login" })
}
return await next()
}
);
```
You can then use this middleware in your route definitions to protect specific routes:
```tsx title="src/routes/dashboard.tsx"
import { createFileRoute } from '@tanstack/react-router'
import { authMiddleware } from '@/lib/middleware'
export const Route = createFileRoute('/dashboard')({
component: RouteComponent,
server: {
middleware: [authMiddleware],
},
})
function RouteComponent() {
return <div>Hello "/dashboard"!</div>
}
```
+14 -14
View File
@@ -612,7 +612,7 @@ You can set the active organization by calling the `organization.setActive` func
set to `null`.
</Callout>
<APIMethod path="/organization/set-active" method="POST">
<APIMethod path="/organization/set-active" method="POST" requireSession>
```ts
type setActiveOrganization = {
/**
@@ -858,7 +858,7 @@ export const auth = betterAuth({
To invite users to an organization, you can use the `invite` function provided by the client. The `invite` function takes an object with the following properties:
<APIMethod path="/organization/invite-member" method="POST">
<APIMethod path="/organization/invite-member" method="POST" requireSession>
```ts
type createInvitation = {
/**
@@ -900,7 +900,7 @@ When a user receives an invitation email, they can click on the invitation link
Make sure to call the `acceptInvitation` function after the user is logged in.
<APIMethod path="/organization/accept-invitation" method="POST">
<APIMethod path="/organization/accept-invitation" method="POST" requireSession>
```ts
type acceptInvitation = {
/**
@@ -937,7 +937,7 @@ If a user has sent out an invitation, you can use this method to cancel it.
If you're looking for how a user can reject an invitation, you can find that [here](#reject-invitation).
<APIMethod path="/organization/cancel-invitation" method="POST" noResult>
<APIMethod path="/organization/cancel-invitation" method="POST" noResult requireSession>
```ts
type cancelInvitation = {
/**
@@ -952,7 +952,7 @@ type cancelInvitation = {
If this user has received an invitation, but wants to decline it, this method will allow you to do so by rejecting it.
<APIMethod path="/organization/reject-invitation" method="POST" noResult>
<APIMethod path="/organization/reject-invitation" method="POST" noResult requireSession>
```ts
type rejectInvitation = {
/**
@@ -993,7 +993,7 @@ type getInvitation = {
To list all invitations for a given organization you can use the `listInvitations` function provided by the client.
<APIMethod path="/organization/list-invitations" method="GET">
<APIMethod path="/organization/list-invitations" method="GET" requireSession>
```ts
type listInvitations = {
/**
@@ -1033,7 +1033,7 @@ const invitations = await auth.api.listUserInvitations({
To list all members of an organization you can use the `listMembers` function.
<APIMethod path="/organization/list-members" method="GET">
<APIMethod path="/organization/list-members" method="GET" requireSession>
```ts
type listMembers = {
@@ -1077,7 +1077,7 @@ type listMembers = {
To remove you can use `organization.removeMember`
<APIMethod path="/organization/remove-member" method="POST">
<APIMethod path="/organization/remove-member" method="POST" requireSession>
```ts
type removeMember = {
/**
@@ -1096,7 +1096,7 @@ type removeMember = {
To update the role of a member in an organization, you can use the `organization.updateMemberRole`. If the user has the permission to update the role of the member, the role will be updated.
<APIMethod path="/organization/update-member-role" method="POST" noResult>
<APIMethod path="/organization/update-member-role" method="POST" noResult requireSession>
```ts
type updateMemberRole = {
/**
@@ -1911,7 +1911,7 @@ type removeTeam = {
Sets the given team as the current active team. If `teamId` is `null` the current active team is unset.
<APIMethod path="/organization/set-active-team" method="POST">
<APIMethod path="/organization/set-active-team" method="POST" requireSession>
```ts
type setActiveTeam = {
/**
@@ -1926,7 +1926,7 @@ type setActiveTeam = {
List all teams that the current user is a part of.
<APIMethod path="/organization/list-user-teams" method="GET">
<APIMethod path="/organization/list-user-teams" method="GET" requireSession>
```ts
type listUserTeams = {
}
@@ -1937,7 +1937,7 @@ List all teams that the current user is a part of.
List the members of the given team.
<APIMethod path="/organization/list-team-members" method="POST" forceAsQuery>
<APIMethod path="/organization/list-team-members" method="POST" forceAsQuery requireSession>
```ts
type listTeamMembers = {
/**
@@ -1952,7 +1952,7 @@ type listTeamMembers = {
Add a member to a team.
<APIMethod path="/organization/add-team-member" method="POST">
<APIMethod path="/organization/add-team-member" method="POST" requireSession>
```ts
type addTeamMember = {
/**
@@ -1971,7 +1971,7 @@ type addTeamMember = {
Remove a member from a team.
<APIMethod path="/organization/remove-team-member" method="POST">
<APIMethod path="/organization/remove-team-member" method="POST" requireSession>
```ts
type removeTeamMember = {
/**
+1 -1
View File
@@ -74,7 +74,7 @@
"lottie-react": "^2.4.1",
"lucide-react": "^0.542.0",
"motion": "^12.23.12",
"next": "16.0.1",
"next": "16.0.7",
"next-themes": "^0.4.6",
"prism-react-renderer": "^2.4.1",
"react": "catalog:react19",
@@ -6,6 +6,7 @@
"typecheck": "tsc --project tsconfig.json"
},
"dependencies": {
"better-auth": "workspace:*"
"better-auth": "workspace:*",
"@better-auth/sso": "workspace:*"
}
}
@@ -0,0 +1,10 @@
import { betterAuth } from "better-auth";
import { oidcProvider } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
oidcProvider({
loginPage: "/login",
}),
],
});
@@ -0,0 +1,6 @@
import { sso } from "@better-auth/sso";
import { betterAuth } from "better-auth";
export const auth = betterAuth({
plugins: [sso()],
});
+2 -2
View File
@@ -449,10 +449,10 @@
"@noble/ciphers": "^2.0.0",
"@noble/hashes": "^2.0.0",
"better-call": "catalog:",
"ms": "4.0.0-nightly.202508271359",
"defu": "^6.1.4",
"jose": "^6.1.0",
"kysely": "^0.28.5",
"ms": "4.0.0-nightly.202508271359",
"nanostores": "^1.0.1",
"zod": "^4.1.12"
},
@@ -471,7 +471,7 @@
"happy-dom": "^20.0.10",
"listhen": "^1.9.0",
"mongodb": "^6.18.0",
"msw": "^2.12.1",
"msw": "^2.12.4",
"mysql2": "^3.14.4",
"next": "^16.0.1",
"oauth2-mock-server": "^7.2.1",
@@ -13,6 +13,83 @@ import { betterAuth } from "../auth";
import type { Account, Session, User, Verification } from "../types";
import type { Logger } from "./test-adapter";
/**
* Test entry type that supports both callback and object formats.
* Object format allows specifying migration options that will be applied before the test runs.
*/
export type TestEntry =
| ((context: {
readonly skip: {
(note?: string | undefined): never;
(condition: boolean, note?: string | undefined): void;
};
}) => Promise<void>)
| {
migrateBetterAuth?: BetterAuthOptions;
test: (context: {
readonly skip: {
(note?: string | undefined): never;
(condition: boolean, note?: string | undefined): void;
};
}) => Promise<void>;
};
/**
* Deep equality comparison for BetterAuthOptions.
* Handles nested objects, arrays, and primitive values.
*/
function deepEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a === "object") {
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!keysB.includes(key)) return false;
if (!deepEqual(a[key], b[key])) return false;
}
return true;
}
return false;
}
/**
* Statistics tracking for test suites.
*/
export type TestSuiteStats = {
migrationCount: number;
totalMigrationTime: number;
testCount: number;
suiteStartTime: number;
suiteDuration: number;
suiteName: string;
groupingStats?: {
totalGroups: number;
averageTestsPerGroup: number;
largestGroupSize: number;
smallestGroupSize: number;
groupsWithMultipleTests: number;
totalTestsInGroups: number;
};
};
type GenerateFn = <M extends "user" | "session" | "verification" | "account">(
Model: M,
) => Promise<
@@ -81,20 +158,7 @@ export type InsertRandomFn = <
>;
export const createTestSuite = <
Tests extends Record<
string,
(context: {
/**
* Mark tests as skipped. All execution after this call will be skipped.
* This function throws an error, so make sure you are not catching it accidentally.
* @see {@link https://vitest.dev/guide/test-context#skip}
*/
readonly skip: {
(note?: string | undefined): never;
(condition: boolean, note?: string | undefined): void;
};
}) => Promise<void>
>,
Tests extends Record<string, TestEntry>,
AdditionalOptions extends Record<string, any> = {},
>(
suiteName: string,
@@ -177,7 +241,7 @@ export const createTestSuite = <
cleanup: () => Promise<void>;
runMigrations: () => Promise<void>;
prefixTests?: string | undefined;
onTestFinish: () => Promise<void>;
onTestFinish: (stats: TestSuiteStats) => Promise<void>;
customIdGenerator?: () => any | Promise<any> | undefined;
transformIdOutput?: (id: any) => string | undefined;
}) => {
@@ -310,18 +374,57 @@ export const createTestSuite = <
}
};
let didMigrateOnOptionsModify = false;
// Track current applied BetterAuth options state
let currentAppliedOptions: BetterAuthOptions | null = null;
const resetBetterAuthOptions = async () => {
adapter = await helpers.adapter();
await helpers.modifyBetterAuthOptions(
config.defaultBetterAuthOptions || {},
// Statistics tracking
const stats: TestSuiteStats = {
migrationCount: 0,
totalMigrationTime: 0,
testCount: 0,
suiteStartTime: performance.now(),
suiteDuration: 0,
suiteName,
};
/**
* Apply BetterAuth options and run migrations if needed.
* Tracks migration statistics.
*/
const applyOptionsAndMigrate = async (
options: BetterAuthOptions | Partial<BetterAuthOptions>,
forceMigrate: boolean = false,
): Promise<BetterAuthOptions> => {
const finalOptions = deepmerge(
config?.defaultBetterAuthOptions || {},
options || {},
);
if (didMigrateOnOptionsModify) {
didMigrateOnOptionsModify = false;
await helpers.runMigrations();
// Check if options have changed
const optionsChanged = !deepEqual(currentAppliedOptions, finalOptions);
if (optionsChanged || forceMigrate) {
adapter = await helpers.adapter();
await helpers.modifyBetterAuthOptions(finalOptions);
if (config.alwaysMigrate || forceMigrate) {
const migrationStart = performance.now();
await helpers.runMigrations();
const migrationTime = performance.now() - migrationStart;
stats.migrationCount++;
stats.totalMigrationTime += migrationTime;
adapter = await helpers.adapter();
}
currentAppliedOptions = finalOptions;
} else {
// Options haven't changed, just update the reference
currentAppliedOptions = finalOptions;
}
return finalOptions;
};
const transformGeneratedModel = (data: Record<string, any>) => {
@@ -493,14 +596,7 @@ export const createTestSuite = <
opts: BetterAuthOptions,
shouldRunMigrations: boolean,
) => {
const res = helpers.modifyBetterAuthOptions(
deepmerge(config?.defaultBetterAuthOptions || {}, opts),
);
if (config.alwaysMigrate || shouldRunMigrations) {
didMigrateOnOptionsModify = true;
await helpers.runMigrations();
}
return res;
return await applyOptionsAndMigrate(opts, shouldRunMigrations);
};
const additionalOptions = { ...options };
@@ -558,55 +654,231 @@ export const createTestSuite = <
await helpers.cleanup();
} catch {}
if (config.defaultBetterAuthOptions && !allDisabled) {
await helpers.modifyBetterAuthOptions(
await applyOptionsAndMigrate(
config.defaultBetterAuthOptions,
config.alwaysMigrate,
);
if (config.alwaysMigrate) {
await helpers.runMigrations();
}
}
});
/**
* Extract test function and migration options from a test entry.
*/
const extractTestEntry = (
entry: TestEntry,
): {
testFn: (context: {
readonly skip: {
(note?: string | undefined): never;
(condition: boolean, note?: string | undefined): void;
};
}) => Promise<void>;
migrateBetterAuth?: BetterAuthOptions;
} => {
if (typeof entry === "function") {
return { testFn: entry };
}
return {
testFn: entry.test,
migrateBetterAuth: entry.migrateBetterAuth,
};
};
// Convert test entries to array with migration info (moved before onFinish for access)
const testEntries = Object.entries(fullTests).map(([name, entry]) => {
const { testFn, migrateBetterAuth } = extractTestEntry(
entry as TestEntry,
);
return { name, testFn, migrateBetterAuth };
});
/**
* Group tests by their migrateBetterAuth options.
* Tests with equal migration options are grouped together.
*/
type TestGroup = {
migrationOptions: BetterAuthOptions | null | undefined;
testIndices: number[];
};
const groupTestsByMigrationOptions = (): TestGroup[] => {
const groups: TestGroup[] = [];
let currentGroup: TestGroup | null = null;
for (let i = 0; i < testEntries.length; i++) {
const { migrateBetterAuth } = testEntries[i]!;
const isSkipped =
(allDisabled &&
options?.disableTests?.[testEntries[i]!.name] !== false) ||
(options?.disableTests?.[testEntries[i]!.name] ?? false);
// Skip grouping for skipped tests - they'll be handled individually
if (isSkipped) {
if (currentGroup) {
groups.push(currentGroup);
currentGroup = null;
}
groups.push({
migrationOptions: migrateBetterAuth,
testIndices: [i],
});
continue;
}
// Check if this test belongs to the current group
if (
currentGroup &&
deepEqual(currentGroup.migrationOptions, migrateBetterAuth)
) {
currentGroup.testIndices.push(i);
} else {
// Start a new group
if (currentGroup) {
groups.push(currentGroup);
}
currentGroup = {
migrationOptions: migrateBetterAuth,
testIndices: [i],
};
}
}
// Add the last group if it exists
if (currentGroup) {
groups.push(currentGroup);
}
return groups;
};
const testGroups = groupTestsByMigrationOptions();
// Calculate grouping statistics
const calculateGroupingStats = () => {
const nonSkippedGroups = testGroups.filter(
(group) => group.testIndices.length > 0,
);
const groupSizes = nonSkippedGroups.map(
(group) => group.testIndices.length,
);
if (groupSizes.length === 0) {
return {
totalGroups: 0,
averageTestsPerGroup: 0,
largestGroupSize: 0,
smallestGroupSize: 0,
groupsWithMultipleTests: 0,
totalTestsInGroups: 0,
};
}
const totalTestsInGroups = groupSizes.reduce(
(sum, size) => sum + size,
0,
);
const groupsWithMultipleTests = groupSizes.filter(
(size) => size > 1,
).length;
return {
totalGroups: nonSkippedGroups.length,
averageTestsPerGroup: totalTestsInGroups / nonSkippedGroups.length,
largestGroupSize: Math.max(...groupSizes),
smallestGroupSize: Math.min(...groupSizes),
groupsWithMultipleTests,
totalTestsInGroups,
};
};
const onFinish = async (testName: string) => {
await cleanupCreatedRows();
await resetBetterAuthOptions();
// Check if this is the last test by comparing current test index with total tests
const testEntries = Object.entries(fullTests);
const currentTestIndex = testEntries.findIndex(
([name]) =>
name === testName.replace(/\[.*?\] /, "").replace(/ ─ /g, " - "),
({ name }) => name === testName,
);
const isLastTest = currentTestIndex === testEntries.length - 1;
if (isLastTest) {
await helpers.onTestFinish();
stats.suiteDuration = performance.now() - stats.suiteStartTime;
stats.groupingStats = calculateGroupingStats();
await helpers.onTestFinish(stats);
}
};
if (allDisabled) {
await resetBetterAuthOptions();
}
// Track the current group's migration options
let currentGroupMigrationOptions: BetterAuthOptions | null | undefined =
null;
for (let i = 0; i < testEntries.length; i++) {
const { name: testName, testFn, migrateBetterAuth } = testEntries[i]!;
// Find which group this test belongs to
const testGroup = testGroups.find((group) =>
group.testIndices.includes(i),
);
const isFirstInGroup = testGroup && testGroup.testIndices[0] === i;
for (let [testName, testFn] of Object.entries(fullTests)) {
let shouldSkip =
(allDisabled && options?.disableTests?.[testName] !== false) ||
(options?.disableTests?.[testName] ?? false);
testName = testName.replace(
let displayName = testName.replace(
" - ",
` ${TTY_COLORS.dim}${dash}${TTY_COLORS.undim} `,
);
if (config.prefixTests) {
testName = `${config.prefixTests} ${TTY_COLORS.dim}>${TTY_COLORS.undim} ${testName}`;
displayName = `${config.prefixTests} ${TTY_COLORS.dim}>${TTY_COLORS.undim} ${displayName}`;
}
if (helpers.prefixTests) {
testName = `[${TTY_COLORS.dim}${helpers.prefixTests}${TTY_COLORS.undim}] ${testName}`;
displayName = `[${TTY_COLORS.dim}${helpers.prefixTests}${TTY_COLORS.undim}] ${displayName}`;
}
test.skipIf(shouldSkip)(
testName,
displayName,
{ timeout: 30000 },
async ({ onTestFailed, skip }) => {
resetDebugLogs();
// Apply migration options before test runs
await (async () => {
if (shouldSkip) return;
const thisMigration = deepmerge(
config.defaultBetterAuthOptions || {},
migrateBetterAuth || {},
);
// If this is the first test in a group, migrate to the group's options
if (isFirstInGroup && testGroup) {
const groupMigrationOptions = testGroup.migrationOptions;
const groupFinalOptions = deepmerge(
config.defaultBetterAuthOptions || {},
groupMigrationOptions || {},
);
// Only migrate if the group's options are different from current state
if (
!deepEqual(
currentGroupMigrationOptions,
groupMigrationOptions,
)
) {
await applyOptionsAndMigrate(groupFinalOptions, true);
currentGroupMigrationOptions = groupMigrationOptions;
}
}
// If this test is not in a group or not first in group, check if migration is needed
else if (
!deepEqual(currentGroupMigrationOptions, migrateBetterAuth)
) {
await applyOptionsAndMigrate(thisMigration, true);
currentGroupMigrationOptions = migrateBetterAuth;
}
})();
stats.testCount++;
onTestFailed(async () => {
printDebugLogs();
await onFinish(testName);
@@ -4,7 +4,7 @@ import { deepmerge, initGetModelName } from "@better-auth/core/db/adapter";
import { TTY_COLORS } from "@better-auth/core/env";
import { afterAll, beforeAll, describe } from "vitest";
import { getAuthTables } from "../db";
import type { createTestSuite } from "./create-test-suite";
import type { createTestSuite, TestSuiteStats } from "./create-test-suite";
export type Logger = {
info: (...args: any[]) => void;
@@ -197,12 +197,158 @@ export const testAdapter = async ({
return {
execute: () => {
describe(adapterDisplayName, async () => {
// Collect statistics from all test suites
const allSuiteStats: TestSuiteStats[] = [];
beforeAll(async () => {
await migrate();
}, 60000);
afterAll(async () => {
await cleanup();
// Display statistics summary
if (allSuiteStats.length > 0) {
const totalMigrations = allSuiteStats.reduce(
(sum, stats) => sum + stats.migrationCount,
0,
);
const totalMigrationTime = allSuiteStats.reduce(
(sum, stats) => sum + stats.totalMigrationTime,
0,
);
const totalTests = allSuiteStats.reduce(
(sum, stats) => sum + stats.testCount,
0,
);
const totalDuration = allSuiteStats.reduce(
(sum, stats) => sum + stats.suiteDuration,
0,
);
const dash = "─";
const separator = `${dash.repeat(80)}`;
console.log(`\n${TTY_COLORS.fg.cyan}${separator}`);
console.log(
`${TTY_COLORS.fg.cyan}${TTY_COLORS.bright}TEST SUITE STATISTICS SUMMARY${TTY_COLORS.reset}`,
);
console.log(
`${TTY_COLORS.fg.cyan}${separator}${TTY_COLORS.reset}\n`,
);
// Per-suite breakdown
for (const stats of allSuiteStats) {
const avgMigrationTime =
stats.migrationCount > 0
? (stats.totalMigrationTime / stats.migrationCount).toFixed(2)
: "0.00";
console.log(
`${TTY_COLORS.fg.magenta}${stats.suiteName}${TTY_COLORS.reset}:`,
);
console.log(
` Tests: ${TTY_COLORS.fg.green}${stats.testCount}${TTY_COLORS.reset}`,
);
console.log(
` Migrations: ${TTY_COLORS.fg.yellow}${stats.migrationCount}${TTY_COLORS.reset} (avg: ${avgMigrationTime}ms)`,
);
console.log(
` Total Migration Time: ${TTY_COLORS.fg.yellow}${stats.totalMigrationTime.toFixed(2)}ms${TTY_COLORS.reset}`,
);
console.log(
` Suite Duration: ${TTY_COLORS.fg.blue}${stats.suiteDuration.toFixed(2)}ms${TTY_COLORS.reset}`,
);
// Display grouping statistics if available
if (stats.groupingStats) {
const {
totalGroups,
averageTestsPerGroup,
largestGroupSize,
smallestGroupSize,
groupsWithMultipleTests,
} = stats.groupingStats;
console.log(
` Test Groups: ${TTY_COLORS.fg.cyan}${totalGroups}${TTY_COLORS.reset}`,
);
if (totalGroups > 0) {
console.log(
` Avg Tests/Group: ${TTY_COLORS.fg.cyan}${averageTestsPerGroup.toFixed(2)}${TTY_COLORS.reset}`,
);
console.log(
` Largest Group: ${TTY_COLORS.fg.cyan}${largestGroupSize}${TTY_COLORS.reset}`,
);
console.log(
` Smallest Group: ${TTY_COLORS.fg.cyan}${smallestGroupSize}${TTY_COLORS.reset}`,
);
console.log(
` Groups w/ Multiple Tests: ${TTY_COLORS.fg.cyan}${groupsWithMultipleTests}${TTY_COLORS.reset}`,
);
}
}
console.log("");
}
// Totals
const avgMigrationTime =
totalMigrations > 0
? (totalMigrationTime / totalMigrations).toFixed(2)
: "0.00";
// Calculate total grouping statistics
const totalGroups = allSuiteStats.reduce(
(sum, stats) => sum + (stats.groupingStats?.totalGroups || 0),
0,
);
const totalGroupsWithMultipleTests = allSuiteStats.reduce(
(sum, stats) =>
sum + (stats.groupingStats?.groupsWithMultipleTests || 0),
0,
);
const totalTestsInGroups = allSuiteStats.reduce(
(sum, stats) =>
sum + (stats.groupingStats?.totalTestsInGroups || 0),
0,
);
const avgTestsPerGroup =
totalGroups > 0 ? totalTestsInGroups / totalGroups : 0;
console.log(`${TTY_COLORS.fg.cyan}${separator}`);
console.log(
`${TTY_COLORS.fg.cyan}${TTY_COLORS.bright}TOTALS${TTY_COLORS.reset}`,
);
console.log(
` Total Tests: ${TTY_COLORS.fg.green}${totalTests}${TTY_COLORS.reset}`,
);
console.log(
` Total Migrations: ${TTY_COLORS.fg.yellow}${totalMigrations}${TTY_COLORS.reset} (avg: ${avgMigrationTime}ms)`,
);
console.log(
` Total Migration Time: ${TTY_COLORS.fg.yellow}${totalMigrationTime.toFixed(2)}ms${TTY_COLORS.reset}`,
);
console.log(
` Total Duration: ${TTY_COLORS.fg.blue}${totalDuration.toFixed(2)}ms${TTY_COLORS.reset}`,
);
// Display total grouping statistics
if (totalGroups > 0) {
console.log(
` Total Test Groups: ${TTY_COLORS.fg.cyan}${totalGroups}${TTY_COLORS.reset}`,
);
console.log(
` Avg Tests/Group: ${TTY_COLORS.fg.cyan}${avgTestsPerGroup.toFixed(2)}${TTY_COLORS.reset}`,
);
console.log(
` Groups w/ Multiple Tests: ${TTY_COLORS.fg.cyan}${totalGroupsWithMultipleTests}${TTY_COLORS.reset}`,
);
}
console.log(
`${TTY_COLORS.fg.cyan}${separator}${TTY_COLORS.reset}\n`,
);
}
await onFinish?.();
}, 60000);
@@ -227,7 +373,9 @@ export const testAdapter = async ({
cleanup,
prefixTests,
runMigrations: migrate,
onTestFinish: async () => {},
onTestFinish: async (stats: TestSuiteStats) => {
allSuiteStats.push(stats);
},
customIdGenerator,
transformIdOutput,
});
@@ -97,36 +97,35 @@ export const getNormalTestSuiteTests = (
});
expect(findResult).toEqual(res);
},
"create - should return null for nullable foreign keys": async () => {
await modifyBetterAuthOptions(
{
plugins: [
{
id: "nullable-test",
schema: {
testModel: {
fields: {
nullableReference: {
type: "string",
references: { field: "id", model: "user" },
required: false,
},
"create - should return null for nullable foreign keys": {
migrateBetterAuth: {
plugins: [
{
id: "nullable-test",
schema: {
testModel: {
fields: {
nullableReference: {
type: "string",
references: { field: "id", model: "user" },
required: false,
},
},
},
} satisfies BetterAuthPlugin,
],
},
true,
);
const { nullableReference } = await adapter.create<{
nullableReference: string | null;
}>({
model: "testModel",
data: { nullableReference: null },
forceAllowId: true,
});
expect(nullableReference).toBeNull();
},
} satisfies BetterAuthPlugin,
],
},
test: async () => {
const { nullableReference } = await adapter.create<{
nullableReference: string | null;
}>({
model: "testModel",
data: { nullableReference: null },
forceAllowId: true,
});
expect(nullableReference).toBeNull();
},
},
"create - should apply default values to fields": async () => {
+1
View File
@@ -38,6 +38,7 @@ export type * from "@better-fetch/fetch";
// @ts-expect-error
export type * from "nanostores";
export type * from "../plugins/access";
export type * from "../plugins/organization";
export type * from "../types/helper";
export type { UnionToIntersection } from "../types/helper";
//#endregion
@@ -3,6 +3,7 @@ import type {
BetterAuthClientPlugin,
} from "@better-auth/core";
import type { BASE_ERROR_CODES } from "@better-auth/core/error";
import { capitalizeFirstLetter } from "@better-auth/core/utils";
import type {
BetterFetchError,
BetterFetchResponse,
@@ -23,10 +24,6 @@ function getAtomKey(str: string) {
return `use${capitalizeFirstLetter(str)}`;
}
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
type InferResolvedHooks<O extends BetterAuthClientOptions> = O extends {
plugins: Array<infer Plugin>;
}
@@ -3,6 +3,7 @@ import type {
BetterAuthClientPlugin,
} from "@better-auth/core";
import type { BASE_ERROR_CODES } from "@better-auth/core/error";
import { capitalizeFirstLetter } from "@better-auth/core/utils";
import type {
BetterFetchError,
BetterFetchResponse,
@@ -23,10 +24,6 @@ function getAtomKey(str: string) {
return `use${capitalizeFirstLetter(str)}`;
}
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
type InferResolvedHooks<O extends BetterAuthClientOptions> = O extends {
plugins: Array<infer Plugin>;
}
@@ -3,13 +3,13 @@ import type {
BetterAuthClientPlugin,
} from "@better-auth/core";
import type { BASE_ERROR_CODES } from "@better-auth/core/error";
import { capitalizeFirstLetter } from "@better-auth/core/utils";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
import type { Accessor } from "solid-js";
import type { PrettifyDeep, UnionToIntersection } from "../../types/helper";
import { capitalizeFirstLetter } from "../../utils/misc";
import { getClientConfig } from "../config";
import { createDynamicPathProxy } from "../proxy";
import type {
@@ -3,13 +3,13 @@ import type {
BetterAuthClientPlugin,
} from "@better-auth/core";
import type { BASE_ERROR_CODES } from "@better-auth/core/error";
import { capitalizeFirstLetter } from "@better-auth/core/utils";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
import type { Atom } from "nanostores";
import type { PrettifyDeep, UnionToIntersection } from "../../types/helper";
import { capitalizeFirstLetter } from "../../utils/misc";
import { getClientConfig } from "../config";
import { createDynamicPathProxy } from "../proxy";
import type {
+1 -1
View File
@@ -3,13 +3,13 @@ import type {
BetterAuthClientPlugin,
} from "@better-auth/core";
import type { BASE_ERROR_CODES } from "@better-auth/core/error";
import { capitalizeFirstLetter } from "@better-auth/core/utils";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
import type { Atom } from "nanostores";
import type { PrettifyDeep, UnionToIntersection } from "../types/helper";
import { capitalizeFirstLetter } from "../utils/misc";
import { getClientConfig } from "./config";
import { createDynamicPathProxy } from "./proxy";
import type {
+1 -1
View File
@@ -3,13 +3,13 @@ import type {
BetterAuthClientPlugin,
} from "@better-auth/core";
import type { BASE_ERROR_CODES } from "@better-auth/core/error";
import { capitalizeFirstLetter } from "@better-auth/core/utils";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
import type { DeepReadonly, Ref } from "vue";
import type { PrettifyDeep, UnionToIntersection } from "../../types/helper";
import { capitalizeFirstLetter } from "../../utils/misc";
import { getClientConfig } from "../config";
import { createDynamicPathProxy } from "../proxy";
import type {
@@ -1,6 +1,7 @@
import type { BetterAuthOptions } from "@better-auth/core";
import { describe, expect, it } from "vitest";
import { getCookieCache, getCookies, getSessionCookie } from "../cookies";
import { parseUserOutput } from "../db/schema";
import { getTestInstance } from "../test-utils/test-instance";
import { parseSetCookieHeader } from "./cookie-utils";
@@ -481,36 +482,6 @@ describe("Cookie Cache Field Filtering", () => {
expect(cache?.user?.internalNote).toBeUndefined();
});
it("should always preserve the user.id in cookie cache", async () => {
const { client, testUser, cookieSetter } = await getTestInstance({
secret: "better-auth.secret",
session: {
cookieCache: {
enabled: true,
},
},
});
const headers = new Headers();
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: cookieSetter(headers),
},
);
const request = new Request("https://example.com/api/auth/session", {
headers,
});
const cache = await getCookieCache(request, {
secret: "better-auth.secret",
});
expect(cache).not.toBeNull();
expect(cache?.user?.id).toBeDefined();
expect(typeof cache?.user?.id).toBe("string");
});
it("should correctly filter multiple user fields based on returned config", async () => {
const { client, testUser, cookieSetter } = await getTestInstance({
secret: "better-auth.secret",
@@ -573,6 +544,26 @@ describe("Cookie Cache Field Filtering", () => {
expect(cache?.user?.adminFlags).toBeUndefined();
});
it("should always include id in parseUserOutput", () => {
const options = {
user: {
additionalFields: {
id: { type: "string", returned: false },
},
},
} as any;
const user = {
id: "custom-oauth-id-123",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
name: "Test User",
};
const result = parseUserOutput(options, user);
expect(result.id).toBe("custom-oauth-id-123");
});
it("should reduce cookie size when large fields are excluded", async () => {
const largeString = "x".repeat(2000);
const { client, testUser, cookieSetter } = await getTestInstance({
+22 -7
View File
@@ -112,9 +112,20 @@ export function parseInputData<T extends Record<string, any>>(
continue;
}
if (fields[key]!.validator?.input && data[key] !== undefined) {
parsedData[key] = fields[key]!.validator.input["~standard"].validate(
const result = fields[key]!.validator.input["~standard"].validate(
data[key],
);
if (result instanceof Promise) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: "Async validation is not supported for additional fields",
});
}
if ("issues" in result && result.issues) {
throw new APIError("BAD_REQUEST", {
message: result.issues[0]?.message || "Validation Error",
});
}
parsedData[key] = result.value;
continue;
}
if (fields[key]!.transform?.input && data[key] !== undefined) {
@@ -180,12 +191,16 @@ export function mergeSchema<S extends BetterAuthPluginDBSchema>(
schema: S,
newSchema?:
| {
[K in keyof S]?: {
modelName?: string;
fields?: {
[P: string]: string;
};
};
[K in keyof S]?:
| {
modelName?: string | undefined;
fields?:
| {
[P: string]: string;
}
| undefined;
}
| undefined;
}
| undefined,
) {
@@ -1681,37 +1681,98 @@ describe("api-key", async () => {
// Sessions from API keys
// =========================================================================
it("should get session from an API key", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
describe("enableSessionForAPIKeys", () => {
it("should get session from an API key", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: true,
}),
],
},
},
);
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers: userHeaders } = await signInWithTestUser();
const { headers: userHeaders } = await signInWithTestUser();
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: userHeaders },
);
if (!apiKey2) return;
const headers = new Headers();
headers.set("x-api-key", apiKey2.key);
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: userHeaders },
);
if (!apiKey2) return;
const headers = new Headers();
headers.set("x-api-key", apiKey2.key);
const session = await auth.api.getSession({
headers: headers,
const session = await auth.api.getSession({
headers: headers,
});
expect(session?.session).toBeDefined();
});
expect(session?.session).toBeDefined();
it("should not get session from an API key if enableSessionForAPIKeys is false", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: false,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers: userHeaders } = await signInWithTestUser();
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: userHeaders },
);
if (!apiKey2) return;
const headers = new Headers();
headers.set("x-api-key", apiKey2.key);
const session = await auth.api.getSession({
headers: headers,
});
expect(session).toBeNull();
});
it("should get the Response object when asResponse is true", async () => {
const { client, auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
apiKey({
enableSessionForAPIKeys: true,
}),
],
},
{
clientOptions: {
plugins: [apiKeyClient()],
},
},
);
const { headers: userHeaders } = await signInWithTestUser();
const { data: apiKey2 } = await client.apiKey.create(
{},
{ headers: userHeaders },
);
if (!apiKey2) return;
const headers = new Headers();
headers.set("x-api-key", apiKey2.key);
const res = await auth.api.getSession({
headers: headers,
asResponse: true,
});
expect(res).toBeInstanceOf(Response);
});
});
// =========================================================================
+60 -5
View File
@@ -7,18 +7,73 @@ import { getJwksAdapter } from "./adapter";
import type { JwtOptions } from "./types";
import { createJwk, toExpJWT } from "./utils";
type JWTPayloadWithOptional = {
/**
* JWT Issuer
*
* @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1 RFC7519#section-4.1.1}
*/
iss?: string | undefined;
/**
* JWT Subject
*
* @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.2 RFC7519#section-4.1.2}
*/
sub?: string | undefined;
/**
* JWT Audience
*
* @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3 RFC7519#section-4.1.3}
*/
aud?: string | string[] | undefined;
/**
* JWT ID
*
* @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.7 RFC7519#section-4.1.7}
*/
jti?: string | undefined;
/**
* JWT Not Before
*
* @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5 RFC7519#section-4.1.5}
*/
nbf?: number | undefined;
/**
* JWT Expiration Time
*
* @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.4 RFC7519#section-4.1.4}
*/
exp?: number | undefined;
/**
* JWT Issued At
*
* @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6 RFC7519#section-4.1.6}
*/
iat?: number | undefined;
/** Any other JWT Claim Set member. */
[propName: string]: unknown | undefined;
};
export async function signJWT(
ctx: GenericEndpointContext,
config: {
options?: JwtOptions | undefined;
payload: JWTPayload;
payload: JWTPayloadWithOptional;
},
) {
const { options, payload } = config;
const { options } = config;
const payload = config.payload as JWTPayload;
// Iat
const nowSeconds = Math.floor(Date.now() / 1000);
const iat = payload.iat;
const iat = payload.iat!;
// Exp
let exp = payload.exp;
@@ -29,7 +84,7 @@ export async function signJWT(
exp = exp ?? defaultExp;
// Nbf
const nbf = payload.nbf;
const nbf = payload.nbf!;
// Iss
const iss = payload.iss;
@@ -94,7 +149,7 @@ export async function getJwtToken(
) {
const payload = !options?.jwt?.definePayload
? ctx.context.session!.user
: await options?.jwt.definePayload(ctx.context.session!);
: await options.jwt.definePayload(ctx.context.session!);
return await signJWT(ctx, {
options,
+12 -12
View File
@@ -13,7 +13,7 @@ export interface JwtOptions {
* Useful if jwks are not managed at /jwks or
* if your jwks are signed with a certificate and placed on your CDN.
*/
remoteUrl?: string;
remoteUrl?: string | undefined;
/**
* Key pair configuration
* @description A subset of the options available for the generateKeyPair function
@@ -22,26 +22,26 @@ export interface JwtOptions {
*
* @default { alg: 'EdDSA', crv: 'Ed25519' }
*/
keyPairConfig?: JWKOptions;
keyPairConfig?: JWKOptions | undefined;
/**
* Disable private key encryption
* @description Disable the encryption of the private key in the database
*
* @default false
*/
disablePrivateKeyEncryption?: boolean;
disablePrivateKeyEncryption?: boolean | undefined;
/**
* The key rotation interval in seconds.
*
* @default undefined (disabled)
*/
rotationInterval?: number;
rotationInterval?: number | undefined;
/**
* The grace period in seconds.
*
* @default 2592000 (30 days)
*/
gracePeriod?: number;
gracePeriod?: number | undefined;
/**
* The path of the endpoint exposing the JWKS.
* When set, this replaces the default /jwks endpoint.
@@ -50,7 +50,7 @@ export interface JwtOptions {
* @default /jwks
* @example "/.well-known/jwks.json"
*/
jwksPath?: string;
jwksPath?: string | undefined;
}
| undefined;
@@ -59,11 +59,11 @@ export interface JwtOptions {
/**
* The issuer of the JWT
*/
issuer?: string;
issuer?: string | undefined;
/**
* The audience of the JWT
*/
audience?: string;
audience?: string | undefined;
/**
* Set the "exp" (Expiration Time) Claim.
*
@@ -87,14 +87,14 @@ export interface JwtOptions {
*
* @default 15m
*/
expirationTime?: number | string | Date;
expirationTime?: number | string | Date | undefined;
/**
* A function that is called to define the payload of the JWT
*/
definePayload?: (session: {
user: User & Record<string, any>;
session: Session & Record<string, any>;
}) => Promise<Record<string, any>> | Record<string, any>;
}) => Promise<Record<string, any>> | Record<string, any> | undefined;
/**
* A function that is called to get the subject of the JWT
*
@@ -103,7 +103,7 @@ export interface JwtOptions {
getSubject?: (session: {
user: User & Record<string, any>;
session: Session & Record<string, any>;
}) => Promise<string> | string;
}) => Promise<string> | string | undefined;
/**
* A custom function to remote sign the jwt payload.
*
@@ -114,7 +114,7 @@ export interface JwtOptions {
* @requires jwks.remoteUrl
* @invalidates other jwt.* options
*/
sign?: (payload: JWTPayload) => Awaitable<string>;
sign?: ((payload: JWTPayload) => Awaitable<string>) | undefined;
}
| undefined;
@@ -148,7 +148,9 @@ export async function authorize(
}
const requestScope =
query.scope?.split(" ").filter((s) => s) || opts.defaultScope.split(" ");
query.scope?.split(" ").filter((s) => s) ||
opts.defaultScope?.split(" ") ||
[];
const invalidScopes = requestScope.filter((scope) => {
return !opts.scopes.includes(scope);
});
@@ -191,7 +193,7 @@ export async function authorize(
}
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
const codeExpiresInMs = opts.codeExpiresIn * 1000;
const codeExpiresInMs = opts.codeExpiresIn! * 1000;
const expiresAt = new Date(Date.now() + codeExpiresInMs);
// Determine if consent is required
@@ -9,6 +9,7 @@ import {
import { getCurrentAuthContext } from "@better-auth/core/context";
import { base64 } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
import type { OpenAPIParameter } from "better-call";
import { jwtVerify, SignJWT } from "jose";
import * as z from "zod";
import { APIError, getSessionFromCtx, sessionMiddleware } from "../../api";
@@ -132,6 +133,10 @@ export const getMetadata = (
};
};
const DEFAULT_CODE_EXPIRES_IN = 600;
const DEFAULT_ACCESS_TOKEN_EXPIRES_IN = 3600;
const DEFAULT_REFRESH_TOKEN_EXPIRES_IN = 604800;
/**
* OpenID Connect (OIDC) plugin for Better Auth. This plugin implements the
* authorization code flow and the token exchange flow. It also implements the
@@ -148,10 +153,10 @@ export const oidcProvider = (options: OIDCOptions) => {
};
const opts = {
codeExpiresIn: 600,
codeExpiresIn: DEFAULT_CODE_EXPIRES_IN,
defaultScope: "openid",
accessTokenExpiresIn: 3600,
refreshTokenExpiresIn: 604800,
accessTokenExpiresIn: DEFAULT_ACCESS_TOKEN_EXPIRES_IN,
refreshTokenExpiresIn: DEFAULT_REFRESH_TOKEN_EXPIRES_IN,
allowPlainCodeChallengeMethod: true,
storeClientSecret: "plain" as const,
...options,
@@ -464,7 +469,8 @@ export const oidcProvider = (options: OIDCOptions) => {
});
}
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
const codeExpiresInMs = opts.codeExpiresIn * 1000;
const codeExpiresInMs =
(opts?.codeExpiresIn ?? DEFAULT_CODE_EXPIRES_IN) * 1000;
const expiresAt = new Date(Date.now() + codeExpiresInMs);
await ctx.context.internalAdapter.updateVerificationValue(
verification.id,
@@ -848,7 +854,8 @@ export const oidcProvider = (options: OIDCOptions) => {
...additionalUserClaims,
};
const expirationTime =
Math.floor(Date.now() / 1000) + opts.accessTokenExpiresIn;
Math.floor(Date.now() / 1000) +
(opts?.accessTokenExpiresIn ?? DEFAULT_ACCESS_TOKEN_EXPIRES_IN);
let idToken: string;
@@ -1519,7 +1526,6 @@ export const oidcProvider = (options: OIDCOptions) => {
"/oauth2/endsession",
{
method: ["GET", "POST"],
operationId: "oauth2EndSession",
query: z
.object({
id_token_hint: z.string().optional(),
@@ -1584,7 +1590,7 @@ export const oidcProvider = (options: OIDCOptions) => {
required: false,
schema: { type: "string" },
},
],
] as OpenAPIParameter[],
responses: {
"302": {
description:
@@ -77,7 +77,7 @@ export interface OIDCOptions {
| ((props: {
clientId: string;
clientName: string;
clientIcon?: string;
clientIcon?: string | undefined;
clientMetadata: Record<string, any> | null;
code: string;
scopes: string[];
-1
View File
@@ -1,4 +1,3 @@
export { generateId } from "@better-auth/core/utils";
export * from "../oauth2/state";
export * from "./hide-metadata";
export * from "./misc";
+3 -3
View File
@@ -51,13 +51,14 @@
"@babel/core": "^7.28.4",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@better-auth/core": "workspace:*",
"@better-auth/telemetry": "workspace:*",
"@better-auth/utils": "0.3.0",
"@clack/prompts": "^0.11.0",
"@mrleebo/prisma-ast": "^0.13.0",
"@prisma/client": "^5.22.0",
"@types/pg": "^8.15.5",
"better-auth": "workspace:*",
"@better-auth/core": "workspace:*",
"better-auth": "workspace:^",
"better-sqlite3": "^12.2.0",
"c12": "^3.2.0",
"chalk": "^5.6.2",
@@ -67,7 +68,6 @@
"open": "^10.2.0",
"pg": "^8.16.3",
"prettier": "^3.6.2",
"prisma": "^5.22.0",
"prompts": "^2.4.2",
"semver": "^7.7.2",
"yocto-spinner": "^0.2.3",
+12 -9
View File
@@ -1,7 +1,10 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { createTelemetry, getTelemetryAuthConfig, logger } from "better-auth";
import {
createTelemetry,
getTelemetryAuthConfig,
} from "@better-auth/telemetry";
import { getAdapter } from "better-auth/db";
import chalk from "chalk";
import { Command } from "commander";
@@ -24,7 +27,7 @@ export async function generateAction(opts: any) {
const cwd = path.resolve(options.cwd);
if (!existsSync(cwd)) {
logger.error(`The directory "${cwd}" does not exist.`);
console.error(`The directory "${cwd}" does not exist.`);
process.exit(1);
}
const config = await getConfig({
@@ -32,14 +35,14 @@ export async function generateAction(opts: any) {
configPath: options.config,
});
if (!config) {
logger.error(
console.error(
"No configuration file found. Add a `auth.ts` file to your project or pass the path to the configuration file using the `--config` flag.",
);
return;
}
const adapter = await getAdapter(config).catch((e) => {
logger.error(e.message);
console.error(e.message);
process.exit(1);
});
@@ -53,7 +56,7 @@ export async function generateAction(opts: any) {
spinner.stop();
if (!schema.code) {
logger.info("Your schema is already up to date.");
console.log("Your schema is already up to date.");
// telemetry: track generate attempted, no changes
try {
const telemetry = await createTelemetry(config);
@@ -98,7 +101,7 @@ export async function generateAction(opts: any) {
} else {
await fs.appendFile(path.join(cwd, schema.fileName), schema.code);
}
logger.success(
console.log(
`🚀 Schema was ${
schema.overwrite ? "overwritten" : "appended"
} successfully!`,
@@ -116,7 +119,7 @@ export async function generateAction(opts: any) {
} catch {}
process.exit(0);
} else {
logger.error("Schema generation aborted.");
console.error("Schema generation aborted.");
// telemetry: track generate aborted
try {
const telemetry = await createTelemetry(config);
@@ -151,7 +154,7 @@ export async function generateAction(opts: any) {
}
if (!confirm) {
logger.error("Schema generation aborted.");
console.error("Schema generation aborted.");
// telemetry: track generate aborted before write
try {
const telemetry = await createTelemetry(config);
@@ -175,7 +178,7 @@ export async function generateAction(opts: any) {
options.output || path.join(cwd, schema.fileName),
schema.code,
);
logger.success(`🚀 Schema was generated successfully!`);
console.log(`🚀 Schema was generated successfully!`);
// telemetry: track generate success
try {
const telemetry = await createTelemetry(config);
+7 -8
View File
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { cancel, confirm, intro, isCancel, outro } from "@clack/prompts";
import { logger } from "better-auth";
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import chalk from "chalk";
@@ -73,7 +72,7 @@ export async function loginAction(opts: any) {
spinner.stop();
if (error || !data) {
logger.error(
console.error(
`Failed to request device authorization: ${error?.error_description || "Unknown error"}`,
);
process.exit(1);
@@ -154,7 +153,7 @@ export async function loginAction(opts: any) {
}
} catch (err) {
spinner.stop();
logger.error(
console.error(
`Login failed: ${err instanceof Error ? err.message : "Unknown error"}`,
);
process.exit(1);
@@ -209,23 +208,23 @@ async function pollForToken(
break;
case "access_denied":
spinner.stop();
logger.error("Access was denied by the user");
console.error("Access was denied by the user");
process.exit(1);
break;
case "expired_token":
spinner.stop();
logger.error("The device code has expired. Please try again.");
console.error("The device code has expired. Please try again.");
process.exit(1);
break;
default:
spinner.stop();
logger.error(`Error: ${error.error_description}`);
console.error(`Error: ${error.error_description}`);
process.exit(1);
}
}
} catch (err) {
spinner.stop();
logger.error(
console.error(
`Network error: ${err instanceof Error ? err.message : "Unknown error"}`,
);
process.exit(1);
@@ -254,7 +253,7 @@ async function storeToken(token: any): Promise<void> {
await fs.writeFile(TOKEN_FILE, JSON.stringify(tokenData, null, 2), "utf-8");
} catch (error) {
logger.warn("Failed to store authentication token locally");
console.warn("Failed to store authentication token locally");
}
}
+16 -13
View File
@@ -1,6 +1,9 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { createTelemetry, getTelemetryAuthConfig, logger } from "better-auth";
import {
createTelemetry,
getTelemetryAuthConfig,
} from "@better-auth/telemetry";
import { getAdapter, getMigrations } from "better-auth/db";
import chalk from "chalk";
import { Command } from "commander";
@@ -21,7 +24,7 @@ export async function migrateAction(opts: any) {
const cwd = path.resolve(options.cwd);
if (!existsSync(cwd)) {
logger.error(`The directory "${cwd}" does not exist.`);
console.error(`The directory "${cwd}" does not exist.`);
process.exit(1);
}
@@ -30,7 +33,7 @@ export async function migrateAction(opts: any) {
configPath: options.config,
});
if (!config) {
logger.error(
console.error(
"No configuration file found. Add a `auth.ts` file to your project or pass the path to the configuration file using the `--config` flag.",
);
return;
@@ -39,7 +42,7 @@ export async function migrateAction(opts: any) {
const db = await getAdapter(config);
if (!db) {
logger.error(
console.error(
"Invalid database configuration. Make sure you're not using adapters. Migrate command only works with built-in Kysely adapter.",
);
process.exit(1);
@@ -47,8 +50,8 @@ export async function migrateAction(opts: any) {
if (db.id !== "kysely") {
if (db.id === "prisma") {
logger.error(
"The migrate command only works with the built-in Kysely adapter. For Prisma, run `npx @better-auth/cli generate` to create the schema, then use Prisma’s migrate or push to apply it.",
console.error(
"The migrate command only works with the built-in Kysely adapter. For Prisma, run `npx @better-auth/cli generate` to create the schema, then use Prisma's migrate or push to apply it.",
);
try {
const telemetry = await createTelemetry(config);
@@ -64,8 +67,8 @@ export async function migrateAction(opts: any) {
process.exit(0);
}
if (db.id === "drizzle") {
logger.error(
"The migrate command only works with the built-in Kysely adapter. For Drizzle, run `npx @better-auth/cli generate` to create the schema, then use Drizzle’s migrate or push to apply it.",
console.error(
"The migrate command only works with the built-in Kysely adapter. For Drizzle, run `npx @better-auth/cli generate` to create the schema, then use Drizzle's migrate or push to apply it.",
);
try {
const telemetry = await createTelemetry(config);
@@ -80,7 +83,7 @@ export async function migrateAction(opts: any) {
} catch {}
process.exit(0);
}
logger.error("Migrate command isn't supported for this adapter.");
console.error("Migrate command isn't supported for this adapter.");
try {
const telemetry = await createTelemetry(config);
await telemetry.publish({
@@ -101,7 +104,7 @@ export async function migrateAction(opts: any) {
if (!toBeAdded.length && !toBeCreated.length) {
spinner.stop();
logger.info("🚀 No migrations needed.");
console.log("🚀 No migrations needed.");
try {
const telemetry = await createTelemetry(config);
await telemetry.publish({
@@ -116,7 +119,7 @@ export async function migrateAction(opts: any) {
}
spinner.stop();
logger.info(`🔑 The migration will affect the following:`);
console.log(`🔑 The migration will affect the following:`);
for (const table of [...toBeCreated, ...toBeAdded]) {
console.log(
@@ -145,7 +148,7 @@ export async function migrateAction(opts: any) {
}
if (!migrate) {
logger.info("Migration cancelled.");
console.log("Migration cancelled.");
try {
const telemetry = await createTelemetry(config);
await telemetry.publish({
@@ -159,7 +162,7 @@ export async function migrateAction(opts: any) {
spinner?.start("migrating...");
await runMigrations();
spinner.stop();
logger.info("🚀 migration was completed successfully!");
console.log("🚀 migration was completed successfully!");
try {
const telemetry = await createTelemetry(config);
await telemetry.publish({
+1 -2
View File
@@ -1,11 +1,10 @@
import Crypto from "node:crypto";
import { logger } from "better-auth";
import chalk from "chalk";
import { Command } from "commander";
export const generateSecret = new Command("secret").action(() => {
const secret = generateSecretHash();
logger.info(`\nAdd the following to your .env file:
console.log(`\nAdd the following to your .env file:
${
chalk.gray("# Auth Secret") + chalk.green(`\nBETTER_AUTH_SECRET=${secret}`)
}`);
+2 -3
View File
@@ -1,5 +1,4 @@
import type { spinner as clackSpinner } from "@clack/prompts";
import { logger } from "better-auth";
import type { SupportedDatabases, SupportedPlugin } from "../commands/init";
export type Import = {
@@ -545,7 +544,7 @@ export async function generateAuthConfig({
`Something went wrong while generating/updating your new auth config file.`,
1,
);
logger.error(error.message);
console.error(error.message);
process.exit(1);
}
}
@@ -566,7 +565,7 @@ export async function generateAuthConfig({
`Something went wrong while generating/updating your new auth config file.`,
1,
);
logger.error(error.message);
console.error(error.message);
process.exit(1);
}
}
+2 -3
View File
@@ -1,6 +1,5 @@
import type { BetterAuthOptions } from "@better-auth/core";
import type { DBAdapter } from "@better-auth/core/db/adapter";
import type { BetterAuthOptions } from "better-auth";
import { logger } from "better-auth";
import { generateDrizzleSchema } from "./drizzle";
import { generateMigrations } from "./kysely";
import { generatePrismaSchema } from "./prisma";
@@ -36,7 +35,7 @@ export const generateSchema = (opts: {
}));
}
logger.error(
console.error(
`${adapter.id} is not supported. If it is a custom adapter, please request the maintainer to implement createSchema`,
);
process.exit(1);
+1 -1
View File
@@ -1,8 +1,8 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { capitalizeFirstLetter } from "@better-auth/core/utils";
import { produceSchema } from "@mrleebo/prisma-ast";
import { capitalizeFirstLetter } from "better-auth";
import { initGetFieldName, initGetModelName } from "better-auth/adapters";
import type { FieldType } from "better-auth/db";
import { getAuthTables } from "better-auth/db";
+2 -1
View File
@@ -1,4 +1,5 @@
import type { BetterAuthOptions, DBAdapter } from "better-auth";
import type { BetterAuthOptions } from "@better-auth/core";
import type { DBAdapter } from "@better-auth/core/db/adapter";
export interface SchemaGenerator {
<Options extends BetterAuthOptions>(opts: {
+11 -11
View File
@@ -4,8 +4,8 @@ import path from "node:path";
import babelPresetReact from "@babel/preset-react";
// @ts-expect-error
import babelPresetTypeScript from "@babel/preset-typescript";
import type { BetterAuthOptions } from "better-auth";
import { BetterAuthError, logger } from "better-auth";
import type { BetterAuthOptions } from "@better-auth/core";
import { BetterAuthError } from "@better-auth/core/error";
import { loadConfig } from "c12";
import type { JitiOptions } from "jiti";
import { addCloudflareModules } from "./add-cloudflare-modules";
@@ -76,7 +76,7 @@ function getPathAliasesRecursive(
visited.add(tsconfigPath);
if (!fs.existsSync(tsconfigPath)) {
logger.warn(`Referenced tsconfig not found: ${tsconfigPath}`);
console.warn(`Referenced tsconfig not found: ${tsconfigPath}`);
return {};
}
@@ -114,7 +114,7 @@ function getPathAliasesRecursive(
return result;
} catch (error) {
logger.warn(`Error parsing tsconfig at ${tsconfigPath}: ${error}`);
console.warn(`Error parsing tsconfig at ${tsconfigPath}: ${error}`);
return {};
}
}
@@ -204,7 +204,7 @@ export async function getConfig({
`Couldn't read your auth config in ${resolvedPath}. Make sure to default export your auth instance or to export as a variable named auth.`,
);
}
logger.error(
console.error(
`[#better-auth]: Couldn't read your auth config in ${resolvedPath}. Make sure to default export your auth instance or to export as a variable named auth.`,
);
process.exit(1);
@@ -236,9 +236,9 @@ export async function getConfig({
"Couldn't read your auth config. Make sure to default export your auth instance or to export as a variable named auth.",
);
}
logger.error("[#better-auth]: Couldn't read your auth config.");
console.error("[#better-auth]: Couldn't read your auth config.");
console.log("");
logger.info(
console.log(
"[#better-auth]: Make sure to default export your auth instance or to export as a variable named auth.",
);
process.exit(1);
@@ -260,7 +260,7 @@ export async function getConfig({
`Please remove import 'server-only' from your auth config file temporarily. The CLI cannot resolve the configuration with it included. You can re-add it after running the CLI.`,
);
}
logger.error(
console.error(
`Please remove import 'server-only' from your auth config file temporarily. The CLI cannot resolve the configuration with it included. You can re-add it after running the CLI.`,
);
process.exit(1);
@@ -268,7 +268,7 @@ export async function getConfig({
if (shouldThrowOnError) {
throw e;
}
logger.error("[#better-auth]: Couldn't read your auth config.", e);
console.error("[#better-auth]: Couldn't read your auth config.", e);
process.exit(1);
}
}
@@ -289,7 +289,7 @@ export async function getConfig({
`Please remove import 'server-only' from your auth config file temporarily. The CLI cannot resolve the configuration with it included. You can re-add it after running the CLI.`,
);
}
logger.error(
console.error(
`Please remove import 'server-only' from your auth config file temporarily. The CLI cannot resolve the configuration with it included. You can re-add it after running the CLI.`,
);
process.exit(1);
@@ -298,7 +298,7 @@ export async function getConfig({
throw e;
}
logger.error("Couldn't read your auth config.", e);
console.error("Couldn't read your auth config.", e);
process.exit(1);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { BetterAuthOptions } from "@better-auth/core";
import { passkey } from "@better-auth/passkey";
import type { BetterAuthOptions } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { twoFactor, username } from "better-auth/plugins";
import { describe, expect, it } from "vitest";
+1 -1
View File
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { BetterAuthOptions } from "better-auth";
import type { BetterAuthOptions } from "@better-auth/core";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { organization, twoFactor, username } from "better-auth/plugins";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { BetterAuthPlugin } from "better-auth";
import type { BetterAuthPlugin } from "@better-auth/core";
import { betterAuth } from "better-auth";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+31 -22
View File
@@ -6,30 +6,39 @@ import type { AsyncLocalStorage } from "node:async_hooks";
// We only export the type here to avoid issues in environments where AsyncLocalStorage is not available.
export type { AsyncLocalStorage };
const AsyncLocalStoragePromise: Promise<typeof AsyncLocalStorage> = import(
/* @vite-ignore */
/* webpackIgnore: true */
"node:async_hooks"
)
.then((mod) => mod.AsyncLocalStorage)
.catch((err) => {
if ("AsyncLocalStorage" in globalThis) {
return (globalThis as any).AsyncLocalStorage;
}
console.warn(
"[better-auth] Warning: AsyncLocalStorage is not available in this environment. Some features may not work as expected.",
);
console.warn(
"[better-auth] Please read more about this warning at https://better-auth.com/docs/installation#mount-handler",
);
console.warn(
"[better-auth] If you are using Cloudflare Workers, please see: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag",
);
throw err;
});
const AsyncLocalStoragePromise: Promise<typeof AsyncLocalStorage | null> =
import(
/* @vite-ignore */
/* webpackIgnore: true */
"node:async_hooks"
)
.then((mod) => mod.AsyncLocalStorage)
.catch((err) => {
if ("AsyncLocalStorage" in globalThis) {
return (globalThis as any).AsyncLocalStorage;
}
if (typeof window !== "undefined") {
return null;
}
console.warn(
"[better-auth] Warning: AsyncLocalStorage is not available in this environment. Some features may not work as expected.",
);
console.warn(
"[better-auth] Please read more about this warning at https://better-auth.com/docs/installation#mount-handler",
);
console.warn(
"[better-auth] If you are using Cloudflare Workers, please see: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag",
);
throw err;
});
export async function getAsyncLocalStorage(): Promise<
typeof AsyncLocalStorage
> {
return AsyncLocalStoragePromise;
const mod = await AsyncLocalStoragePromise;
if (mod === null) {
throw new Error("getAsyncLocalStorage is only available in server code");
} else {
return mod;
}
}
+1
View File
@@ -1,3 +1,4 @@
export { defineErrorCodes } from "./error-codes";
export { generateId } from "./id";
export { safeJSONParse } from "./json";
export { capitalizeFirstLetter } from "./string";
-85
View File
File diff suppressed because one or more lines are too long
+1602 -2153
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -16,10 +16,7 @@ catalogs:
react19:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
react: ^19.2.0
react-dom: ^19.2.0
react: ^19.2.1
react-dom: ^19.2.1
neverBuiltDependencies: []
patchedDependencies:
msw: patches/msw.patch
+1 -1
View File
@@ -10,7 +10,7 @@
"@better-auth/core": "workspace:*",
"@better-fetch/fetch": "catalog:",
"better-auth": "workspace:*",
"msw": "^2.12.1",
"msw": "^2.12.4",
"openid-client": "^6.8.1",
"vitest": "catalog:"
}