feat: support device authorization (#3811)

This commit is contained in:
Alex Yang
2025-08-21 14:59:31 -07:00
committed by GitHub
parent 2edb2d6816
commit 5ded0904d4
28 changed files with 3150 additions and 74 deletions

View File

@@ -0,0 +1,16 @@
export default function Loading() {
return (
<div className="w-full">
<div className="flex items-center flex-col justify-center w-full md:py-10">
<div className="md:w-[400px] animate-pulse">
<div className="h-10 bg-gray-200 rounded-lg mb-4"></div>
<div className="space-y-4">
<div className="h-12 bg-gray-200 rounded"></div>
<div className="h-12 bg-gray-200 rounded"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -4,12 +4,14 @@ import SignIn from "@/components/sign-in";
import { SignUp } from "@/components/sign-up";
import { Tabs } from "@/components/ui/tabs2";
import { client } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { getCallbackURL } from "@/lib/shared";
export default function Page() {
const router = useRouter();
const params = useSearchParams();
useEffect(() => {
client.oneTap({
fetchOptions: {
@@ -18,7 +20,7 @@ export default function Page() {
},
onSuccess: () => {
toast.success("Successfully signed in");
router.push("/dashboard");
router.push(getCallbackURL(params));
},
},
});

View File

@@ -208,8 +208,9 @@ export default function UserCard(props: {
) : (
<Laptop size={16} />
)}
{new UAParser(session.userAgent || "").getOS().name},{" "}
{new UAParser(session.userAgent || "").getBrowser().name}
{new UAParser(session.userAgent || "").getOS().name ||
session.userAgent}
, {new UAParser(session.userAgent || "").getBrowser().name}
<button
className="text-red-500 opacity-80 cursor-pointer text-xs border-muted-foreground border-red-600 underline "
onClick={async () => {

View File

@@ -0,0 +1,121 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { client, useSession } from "@/lib/auth-client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Check, X } from "lucide-react";
export default function DeviceApprovalPage() {
const router = useRouter();
const searchParams = useSearchParams();
const userCode = searchParams.get("user_code");
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleApprove = () => {
if (!userCode) return;
setError(null);
startTransition(async () => {
try {
await client.device.approve({
userCode,
});
router.push("/device/success");
} catch (err: any) {
setError(err.error?.message || "Failed to approve device");
}
});
};
const handleDeny = () => {
if (!userCode) return;
setError(null);
startTransition(async () => {
try {
await client.device.deny({
userCode,
});
router.push("/device/denied");
} catch (err: any) {
setError(err.error?.message || "Failed to deny device");
}
});
};
if (!session) {
return null;
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md p-6">
<div className="space-y-4">
<div className="text-center">
<h1 className="text-2xl font-bold">Approve Device</h1>
<p className="text-muted-foreground mt-2">
A device is requesting access to your account
</p>
</div>
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4">
<p className="text-sm font-medium">Device Code</p>
<p className="font-mono text-lg">{userCode}</p>
</div>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm font-medium">Signed in as</p>
<p>{session.user.email}</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-3">
<Button
onClick={handleDeny}
variant="outline"
className="flex-1"
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<X className="mr-2 h-4 w-4" />
Deny
</>
)}
</Button>
<Button
onClick={handleApprove}
className="flex-1"
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" />
Approve
</>
)}
</Button>
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import Link from "next/link";
export default function DeviceDeniedPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md p-6">
<div className="space-y-4 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<X className="h-6 w-6 text-red-600" />
</div>
<div>
<h1 className="text-2xl font-bold">Device Denied</h1>
<p className="text-muted-foreground mt-2">
The device authorization request has been denied.
</p>
</div>
<p className="text-sm text-muted-foreground">
The device will not be able to access your account.
</p>
<Button asChild className="w-full">
<Link href="/">Return to Home</Link>
</Button>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function DevicePage({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session === null) {
throw redirect("/sign-in?callbackUrl=/device");
}
return children;
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { client } from "@/lib/auth-client";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
export default function DeviceAuthorizationPage() {
const router = useRouter();
const params = useSearchParams();
const user_code = params.get("user_code");
const [userCode, setUserCode] = useState<string>(user_code ? user_code : "");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
const finalCode = userCode.trim().replaceAll(/-/g, "").toUpperCase();
// Get the device authorization status
const response = await client.device({
query: {
user_code: finalCode,
},
});
if (response.data) {
router.push(`/device/approve?user_code=${finalCode}`);
}
} catch (err: any) {
setError(
err.error?.message || "Invalid code. Please check and try again.",
);
}
});
};
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md p-6">
<div className="space-y-4">
<div className="text-center">
<h1 className="text-2xl font-bold">Device Authorization</h1>
<p className="text-muted-foreground mt-2">
Enter the code displayed on your device
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="userCode">Device Code</Label>
<Input
id="userCode"
type="text"
placeholder="XXXX-XXXX"
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
className="text-center text-lg font-mono uppercase"
maxLength={9}
disabled={isPending}
required
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
"Continue"
)}
</Button>
</form>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Check } from "lucide-react";
import Link from "next/link";
export default function DeviceSuccessPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md p-6">
<div className="space-y-4 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<Check className="h-6 w-6 text-green-600" />
</div>
<div>
<h1 className="text-2xl font-bold">Device Approved</h1>
<p className="text-muted-foreground mt-2">
The device has been successfully authorized to access your
account.
</p>
</div>
<p className="text-sm text-muted-foreground">
You can now return to your device to continue.
</p>
<Button asChild className="w-full">
<Link href="/">Return to Home</Link>
</Button>
</div>
</Card>
</div>
);
}

View File

