mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 12:27:43 +00:00
feat: support device authorization (#3811)
This commit is contained in:
16
demo/nextjs/app/(auth)/sign-in/loading.tsx
Normal file
16
demo/nextjs/app/(auth)/sign-in/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
121
demo/nextjs/app/device/approve/page.tsx
Normal file
121
demo/nextjs/app/device/approve/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
demo/nextjs/app/device/denied/page.tsx
Normal file
35
demo/nextjs/app/device/denied/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
demo/nextjs/app/device/layout.tsx
Normal file
17
demo/nextjs/app/device/layout.tsx
Normal 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;
|
||||
}
|
||||
94
demo/nextjs/app/device/page.tsx
Normal file
94
demo/nextjs/app/device/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
demo/nextjs/app/device/success/page.tsx
Normal file
36
demo/nextjs/app/device/success/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
router.push("/dashboard");
|
||||
if (typeof params.callbackUrl === "string") {
|
||||
router.push(params.callbackUrl);
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
19
demo/nextjs/lib/shared.ts
Normal 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";
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
654
docs/content/docs/plugins/device-authorization.mdx
Normal file
654
docs/content/docs/plugins/device-authorization.mdx
Normal 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",
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
904
packages/better-auth/src/plugins/device-authorization/index.ts
Normal file
904
packages/better-auth/src/plugins/device-authorization/index.ts
Normal 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;
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -24,3 +24,4 @@ export * from "./haveibeenpwned";
|
||||
export * from "./one-time-token";
|
||||
export * from "./mcp";
|
||||
export * from "./siwe";
|
||||
export * from "./device-authorization";
|
||||
|
||||
236
packages/better-auth/src/utils/time/ms.ts
Normal file
236
packages/better-auth/src/utils/time/ms.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
270
packages/cli/src/commands/login.ts
Normal file
270
packages/cli/src/commands/login.ts
Normal 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);
|
||||
@@ -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
158
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user