Merge branch 'canary' into v1.3.9-staging

# Conflicts:
#	packages/better-auth/package.json
#	packages/cli/package.json
#	packages/expo/package.json
#	packages/sso/package.json
#	packages/stripe/package.json
This commit is contained in:
Alex Yang
2025-09-08 12:11:13 -07:00
57 changed files with 1151 additions and 133 deletions

View File

@@ -46,9 +46,9 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: System info label: System info
description: Output of `npx envinfo --system --browsers` description: Output of `npx @better-auth/cli info --json`
render: bash render: bash
placeholder: System and browsers placeholder: System and Better Auth info
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

36
.github/workflows/branch-rules.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Branch Rules
on:
pull_request:
branches:
- main
- canary
- 'v*-staging'
jobs:
check-branch:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Filter changed files
uses: dorny/paths-filter@v3
id: filter
with:
base: ${{ github.base_ref }}
filters: |
# 'src' filter matches any file NOT in the docs/ directory
src:
- '!docs/**'
# 'docs' filter matches any file in the docs/ directory
docs:
- 'docs/**'
- name: Enforce code change PRs target canary or staging
if: steps.filter.outputs.src == 'true' && github.base_ref != 'canary' && !startsWith(github.base_ref, 'v')
run: |
echo "Error: Pull requests with code changes must target 'canary' or a versioned staging branch (e.g., 'v1.2.3-staging')."
exit 1

View File

@@ -53,7 +53,9 @@ export default function Layout({ children }: { children: ReactNode }) {
}} }}
search={{ search={{
enabled: true, enabled: true,
SearchDialog: CustomSearchDialog, SearchDialog: process.env.ORAMA_PRIVATE_API_KEY
? CustomSearchDialog
: undefined,
}} }}
> >
<NavbarProvider> <NavbarProvider>

View File