@@ -17,7 +17,9 @@ import { Loader2 } from "lucide-react";
import { signIn } from "@/lib/auth-client";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { getCallbackURL } from "@/lib/shared";
export default function SignIn() {
const [email, setEmail] = useState("");
@@ -25,6 +27,7 @@ export default function SignIn() {
const [loading, startTransition] = useTransition();
const [rememberMe, setRememberMe] = useState(false);
const router = useRouter();
const params = useSearchParams();
return (
<Card className="max-w-md rounded-none">
@@ -88,7 +91,8 @@ export default function SignIn() {
{ email, password, rememberMe },
{
onSuccess(context) {
router.push("/dashboard");
toast.success("Successfully signed in");
router.push(getCallbackURL(params));
},
},
);

View File

@@ -16,7 +16,7 @@ import Image from "next/image";
import { Loader2, X } from "lucide-react";
import { signUp } from "@/lib/auth-client";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
export function SignUp() {
@@ -28,6 +28,7 @@ export function SignUp() {
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const router = useRouter();
const params = useParams();
const [loading, setLoading] = useState(false);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -168,7 +169,11 @@ export function SignUp() {
toast.error(ctx.error.message);
},
onSuccess: async () => {
if (typeof params.callbackUrl === "string") {
router.push(params.callbackUrl);
} else {
router.push("/dashboard");
}
},
},
});

View File

@@ -8,6 +8,7 @@ import {
oneTapClient,
oidcClient,
genericOAuthClient,
deviceAuthorizationClient,
} from "better-auth/client/plugins";
import { toast } from "sonner";
import { stripeClient } from "@better-auth/stripe/client";
@@ -34,6 +35,7 @@ export const client = createAuthClient({
stripeClient({
subscription: true,
}),
deviceAuthorizationClient(),
],
fetchOptions: {
onError(e) {

View File

@@ -9,6 +9,7 @@ import {
oAuthProxy,
openAPI,
customSession,
deviceAuthorization,
} from "better-auth/plugins";
import { reactInvitationEmail } from "./email/invitation";
import { LibsqlDialect } from "@libsql/kysely-libsql";
@@ -198,6 +199,10 @@ export const auth = betterAuth({
],
},
}),
deviceAuthorization({
expiresIn: "3min",
interval: "5s",
}),
],
trustedOrigins: ["exp://"],
advanced: {

19
demo/nextjs/lib/shared.ts Normal file
View File

@@ -0,0 +1,19 @@
import { ReadonlyURLSearchParams } from "next/navigation";
const allowedCallbackSet: ReadonlySet<string> = new Set([
"/dashboard",
"/device",
]);
export const getCallbackURL = (
queryParams: ReadonlyURLSearchParams,
): string => {
const callbackUrl = queryParams.get("callbackUrl");
if (callbackUrl) {
if (allowedCallbackSet.has(callbackUrl)) {
return callbackUrl;
}
return "/dashboard";
}
return "/dashboard";
};

View File

@@ -1476,6 +1476,24 @@ 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: () => <Key className="w-4 h-4" />,
href: "/docs/plugins/bearer",
},
{
title: "Device Authorization",
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2zM4 6h16v10H4z"
/>
</svg>
),
href: "/docs/plugins/device-authorization",
isNew: true,
},
{
title: "Captcha",
href: "/docs/plugins/captcha",

View File

@@ -0,0 +1,654 @@
---
title: Device Authorization
description: OAuth 2.0 Device Authorization Grant for limited-input devices
---
`RFC 8628` `CLI` `Smart TV` `IoT`
The Device Authorization plugin implements the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)), enabling authentication for devices with limited input capabilities such as smart TVs, CLI applications, IoT devices, and gaming consoles.
## Try It Out
You can test the device authorization flow right now using the Better Auth CLI:
```bash
npx @better-auth/cli login
```
This will demonstrate the complete device authorization flow by:
1. Requesting a device code from the Better Auth demo server
2. Displaying a user code for you to enter
3. Opening your browser to the verification page
4. Polling for authorization completion
<Callout type="info">
The CLI login command is a demo feature that connects to the Better Auth demo server to showcase the device authorization flow in action.
</Callout>
## Installation
<Steps>
<Step>
### Add the plugin to your auth config
Add the device authorization plugin to your server configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { deviceAuthorization } from "better-auth/plugins"; // [!code highlight]
export const auth = betterAuth({
// ... other config
plugins: [ // [!code highlight]
deviceAuthorization({ // [!code highlight]
// Optional configuration
expiresIn: "30m", // Device code expiration time // [!code highlight]
interval: "5s", // Minimum polling interval // [!code highlight]
}), // [!code highlight]
], // [!code highlight]
});
```
</Step>
<Step>
### Migrate the database
Run the migration or generate the schema to add the necessary tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```bash
npx @better-auth/cli migrate
```
</Tab>
<Tab value="generate">
```bash
npx @better-auth/cli generate
```
</Tab>
</Tabs>
See the [Schema](#schema) section to add the fields manually.
</Step>
<Step>
### Add the client plugin
Add the device authorization plugin to your client.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins"; // [!code highlight]
export const authClient = createAuthClient({
plugins: [ // [!code highlight]
deviceAuthorizationClient(), // [!code highlight]
], // [!code highlight]
});
```
</Step>
</Steps>
## How It Works
The device flow follows these steps:
1. **Device requests codes**: The device requests a device code and user code from the authorization server
2. **User authorizes**: The user visits a verification URL and enters the user code
3. **Device polls for token**: The device polls the server until the user completes authorization
4. **Access granted**: Once authorized, the device receives an access token
## Basic Usage
### Requesting Device Authorization
To initiate device authorization, call `device.code` with the client ID:
<APIMethod
path="/device/code"
method="POST"
>
```ts
type deviceCode = {
/**
* The OAuth client identifier
*/
client_id: string;
/**
* Space-separated list of requested scopes (optional)
*/
scope?: string;
}
```
</APIMethod>
Example usage:
```ts
const response = await authClient.device.code({
client_id: "your-client-id",
scope: "openid profile email",
});
// Returns:
// {
// device_code: string, // Device verification code
// user_code: string, // User-friendly code (e.g., "ABCD1234")
// verification_uri: string, // URL for user verification
// verification_uri_complete?: string, // URL with embedded user code
// expires_in: number, // Seconds until expiration
// interval: number // Minimum polling interval in seconds
// }
console.log(`Please visit: ${response.verification_uri}`);
console.log(`And enter code: ${response.user_code}`);
```
### Polling for Token
After displaying the user code, poll for the access token:
<APIMethod
path="/device/token"
method="POST"
>
```ts
type deviceToken = {
/**
* Must be "urn:ietf:params:oauth:grant-type:device_code"
*/
grant_type: string;
/**
* The device code from the initial request
*/
device_code: string;
/**
* The OAuth client identifier
*/
client_id: string;
}
```
</APIMethod>
Example polling implementation:
```ts
const pollForToken = async () => {
try {
const { data } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: "your-client-id",
});
if (data?.access_token) {
console.log("Authorization successful!");
return data;
}
} catch (error) {
if (error.body?.error === "authorization_pending") {
// User hasn't authorized yet, continue polling
setTimeout(pollForToken, interval * 1000);
} else if (error.body?.error === "slow_down") {
// Polling too fast, increase interval
setTimeout(pollForToken, (interval + 5) * 1000);
} else {
// Handle other errors
console.error("Authorization failed:", error);
}
}
};
pollForToken();
```
### User Authorization Flow
The user authorization flow requires two steps:
1. **Code Verification**: Check if the entered user code is valid
2. **Authorization**: User must be authenticated to approve/deny the device
<Callout type="warn">
Users must be authenticated before they can approve or deny device authorization requests. If not authenticated, redirect them to the login page with a return URL.
</Callout>
Create a page where users can enter their code:
```tsx title="app/device/page.tsx"
export default function DeviceAuthorizationPage() {
const [userCode, setUserCode] = useState("");
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
try {
// Format the code: remove dashes and convert to uppercase
const formattedCode = userCode.trim().replace(/-/g, "").toUpperCase();
// Check if the code is valid using GET /device endpoint
const response = await authClient.device.deviceVerify({
query: { user_code: formattedCode },
});
if (response.data) {
// Redirect to approval page
window.location.href = `/device/approve?userCode=${formattedCode}`;
}
} catch (err) {
setError("Invalid or expired code");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="Enter device code (e.g., ABCD-1234)"
maxLength={12}
/>
<button type="submit">Continue</button>
{error && <p>{error}</p>}
</form>
);
}
```
### Approving or Denying Device
Users must be authenticated to approve or deny device authorization requests:
<APIMethod
path="/device/approve"
method="POST"
requireSession
>
```ts
type deviceApprove = {
/**
* The user code to approve
*/
userCode: string;
}
```
</APIMethod>
<APIMethod
path="/device/deny"
method="POST"
requireSession
>
```ts
type deviceDeny = {
/**
* The user code to deny
*/
userCode: string;
}
```
</APIMethod>
Example approval page:
```tsx title="app/device/approve/page.tsx"
export default function DeviceApprovalPage() {
const { user } = useAuth(); // Must be authenticated
const searchParams = useSearchParams();
const userCode = searchParams.get("userCode");
const [isProcessing, setIsProcessing] = useState(false);
const handleApprove = async () => {
setIsProcessing(true);
try {
await authClient.device.deviceApprove({
userCode: userCode,
});
// Show success message
alert("Device approved successfully!");
window.location.href = "/";
} catch (error) {
alert("Failed to approve device");
}
setIsProcessing(false);
};
const handleDeny = async () => {
setIsProcessing(true);
try {
await authClient.device.deviceDeny({
userCode: userCode,
});
alert("Device denied");
window.location.href = "/";
} catch (error) {
alert("Failed to deny device");
}
setIsProcessing(false);
};
if (!user) {
// Redirect to login if not authenticated
window.location.href = `/login?redirect=/device/approve?userCode=${userCode}`;
return null;
}
return (
<div>
<h2>Device Authorization Request</h2>
<p>A device is requesting access to your account.</p>
<p>Code: {userCode}</p>
<button onClick={handleApprove} disabled={isProcessing}>
Approve
</button>
<button onClick={handleDeny} disabled={isProcessing}>
Deny
</button>
</div>
);
}
```
## Advanced Configuration
### Client Validation
You can validate client IDs to ensure only authorized applications can use the device flow:
```ts
deviceAuthorization({
validateClient: async (clientId) => {
// Check if client is authorized
const client = await db.oauth_clients.findOne({ id: clientId });
return client && client.allowDeviceFlow;
},
onDeviceAuthRequest: async (clientId, scope) => {
// Log device authorization requests
await logDeviceAuthRequest(clientId, scope);
},
})
```
### Custom Code Generation
Customize how device and user codes are generated:
```ts
deviceAuthorization({
generateDeviceCode: async () => {
// Custom device code generation
return crypto.randomBytes(32).toString("hex");
},
generateUserCode: async () => {
// Custom user code generation
// Default uses: ABCDEFGHJKLMNPQRSTUVWXYZ23456789
// (excludes 0, O, 1, I to avoid confusion)
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 8; i++) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
},
})
```
## Error Handling
The device flow defines specific error codes:
| Error Code | Description |
|------------|-------------|
| `authorization_pending` | User hasn't approved yet (continue polling) |
| `slow_down` | Polling too frequently (increase interval) |
| `expired_token` | Device code has expired |
| `access_denied` | User denied the authorization |
| `invalid_grant` | Invalid device code or client ID |
## Example: CLI Application
Here's a complete example for a CLI application based on the actual demo:
```ts title="cli-auth.ts"
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import open from "open";
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [deviceAuthorizationClient()],
});
async function authenticateCLI() {
console.log("🔐 Better Auth Device Authorization Demo");
console.log("⏳ Requesting device authorization...");
try {
// Request device code
const { data, error } = await authClient.device.code({
client_id: "demo-cli",
scope: "openid profile email",
});
if (error || !data) {
console.error("❌ Error:", error?.error_description);
process.exit(1);
}
const {
device_code,
user_code,
verification_uri,
verification_uri_complete,
interval = 5,
} = data;
console.log("\n📱 Device Authorization in Progress");
console.log(`Please visit: ${verification_uri}`);
console.log(`Enter code: ${user_code}\n`);
// Open browser with the complete URL
const urlToOpen = verification_uri_complete || verification_uri;
if (urlToOpen) {
console.log("🌐 Opening browser...");
await open(urlToOpen);
}
console.log(`⏳ Waiting for authorization... (polling every ${interval}s)`);
// Poll for token
await pollForToken(device_code, interval);
} catch (err) {
console.error("❌ Error:", err.message);
process.exit(1);
}
}
async function pollForToken(deviceCode: string, interval: number) {
let pollingInterval = interval;
return new Promise<void>((resolve) => {
const poll = async () => {
try {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: "demo-cli",
});
if (data?.access_token) {
console.log("\n✅ Authorization Successful!");
console.log("Access token received!");
// Get user session
const { data: session } = await authClient.getSession({
fetchOptions: {
headers: {
Authorization: `Bearer ${data.access_token}`,
},
},
});
console.log(`Hello, ${session?.user?.name || "User"}!`);
resolve();
process.exit(0);
} else if (error) {
switch (error.error) {
case "authorization_pending":
// Continue polling silently
break;
case "slow_down":
pollingInterval += 5;
console.log(`⚠️ Slowing down polling to ${pollingInterval}s`);
break;
case "access_denied":
console.error("❌ Access was denied by the user");
process.exit(1);
break;
case "expired_token":
console.error("❌ The device code has expired. Please try again.");
process.exit(1);
break;
default:
console.error("❌ Error:", error.error_description);
process.exit(1);
}
}
} catch (err) {
console.error("❌ Network error:", err.message);
process.exit(1);
}
// Schedule next poll
setTimeout(poll, pollingInterval * 1000);
};
// Start polling
setTimeout(poll, pollingInterval * 1000);
});
}
// Run the authentication flow
authenticateCLI().catch((err) => {
console.error("❌ Fatal error:", err);
process.exit(1);
});
```
## Security Considerations
1. **Rate Limiting**: The plugin enforces polling intervals to prevent abuse
2. **Code Expiration**: Device and user codes expire after the configured time (default: 30 minutes)
3. **Client Validation**: Always validate client IDs in production to prevent unauthorized access
4. **HTTPS Only**: Always use HTTPS in production for device authorization
5. **User Code Format**: User codes use a limited character set (excluding similar-looking characters like 0/O, 1/I) to reduce typing errors
6. **Authentication Required**: Users must be authenticated before they can approve or deny device requests
## Options
### Server
**expiresIn**: The expiration time for device codes. Default: `"30m"` (30 minutes).
**interval**: The minimum polling interval. Default: `"5s"` (5 seconds).
**userCodeLength**: The length of the user code. Default: `8`.
**deviceCodeLength**: The length of the device code. Default: `40`.
**generateDeviceCode**: Custom function to generate device codes. Returns a string or `Promise<string>`.
**generateUserCode**: Custom function to generate user codes. Returns a string or `Promise<string>`.
**validateClient**: Function to validate client IDs. Takes a clientId and returns boolean or `Promise<boolean>`.
**onDeviceAuthRequest**: Hook called when device authorization is requested. Takes clientId and optional scope.
### Client
No client-specific configuration options. The plugin adds the following methods:
- **device.code()**: Request device and user codes
- **device.token()**: Poll for access token
- **device.deviceVerify()**: Verify user code validity
- **device.deviceApprove()**: Approve device (requires authentication)
- **device.deviceDeny()**: Deny device (requires authentication)
## Schema
The plugin requires a new table to store device authorization data.
Table Name: `deviceCode`
<DatabaseTable
fields={[
{
name: "id",
type: "string",
description: "Unique identifier for the device authorization request",
isPrimaryKey: true
},
{
name: "deviceCode",
type: "string",
description: "The device verification code",
},
{
name: "userCode",
type: "string",
description: "The user-friendly code for verification",
},
{
name: "userId",
type: "string",
description: "The ID of the user who approved/denied",
isOptional: true,
isForeignKey: true
},
{
name: "clientId",
type: "string",
description: "The OAuth client identifier",
isOptional: true
},
{
name: "scope",
type: "string",
description: "Requested OAuth scopes",
isOptional: true
},
{
name: "status",
type: "string",
description: "Current status: pending, approved, or denied",
},
{
name: "expiresAt",
type: "Date",
description: "When the device code expires",
},
{
name: "lastPolledAt",
type: "Date",
description: "Last time the device polled for status",
isOptional: true
},
{
name: "pollingInterval",
type: "number",
description: "Minimum seconds between polls",
isOptional: true
},
{
name: "createdAt",
type: "Date",
description: "When the request was created",
},
{
name: "updatedAt",
type: "Date",
description: "When the request was last updated",
}
]}
/>

View File

@@ -96,6 +96,7 @@ export default defineBuildConfig({
"./src/plugins/bearer/index.ts",
"./src/plugins/captcha/index.ts",
"./src/plugins/custom-session/index.ts",
"./src/plugins/device-authorization/index.ts",
"./src/plugins/email-otp/index.ts",
"./src/plugins/generic-oauth/index.ts",
"./src/plugins/jwt/index.ts",

View File

@@ -537,6 +537,16 @@
"types": "./dist/plugins/siwe/index.d.cts",
"default": "./dist/plugins/siwe/index.cjs"
}
},
"./plugins/device-authorization": {
"import": {
"types": "./dist/plugins/device-authorization/index.d.ts",
"default": "./dist/plugins/device-authorization/index.mjs"
},
"require": {
"types": "./dist/plugins/device-authorization/index.d.cts",
"default": "./dist/plugins/device-authorization/index.cjs"
}
}
},
"typesVersions": {
@@ -631,6 +641,9 @@
"plugins/bearer": [
"./dist/plugins/bearer/index.d.ts"
],
"plugins/custom-session": [
"./dist/plugins/custom-session/index.d.ts"
],
"plugins/email-otp": [
"./dist/plugins/email-otp/index.d.ts"
],
@@ -678,6 +691,9 @@
],
"plugins/siwe": [
"./dist/plugins/siwe/index.d.ts"
],
"plugins/device-authorization": [
"./dist/plugins/device-authorization/index.d.ts"
]
}
},

View File

@@ -19,4 +19,5 @@ export * from "../../plugins/oidc-provider/client";
export * from "../../plugins/api-key/client";
export * from "../../plugins/one-time-token/client";
export * from "../../plugins/siwe/client";
export * from "../../plugins/device-authorization/client";
export type * from "@simplewebauthn/server";

View File

@@ -0,0 +1,16 @@
import type { deviceAuthorization } from ".";
import type { BetterAuthClientPlugin } from "../../client/types";
export const deviceAuthorizationClient = () => {
return {
id: "device-authorization",
$InferServerPlugin: {} as ReturnType<typeof deviceAuthorization>,
pathMethods: {
"/device/code": "POST",
"/device/token": "POST",
"/device": "GET",
"/device/approve": "POST",
"/device/deny": "POST",
},
} satisfies BetterAuthClientPlugin;
};

View File