@@ -44,6 +44,27 @@ export const Icons = {
></path> ></path>
</svg> </svg>
), ),
lynx: (props?: SVGProps<any>) => (
<svg
className={props?.className}
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 20 20"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.19683 4.42475L2.58326 6.22507C2.29923 6.42072 2.10258 6.71472 2.0335 7.04698L1.77798 8.27604C1.76443 8.34121 1.73438 8.40204 1.69058 8.45301L0.504615 10.0077C0.344941 10.1935 0.348791 10.6871 0.786137 11.0031C0.953848 11.1493 1.17807 11.477 1.43312 11.8497L1.43312 11.8497C1.97313 12.639 2.65131 13.6302 3.22376 13.5272C4.03382 13.2426 5.02541 13.1516 5.78673 13.5272C7.2667 14.8049 6.90331 15.983 6.38234 17.672C6.17271 18.3516 5.93755 19.114 5.78673 19.9988C6.49755 17.4117 8.09592 14.4069 10.9781 13.3874C10.4588 12.9632 9.39906 12.5691 8.46129 12.4742C8.46129 12.4742 11.3423 10.0077 14.8957 8.87434C12.4151 2.97197 8.32052 0.151295 8.32052 0.151295C8.11187 -0.10677 7.69054 -0.0221092 7.6036 0.295351C7.53376 1.22845 7.41798 1.86295 7.22685 2.46105L5.78673 0.799291C5.6363 0.61794 5.33557 0.722655 5.33707 0.955861C5.5809 2.31136 5.54668 3.07222 5.19683 4.42475ZM6.21052 4.30085L6.21912 4.3003C6.23794 4.29909 6.25646 4.29664 6.27456 4.29302L6.21052 4.30085ZM8.15541 1.25793C9.1423 2.96321 9.58937 3.932 9.74102 5.73998C8.6912 5.14364 8.23382 4.99187 7.46183 4.99565C7.91215 3.62621 8.04976 2.8016 8.15541 1.25793Z"
fill="currentColor"
/>
<path
d="M14.4988 13.9228C10.479 14.8427 8.19556 16.2278 6.44922 19.9994C9.58406 14.7399 19.0737 15.6805 19.0737 15.6805C18.8964 14.8214 17.0097 13.183 15.7688 12.1782C15.7688 12.1782 16.7699 10.9474 18.983 10.3244C18.983 10.3244 14.728 10.5709 12.2508 11.9084C13.0533 12.3319 14.0812 13.0467 14.4988 13.9228Z"
fill="currentColor"
/>
</svg>
),
solidStart: (props?: SVGProps<any>) => ( solidStart: (props?: SVGProps<any>) => (
<svg <svg
className={props?.className} className={props?.className}

View File

@@ -1402,6 +1402,11 @@ 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,
icon: Icons.expo, icon: Icons.expo,
href: "/docs/integrations/expo", href: "/docs/integrations/expo",
}, },
{
title: "Lynx",
icon: Icons.lynx,
href: "/docs/integrations/lynx",
},
], ],
}, },
{ {

View File

@@ -109,6 +109,21 @@ export const auth = betterAuth({
}) })
``` ```
If you like to disable rate limiting for a specific path, you can set it to `false` or return `false` from the custom rule function.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
//...other options
rateLimit: {
customRules: {
"/get-session": false,
},
},
})
```
### Storage ### Storage
By default, rate limit data is stored in memory, which may not be suitable for many use cases, particularly in serverless environments. To address this, you can use a database, secondary storage, or custom storage for storing rate limit data. By default, rate limit data is stored in memory, which may not be suitable for many use cases, particularly in serverless environments. To address this, you can use a database, secondary storage, or custom storage for storing rate limit data.

View File

@@ -294,7 +294,7 @@ Create a new file or route in your framework's designated catch-all route handle
const app = new Hono(); const app = new Hono();
app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw)); app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
serve(app); serve(app);
``` ```

View File

@@ -0,0 +1,212 @@
---
title: Lynx Integration
description: Integrate Better Auth with Lynx cross-platform framework.
---
This integration guide is for using Better Auth with [Lynx](https://lynxjs.org), a cross-platform rendering framework that enables developers to build applications for Android, iOS, and Web platforms with native rendering performance.
Before you start, make sure you have a Better Auth instance configured. If you haven't done that yet, check out the [installation](/docs/installation).
## Installation
Install Better Auth and the Lynx React dependency:
```package-install
better-auth @lynx-js/react
```
## Create Client Instance
Import `createAuthClient` from `better-auth/lynx` to create your client instance:
```ts title="lib/auth-client.ts"
import { createAuthClient } from "better-auth/lynx"
export const authClient = createAuthClient({
baseURL: "http://localhost:3000" // The base URL of your auth server
})
```
## Usage
The Lynx client provides the same API as other Better Auth clients, with optimized integration for Lynx's reactive system.
### Authentication Methods
```ts
import { authClient } from "./lib/auth-client"
// Sign in with email and password
await authClient.signIn.email({
email: "test@user.com",
password: "password1234"
})
// Sign up
await authClient.signUp.email({
email: "test@user.com",
password: "password1234",
name: "John Doe"
})
// Sign out
await authClient.signOut()
```
### Hooks
The Lynx client includes reactive hooks that integrate seamlessly with Lynx's component system:
#### useSession
```tsx title="components/user.tsx"
import { authClient } from "../lib/auth-client"
export function User() {
const {
data: session,
isPending, // loading state
error // error object
} = authClient.useSession()
if (isPending) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
{session ? (
<div>
<p>Welcome, {session.user.name}!</p>
<button onClick={() => authClient.signOut()}>
Sign Out
</button>
</div>
) : (
<button onClick={() => authClient.signIn.social({
provider: 'github'
})}>
Sign In with GitHub
</button>
)}
</div>
)
}
```
### Store Integration
The Lynx client uses [nanostores](https://github.com/nanostores/nanostores) for state management and provides a `useStore` hook for accessing reactive state:
```tsx title="components/session-info.tsx"
import { useStore } from "better-auth/lynx"
import { authClient } from "../lib/auth-client"
export function SessionInfo() {
// Access the session store directly
const session = useStore(authClient.$store.session)
return (
<div>
{session && (
<pre>{JSON.stringify(session, null, 2)}</pre>
)}
</div>
)
}
```
### Advanced Store Usage
You can use the store with selective key watching for optimized re-renders:
```tsx title="components/optimized-user.tsx"
import { useStore } from "better-auth/lynx"
import { authClient } from "../lib/auth-client"
export function OptimizedUser() {
// Only re-render when specific keys change
const session = useStore(authClient.$store.session, {
keys: ['user.name', 'user.email'] // Only watch these specific keys
})
return (
<div>
{session?.user && (
<div>
<h2>{session.user.name}</h2>
<p>{session.user.email}</p>
</div>
)}
</div>
)
}
```
## Plugin Support
The Lynx client supports all Better Auth plugins:
```ts title="lib/auth-client.ts"
import { createAuthClient } from "better-auth/lynx"
import { magicLinkClient } from "better-auth/client/plugins"
const authClient = createAuthClient({
plugins: [
magicLinkClient()
]
})
// Use plugin methods
await authClient.signIn.magicLink({
email: "test@email.com"
})
```
## Error Handling
Error handling works the same as other Better Auth clients:
```tsx title="components/login-form.tsx"
import { authClient } from "../lib/auth-client"
export function LoginForm() {
const signIn = async (email: string, password: string) => {
const { data, error } = await authClient.signIn.email({
email,
password
})
if (error) {
console.error('Login failed:', error.message)
return
}
console.log('Login successful:', data)
}
return (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target)
signIn(formData.get('email'), formData.get('password'))
}}>
<input name="email" type="email" placeholder="Email" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Sign In</button>
</form>
)
}
```
## Features
The Lynx client provides:
- **Cross-Platform Support**: Works across Android, iOS, and Web platforms
- **Optimized Performance**: Built specifically for Lynx's reactive system
- **Nanostores Integration**: Uses nanostores for efficient state management
- **Selective Re-rendering**: Watch specific store keys to minimize unnecessary updates
- **Full API Compatibility**: All Better Auth methods and plugins work seamlessly
- **TypeScript Support**: Full type safety with TypeScript inference
The Lynx integration maintains all the features and benefits of Better Auth while providing optimal performance and developer experience within Lynx's cross-platform ecosystem.

View File

@@ -57,7 +57,7 @@ The **MCP** plugin lets your app act as an OAuth provider for MCP clients. It ha
### OAuth Discovery Metadata ### OAuth Discovery Metadata
Add a route to expose OAuth metadata for MCP clients: Better Auth already handles the `/api/auth/.well-known/oauth-authorization-server` route automatically but some client may fail to parse the `WWW-Authenticate` header and default to `/.well-known/oauth-authorization-server` (this can happen, for example, if your CORS configuration doesn't expose the `WWW-Authenticate`). For this reason it's better to add a route to expose OAuth metadata for MCP clients:
```ts title=".well-known/oauth-authorization-server/route.ts" ```ts title=".well-known/oauth-authorization-server/route.ts"
import { oAuthDiscoveryMetadata } from "better-auth/plugins"; import { oAuthDiscoveryMetadata } from "better-auth/plugins";
@@ -68,7 +68,7 @@ export const GET = oAuthDiscoveryMetadata(auth);
### OAuth Protected Resource Metadata ### OAuth Protected Resource Metadata
Add a route to expose protected resource metadata for MCP clients: Better Auth already handles the `/api/auth/.well-known/oauth-protected-resource` route automatically but some client may fail to parse the `WWW-Authenticate` header and default to `/.well-known/oauth-protected-resource` (this can happen, for example, if your CORS configuration doesn't expose the `WWW-Authenticate`). For this reason it's better to add a route to expose OAuth metadata for MCP clients:
```ts title="/.well-known/oauth-protected-resource/route.ts" ```ts title="/.well-known/oauth-protected-resource/route.ts"
import { oAuthProtectedResourceMetadata } from "better-auth/plugins"; import { oAuthProtectedResourceMetadata } from "better-auth/plugins";
@@ -187,6 +187,11 @@ The MCP plugin accepts the following configuration options:
type: "string", type: "string",
required: true required: true
}, },
resource: {
description: "The resource that should be returned by the protected resource metadata endpoint",
type: "string",
required: false
},
oidcConfig: { oidcConfig: {
description: "Optional OIDC configuration options", description: "Optional OIDC configuration options",
type: "object", type: "object",

View File

@@ -2,6 +2,7 @@ import { describe, it } from "node:test";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { join } from "node:path"; import { join } from "node:path";
import assert from "node:assert/strict";
const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url)); const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
@@ -16,12 +17,26 @@ describe("(cloudflare) simple server", () => {
cp.kill("SIGINT"); cp.kill("SIGINT");
}); });
const unexpectedStrings = new Set(["node:sqlite"]);
cp.stdout.on("data", (data) => { cp.stdout.on("data", (data) => {
console.log(data.toString()); console.log(data.toString());
for (const str of unexpectedStrings) {
assert(
!data.toString().includes(str),
`Output should not contain "${str}"`,
);
}
}); });
cp.stderr.on("data", (data) => { cp.stderr.on("data", (data) => {
console.error(data.toString()); console.error(data.toString());
for (const str of unexpectedStrings) {
assert(
!data.toString().includes(str),
`Error output should not contain "${str}"`,
);
}
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {

View File

@@ -1,6 +1,6 @@
{ {
"name": "better-auth", "name": "better-auth",
"version": "1.3.8", "version": "1.3.9-beta.4",
"description": "The most comprehensive authentication library for TypeScript.", "description": "The most comprehensive authentication library for TypeScript.",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -700,22 +700,26 @@
"dependencies": { "dependencies": {
"@better-auth/utils": "0.2.6", "@better-auth/utils": "0.2.6",
"@better-fetch/fetch": "catalog:", "@better-fetch/fetch": "catalog:",
"@noble/ciphers": "^0.6.0", "@noble/ciphers": "^2.0.0",
"@noble/hashes": "^1.8.0", "@noble/hashes": "^2.0.0",
"@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/browser": "^13.1.2",
"@simplewebauthn/server": "^13.1.2", "@simplewebauthn/server": "^13.1.2",
"better-call": "catalog:", "better-call": "catalog:",
"defu": "^6.1.4", "defu": "^6.1.4",
"jose": "^5.10.0", "jose": "^6.1.0",
"kysely": "^0.28.5", "kysely": "^0.28.5",
"nanostores": "^0.11.4", "nanostores": "^0.11.4",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
"peerDependencies": { "peerDependencies": {
"@lynx-js/react": "*",
"react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0" "react-dom": "^18.0.0 || ^19.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@lynx-js/react": {
"optional": true
},
"react": { "react": {
"optional": true "optional": true
}, },
@@ -724,6 +728,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@lynx-js/react": "^0.112.5",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@tanstack/react-start": "^1.131.3", "@tanstack/react-start": "^1.131.3",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",

View File

@@ -7,6 +7,7 @@ import {
gt, gt,
gte, gte,
inArray, inArray,
notInArray,
like, like,
lt, lt,
lte, lte,
@@ -163,6 +164,15 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) =>
return [inArray(schemaModel[field], w.value)]; return [inArray(schemaModel[field], w.value)];
} }
if (w.operator === "not_in") {
if (!Array.isArray(w.value)) {
throw new BetterAuthError(
`The value for the field "${w.field}" must be an array when using the "not_in" operator.`,
);
}
return [notInArray(schemaModel[field], w.value)];
}
if (w.operator === "contains") { if (w.operator === "contains") {
return [like(schemaModel[field], `%${w.value}%`)]; return [like(schemaModel[field], `%${w.value}%`)];
} }
@@ -213,6 +223,14 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) =>
} }
return inArray(schemaModel[field], w.value); return inArray(schemaModel[field], w.value);
} }
if (w.operator === "not_in") {
if (!Array.isArray(w.value)) {
throw new BetterAuthError(
`The value for the field "${w.field}" must be an array when using the "not_in" operator.`,
);
}
return notInArray(schemaModel[field], w.value);
}
return eq(schemaModel[field], w.value); return eq(schemaModel[field], w.value);
}), }),
); );

View File

@@ -110,7 +110,14 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => {
let DatabaseSync: typeof import("node:sqlite").DatabaseSync | undefined = let DatabaseSync: typeof import("node:sqlite").DatabaseSync | undefined =
undefined; undefined;
try { try {
({ DatabaseSync } = await import("node:sqlite")); let nodeSqlite: string = "node:sqlite";
// Ignore both Vite and Webpack for dynamic import as they both try to pre-bundle 'node:sqlite' which might fail
// It's okay because we are in a try-catch block
({ DatabaseSync } = await import(
/* @vite-ignore */
/* webpackIgnore: true */
nodeSqlite
));
} catch (error: unknown) { } catch (error: unknown) {
if ( if (
error !== null && error !== null &&

View File

@@ -136,6 +136,14 @@ export const kyselyAdapter = (db: Kysely<any>, config?: KyselyAdapterConfig) =>
return eb(field, "in", Array.isArray(value) ? value : [value]); return eb(field, "in", Array.isArray(value) ? value : [value]);
} }
if (operator.toLowerCase() === "not_in") {
return eb(
field,
"not in",
Array.isArray(value) ? value : [value],
);
}
if (operator === "contains") { if (operator === "contains") {
return eb(field, "like", `%${value}%`); return eb(field, "like", `%${value}%`);
} }

View File

@@ -51,6 +51,12 @@ export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) =>
} }
// @ts-expect-error // @ts-expect-error
return value.includes(record[field]); return value.includes(record[field]);
} else if (operator === "not_in") {
if (!Array.isArray(value)) {
throw new Error("Value must be an array");
}
// @ts-expect-error
return !value.includes(record[field]);
} else if (operator === "contains") { } else if (operator === "contains") {
return record[field].includes(value); return record[field].includes(value);
} else if (operator === "starts_with") { } else if (operator === "starts_with") {

View File

@@ -177,6 +177,15 @@ export const mongodbAdapter = (db: Db, config?: MongoDBAdapterConfig) => {
}, },
}; };
break; break;
case "not_in":
condition = {
[field]: {
$nin: Array.isArray(value)
? value.map((v) => serializeID({ field, value: v, model }))
: [serializeID({ field, value, model })],
},
};
break;
case "gt": case "gt":
condition = { [field]: { $gt: value } }; condition = { [field]: { $gt: value } };
break; break;

View File

@@ -70,6 +70,8 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) =>
return "endsWith"; return "endsWith";
case "ne": case "ne":
return "not"; return "not";
case "not_in":
return "notIn";
default: default:
return operator; return operator;
} }

View File

@@ -31,6 +31,8 @@ const adapterTests = {
SHOULD_FIND_MANY_WITH_WHERE: "should find many with where", SHOULD_FIND_MANY_WITH_WHERE: "should find many with where",
SHOULD_FIND_MANY_WITH_OPERATORS: "should find many with operators", SHOULD_FIND_MANY_WITH_OPERATORS: "should find many with operators",
SHOULD_WORK_WITH_REFERENCE_FIELDS: "should work with reference fields", SHOULD_WORK_WITH_REFERENCE_FIELDS: "should work with reference fields",
SHOULD_FIND_MANY_WITH_NOT_IN_OPERATOR:
"should find many with not in operator",
SHOULD_FIND_MANY_WITH_SORT_BY: "should find many with sortBy", SHOULD_FIND_MANY_WITH_SORT_BY: "should find many with sortBy",
SHOULD_FIND_MANY_WITH_LIMIT: "should find many with limit", SHOULD_FIND_MANY_WITH_LIMIT: "should find many with limit",
SHOULD_FIND_MANY_WITH_OFFSET: "should find many with offset", SHOULD_FIND_MANY_WITH_OFFSET: "should find many with offset",
@@ -369,6 +371,54 @@ async function adapterTest(
}, },
); );
test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_NOT_IN_OPERATOR)(
`${testPrefix ? `${testPrefix} - ` : ""}${
adapterTests.SHOULD_FIND_MANY_WITH_NOT_IN_OPERATOR
}`,
async ({ onTestFailed }) => {
await resetDebugLogs();
onTestFailed(async () => {
await printDebugLogs();
});
const newUser3 = await (await adapter()).create<User>({
model: "user",
data: {
name: "user",
email: "test-email3email.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
},
});
const allUsers = await (await adapter()).findMany<User>({
model: "user",
});
expect(allUsers.length).toBe(6);
const usersWithoutNotIn = await (await adapter()).findMany<User>({
model: "user",
where: [
{
field: "id",
operator: "not_in",
value: [user.id, newUser3.id],
},
],
});
expect(usersWithoutNotIn.length).toBe(4);
//cleanup
await (await adapter()).delete({
model: "user",
where: [
{
field: "id",
value: newUser3.id,
},
],
});
},
);
test.skipIf(disabledTests?.SHOULD_WORK_WITH_REFERENCE_FIELDS)( test.skipIf(disabledTests?.SHOULD_WORK_WITH_REFERENCE_FIELDS)(
`${testPrefix ? `${testPrefix} - ` : ""}${ `${testPrefix ? `${testPrefix} - ` : ""}${
adapterTests.SHOULD_WORK_WITH_REFERENCE_FIELDS adapterTests.SHOULD_WORK_WITH_REFERENCE_FIELDS

View File

@@ -3,7 +3,6 @@ import type { AuthContext } from "../init";
import type { BetterAuthOptions } from "../types"; import type { BetterAuthOptions } from "../types";
import type { UnionToIntersection } from "../types/helper"; import type { UnionToIntersection } from "../types/helper";
import { originCheckMiddleware } from "./middlewares/origin-check"; import { originCheckMiddleware } from "./middlewares/origin-check";
import { BetterAuthError } from "../error";
import { import {
callbackOAuth, callbackOAuth,
forgetPassword, forgetPassword,

View File

@@ -162,6 +162,10 @@ export async function onRequestRateLimit(req: Request, ctx: AuthContext) {
window = resolved.window; window = resolved.window;
max = resolved.max; max = resolved.max;
} }
if (resolved === false) {
return;
}
} }
} }

View File

@@ -155,6 +155,7 @@ describe("should work with custom rules", async () => {
window: 10, window: 10,
max: 3, max: 3,
}, },
"/get-session": false,
}, },
}, },
}); });
@@ -196,4 +197,14 @@ describe("should work with custom rules", async () => {
} }
} }
}); });
it("should not rate limit if custom rule is false", async () => {
let i = 0;
let response = null;
for (; i < 110; i++) {
response = await client.getSession().then((res) => res.error);
}
expect(response).toBeNull();
expect(i).toBe(110);
});
}); });

View File

@@ -132,6 +132,13 @@ export const callbackOAuth = createAuthEndpoint(
return redirectOnError("unable_to_link_account"); return redirectOnError("unable_to_link_account");
} }
if (
userInfo.email !== link.email &&
c.context.options.account?.accountLinking?.allowDifferentEmails !== true
) {
return redirectOnError("email_doesn't_match");
}
const existingAccount = await c.context.internalAdapter.findAccount( const existingAccount = await c.context.internalAdapter.findAccount(
String(userInfo.id), String(userInfo.id),
); );

View File

@@ -1,6 +1,8 @@
import { describe, expect } from "vitest"; import { describe, expect, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance"; import { getTestInstance } from "../../test-utils/test-instance";
import { parseSetCookieHeader } from "../../cookies"; import { parseSetCookieHeader } from "../../cookies";
import { APIError } from "better-call";
import { BASE_ERROR_CODES } from "../../error/codes";
/** /**
* More test can be found in `session.test.ts` * More test can be found in `session.test.ts`
@@ -44,4 +46,66 @@ describe("sign-in", async (it) => {
expect(session?.session.ipAddress).toBe(headerObj["X-Forwarded-For"]); expect(session?.session.ipAddress).toBe(headerObj["X-Forwarded-For"]);
expect(session?.session.userAgent).toBe(headerObj["User-Agent"]); expect(session?.session.userAgent).toBe(headerObj["User-Agent"]);
}); });
it("verification email will be sent if sendOnSignIn is enabled", async () => {
const sendVerificationEmail = vi.fn();
const { auth, testUser } = await getTestInstance({
emailVerification: {
sendOnSignIn: true,
sendVerificationEmail,
},
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
});
expect(sendVerificationEmail).toHaveBeenCalledTimes(1);
await expect(
auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
}),
).rejects.toThrowError(
new APIError("FORBIDDEN", {
message: BASE_ERROR_CODES.EMAIL_NOT_VERIFIED,
}),
);
expect(sendVerificationEmail).toHaveBeenCalledTimes(2);
});
it("verification email will not be sent if sendOnSignIn is disabled", async () => {
const sendVerificationEmail = vi.fn();
const { auth, testUser } = await getTestInstance({
emailVerification: {
sendOnSignIn: false,
sendVerificationEmail,
},
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
});
expect(sendVerificationEmail).toHaveBeenCalledTimes(1);
await expect(
auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
}),
).rejects.toThrowError(
new APIError("FORBIDDEN", {
message: BASE_ERROR_CODES.EMAIL_NOT_VERIFIED,
}),
);
expect(sendVerificationEmail).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -134,6 +134,22 @@ export const signUpEmail = <O extends BetterAuthOptions>() =>
}, },
}, },
}, },
"422": {
description:
"Unprocessable Entity. User already exists or failed to create user.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: {
type: "string",
},
},
},
},
},
},
}, },
}, },
}, },

View File

@@ -689,6 +689,21 @@ export const changeEmail = createAuthEndpoint(
}, },
}, },
}, },
"422": {
description: "Unprocessable Entity. Email already exists",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: {
type: "string",
},
},
},
},
},
},
}, },
}, },
}, },

View File

@@ -0,0 +1,106 @@
import { getClientConfig } from "../config";
import type {
BetterAuthClientPlugin,
ClientOptions,
InferActions,
InferClientAPI,
InferErrorCodes,
IsSignal,
} from "../types";
import { createDynamicPathProxy } from "../proxy";
import type { PrettifyDeep, UnionToIntersection } from "../../types/helper";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
import { useStore } from "./lynx-store";
import type { BASE_ERROR_CODES } from "../../error/codes";
import type { SessionQueryParams } from "../types";
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 ClientOptions> = O["plugins"] extends Array<
infer Plugin
>
? Plugin extends BetterAuthClientPlugin
? Plugin["getAtoms"] extends (fetch: any) => infer Atoms
? Atoms extends Record<string, any>
? {
[key in keyof Atoms as IsSignal<key> extends true
? never
: key extends string
? `use${Capitalize<key>}`
: never]: () => ReturnType<Atoms[key]["get"]>;
}
: {}
: {}
: {}
: {};
export function createAuthClient<Option extends ClientOptions>(
options?: Option,
) {
const {
pluginPathMethods,
pluginsActions,
pluginsAtoms,
$fetch,
$store,
atomListeners,
} = getClientConfig(options);
let resolvedHooks: Record<string, any> = {};
for (const [key, value] of Object.entries(pluginsAtoms)) {
resolvedHooks[getAtomKey(key)] = () => useStore(value);
}
const routes = {
...pluginsActions,
...resolvedHooks,
$fetch,
$store,
};
const proxy = createDynamicPathProxy(
routes,
$fetch,
pluginPathMethods,
pluginsAtoms,
atomListeners,
);
type ClientAPI = InferClientAPI<Option>;
type Session = ClientAPI extends {
getSession: () => Promise<infer Res>;
}
? Res extends BetterFetchResponse<infer S>
? S
: Res
: never;
return proxy as UnionToIntersection<InferResolvedHooks<Option>> &
ClientAPI &
InferActions<Option> & {
useSession: () => {
data: Session;
isPending: boolean;
error: BetterFetchError | null;
refetch: (queryParams?: { query?: SessionQueryParams }) => void;
};
$Infer: {
Session: NonNullable<Session>;
};
$fetch: typeof $fetch;
$store: typeof $store;
$ERROR_CODES: PrettifyDeep<
InferErrorCodes<Option> & typeof BASE_ERROR_CODES
>;
};
}
export { useStore };
export type * from "@better-fetch/fetch";
export type * from "nanostores";

View File

@@ -0,0 +1,73 @@
import { listenKeys } from "nanostores";
import { useCallback, useRef, useSyncExternalStore } from "@lynx-js/react";
import type { Store, StoreValue } from "nanostores";
import type { DependencyList } from "@lynx-js/react";
type StoreKeys<T> = T extends { setKey: (k: infer K, v: any) => unknown }
? K
: never;
export interface UseStoreOptions<SomeStore> {
/**
* @default
* ```ts
* [store, options.keys]
* ```
*/
deps?: DependencyList;
/**
* Will re-render components only on specific key changes.
*/
keys?: StoreKeys<SomeStore>[];
}
/**
* Subscribe to store changes and get store's value.
*
* Can be used with store builder too.
*
* ```js
* import { useStore } from 'nanostores/react'
*
* import { router } from '../store/router'
*
* export const Layout = () => {
* let page = useStore(router)
* if (page.route === 'home') {
* return <HomePage />
* } else {
* return <Error404 />
* }
* }
* ```
*
* @param store Store instance.
* @returns Store value.
*/
export function useStore<SomeStore extends Store>(
store: SomeStore,
options: UseStoreOptions<SomeStore> = {},
): StoreValue<SomeStore> {
let snapshotRef = useRef<StoreValue<SomeStore>>(store.get());
const { keys, deps = [store, keys] } = options;
let subscribe = useCallback((onChange: () => void) => {
const emitChange = (value: StoreValue<SomeStore>) => {
if (snapshotRef.current === value) return;
snapshotRef.current = value;
onChange();
};
emitChange(store.value);
if (keys?.length) {
return listenKeys(store as any, keys, emitChange);
}
return store.listen(emitChange);
}, deps);
let get = () => snapshotRef.current as StoreValue<SomeStore>;
return useSyncExternalStore(subscribe, get, get);
}

View File

@@ -25,7 +25,7 @@ export interface UseStoreOptions<SomeStore> {
/** /**
* Subscribe to store changes and get store's value. * Subscribe to store changes and get store's value.
* *
* Can be user with store builder too. * Can be used with store builder too.
* *
* ```js * ```js
* import { useStore } from 'nanostores/react' * import { useStore } from 'nanostores/react'

View File

@@ -1,7 +1,11 @@
import { createHash } from "@better-auth/utils/hash"; import { createHash } from "@better-auth/utils/hash";
import { xchacha20poly1305 } from "@noble/ciphers/chacha"; import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/ciphers/utils"; import {
import { managedNonce } from "@noble/ciphers/webcrypto"; bytesToHex,
hexToBytes,
utf8ToBytes,
managedNonce,
} from "@noble/ciphers/utils.js";
export type SymmetricEncryptOptions = { export type SymmetricEncryptOptions = {
key: string; key: string;

View File

@@ -1,8 +1,8 @@
import { constantTimeEqual } from "./buffer"; import { constantTimeEqual } from "./buffer";
import { scryptAsync } from "@noble/hashes/scrypt"; import { scryptAsync } from "@noble/hashes/scrypt.js";
import { getRandomValues } from "@better-auth/utils"; import { getRandomValues } from "@better-auth/utils";
import { hex } from "@better-auth/utils/hex"; import { hex } from "@better-auth/utils/hex";
import { hexToBytes } from "@noble/hashes/utils"; import { hexToBytes } from "@noble/hashes/utils.js";
import { BetterAuthError } from "../error"; import { BetterAuthError } from "../error";
const config = { const config = {

View File

@@ -1,4 +1,4 @@
import { APIError, createAuthEndpoint, sessionMiddleware } from "../../../api"; import { createAuthEndpoint, sessionMiddleware } from "../../../api";
import type { apiKeySchema } from "../schema"; import type { apiKeySchema } from "../schema";
import type { ApiKey } from "../types"; import type { ApiKey } from "../types";
import type { AuthContext } from "../../../types"; import type { AuthContext } from "../../../types";

View File

@@ -9,7 +9,6 @@ import type { PredefinedApiKeyOptions } from ".";
import { safeJSONParse } from "../../../utils/json"; import { safeJSONParse } from "../../../utils/json";
import { role } from "../../access"; import { role } from "../../access";
import { defaultKeyHasher } from "../"; import { defaultKeyHasher } from "../";
import { createApiKey } from "./create-api-key";
export async function validateApiKey({ export async function validateApiKey({
hashedKey, hashedKey,

View File

@@ -93,7 +93,9 @@ describe("Custom Session Plugin Tests", async () => {
expect(session.newData).toEqual({ message: "Hello, World!" }); expect(session.newData).toEqual({ message: "Hello, World!" });
}); });
it("should not create memory leaks with multiple plugin instances", async () => { it.skipIf(globalThis.gc == null)(
"should not create memory leaks with multiple plugin instances",
async () => {
const initialMemory = process.memoryUsage(); const initialMemory = process.memoryUsage();
const pluginInstances = []; const pluginInstances = [];
@@ -112,11 +114,9 @@ describe("Custom Session Plugin Tests", async () => {
}); });
pluginInstances.push(plugin); pluginInstances.push(plugin);
} }
// Force garbage collection (only works if Node.js is started with --expose-gc)
// Force garbage collection if available (in test environment) // @ts-expect-error
if (global.gc) { globalThis.gc();
global.gc();
}
const afterPluginCreation = process.memoryUsage(); const afterPluginCreation = process.memoryUsage();
@@ -130,5 +130,6 @@ describe("Custom Session Plugin Tests", async () => {
expect(pluginInstances).toHaveLength(sessionCount); expect(pluginInstances).toHaveLength(sessionCount);
expect(pluginInstances[0].id).toBe("custom-session"); expect(pluginInstances[0].id).toBe("custom-session");
expect(pluginInstances[sessionCount - 1].id).toBe("custom-session"); expect(pluginInstances[sessionCount - 1].id).toBe("custom-session");
}); },
);
}); });

View File

@@ -99,6 +99,7 @@ export async function getJwtToken(
return await signJWT(ctx, { return await signJWT(ctx, {
options, options,
payload: { payload: {
iat: Math.floor(Date.now() / 1000),
...payload, ...payload,
sub: sub:
(await options?.jwt?.getSubject?.(ctx.context.session!)) ?? (await options?.jwt?.getSubject?.(ctx.context.session!)) ??

View File

@@ -28,6 +28,7 @@ import { logger } from "../../utils";
interface MCPOptions { interface MCPOptions {
loginPage: string; loginPage: string;
resource?: string;
oidcConfig?: OIDCOptions; oidcConfig?: OIDCOptions;
} }
@@ -85,15 +86,15 @@ export const getMCPProviderMetadata = (
export const getMCPProtectedResourceMetadata = ( export const getMCPProtectedResourceMetadata = (
ctx: GenericEndpointContext, ctx: GenericEndpointContext,
options?: OIDCOptions, options?: MCPOptions,
) => { ) => {
const baseURL = ctx.context.baseURL; const baseURL = ctx.context.baseURL;
return { return {
resource: baseURL, resource: options?.resource ?? new URL(baseURL).origin,
authorization_servers: [baseURL], authorization_servers: [baseURL],
jwks_uri: options?.metadata?.jwks_uri ?? `${baseURL}/mcp/jwks`, jwks_uri: options?.oidcConfig?.metadata?.jwks_uri ?? `${baseURL}/mcp/jwks`,
scopes_supported: options?.metadata?.scopes_supported ?? [ scopes_supported: options?.oidcConfig?.metadata?.scopes_supported ?? [
"openid", "openid",
"profile", "profile",
"email", "email",
@@ -946,7 +947,7 @@ export const withMcpAuth = <
const session = await auth.api.getMcpSession({ const session = await auth.api.getMcpSession({
headers: req.headers, headers: req.headers,
}); });
const wwwAuthenticateValue = `Bearer resource_metadata=${baseURL}/api/auth/.well-known/oauth-protected-resource`; const wwwAuthenticateValue = `Bearer resource_metadata="${baseURL}/.well-known/oauth-protected-resource"`;
if (!session) { if (!session) {
return Response.json( return Response.json(
{ {
@@ -962,6 +963,8 @@ export const withMcpAuth = <
status: 401, status: 401,
headers: { headers: {
"WWW-Authenticate": wwwAuthenticateValue, "WWW-Authenticate": wwwAuthenticateValue,
// we also add this headers otherwise browser based clients will not be able to read the `www-authenticate` header
"Access-Control-Expose-Headers": "WWW-Authenticate",
}, },
}, },
); );

View File

@@ -1,6 +1,6 @@
import { afterAll, describe, it } from "vitest"; import { afterAll, describe, it } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance"; import { getTestInstance } from "../../test-utils/test-instance";
import { mcp } from "."; import { mcp, withMcpAuth } from ".";
import { genericOAuth } from "../generic-oauth"; import { genericOAuth } from "../generic-oauth";
import type { Client } from "../oidc-provider/types"; import type { Client } from "../oidc-provider/types";
import { createAuthClient } from "../../client"; import { createAuthClient } from "../../client";
@@ -354,6 +354,21 @@ describe("mcp", async () => {
}); });
}); });
it("should expose OAuth protected resource metadata", async ({ expect }) => {
const metadata = await serverClient.$fetch(
"/.well-known/oauth-protected-resource",
);
expect(metadata.data).toMatchObject({
resource: baseURL,
authorization_servers: [`${baseURL}/api/auth`],
jwks_uri: `${baseURL}/api/auth/mcp/jwks`,
scopes_supported: ["openid", "profile", "email", "offline_access"],
bearer_methods_supported: ["header"],
resource_signing_alg_values_supported: ["RS256", "none"],
});
});
it("should handle token refresh flow", async ({ expect }) => { it("should handle token refresh flow", async ({ expect }) => {
// Create a confidential client for easier testing (avoids PKCE complexity) // Create a confidential client for easier testing (avoids PKCE complexity)
const createdClient = await serverClient.$fetch("/mcp/register", { const createdClient = await serverClient.$fetch("/mcp/register", {
@@ -511,4 +526,24 @@ describe("mcp", async () => {
"code verifier is missing", "code verifier is missing",
); );
}); });
describe("withMCPAuth", () => {
it("should return 401 if the request is not authenticated returning the right WWW-Authenticate header", async ({
expect,
}) => {
// Test the handler using a newly instantiated Request instead of the server, since this route isn't handled by the server
const response = await withMcpAuth(auth, async () => {
// it will never be reached since the request is not authenticated
return new Response("unnecessary");
})(new Request(`${baseURL}/mcp`));
expect(response.status).toBe(401);
expect(response.headers.get("WWW-Authenticate")).toBe(
`Bearer resource_metadata="${baseURL}/api/auth/.well-known/oauth-protected-resource"`,
);
expect(response.headers.get("Access-Control-Expose-Headers")).toBe(
"WWW-Authenticate",
);
});
});
}); });

View File

@@ -4,16 +4,7 @@ import type {
OpenAPIParameter, OpenAPIParameter,
OpenAPISchemaType, OpenAPISchemaType,
} from "better-call"; } from "better-call";
import { import { z, ZodObject, ZodOptional, ZodType } from "zod/v4";
z,
ZodArray,
ZodBoolean,
ZodNumber,
ZodObject,
ZodOptional,
ZodString,
ZodType,
} from "zod/v4";
import { getEndpoints } from "../../api"; import { getEndpoints } from "../../api";
import { getAuthTables } from "../../db"; import { getAuthTables } from "../../db";
import type { AuthContext, BetterAuthOptions } from "../../types"; import type { AuthContext, BetterAuthOptions } from "../../types";
@@ -117,13 +108,12 @@ function getParameters(options: EndpointOptions) {
name: key, name: key,
in: "query", in: "query",
schema: { schema: {
type: getTypeFromZodType(value as ZodType<any>), ...processZodType(value as ZodType<any>),
...("minLength" in value && (value as any).minLength ...("minLength" in value && (value as any).minLength
? { ? {
minLength: (value as any).minLength as number, minLength: (value as any).minLength as number,
} }
: {}), : {}),
description: (value as any).description,
}, },
}); });
} }
@@ -148,10 +138,7 @@ function getRequestBody(options: EndpointOptions): any {
const required: string[] = []; const required: string[] = [];
Object.entries(shape).forEach(([key, value]) => { Object.entries(shape).forEach(([key, value]) => {
if (value instanceof ZodType) { if (value instanceof ZodType) {
properties[key] = { properties[key] = processZodType(value as ZodType<any>);
type: getTypeFromZodType(value as ZodType<any>),
description: (value as any).description,
};
if (!(value instanceof z.ZodOptional)) { if (!(value instanceof z.ZodOptional)) {
required.push(key); required.push(key);
} }
@@ -178,6 +165,48 @@ function getRequestBody(options: EndpointOptions): any {
return undefined; return undefined;
} }
function processZodType(zodType: ZodType<any>): any {
// optional unwrapping
if (zodType instanceof ZodOptional) {
const innerType = (zodType as any)._def.innerType;
const innerSchema = processZodType(innerType);
return {
...innerSchema,
nullable: true,
};
}
// object unwrapping
if (zodType instanceof ZodObject) {
const shape = (zodType as any).shape;
if (shape) {
const properties: Record<string, any> = {};
const required: string[] = [];
Object.entries(shape).forEach(([key, value]) => {
if (value instanceof ZodType) {
properties[key] = processZodType(value as ZodType<any>);
if (!(value instanceof z.ZodOptional)) {
required.push(key);
}
}
});
return {
type: "object",
properties,
...(required.length > 0 ? { required } : {}),
description: (zodType as any).description,
};
}
}
// For primitive types, get the correct type from the unwrapped ZodType
const baseSchema = {
type: getTypeFromZodType(zodType),
description: (zodType as any).description,
};
return baseSchema;
}
function getResponse(responses?: Record<string, any>) { function getResponse(responses?: Record<string, any>) {
return { return {
"400": { "400": {

View File

@@ -54,4 +54,35 @@ describe("open-api", async (it) => {
expect(schemas["User"].required).toContain("role"); expect(schemas["User"].required).toContain("role");
expect(schemas["User"].required).not.toContain("preferences"); expect(schemas["User"].required).not.toContain("preferences");
}); });
it("should properly handle nested objects in request body schema", async () => {
const schema = await auth.api.generateOpenAPISchema();
const paths = schema.paths as Record<string, any>;
const signInSocialPath = paths["/sign-in/social"];
expect(signInSocialPath).toBeDefined();
const requestBody = signInSocialPath.post.requestBody;
expect(requestBody).toBeDefined();
const schema_properties =
requestBody.content["application/json"].schema.properties;
expect(schema_properties.idToken).toBeDefined();
expect(schema_properties.idToken.type).toBe("object");
expect(schema_properties.idToken.properties).toBeDefined();
expect(schema_properties.idToken.properties.token).toBeDefined();
expect(schema_properties.idToken.properties.token.type).toBe("string");
expect(schema_properties.idToken.properties.accessToken).toBeDefined();
expect(schema_properties.idToken.properties.accessToken.type).toBe(
"string",
);
expect(schema_properties.idToken.properties.refreshToken).toBeDefined();
expect(schema_properties.idToken.properties.refreshToken.type).toBe(
"string",
);
expect(schema_properties.idToken.required).toContain("token");
expect(schema_properties.idToken.required).not.toContain("accessToken");
expect(schema_properties.idToken.required).not.toContain("refreshToken");
});
}); });

View File

@@ -215,6 +215,7 @@ export const organizationClient = <CO extends OrganizationClientOptions>(
}, },
pathMethods: { pathMethods: {
"/organization/get-full-organization": "GET", "/organization/get-full-organization": "GET",
"/organization/list-user-teams": "GET",
}, },
atomListeners: [ atomListeners: [
{ {

View File

@@ -491,7 +491,7 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
/** /**
* ### Endpoint * ### Endpoint
* *
* POST `/organization/list-user-teams` * GET `/organization/list-user-teams`
* *
* ### API Methods * ### API Methods
* *

View File

@@ -201,6 +201,21 @@ export const username = (options?: UsernameOptions) => {
}, },
}, },
}, },
422: {
description: "Unprocessable Entity. Validation error",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: {
type: "string",
},
},
},
},
},
},
}, },
}, },
}, },

View File

@@ -4,6 +4,7 @@ import type { OAuthProvider, ProviderOptions } from "../oauth2";
import { createAuthorizationURL } from "../oauth2"; import { createAuthorizationURL } from "../oauth2";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { decodeJwt } from "jose"; import { decodeJwt } from "jose";
import { base64 } from "@better-auth/utils/base64";
export interface PayPalProfile { export interface PayPalProfile {
user_id: string; user_id: string;
@@ -108,9 +109,9 @@ export const paypal = (options: PayPalOptions) => {
* PayPal requires Basic Auth for token exchange * PayPal requires Basic Auth for token exchange
**/ **/
const credentials = Buffer.from( const credentials = base64.encode(
`${options.clientId}:${options.clientSecret}`, `${options.clientId}:${options.clientSecret}`,
).toString("base64"); );
try { try {
const response = await betterFetch(tokenEndpoint, { const response = await betterFetch(tokenEndpoint, {
@@ -153,9 +154,9 @@ export const paypal = (options: PayPalOptions) => {
refreshAccessToken: options.refreshAccessToken refreshAccessToken: options.refreshAccessToken
? options.refreshAccessToken ? options.refreshAccessToken
: async (refreshToken) => { : async (refreshToken) => {
const credentials = Buffer.from( const credentials = base64.encode(
`${options.clientId}:${options.clientSecret}`, `${options.clientId}:${options.clientSecret}`,
).toString("base64"); );
try { try {
const response = await betterFetch(tokenEndpoint, { const response = await betterFetch(tokenEndpoint, {

View File

@@ -161,10 +161,13 @@ export const tiktok = (options: TiktokOptions) => {
return refreshAccessToken({ return refreshAccessToken({
refreshToken, refreshToken,
options: { options: {
clientKey: options.clientKey,
clientSecret: options.clientSecret, clientSecret: options.clientSecret,
}, },
tokenEndpoint: "https://open.tiktokapis.com/v2/oauth/token/", tokenEndpoint: "https://open.tiktokapis.com/v2/oauth/token/",
authentication: "post",
extraParams: {
client_key: options.clientKey,
},
}); });
}, },
async getUserInfo(token) { async getUserInfo(token) {

View File

@@ -13,6 +13,7 @@ export type Where = {
| "gt" | "gt"
| "gte" | "gte"
| "in" | "in"
| "not_in"
| "contains" | "contains"
| "starts_with" | "starts_with"
| "ends_with"; //eq by default | "ends_with"; //eq by default

View File

@@ -609,12 +609,17 @@ export type BetterAuthOptions = {
*/ */
max: number; max: number;
} }
| false
| ((request: Request) => | ((request: Request) =>
| { window: number; max: number } | { window: number; max: number }
| Promise<{ | false
| Promise<
| {
window: number; window: number;
max: number; max: number;
}>); }
| false
>);
}; };
/** /**
* Storage configuration * Storage configuration

View File

@@ -1,4 +1,5 @@
import { keccak_256 } from "@noble/hashes/sha3"; import { keccak_256 } from "@noble/hashes/sha3.js";
import { utf8ToBytes } from "@noble/hashes/utils.js";
/** /**
* TS implementation of ERC-55 ("Mixed-case checksum address encoding") using @noble/hashes * TS implementation of ERC-55 ("Mixed-case checksum address encoding") using @noble/hashes
@@ -8,7 +9,7 @@ import { keccak_256 } from "@noble/hashes/sha3";
export function toChecksumAddress(address: string) { export function toChecksumAddress(address: string) {
address = address.toLowerCase().replace("0x", ""); address = address.toLowerCase().replace("0x", "");
// Hash the address (treat it as UTF-8) and return as a hex string // Hash the address (treat it as UTF-8) and return as a hex string
const hash = [...keccak_256(address)] const hash = [...keccak_256(utf8ToBytes(address))]
.map((v) => v.toString(16).padStart(2, "0")) .map((v) => v.toString(16).padStart(2, "0"))
.join(""); .join("");
let ret = "0x"; let ret = "0x";

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
poolOptions: {
forks: {
execArgv: ["--expose-gc"],
},
},
},
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "@better-auth/cli", "name": "@better-auth/cli",
"version": "1.3.8", "version": "1.3.9-beta.4",
"description": "The CLI for Better Auth", "description": "The CLI for Better Auth",
"module": "dist/index.mjs", "module": "dist/index.mjs",
"repository": { "repository": {
@@ -49,7 +49,7 @@
"better-auth": "workspace:*", "better-auth": "workspace:*",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.2.0",
"c12": "^3.2.0", "c12": "^3.2.0",
"chalk": "^5.6.0", "chalk": "^5.6.2",
"commander": "^12.1.0", "commander": "^12.1.0",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@better-auth/expo", "name": "@better-auth/expo",
"version": "1.3.8", "version": "1.3.9-beta.4",
"description": "", "description": "",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"module": "dist/index.mjs", "module": "dist/index.mjs",

View File

@@ -202,7 +202,8 @@ export const expoClient = (opts: ExpoClientOptions) => {
if ( if (
context.data?.redirect && context.data?.redirect &&
context.request.url.toString().includes("/sign-in") && (context.request.url.toString().includes("/sign-in") ||
context.request.url.toString().includes("/link-social")) &&
!context.request?.body.includes("idToken") // id token is used for silent sign-in !context.request?.body.includes("idToken") // id token is used for silent sign-in
) { ) {
const callbackURL = JSON.parse(context.request.body)?.callbackURL; const callbackURL = JSON.parse(context.request.body)?.callbackURL;

View File

@@ -1,7 +1,7 @@
{ {
"name": "@better-auth/sso", "name": "@better-auth/sso",
"author": "Bereket Engida", "author": "Bereket Engida",
"version": "1.3.8", "version": "1.3.9-beta.4",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [

View File

@@ -1,7 +1,7 @@
{ {
"name": "@better-auth/stripe", "name": "@better-auth/stripe",
"author": "Bereket Engida", "author": "Bereket Engida",
"version": "1.3.8", "version": "1.3.9-beta.4",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [

View File

@@ -265,6 +265,11 @@ export const stripe = <O extends StripeOptions>(options: O) => {
}, },
], ],
}) })
: referenceId
? await ctx.context.adapter.findOne<Subscription>({
model: "subscription",
where: [{ field: "referenceId", value: referenceId }],
})
: null; : null;
if (ctx.body.subscriptionId && !subscriptionToUpdate) { if (ctx.body.subscriptionId && !subscriptionToUpdate) {
@@ -329,12 +334,14 @@ export const stripe = <O extends StripeOptions>(options: O) => {
(sub) => sub.status === "active" || sub.status === "trialing", (sub) => sub.status === "active" || sub.status === "trialing",
), ),
); );
const activeSubscription = activeSubscriptions.find((sub) => const activeSubscription = activeSubscriptions.find((sub) =>
subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId
? sub.id === subscriptionToUpdate?.stripeSubscriptionId || ? sub.id === subscriptionToUpdate?.stripeSubscriptionId ||
sub.id === ctx.body.subscriptionId sub.id === ctx.body.subscriptionId
: true, : false,
); );
const subscriptions = subscriptionToUpdate const subscriptions = subscriptionToUpdate
? [subscriptionToUpdate] ? [subscriptionToUpdate]
: await ctx.context.adapter.findMany<Subscription>({ : await ctx.context.adapter.findMany<Subscription>({
@@ -431,12 +438,16 @@ export const stripe = <O extends StripeOptions>(options: O) => {
ctx, ctx,
); );
const alreadyHasTrial = subscription.status === "trialing"; const hasEverTrialed = subscriptions.some((s) => {
const samePlan = s.plan?.toLowerCase() === plan.name.toLowerCase();
const hadTrial =
!!(s.trialStart || s.trialEnd) || s.status === "trialing";
return samePlan && hadTrial;
});
const freeTrial = const freeTrial =
!alreadyHasTrial && plan.freeTrial !hasEverTrialed && plan.freeTrial
? { ? { trial_period_days: plan.freeTrial.days }
trial_period_days: plan.freeTrial.days,
}
: undefined; : undefined;
let priceIdToUse: string | undefined = undefined; let priceIdToUse: string | undefined = undefined;

View File

@@ -994,4 +994,112 @@ describe("stripe", async () => {
return_url: "http://localhost:3000/dashboard", return_url: "http://localhost:3000/dashboard",
}); });
}); });
it("should not update personal subscription when upgrading with an org referenceId", async () => {
const orgId = "org_b67GF32Cljh7u588AuEblmLVobclDRcP";
const testOptions = {
...stripeOptions,
stripeClient: _stripe,
subscription: {
...stripeOptions.subscription,
authorizeReference: async () => true,
},
} as unknown as StripeOptions;
const testAuth = betterAuth({
baseURL: "http://localhost:3000",
database: memory,
emailAndPassword: { enabled: true },
plugins: [stripe(testOptions)],
});
const testCtx = await testAuth.$context;
const testAuthClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [bearer(), stripeClient({ subscription: true })],
fetchOptions: {
customFetchImpl: async (url, init) =>
testAuth.handler(new Request(url, init)),
},
});
// Sign up and sign in the user
const userRes = await testAuthClient.signUp.email(
{ ...testUser, email: "org-ref@email.com" },
{ throw: true },
);
const headers = new Headers();
await testAuthClient.signIn.email(
{ ...testUser, email: "org-ref@email.com" },
{ throw: true, onSuccess: setCookieToHeader(headers) },
);
// Create a personal subscription (referenceId = user id)
await testAuthClient.subscription.upgrade({
plan: "starter",
fetchOptions: { headers },
});
const personalSub = await testCtx.adapter.findOne<Subscription>({
model: "subscription",
where: [{ field: "referenceId", value: userRes.user.id }],
});
expect(personalSub).toBeTruthy();
await testCtx.adapter.update({
model: "subscription",
update: {
status: "active",
stripeSubscriptionId: "sub_personal_active_123",
},
where: [{ field: "id", value: personalSub!.id }],
});
mockStripe.subscriptions.list.mockResolvedValue({
data: [
{
id: "sub_personal_active_123",
status: "active",
items: {
data: [
{
id: "si_1",
price: { id: process.env.STRIPE_PRICE_ID_1 },
quantity: 1,
},
],
},
},
],
});
// Attempt to upgrade using an org referenceId
const upgradeRes = await testAuthClient.subscription.upgrade({
plan: "starter",
referenceId: orgId,
fetchOptions: { headers },
});
console.log(upgradeRes);
// // It should NOT go through billing portal (which would update the personal sub)
expect(mockStripe.billingPortal.sessions.create).not.toHaveBeenCalled();
expect(upgradeRes.data?.url).toBeDefined();
const orgSub = await testCtx.adapter.findOne<Subscription>({
model: "subscription",
where: [{ field: "referenceId", value: orgId }],
});
expect(orgSub).toMatchObject({
referenceId: orgId,
status: "incomplete",
plan: "starter",
});
const personalAfter = await testCtx.adapter.findOne<Subscription>({
model: "subscription",
where: [{ field: "id", value: personalSub!.id }],
});
expect(personalAfter?.status).toBe("active");
});
}); });

82
pnpm-lock.yaml generated
View File

@@ -10,8 +10,8 @@ catalogs:
specifier: ^1.1.18 specifier: ^1.1.18
version: 1.1.18 version: 1.1.18
better-call: better-call:
specifier: 1.0.16 specifier: 1.0.18
version: 1.0.16 version: 1.0.18
typescript: typescript:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.2 version: 5.9.2
@@ -179,7 +179,7 @@ importers:
version: link:../../packages/better-auth version: link:../../packages/better-auth
better-call: better-call:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.0.16 version: 1.0.18
better-sqlite3: better-sqlite3:
specifier: ^12.2.0 specifier: ^12.2.0
version: 12.2.0 version: 12.2.0
@@ -613,11 +613,11 @@ importers:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.1.18 version: 1.1.18
'@noble/ciphers': '@noble/ciphers':
specifier: ^0.6.0 specifier: ^2.0.0
version: 0.6.0 version: 2.0.0
'@noble/hashes': '@noble/hashes':
specifier: ^1.8.0 specifier: ^2.0.0
version: 1.8.0 version: 2.0.0
'@simplewebauthn/browser': '@simplewebauthn/browser':
specifier: ^13.1.2 specifier: ^13.1.2
version: 13.1.2 version: 13.1.2
@@ -626,13 +626,13 @@ importers:
version: 13.1.2 version: 13.1.2
better-call: better-call:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.0.16 version: 1.0.18
defu: defu:
specifier: ^6.1.4 specifier: ^6.1.4
version: 6.1.4 version: 6.1.4
jose: jose:
specifier: ^5.10.0 specifier: ^6.1.0
version: 5.10.0 version: 6.1.0
kysely: kysely:
specifier: ^0.28.5 specifier: ^0.28.5
version: 0.28.5 version: 0.28.5
@@ -643,6 +643,9 @@ importers:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.1.5 version: 4.1.5
devDependencies: devDependencies:
'@lynx-js/react':
specifier: ^0.112.5
version: 0.112.5(@types/react@18.3.23)
'@prisma/client': '@prisma/client':
specifier: ^5.22.0 specifier: ^5.22.0
version: 5.22.0(prisma@5.22.0) version: 5.22.0(prisma@5.22.0)
@@ -773,8 +776,8 @@ importers:
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.2.0(magicast@0.3.5) version: 3.2.0(magicast@0.3.5)
chalk: chalk:
specifier: ^5.6.0 specifier: ^5.6.2
version: 5.6.0 version: 5.6.2
commander: commander:
specifier: ^12.1.0 specifier: ^12.1.0
version: 12.1.0 version: 12.1.0
@@ -885,7 +888,7 @@ importers:
version: link:../better-auth version: link:../better-auth
better-call: better-call:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.0.16 version: 1.0.18
express: express:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.1.0 version: 5.1.0
@@ -901,7 +904,7 @@ importers:
version: link:../better-auth version: link:../better-auth
better-call: better-call:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.0.16 version: 1.0.18
stripe: stripe:
specifier: ^18.5.0 specifier: ^18.5.0
version: 18.5.0(@types/node@24.3.0) version: 18.5.0(@types/node@24.3.0)
@@ -2459,6 +2462,9 @@ packages:
'@hexagon/base64@1.1.28': '@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@hongzhiyuan/preact@10.24.0-00213bad':
resolution: {integrity: sha512-bHWp4ZDK5ZimcY+bTWw3S3xGiB8eROpZj0RK3FClNIaTOajb0b11CsT3K+pdeakgPgq1jWN3T2e2rfrPm40JsQ==}
'@hookform/resolvers@5.2.1': '@hookform/resolvers@5.2.1':
resolution: {integrity: sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==} resolution: {integrity: sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==}
peerDependencies: peerDependencies:
@@ -2908,6 +2914,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@lynx-js/react@0.112.5':
resolution: {integrity: sha512-noFV05FjKsLtnTQLjEbjK4IPLPK+AEJ3r2klYjvLCPe9pQYXyHqoiN9bWwLFZDvcPGsXSdJ5TiahTTZuagGRjA==}
peerDependencies:
'@lynx-js/types': '*'
'@types/react': ^18
peerDependenciesMeta:
'@lynx-js/types':
optional: true
'@mapbox/node-pre-gyp@2.0.0': '@mapbox/node-pre-gyp@2.0.0':
resolution: {integrity: sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==} resolution: {integrity: sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3031,13 +3046,18 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@noble/ciphers@0.6.0': '@noble/ciphers@2.0.0':
resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==} resolution: {integrity: sha512-j/l6jpnpaIBM87cAYPJzi/6TgqmBv9spkqPyCXvRYsu5uxqh6tPJZDnD85yo8VWqzTuTQPgfv7NgT63u7kbwAQ==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@1.8.0': '@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16} engines: {node: ^14.21.3 || >=16}
'@noble/hashes@2.0.0':
resolution: {integrity: sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -5783,8 +5803,8 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
better-call@1.0.16: better-call@1.0.18:
resolution: {integrity: sha512-42dgJ1rOtc0anOoxjXPOWuel/Z/4aeO7EJ2SiXNwvlkySSgjXhNjAjTMWa8DL1nt6EXS3jl3VKC3mPsU/lUgVA==} resolution: {integrity: sha512-Ojyck3P3fs/egBmCW50tvfbCJorNV5KphfPOKrkCxPfOr8Brth1ruDtAJuhHVHEUiWrXv+vpEgWQk7m7FzhbbQ==}
better-opn@3.0.2: better-opn@3.0.2:
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
@@ -5986,8 +6006,8 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
chalk@5.6.0: chalk@5.6.2:
resolution: {integrity: sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==} resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
character-entities-html4@2.1.0: character-entities-html4@2.1.0:
@@ -8489,6 +8509,9 @@ packages:
jose@5.10.0: jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
jotai@2.13.1: jotai@2.13.1:
resolution: {integrity: sha512-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A==} resolution: {integrity: sha512-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -14136,6 +14159,8 @@ snapshots:
'@hexagon/base64@1.1.28': {} '@hexagon/base64@1.1.28': {}
'@hongzhiyuan/preact@10.24.0-00213bad': {}
'@hookform/resolvers@5.2.1(react-hook-form@7.62.0(react@19.1.1))': '@hookform/resolvers@5.2.1(react-hook-form@7.62.0(react@19.1.1))':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
@@ -14552,6 +14577,11 @@ snapshots:
'@libsql/win32-x64-msvc@0.5.20': '@libsql/win32-x64-msvc@0.5.20':
optional: true optional: true
'@lynx-js/react@0.112.5(@types/react@18.3.23)':
dependencies:
'@types/react': 18.3.23
preact: '@hongzhiyuan/preact@10.24.0-00213bad'
'@mapbox/node-pre-gyp@2.0.0': '@mapbox/node-pre-gyp@2.0.0':
dependencies: dependencies:
consola: 3.4.2 consola: 3.4.2
@@ -14759,10 +14789,12 @@ snapshots:
'@next/swc-win32-x64-msvc@15.5.2': '@next/swc-win32-x64-msvc@15.5.2':
optional: true optional: true
'@noble/ciphers@0.6.0': {} '@noble/ciphers@2.0.0': {}
'@noble/hashes@1.8.0': {} '@noble/hashes@1.8.0': {}
'@noble/hashes@2.0.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -16213,7 +16245,9 @@ snapshots:
metro-runtime: 0.83.1 metro-runtime: 0.83.1
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- bufferutil
- supports-color - supports-color
- utf-8-validate
'@react-native/normalize-colors@0.79.5': {} '@react-native/normalize-colors@0.79.5': {}
@@ -18098,7 +18132,7 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.1.2 safe-buffer: 5.1.2
better-call@1.0.16: better-call@1.0.18:
dependencies: dependencies:
'@better-fetch/fetch': 1.1.18 '@better-fetch/fetch': 1.1.18
rou3: 0.5.1 rou3: 0.5.1
@@ -18357,7 +18391,7 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
chalk@5.6.0: {} chalk@5.6.2: {}
character-entities-html4@2.1.0: {} character-entities-html4@2.1.0: {}
@@ -21129,6 +21163,8 @@ snapshots:
jose@5.10.0: {} jose@5.10.0: {}
jose@6.1.0: {}
jotai@2.13.1(@babel/core@7.28.3)(@babel/template@7.27.2)(@types/react@19.1.12)(react@19.1.1): jotai@2.13.1(@babel/core@7.28.3)(@babel/template@7.27.2)(@types/react@19.1.12)(react@19.1.1):
optionalDependencies: optionalDependencies:
'@babel/core': 7.28.3 '@babel/core': 7.28.3

View File

@@ -3,16 +3,26 @@ packages:
- docs - docs
- demo/* - demo/*
- e2e/** - e2e/**
neverBuiltDependencies: []
catalog:
'@better-fetch/fetch': ^1.1.18
better-call: 1.0.18
typescript: ^5.9.2
unbuild: ^3.6.1
vitest: ^3.2.4
catalogs: catalogs:
react18: react18:
'@types/react': ^19.1.12 '@types/react': ^19.1.12
'@types/react-dom': ^19.1.9 '@types/react-dom': ^19.1.9
react: 19.1.1 react: 19.1.1
react-dom: 19.1.1 react-dom: 19.1.1
catalog:
"better-call": "1.0.16" neverBuiltDependencies: []
"@better-fetch/fetch": "^1.1.18"
"unbuild": "^3.6.1" overrides:
"typescript": "^5.9.2" brace-expansion@>=1.0.0 <=1.1.11: '>=1.1.12'
"vitest": "^3.2.4" cookie@<0.7.0: '>=0.7.0'
esbuild@<=0.24.2: '>=0.25.0'
miniflare>zod: ^3.25.1
zod: ^4.1.5