@@ -0,0 +1,511 @@
import { describe, expect, it, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { $deviceAuthorizationOptionsSchema, deviceAuthorization } from ".";
import { deviceAuthorizationClient } from "./client";
describe("device authorization plugin input validation", () => {
it("basic validation", async () => {
const options = $deviceAuthorizationOptionsSchema.parse({});
expect(options).toMatchInlineSnapshot(`
{
"deviceCodeLength": 40,
"expiresIn": "30m",
"interval": "5s",
"userCodeLength": 8,
}
`);
});
it("should validate custom options", async () => {
const options = $deviceAuthorizationOptionsSchema.parse({
expiresIn: 60 * 1000,
interval: 2 * 1000,
deviceCodeLength: 50,
userCodeLength: 10,
});
expect(options).toMatchInlineSnapshot(`
{
"deviceCodeLength": 50,
"expiresIn": 60000,
"interval": 2000,
"userCodeLength": 10,
}
`);
});
});
describe("client validation", async () => {
const validClients = ["valid-client-1", "valid-client-2"];
const { auth } = await getTestInstance({
plugins: [
deviceAuthorization({
validateClient: async (clientId) => {
return validClients.includes(clientId);
},
}),
],
});
it("should reject invalid client in device code request", async () => {
await expect(
auth.api.deviceCode({
body: {
client_id: "invalid-client",
},
}),
).rejects.toMatchObject({
body: {
error: "invalid_client",
error_description: "Invalid client ID",
},
});
});
it("should accept valid client in device code request", async () => {
const response = await auth.api.deviceCode({
body: {
client_id: "valid-client-1",
},
});
expect(response.device_code).toBeDefined();
});
it("should reject invalid client in token request", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "valid-client-1",
},
});
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: "invalid-client",
},
}),
).rejects.toMatchObject({
body: {
error: "invalid_grant",
error_description: "Invalid client ID",
},
});
});
it("should reject mismatched client_id in token request", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "valid-client-1",
},
});
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: "valid-client-2",
},
}),
).rejects.toMatchObject({
body: {
error: "invalid_grant",
error_description: "Client ID mismatch",
},
});
});
});
describe("device authorization flow", async () => {
const { auth, client, sessionSetter, signInWithTestUser } =
await getTestInstance(
{
plugins: [
deviceAuthorization({
expiresIn: "5min",
interval: "2s",
}),
],
},
{
clientOptions: {
plugins: [deviceAuthorizationClient()],
},
},
);
describe("device code request", () => {
it("should generate device and user codes", async () => {
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
expect(response.device_code).toBeDefined();
expect(response.user_code).toBeDefined();
expect(response.verification_uri).toBeDefined();
expect(response.verification_uri_complete).toBeDefined();
expect(response.expires_in).toBe(300);
expect(response.interval).toBe(2);
expect(response.user_code).toMatch(/^[A-Z0-9]{8}$/);
expect(response.verification_uri_complete).toContain(response.user_code);
});
it("should support custom client ID and scope", async () => {
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
scope: "read write",
},
});
expect(response.device_code).toBeDefined();
expect(response.user_code).toBeDefined();
});
});
describe("device token polling", () => {
it("should return authorization_pending when not approved", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "authorization_pending",
error_description: "Authorization pending",
},
});
});
it("should return expired_token for expired device codes", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
// Advance time past expiration
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(301 * 1000); // 301 seconds
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "expired_token",
error_description: "Device code has expired",
},
});
vi.useRealTimers();
});
it("should return error for invalid device code", async () => {
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: "invalid-code",
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "invalid_grant",
error_description: "Invalid device code",
},
});
});
});
describe("device verification", () => {
it("should verify valid user code", async () => {
const { user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
const response = await auth.api.deviceVerify({
query: { user_code },
});
expect("error" in response).toBe(false);
if (!("error" in response)) {
expect(response.user_code).toBe(user_code);
expect(response.status).toBe("pending");
}
});
it("should handle invalid user code", async () => {
await expect(
auth.api.deviceVerify({
query: { user_code: "INVALID" },
}),
).rejects.toMatchObject({
body: {
error: "invalid_request",
error_description: "Invalid user code",
},
});
});
});
describe("device approval flow", () => {
it("should approve device and create session", async () => {
// First, sign in as a user
const { headers } = await signInWithTestUser();
// Request device code
const { device_code, user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
// Approve the device
const approveResponse = await auth.api.deviceApprove({
body: { userCode: user_code },
headers,
});
expect("success" in approveResponse && approveResponse.success).toBe(
true,
);
// Poll for token should now succeed
const tokenResponse = await auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
});
// Check OAuth 2.0 compliant response
expect("access_token" in tokenResponse).toBe(true);
if ("access_token" in tokenResponse) {
expect(tokenResponse.access_token).toBeDefined();
expect(tokenResponse.token_type).toBe("Bearer");
expect(tokenResponse.expires_in).toBeGreaterThan(0);
expect(tokenResponse.scope).toBeDefined();
}
});
it("should deny device authorization", async () => {
const { device_code, user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
// Deny the device
const denyResponse = await auth.api.deviceDeny({
body: { userCode: user_code },
headers: new Headers(),
});
expect("success" in denyResponse && denyResponse.success).toBe(true);
// Poll for token should return access_denied
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "access_denied",
error_description: "Access denied",
},
});
});
it("should require authentication for approval", async () => {
const { user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
await expect(
auth.api.deviceApprove({
body: { userCode: user_code },
headers: new Headers(),
}),
).rejects.toMatchObject({
body: {
error: "unauthorized",
error_description: "Authentication required",
},
});
});
it("should enforce rate limiting with slow_down error", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
await auth.api
.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
})
.catch(
// ignore the error
() => {},
);
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "slow_down",
error_description: "Polling too frequently",
},
});
});
});
describe("edge cases", () => {
it("should not allow approving already processed device code", async () => {
// Sign in as a user
const { headers } = await signInWithTestUser();
// Request and approve device
const { user_code: userCode } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
await auth.api.deviceApprove({
body: { userCode },
headers,
});
await expect(
auth.api.deviceApprove({
body: { userCode },
headers,
}),
).rejects.toMatchObject({
body: {
error: "invalid_request",
error_description: "Device code already processed",
},
});
});
it("should handle user code without dashes", async () => {
const { user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
const cleanUserCode = user_code.replace(/-/g, "");
const response = await auth.api.deviceVerify({
query: { user_code: cleanUserCode },
});
expect("status" in response && response.status).toBe("pending");
});
it("should store and use scope from device code request", async () => {
const { headers } = await signInWithTestUser();
const { device_code, user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
scope: "read write profile",
},
});
await auth.api.deviceApprove({
body: { userCode: user_code },
headers,
});
const tokenResponse = await auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
});
expect("scope" in tokenResponse && tokenResponse.scope).toBe(
"read write profile",
);
});
});
});
describe("device authorization with custom options", async () => {
it("should use custom code generators", async () => {
const customDeviceCode = "custom-device-code-12345";
const customUserCode = "CUSTOM12";
const { auth } = await getTestInstance({
plugins: [
deviceAuthorization({
generateDeviceCode: () => customDeviceCode,
generateUserCode: () => customUserCode,
}),
],
});
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
expect(response.device_code).toBe(customDeviceCode);
expect(response.user_code).toBe(customUserCode);
});
it("should respect custom expiration time", async () => {
const { auth } = await getTestInstance({
plugins: [
deviceAuthorization({
expiresIn: "1min",
}),
],
});
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
expect(response.expires_in).toBe(60);
});
});

View File

@@ -0,0 +1,904 @@
import { z } from "zod/v4";
import { APIError } from "better-call";
import { createAuthEndpoint } from "../../api/call";
import type { BetterAuthPlugin, InferOptionSchema } from "../../types/plugins";
import { generateRandomString } from "../../crypto";
import { getSessionFromCtx } from "../../api/routes/session";
import { ms, type StringValue as MSStringValue } from "../../utils/time/ms";
import { getRandomValues } from "@better-auth/utils";
import { schema, type DeviceCode } from "./schema";
import { mergeSchema } from "../../db";
const msStringValueSchema = z.custom<MSStringValue>(
(val) => {
try {
ms(val as MSStringValue);
} catch (e) {
return false;
}
return true;
},
{
message:
"Invalid time string format. Use formats like '30m', '5s', '1h', etc.",
},
);
export const $deviceAuthorizationOptionsSchema = z.object({
expiresIn: msStringValueSchema
.default("30m")
.describe(
"Time in seconds until the device code expires. Use formats like '30m', '5s', '1h', etc.",
),
interval: msStringValueSchema
.default("5s")
.describe(
"Time in seconds between polling attempts. Use formats like '30m', '5s', '1h', etc.",
),
deviceCodeLength: z
.number()
.int()
.positive()
.default(40)
.describe(
"Length of the device code to be generated. Default is 40 characters.",
),
userCodeLength: z
.number()
.int()
.positive()
.default(8)
.describe(
"Length of the user code to be generated. Default is 8 characters.",
),
generateDeviceCode: z
.custom<() => string | Promise<string>>(
(val) => typeof val === "function",
{
message:
"generateDeviceCode must be a function that returns a string or a promise that resolves to a string.",
},
)
.optional()
.describe(
"Function to generate a device code. If not provided, a default random string generator will be used.",
),
generateUserCode: z
.custom<() => string | Promise<string>>(
(val) => typeof val === "function",
{
message:
"generateUserCode must be a function that returns a string or a promise that resolves to a string.",
},
)
.optional()
.describe(
"Function to generate a user code. If not provided, a default random string generator will be used.",
),
validateClient: z
.custom<(clientId: string) => boolean | Promise<boolean>>(
(val) => typeof val === "function",
{
message:
"validateClient must be a function that returns a boolean or a promise that resolves to a boolean.",
},
)
.optional()
.describe(
"Function to validate the client ID. If not provided, no validation will be performed.",
),
onDeviceAuthRequest: z
.custom<
(clientId: string, scope: string | undefined) => void | Promise<void>
>((val) => typeof val === "function", {
message:
"onDeviceAuthRequest must be a function that returns void or a promise that resolves to void.",
})
.optional()
.describe(
"Function to handle device authorization requests. If not provided, no additional actions will be taken.",
),
schema: z.custom<InferOptionSchema<typeof schema>>(() => true),
});
/**
* @see {$deviceAuthorizationOptionsSchema}
*/
export type DeviceAuthorizationOptions = {
expiresIn: MSStringValue;
interval: MSStringValue;
deviceCodeLength: number;
userCodeLength: number;
generateDeviceCode?: () => string | Promise<string>;
generateUserCode?: () => string | Promise<string>;
validateClient?: (clientId: string) => boolean | Promise<boolean>;
onDeviceAuthRequest?: (
clientId: string,
scope: string | undefined,
) => void | Promise<void>;
schema?: InferOptionSchema<typeof schema>;
};
export { deviceAuthorizationClient } from "./client";
const DEVICE_AUTHORIZATION_ERROR_CODES = {
INVALID_DEVICE_CODE: "Invalid device code",
EXPIRED_DEVICE_CODE: "Device code has expired",
EXPIRED_USER_CODE: "User code has expired",
AUTHORIZATION_PENDING: "Authorization pending",
ACCESS_DENIED: "Access denied",
INVALID_USER_CODE: "Invalid user code",
DEVICE_CODE_ALREADY_PROCESSED: "Device code already processed",
POLLING_TOO_FREQUENTLY: "Polling too frequently",
USER_NOT_FOUND: "User not found",
FAILED_TO_CREATE_SESSION: "Failed to create session",
INVALID_DEVICE_CODE_STATUS: "Invalid device code status",
AUTHENTICATION_REQUIRED: "Authentication required",
} as const;
const defaultCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/**
* @internal
*/
const defaultGenerateDeviceCode = (length: number) => {
return generateRandomString(length, "a-z", "A-Z", "0-9");
};
/**
* @internal
*/
const defaultGenerateUserCode = (length: number) => {
const chars = new Uint8Array(length);
return Array.from(getRandomValues(chars))
.map((byte) => defaultCharset[byte % defaultCharset.length])
.join("");
};
export const deviceAuthorization = (
options: Partial<DeviceAuthorizationOptions> = {},
) => {
const opts = $deviceAuthorizationOptionsSchema.parse(options);
const generateDeviceCode = async () => {
if (opts.generateDeviceCode) {
return opts.generateDeviceCode();
}
return defaultGenerateDeviceCode(opts.deviceCodeLength);
};
const generateUserCode = async () => {
if (opts.generateUserCode) {
return opts.generateUserCode();
}
return defaultGenerateUserCode(opts.userCodeLength);
};
return {
id: "device-authorization",
schema: mergeSchema(schema, options?.schema),
endpoints: {
deviceCode: createAuthEndpoint(
"/device/code",
{
method: "POST",
body: z.object({
client_id: z.string().meta({
description: "The client ID of the application",
}),
scope: z
.string()
.meta({
description: "Space-separated list of scopes",
})
.optional(),
}),
error: z.object({
error: z.enum(["invalid_request", "invalid_client"]).meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
metadata: {
openapi: {
description: `Request a device and user code
Follow [rfc8628#section-3.2](https://datatracker.ietf.org/doc/html/rfc8628#section-3.2)`,
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
device_code: {
type: "string",
description: "The device verification code",
},
user_code: {
type: "string",
description: "The user code to display",
},
verification_uri: {
type: "string",
description: "The URL for user verification",
},
verification_uri_complete: {
type: "string",
description: "The complete URL with user code",
},
expires_in: {
type: "number",
description:
"Lifetime in seconds of the device code",
},
interval: {
type: "number",
description: "Minimum polling interval in seconds",
},
},
},
},
},
},
400: {
description: "Error response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
enum: ["invalid_request", "invalid_client"],
},
error_description: {
type: "string",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
if (opts.validateClient) {
const isValid = await opts.validateClient(ctx.body.client_id);
if (!isValid) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client",
error_description: "Invalid client ID",
});
}
}
if (opts.onDeviceAuthRequest) {
await opts.onDeviceAuthRequest(ctx.body.client_id, ctx.body.scope);
}
const deviceCode = await generateDeviceCode();
const userCode = await generateUserCode();
const expiresIn = ms(opts.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn);
await ctx.context.adapter.create({
model: "deviceCode",
data: {
deviceCode,
userCode,
expiresAt,
status: "pending",
pollingInterval: opts.interval,
clientId: ctx.body.client_id,
scope: ctx.body.scope,
},
});
const baseURL = new URL(ctx.context.baseURL);
const verification_uri = new URL("/device", baseURL);
const verification_uri_complete = new URL(verification_uri);
verification_uri_complete.searchParams.set(
"user_code",
// should we support custom formatting function here?
encodeURIComponent(userCode),
);
return ctx.json(
{
device_code: deviceCode,
user_code: userCode,
verification_uri: verification_uri.toString(),
verification_uri_complete: verification_uri_complete.toString(),
expires_in: Math.floor(expiresIn / 1000),
interval: Math.floor(ms(opts.interval) / 1000),
},
{
headers: {
"Cache-Control": "no-store",
},
},
);
},
),
deviceToken: createAuthEndpoint(
"/device/token",
{
method: "POST",
body: z.object({
grant_type: z
.literal("urn:ietf:params:oauth:grant-type:device_code")
.meta({
description: "The grant type for device flow",
}),
device_code: z.string().meta({
description: "The device verification code",
}),
client_id: z.string().meta({
description: "The client ID of the application",
}),
}),
error: z.object({
error: z
.enum([
"authorization_pending",
"slow_down",
"expired_token",
"access_denied",
"invalid_request",
"invalid_grant",
])
.meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
metadata: {
openapi: {
description: `Exchange device code for access token
Follow [rfc8628#section-3.4](https://datatracker.ietf.org/doc/html/rfc8628#section-3.4)`,
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
session: {
$ref: "#/components/schemas/Session",
},
user: {
$ref: "#/components/schemas/User",
},
},
},
},
},
},
400: {
description: "Error response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
enum: [
"authorization_pending",
"slow_down",
"expired_token",
"access_denied",
"invalid_request",
"invalid_grant",
],
},
error_description: {
type: "string",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
const { device_code, client_id } = ctx.body;
if (opts.validateClient) {
const isValid = await opts.validateClient(client_id);
if (!isValid) {
throw new APIError("BAD_REQUEST", {
error: "invalid_grant",
error_description: "Invalid client ID",
});
}
}
const deviceCodeRecord = await ctx.context.adapter.findOne<{
id: string;
deviceCode: string;
userCode: string;
userId?: string;
expiresAt: Date;
status: string;
lastPolledAt?: Date;
pollingInterval?: number;
clientId?: string;
scope?: string;
}>({
model: "deviceCode",
where: [
{
field: "deviceCode",
value: device_code,
},
],
});
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_grant",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_DEVICE_CODE,
});
}
if (
deviceCodeRecord.clientId &&
deviceCodeRecord.clientId !== client_id
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_grant",
error_description: "Client ID mismatch",
});
}
// Check for rate limiting
if (
deviceCodeRecord.lastPolledAt &&
deviceCodeRecord.pollingInterval
) {
const timeSinceLastPoll =
Date.now() - new Date(deviceCodeRecord.lastPolledAt).getTime();
const minInterval =
typeof deviceCodeRecord.pollingInterval === "string"
? ms(deviceCodeRecord.pollingInterval as MSStringValue)
: deviceCodeRecord.pollingInterval;
if (timeSinceLastPoll < minInterval) {
throw new APIError("BAD_REQUEST", {
error: "slow_down",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.POLLING_TOO_FREQUENTLY,
});
}
}
// Update last polled time
await ctx.context.adapter.update({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
update: {
lastPolledAt: new Date(),
},
});
if (deviceCodeRecord.expiresAt < new Date()) {
await ctx.context.adapter.delete({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
});
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_DEVICE_CODE,
});
}
if (deviceCodeRecord.status === "pending") {
throw new APIError("BAD_REQUEST", {
error: "authorization_pending",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.AUTHORIZATION_PENDING,
});
}
if (deviceCodeRecord.status === "denied") {
await ctx.context.adapter.delete({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
});
throw new APIError("BAD_REQUEST", {
error: "access_denied",
error_description: DEVICE_AUTHORIZATION_ERROR_CODES.ACCESS_DENIED,
});
}
if (
deviceCodeRecord.status === "approved" &&
deviceCodeRecord.userId
) {
// Delete the device code after successful authorization
await ctx.context.adapter.delete({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
});
const user = await ctx.context.internalAdapter.findUserById(
deviceCodeRecord.userId,
);
if (!user) {
throw new APIError("INTERNAL_SERVER_ERROR", {
error: "server_error",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.USER_NOT_FOUND,
});
}
const session = await ctx.context.internalAdapter.createSession(
user.id,
ctx,
);
if (!session) {
throw new APIError("INTERNAL_SERVER_ERROR", {
error: "server_error",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.FAILED_TO_CREATE_SESSION,
});
}
// Return OAuth 2.0 compliant token response
return ctx.json(
{
access_token: session.token,
token_type: "Bearer",
expires_in: Math.floor(
(new Date(session.expiresAt).getTime() - Date.now()) / 1000,
),
scope: deviceCodeRecord.scope || "",
},
{
headers: {
"Cache-Control": "no-store",
Pragma: "no-cache",
},
},
);
}
throw new APIError("INTERNAL_SERVER_ERROR", {
error: "server_error",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_DEVICE_CODE_STATUS,
});
},
),
deviceVerify: createAuthEndpoint(
"/device",
{
method: "GET",
query: z.object({
user_code: z.string().meta({
description: "The user code to verify",
}),
}),
error: z.object({
error: z.enum(["invalid_request"]).meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
metadata: {
openapi: {
description: "Display device verification page",
responses: {
200: {
description: "Verification page HTML",
content: {
"application/json": {
schema: {
type: "object",
properties: {
user_code: {
type: "string",
description: "The user code to verify",
},
status: {
type: "string",
enum: ["pending", "approved", "denied"],
description:
"Current status of the device authorization",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
// This endpoint would typically serve an HTML page for user verification
// For now, we'll return a simple JSON response
const { user_code } = ctx.query;
const cleanUserCode = user_code.replace(/-/g, "");
const deviceCodeRecord =
await ctx.context.adapter.findOne<DeviceCode>({
model: "deviceCode",
where: [
{
field: "userCode",
value: cleanUserCode,
},
],
});
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE,
});
}
if (deviceCodeRecord.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE,
});
}
return ctx.json({
user_code: user_code,
status: deviceCodeRecord.status,
});
},
),
deviceApprove: createAuthEndpoint(
"/device/approve",
{
method: "POST",
body: z.object({
userCode: z.string().meta({
description: "The user code to approve",
}),
}),
error: z.object({
error: z
.enum([
"invalid_request",
"expired_token",
"device_code_already_processed",
])
.meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
requireHeaders: true,
metadata: {
openapi: {
description: "Approve device authorization",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session) {
throw new APIError("UNAUTHORIZED", {
error: "unauthorized",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.AUTHENTICATION_REQUIRED,
});
}
const { userCode } = ctx.body;
const deviceCodeRecord =
await ctx.context.adapter.findOne<DeviceCode>({
model: "deviceCode",
where: [
{
field: "userCode",
value: userCode,
},
],
});
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE,
});
}
if (deviceCodeRecord.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE,
});
}
if (deviceCodeRecord.status !== "pending") {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED,
});
}
// Update device code with approved status and user ID
await ctx.context.adapter.update({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
update: {
status: "approved",
userId: session.user.id,
},
});
return ctx.json({
success: true,
});
},
),
deviceDeny: createAuthEndpoint(
"/device/deny",
{
method: "POST",
body: z.object({
userCode: z.string().meta({
description: "The user code to deny",
}),
}),
metadata: {
openapi: {
description: "Deny device authorization",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
const { userCode } = ctx.body;
const cleanUserCode = userCode.replace(/-/g, "");
const deviceCodeRecord =
await ctx.context.adapter.findOne<DeviceCode>({
model: "deviceCode",
where: [
{
field: "userCode",
value: cleanUserCode,
},
],
});
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE,
});
}
if (deviceCodeRecord.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE,
});
}
if (deviceCodeRecord.status !== "pending") {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED,
});
}
// Update device code with denied status
await ctx.context.adapter.update({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
update: {
status: "denied",
},
});
return ctx.json({
success: true,
});
},
),
},
$ERROR_CODES: DEVICE_AUTHORIZATION_ERROR_CODES,
} satisfies BetterAuthPlugin;
};

View File

@@ -0,0 +1,60 @@
import type { AuthPluginSchema } from "../../types";
import * as z from "zod/v4";
export const schema = {
deviceCode: {
fields: {
deviceCode: {
type: "string",
required: true,
},
userCode: {
type: "string",
required: true,
},
userId: {
type: "string",
required: false,
},
expiresAt: {
type: "date",
required: true,
},
status: {
type: "string",
required: true,
},
lastPolledAt: {
type: "date",
required: false,
},
pollingInterval: {
type: "number",
required: false,
},
clientId: {
type: "string",
required: false,
},
scope: {
type: "string",
required: false,
},
},
},
} satisfies AuthPluginSchema;
export const deviceCode = z.object({
id: z.string(),
deviceCode: z.string(),
userCode: z.string(),
userId: z.string().optional(),
expiresAt: z.date(),
status: z.string(),
lastPolledAt: z.date().optional(),
pollingInterval: z.number().optional(),
clientId: z.string().optional(),
scope: z.string().optional(),
});
export type DeviceCode = z.infer<typeof deviceCode>;

View File

@@ -24,3 +24,4 @@ export * from "./haveibeenpwned";
export * from "./one-time-token";
export * from "./mcp";
export * from "./siwe";
export * from "./device-authorization";

View File

@@ -0,0 +1,236 @@
// https://github.com/vercel/ms/blob/0d5ab182ef22686cb4086fe9b67b1276bcb644ef/src/index.ts
/**
* The MIT License (MIT)
*
* Copyright (c) 2025 Vercel, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// Helpers.
const s = 1000;
const m = s * 60;
const h = m * 60;
const d = h * 24;
const w = d * 7;
const y = d * 365.25;
type Years = "years" | "year" | "yrs" | "yr" | "y";
type Weeks = "weeks" | "week" | "w";
type Days = "days" | "day" | "d";
type Hours = "hours" | "hour" | "hrs" | "hr" | "h";
type Minutes = "minutes" | "minute" | "mins" | "min" | "m";
type Seconds = "seconds" | "second" | "secs" | "sec" | "s";
type Milliseconds = "milliseconds" | "millisecond" | "msecs" | "msec" | "ms";
type Unit = Years | Weeks | Days | Hours | Minutes | Seconds | Milliseconds;
type UnitAnyCase = Capitalize<Unit> | Uppercase<Unit> | Unit;
export type StringValue =
| `${number}`
| `${number}${UnitAnyCase}`
| `${number} ${UnitAnyCase}`;
interface Options {
/**
* Set to `true` to use verbose formatting. Defaults to `false`.
*/
long?: boolean;
}
/**
* Parse or format the given value.
*
* @param value - The string or number to convert
* @param options - Options for the conversion
* @throws Error if `value` is not a non-empty string or a number
*/
export function ms(value: StringValue, options?: Options): number;
export function ms(value: number, options?: Options): string;
export function ms(
value: StringValue | number,
options?: Options,
): number | string {
if (typeof value === "string") {
return parse(value);
} else if (typeof value === "number") {
return format(value, options);
}
throw new Error(
`Value provided to ms() must be a string or number. value=${JSON.stringify(value)}`,
);
}
/**
* Parse the given string and return milliseconds.
*
* @param str - A string to parse to milliseconds
* @returns The parsed value in milliseconds, or `NaN` if the string can't be
* parsed
*/
export function parse(str: string): number {
if (typeof str !== "string" || str.length === 0 || str.length > 100) {
throw new Error(
`Value provided to ms.parse() must be a string with length between 1 and 99. value=${JSON.stringify(str)}`,
);
}
const match =
/^(?<value>-?\d*\.?\d+) *(?<unit>milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(
str,
);
if (!match?.groups) {
return NaN;
}
// Named capture groups need to be manually typed today.
// https://github.com/microsoft/TypeScript/issues/32098
const { value, unit = "ms" } = match.groups as {
value: string;
unit: string | undefined;
};
const n = parseFloat(value);
const matchUnit = unit.toLowerCase() as Lowercase<Unit>;
/* istanbul ignore next - istanbul doesn't understand, but thankfully the TypeScript the exhaustiveness check in the default case keeps us type safe here */
switch (matchUnit) {
case "years":
case "year":
case "yrs":
case "yr":
case "y":
return n * y;
case "weeks":
case "week":
case "w":
return n * w;
case "days":
case "day":
case "d":
return n * d;
case "hours":
case "hour":
case "hrs":
case "hr":
case "h":
return n * h;
case "minutes":
case "minute":
case "mins":
case "min":
case "m":
return n * m;
case "seconds":
case "second":
case "secs":
case "sec":
case "s":
return n * s;
case "milliseconds":
case "millisecond":
case "msecs":
case "msec":
case "ms":
return n;
default:
matchUnit satisfies never;
throw new Error(
`Unknown unit "${matchUnit}" provided to ms.parse(). value=${JSON.stringify(str)}`,
);
}
}
/**
* Parse the given StringValue and return milliseconds.
*
* @param value - A typesafe StringValue to parse to milliseconds
* @returns The parsed value in milliseconds, or `NaN` if the string can't be
* parsed
*/
export function parseStrict(value: StringValue): number {
return parse(value);
}
/**
* Short format for `ms`.
*/
function fmtShort(ms: number): StringValue {
const msAbs = Math.abs(ms);
if (msAbs >= d) {
return `${Math.round(ms / d)}d`;
}
if (msAbs >= h) {
return `${Math.round(ms / h)}h`;
}
if (msAbs >= m) {
return `${Math.round(ms / m)}m`;
}
if (msAbs >= s) {
return `${Math.round(ms / s)}s`;
}
return `${ms}ms`;
}
/**
* Long format for `ms`.
*/
function fmtLong(ms: number): StringValue {
const msAbs = Math.abs(ms);
if (msAbs >= d) {
return plural(ms, msAbs, d, "day");
}
if (msAbs >= h) {
return plural(ms, msAbs, h, "hour");
}
if (msAbs >= m) {
return plural(ms, msAbs, m, "minute");
}
if (msAbs >= s) {
return plural(ms, msAbs, s, "second");
}
return `${ms} ms`;
}
/**
* Format the given integer as a string.
*
* @param ms - milliseconds
* @param options - Options for the conversion
* @returns The formatted string
*/
export function format(ms: number, options?: Options): string {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
throw new Error("Value provided to ms.format() must be of type number.");
}
return options?.long ? fmtLong(ms) : fmtShort(ms);
}
/**
* Pluralization helper.
*/
function plural(
ms: number,
msAbs: number,
n: number,
name: string,
): StringValue {
const isPlural = msAbs >= n * 1.5;
return `${Math.round(ms / n)} ${name}${isPlural ? "s" : ""}` as StringValue;
}

View File

@@ -14,6 +14,7 @@
"build": "unbuild",
"stub": "unbuild --stub",
"start": "node ./dist/index.mjs",
"dev": "tsx ./src/index.ts",
"test": "vitest"
},
"publishConfig": {
@@ -34,6 +35,7 @@
"devDependencies": {
"@types/diff": "^7.0.1",
"@types/fs-extra": "^11.0.4",
"tsx": "^4.20.4",
"typescript": "catalog:",
"unbuild": "catalog:",
"zod": "^4.0.0"
@@ -56,6 +58,7 @@
"drizzle-orm": "^0.33.0",
"fs-extra": "^11.3.0",
"get-tsconfig": "^4.8.1",
"open": "^10.1.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"prompts": "^2.4.2",

View File

@@ -0,0 +1,270 @@
import { Command } from "commander";
import { logger } from "better-auth";
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import chalk from "chalk";
import open from "open";
import yoctoSpinner from "yocto-spinner";
import * as z from "zod/v4";
import { intro, outro, confirm, isCancel, cancel } from "@clack/prompts";
import fs from "fs/promises";
import path from "path";
import os from "os";
const DEMO_URL = "https://demo.better-auth.com";
const CLIENT_ID = "better-auth-cli";
const CONFIG_DIR = path.join(os.homedir(), ".better-auth");
const TOKEN_FILE = path.join(CONFIG_DIR, "token.json");
export async function loginAction(opts: any) {
const options = z
.object({
serverUrl: z.string().optional(),
clientId: z.string().optional(),
})
.parse(opts);
const serverUrl = options.serverUrl || DEMO_URL;
const clientId = options.clientId || CLIENT_ID;
intro(chalk.bold("🔐 Better Auth CLI Login (Demo)"));
console.log(
chalk.yellow(
"⚠️ This is a demo feature for testing device authorization flow.",
),
);
console.log(
chalk.gray(
" It connects to the Better Auth demo server for testing purposes.\n",
),
);
// Check if already logged in
const existingToken = await getStoredToken();
if (existingToken) {
const shouldReauth = await confirm({
message: "You're already logged in. Do you want to log in again?",
initialValue: false,
});
if (isCancel(shouldReauth) || !shouldReauth) {
cancel("Login cancelled");
process.exit(0);
}
}
// Create the auth client
const authClient = createAuthClient({
baseURL: serverUrl,
plugins: [deviceAuthorizationClient()],
});
const spinner = yoctoSpinner({ text: "Requesting device authorization..." });
spinner.start();
try {
// Request device code
const { data, error } = await authClient.device.code({
client_id: clientId,
scope: "openid profile email",
});
spinner.stop();
if (error || !data) {
logger.error(
`Failed to request device authorization: ${error?.error_description || "Unknown error"}`,
);
process.exit(1);
}
const {
device_code,
user_code,
verification_uri,
verification_uri_complete,
interval = 5,
expires_in,
} = data;
// Display authorization instructions
console.log("");
console.log(chalk.cyan("📱 Device Authorization Required"));
console.log("");
console.log(`Please visit: ${chalk.underline.blue(verification_uri)}`);
console.log(`Enter code: ${chalk.bold.green(user_code)}`);
console.log("");
// Ask if user wants to open browser
const shouldOpen = await confirm({
message: "Open browser automatically?",
initialValue: true,
});
if (!isCancel(shouldOpen) && shouldOpen) {
const urlToOpen = verification_uri_complete || verification_uri;
await open(urlToOpen);
}
// Start polling
console.log(
chalk.gray(
`Waiting for authorization (expires in ${Math.floor(expires_in / 60)} minutes)...`,
),
);
const token = await pollForToken(
authClient,
device_code,
clientId,
interval,
);
if (token) {
// Store the token
await storeToken(token);
// Get user info
const { data: session } = await authClient.getSession({
fetchOptions: {
headers: {
Authorization: `Bearer ${token.access_token}`,
},
},
});
outro(
chalk.green(
`✅ Demo login successful! Logged in as ${session?.user?.name || session?.user?.email || "User"}`,
),
);
console.log(
chalk.gray(
"\n📝 Note: This was a demo authentication for testing purposes.",
),
);
}
} catch (err) {
spinner.stop();
logger.error(
`Login failed: ${err instanceof Error ? err.message : "Unknown error"}`,
);
process.exit(1);
}
}
async function pollForToken(
authClient: any,
deviceCode: string,
clientId: string,
initialInterval: number,
): Promise<any> {
let pollingInterval = initialInterval;
const spinner = yoctoSpinner({ text: "", color: "cyan" });
let dots = 0;
return new Promise((resolve, reject) => {
const poll = async () => {
// Update spinner text with animated dots
dots = (dots + 1) % 4;
spinner.text = chalk.gray(
`Polling for authorization${".".repeat(dots)}${" ".repeat(3 - dots)}`,
);
if (!spinner.isSpinning) spinner.start();
try {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: clientId,
fetchOptions: {
headers: {
"user-agent": `Better Auth CLI`,
},
},
});
if (data?.access_token) {
spinner.stop();
resolve(data);
return;
} else if (error) {
switch (error.error) {
case "authorization_pending":
// Continue polling
break;
case "slow_down":
pollingInterval += 5;
spinner.text = chalk.yellow(
`Slowing down polling to ${pollingInterval}s`,
);
break;
case "access_denied":
spinner.stop();
logger.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.");
process.exit(1);
break;
default:
spinner.stop();
logger.error(`Error: ${error.error_description}`);
process.exit(1);
}
}
} catch (err) {
spinner.stop();
logger.error(
`Network error: ${err instanceof Error ? err.message : "Unknown error"}`,
);
process.exit(1);
}
setTimeout(poll, pollingInterval * 1000);
};
// Start polling after initial interval
setTimeout(poll, pollingInterval * 1000);
});
}
async function storeToken(token: any): Promise<void> {
try {
// Ensure config directory exists
await fs.mkdir(CONFIG_DIR, { recursive: true });
// Store token with metadata
const tokenData = {
access_token: token.access_token,
token_type: token.token_type || "Bearer",
scope: token.scope,
created_at: new Date().toISOString(),
};
await fs.writeFile(TOKEN_FILE, JSON.stringify(tokenData, null, 2), "utf-8");
} catch (error) {
logger.warn("Failed to store authentication token locally");
}
}
async function getStoredToken(): Promise<any> {
try {
const data = await fs.readFile(TOKEN_FILE, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
}
export const login = new Command("login")
.description(
"Demo: Test device authorization flow with Better Auth demo server",
)
.option("--server-url <url>", "The Better Auth server URL", DEMO_URL)
.option("--client-id <id>", "The OAuth client ID", CLIENT_ID)
.action(loginAction);

View File

@@ -6,6 +6,7 @@ import { init } from "./commands/init";
import { migrate } from "./commands/migrate";
import { generate } from "./commands/generate";
import { generateSecret } from "./commands/secret";
import { login } from "./commands/login";
import { getPackageInfo } from "./utils/get-package-info";
import "dotenv/config";
@@ -28,6 +29,7 @@ async function main() {
.addCommand(migrate)
.addCommand(generate)
.addCommand(generateSecret)
.addCommand(login)
.version(packageInfo.version || "1.1.2")
.description("Better Auth CLI")
.action(() => program.help());

158
pnpm-lock.yaml generated
View File

@@ -65,7 +65,7 @@ importers:
version: 5.9.2
vitest:
specifier: 'catalog:'
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
demo/nextjs:
dependencies:
@@ -429,7 +429,7 @@ importers:
version: 0.5.15(next@15.3.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.89.1))(react@19.1.1)
'@vercel/analytics':
specifier: ^1.5.0
version: 1.5.0(@remix-run/react@2.16.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@sveltejs/kit@2.21.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(next@15.3.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.89.1))(react@19.1.1)(svelte@5.36.16)(vue-router@4.5.1(vue@3.5.18(typescript@5.9.2)))(vue@3.5.18(typescript@5.9.2))
version: 1.5.0(@remix-run/react@2.16.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@sveltejs/kit@2.21.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(next@15.3.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.89.1))(react@19.1.1)(svelte@5.36.16)(vue-router@4.5.1(vue@3.5.18(typescript@5.9.2)))(vue@3.5.18(typescript@5.9.2))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -591,7 +591,7 @@ importers:
devDependencies:
'@cloudflare/vitest-pool-workers':
specifier: ^0.8.60
version: 0.8.60(@cloudflare/workers-types@4.20250805.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
version: 0.8.60(@cloudflare/workers-types@4.20250805.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@cloudflare/workers-types':
specifier: ^4.20250805.0
version: 4.20250805.0
@@ -643,7 +643,7 @@ importers:
version: 5.22.0(prisma@5.22.0)
'@tanstack/react-start':
specifier: ^1.131.3
version: 1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
version: 1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
@@ -785,6 +785,9 @@ importers:
get-tsconfig:
specifier: ^4.8.1
version: 4.10.1
open:
specifier: ^10.1.0
version: 10.2.0
prettier:
specifier: ^3.4.2
version: 3.5.3
@@ -810,6 +813,9 @@ importers:
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
tsx:
specifier: ^4.20.4
version: 4.20.4
typescript:
specifier: 'catalog:'
version: 5.9.2
@@ -10426,8 +10432,8 @@ packages:
oniguruma-to-es@4.3.3:
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
open@10.1.2:
resolution: {integrity: sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==}
open@10.2.0:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
open@6.4.0:
@@ -12337,6 +12343,11 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tsx@4.20.4:
resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==}
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
@@ -13075,6 +13086,10 @@ packages:
utf-8-validate:
optional: true
wsl-utils@0.1.0:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
xcode@3.0.1:
resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
engines: {node: '>=10.0.0'}
@@ -13321,7 +13336,7 @@ snapshots:
'@azure/logger': 1.2.0
'@azure/msal-browser': 4.13.0
'@azure/msal-node': 3.6.0
open: 10.1.2
open: 10.2.0
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -14398,7 +14413,7 @@ snapshots:
optionalDependencies:
workerd: 1.20250803.0
'@cloudflare/vitest-pool-workers@0.8.60(@cloudflare/workers-types@4.20250805.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@cloudflare/vitest-pool-workers@0.8.60(@cloudflare/workers-types@4.20250805.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -14407,7 +14422,7 @@ snapshots:
devalue: 4.3.3
miniflare: 4.20250803.0
semver: 7.7.2
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
wrangler: 4.28.0(@cloudflare/workers-types@4.20250805.0)
zod: 3.25.42
transitivePeerDependencies:
@@ -17644,10 +17659,10 @@ snapshots:
acorn: 8.15.0
optional: true
'@sveltejs/kit@2.21.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@sveltejs/kit@2.21.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0)
'@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@types/cookie': 0.6.0
acorn: 8.15.0
cookie: 0.6.0
@@ -17660,29 +17675,29 @@ snapshots:
set-cookie-parser: 2.7.1
sirv: 3.0.1
svelte: 5.36.16
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
optional: true
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
debug: 4.4.1
svelte: 5.36.16
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
transitivePeerDependencies:
- supports-color
optional: true
'@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
debug: 4.4.1
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.17
svelte: 5.36.16
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
transitivePeerDependencies:
- supports-color
optional: true
@@ -17765,7 +17780,7 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.8
'@tanstack/directive-functions-plugin@1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@tanstack/directive-functions-plugin@1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.28.0
@@ -17774,7 +17789,7 @@ snapshots:
'@tanstack/router-utils': 1.131.2
babel-dead-code-elimination: 1.0.10
tiny-invariant: 1.3.3
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
transitivePeerDependencies:
- supports-color
@@ -17809,12 +17824,12 @@ snapshots:
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
'@tanstack/react-start-plugin@1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@tanstack/react-start-plugin@1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@tanstack/start-plugin-core': 1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@vitejs/plugin-react': 4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/start-plugin-core': 1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@vitejs/plugin-react': 4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
pathe: 2.0.3
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
zod: 3.25.42
transitivePeerDependencies:
- '@azure/app-configuration'
@@ -17860,17 +17875,17 @@ snapshots:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
'@tanstack/react-start@1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@tanstack/react-start@1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@tanstack/react-start-client': 1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@tanstack/react-start-plugin': 1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/react-start-plugin': 1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@tanstack/react-start-server': 1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@tanstack/start-server-functions-client': 1.131.3(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/start-server-functions-server': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@vitejs/plugin-react': 4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/start-server-functions-client': 1.131.3(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@tanstack/start-server-functions-server': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@vitejs/plugin-react': 4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -17933,7 +17948,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/router-plugin@1.131.3(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@tanstack/router-plugin@1.131.3(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.0)
@@ -17951,8 +17966,8 @@ snapshots:
zod: 3.25.42
optionalDependencies:
'@tanstack/react-router': 1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite-plugin-solid: 2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
vite-plugin-solid: 2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
transitivePeerDependencies:
- supports-color
@@ -17967,7 +17982,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/server-functions-plugin@1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@tanstack/server-functions-plugin@1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.28.0
@@ -17976,7 +17991,7 @@ snapshots:
'@babel/template': 7.27.2
'@babel/traverse': 7.28.0
'@babel/types': 7.28.2
'@tanstack/directive-functions-plugin': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/directive-functions-plugin': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
babel-dead-code-elimination: 1.0.10
tiny-invariant: 1.3.3
transitivePeerDependencies:
@@ -17991,16 +18006,16 @@ snapshots:
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
'@tanstack/start-plugin-core@1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@tanstack/start-plugin-core@1.131.3(@azure/identity@4.10.0)(@libsql/client@0.12.0)(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@babel/code-frame': 7.26.2
'@babel/core': 7.28.0
'@babel/types': 7.28.2
'@tanstack/router-core': 1.131.3
'@tanstack/router-generator': 1.131.3
'@tanstack/router-plugin': 1.131.3(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/router-plugin': 1.131.3(@tanstack/react-router@1.131.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@tanstack/router-utils': 1.131.2
'@tanstack/server-functions-plugin': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/server-functions-plugin': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@tanstack/start-server-core': 1.131.3
'@types/babel__code-frame': 7.0.6
'@types/babel__core': 7.20.5
@@ -18010,8 +18025,8 @@ snapshots:
nitropack: 2.11.12(@azure/identity@4.10.0)(@libsql/client@0.12.0)(better-sqlite3@11.10.0)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250805.0)(@libsql/client@0.12.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@18.3.23)(better-sqlite3@11.10.0)(bun-types@1.2.20(@types/react@18.3.23))(kysely@0.28.5)(mysql2@3.14.3)(pg@8.16.3)(prisma@5.22.0)(react@19.1.1))(mysql2@3.14.3)
pathe: 2.0.3
ufo: 1.6.1
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
xmlbuilder2: 3.1.1
zod: 3.25.42
transitivePeerDependencies:
@@ -18058,9 +18073,9 @@ snapshots:
tiny-warning: 1.0.3
unctx: 2.4.1
'@tanstack/start-server-functions-client@1.131.3(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@tanstack/start-server-functions-client@1.131.3(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@tanstack/server-functions-plugin': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/server-functions-plugin': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@tanstack/start-server-functions-fetcher': 1.131.3
transitivePeerDependencies:
- supports-color
@@ -18071,9 +18086,9 @@ snapshots:
'@tanstack/router-core': 1.131.3
'@tanstack/start-client-core': 1.131.3
'@tanstack/start-server-functions-server@1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@tanstack/start-server-functions-server@1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@tanstack/server-functions-plugin': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@tanstack/server-functions-plugin': 1.131.2(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
tiny-invariant: 1.3.3
transitivePeerDependencies:
- supports-color
@@ -18582,10 +18597,10 @@ snapshots:
'@urql/core': 5.2.0(graphql@15.10.1)
wonka: 6.3.5
'@vercel/analytics@1.5.0(@remix-run/react@2.16.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@sveltejs/kit@2.21.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(next@15.3.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.89.1))(react@19.1.1)(svelte@5.36.16)(vue-router@4.5.1(vue@3.5.18(typescript@5.9.2)))(vue@3.5.18(typescript@5.9.2))':
'@vercel/analytics@1.5.0(@remix-run/react@2.16.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@sveltejs/kit@2.21.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(next@15.3.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.89.1))(react@19.1.1)(svelte@5.36.16)(vue-router@4.5.1(vue@3.5.18(typescript@5.9.2)))(vue@3.5.18(typescript@5.9.2))':
optionalDependencies:
'@remix-run/react': 2.16.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)
'@sveltejs/kit': 2.21.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@sveltejs/kit': 2.21.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)))(svelte@5.36.16)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
next: 15.3.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.89.1)
react: 19.1.1
svelte: 5.36.16
@@ -18630,7 +18645,7 @@ snapshots:
- rollup
- supports-color
'@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0)
@@ -18638,7 +18653,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.9
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
transitivePeerDependencies:
- supports-color
@@ -18650,13 +18665,13 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))':
'@vitest/mocker@3.2.4(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -24225,12 +24240,12 @@ snapshots:
regex: 6.0.1
regex-recursion: 6.0.2
open@10.1.2:
open@10.2.0:
dependencies:
default-browser: 5.2.1
define-lazy-prop: 3.0.0
is-inside-container: 1.0.0
is-wsl: 3.1.0
wsl-utils: 0.1.0
open@6.4.0:
dependencies:
@@ -26542,6 +26557,13 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tsx@4.20.4:
dependencies:
esbuild: 0.25.5
get-tsconfig: 4.10.1
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
@@ -27014,13 +27036,13 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-node@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0):
vite-node@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -27035,7 +27057,7 @@ snapshots:
- tsx
- yaml
vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)):
vite-plugin-solid@2.11.6(solid-js@1.9.8)(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)):
dependencies:
'@babel/core': 7.28.0
'@types/babel__core': 7.20.5
@@ -27043,13 +27065,13 @@ snapshots:
merge-anything: 5.1.7
solid-js: 1.9.8
solid-refresh: 0.6.3(solid-js@1.9.8)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
vitefu: 1.1.1(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
transitivePeerDependencies:
- supports-color
optional: true
vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0):
vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0):
dependencies:
esbuild: 0.25.5
fdir: 6.4.5(picomatch@4.0.2)
@@ -27065,18 +27087,18 @@ snapshots:
lightningcss: 1.30.1
sass: 1.89.1
terser: 5.40.0
tsx: 4.19.4
tsx: 4.20.4
yaml: 2.8.0
vitefu@1.1.1(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)):
vitefu@1.1.1(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)):
optionalDependencies:
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@15.11.7)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0))
'@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -27094,8 +27116,8 @@ snapshots:
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0)
vite: 6.3.5(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.1)(terser@5.40.0)(tsx@4.20.4)(yaml@2.8.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
@@ -27357,6 +27379,10 @@ snapshots:
ws@8.18.3: {}
wsl-utils@0.1.0:
dependencies:
is-wsl: 3.1.0
xcode@3.0.1:
dependencies:
simple-plist: 1.3.1