refactor(web): replace Auth.js with stateless Better Auth #106

Merged
eleith merged 1 commits from refactor/better-auth into main 2026-10-30 01:06:49 +00:00
23 changed files with 936 additions and 340 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@when/web': patch
---
Replace Auth.js with stateless Better Auth for admin sign-in, including native request throttling and fixed seven-day sessions. Existing sessions require signing in again.
+22 -3
View File
@@ -1,5 +1,5 @@
import { expect, test } from './support/test.ts';
import { ADMIN, signInAsAdmin } from './support/auth.ts';
import { ADMIN, signInAsAdminViaForm } from './support/auth.ts';
test('an admin page sends a signed-out visitor to sign in', async ({ page }) => {
await page.goto('/admin');
@@ -20,11 +20,30 @@ test('the wrong password is rejected', async ({ page }) => {
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid username or password');
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeEnabled();
await expect(page).not.toHaveURL('/admin');
});
test('the fixture credentials reach the admin dashboard', async ({ page }) => {
await signInAsAdmin(page);
test('the fixture credentials reach the admin dashboard without a pending banner flash', async ({
page
}) => {
let releaseLogin!: () => void;
const waiting = new Promise<void>((resolve) => {
releaseLogin = resolve;
});
await page.route('**/auth/sign-in/credentials', async (route) => {
await waiting;
await route.continue();
});
const signingIn = signInAsAdminViaForm(page);
try {
await expect(page.locator('.signin-form')).toHaveAttribute('aria-busy', 'true');
await expect(page.getByRole('button', { name: 'Signing in…' })).toBeDisabled();
await expect(page.locator('.error-banner')).toHaveCount(0);
} finally {
releaseLogin();
await signingIn;
}
await expect(page).toHaveURL('/admin');
});
+31 -3
View File
@@ -1,12 +1,40 @@
import type { Page } from '@playwright/test';
import type { Cookie, Page } from '@playwright/test';
// Matches auth.credentials in the fixture config.
export const ADMIN = { username: 'admin', password: 'e2e-admin-password' };
export async function signInAsAdmin(page: Page): Promise<void> {
await page.goto('/signin');
// Each worker has its own module state and server. Reuse only stateless auth cookies,
// never a test's theme/timezone preferences or other browser state.
const sessions = new Map<string, Cookie[]>();
const sessionCookie = /^(?:__Secure-)?better-auth\.session_(?:token|data)(?:\.\d+)?$/;
async function submitAdminForm(page: Page): Promise<void> {
await page.getByLabel('Username').fill(ADMIN.username);
await page.getByLabel('Password').fill(ADMIN.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/admin');
}
// Auth-critical coverage must exercise the real form, even when a session is cached.
export async function signInAsAdminViaForm(page: Page): Promise<void> {
await page.goto('/signin');
await submitAdminForm(page);
}
export async function signInAsAdmin(page: Page): Promise<void> {
await page.goto('/signin');
const origin = new URL(page.url()).origin;
const cached = sessions.get(origin);
if (cached) {
await page.context().addCookies(cached);
await page.goto('/admin');
await page.waitForURL('**/admin');
return;
}
await submitAdminForm(page);
sessions.set(
origin,
(await page.context().cookies(origin)).filter((cookie) => sessionCookie.test(cookie.name))
);
}
+6 -7
View File
@@ -261,7 +261,7 @@ test('the nonced head bootstrap applies the saved palette without hydration', as
expect(violations).toEqual([]);
});
test('admin icon actions stay ordered, tappable and separate from the real sign-out form', async ({
test('admin icon actions stay ordered and tappable, and theme changes do not sign out', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
@@ -276,11 +276,6 @@ test('admin icon actions stay ordered, tappable and separate from the real sign-
await expect(toggle.locator('svg')).toHaveCount(1);
await expect(toggle.locator('svg text')).toHaveCount(0);
await expect(page.getByRole('navigation', { name: 'Admin breadcrumb' })).toHaveText('');
await expect(page.locator('.nav-actions > button + form')).toHaveAttribute(
'action',
'/admin?/signout'
);
await expect(page.locator('.signout-form')).toHaveAttribute('method', 'POST');
const toggleBounds = await touchTarget(toggle);
const signoutBounds = await touchTarget(signout);
expect(toggleBounds.x + toggleBounds.width).toBeLessThanOrEqual(signoutBounds.x);
@@ -292,7 +287,9 @@ test('admin icon actions stay ordered, tappable and separate from the real sign-
let signoutPosts = 0;
page.on('request', (request) => {
if (request.method() === 'POST' && request.url().endsWith('/admin?/signout')) signoutPosts++;
if (request.method() === 'POST' && new URL(request.url()).pathname === '/auth/sign-out') {
signoutPosts++;
}
});
await toggle.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
@@ -334,4 +331,6 @@ test('admin icon actions stay ordered, tappable and separate from the real sign-
expect(signoutPosts).toBe(1);
await expect(themeButton(page)).toHaveCount(0);
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
const denied = await page.request.get('/admin', { headers: { accept: 'application/json' } });
expect(denied.status()).toBe(403);
});
+1 -1
View File
@@ -50,7 +50,6 @@
"vitest": "4.1.7"
},
"dependencies": {
"@auth/sveltekit": "1.11.3",
"@dicebear/core": "10.4.0",
"@internationalized/date": "3.12.2",
"@when/calendar": "workspace:*",
@@ -58,6 +57,7 @@
"@when/db": "workspace:*",
"@when/jobs": "workspace:*",
"@when/video-chat": "workspace:*",
"better-auth": "1.7.14",
"bits-ui": "2.18.1",
"kysely": "0.28.17",
"mrmime": "2.0.1",
+8 -2
View File
@@ -1,10 +1,16 @@
import type { WhenSession } from '$lib/server/auth';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
interface Locals {
auth: () => Promise<WhenSession | null>;
}
interface PageData {
session?: WhenSession | null;
}
// interface PageState {}
// interface Platform {}
}
+1 -3
View File
@@ -6,9 +6,7 @@ vi.mock('$lib/server/boot', () => ({ bootApp: async () => {} }));
vi.mock('$lib/server/appearance', () => ({ themeStyleTag: () => '' }));
vi.mock('$lib/server/state', () => ({ getConfig: () => validConfig }));
vi.mock('$lib/server/auth', () => ({
getAuth: () => ({
handle: (({ event, resolve }) => resolve(event)) as Handle
})
authHandle: (({ event, resolve }) => resolve(event)) as Handle
}));
import { authGate, securityHeaders } from './hooks.server';
+2 -2
View File
@@ -1,6 +1,6 @@
import { sequence } from '@sveltejs/kit/hooks';
import { redirect, error, type Handle } from '@sveltejs/kit';
import { getAuth } from '$lib/server/auth';
import { authHandle } from '$lib/server/auth';
import { localRedirect } from '$lib/server/redirect';
import { bootApp } from '$lib/server/boot';
import { logger } from '$lib/server/logger';
@@ -48,4 +48,4 @@ export const authGate: Handle = async ({ event, resolve }) => {
});
};
export const handle = sequence(securityHeaders, getAuth().handle, authGate);
export const handle = sequence(securityHeaders, authHandle, authGate);
+3
View File
@@ -0,0 +1,3 @@
import { createAuthClient } from 'better-auth/svelte';
export const authClient = createAuthClient({ basePath: '/auth' });
+58 -7
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { authClient } from '$lib/auth-client';
import IconCalendarBlank from 'virtual:icons/ph/calendar-blank';
import IconArrowUpLeft from 'virtual:icons/ph/arrow-up-left';
import IconSignOut from 'virtual:icons/ph/sign-out';
@@ -13,6 +15,30 @@
let pathname = $derived(page.url.pathname);
let isHome = $derived(pathname === '/admin' || pathname === '/admin/');
let pending = $state(false);
let signoutError = $state<'rate' | 'network' | 'auth' | null>(null);
async function signOut(event: SubmitEvent) {
event.preventDefault();
if (pending) return;
const redirectTo = `/signin?callbackUrl=${encodeURIComponent(currentPath)}`;
pending = true;
signoutError = null;
try {
const { error } = await authClient.signOut();
if (error) {
signoutError = error.status === 429 ? 'rate' : 'auth';
return;
}
await goto(redirectTo, { invalidateAll: true });
} catch {
signoutError = 'network';
} finally {
pending = false;
}
}
</script>
<header class="admin-nav">
@@ -41,18 +67,36 @@
</nav>
<div class="nav-actions">
<ThemeToggle />
<form method="POST" action="/admin?/signout" class="signout-form" aria-label="Sign out">
<input
type="hidden"
name="redirectTo"
value="/signin?callbackUrl={encodeURIComponent(currentPath)}"
/>
<button type="submit" class="logout-btn" aria-label="Sign out">
<form
method="POST"
class="signout-form"
aria-label="Sign out"
aria-busy={pending}
onsubmit={signOut}
>
<button
type="submit"
class="logout-btn"
aria-label={pending ? 'Signing out' : 'Sign out'}
aria-describedby={signoutError ? 'signout-error' : undefined}
disabled={pending}
>
<IconSignOut aria-hidden="true" />
</button>
</form>
</div>
</div>
{#if signoutError}
<p id="signout-error" class="signout-error" role="alert">
{#if signoutError === 'rate'}
Too many requests. Please wait and try signing out again.
{:else if signoutError === 'network'}
Unable to connect. Please try signing out again.
{:else}
Unable to sign out. Please try again.
{/if}
</p>
{/if}
</header>
<style>
@@ -138,6 +182,13 @@
align-items: center;
}
.signout-error {
margin: var(--space-2) var(--space-6) 0;
color: var(--color-danger);
font-size: var(--font-size-sm);
text-align: right;
}
.logout-btn {
background: none;
border: none;
+242
View File
@@ -0,0 +1,242 @@
import { afterAll, afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { Handle } from '@sveltejs/kit';
import { parseSetCookieHeader } from 'better-auth/cookies';
import { validConfig } from '../__fixtures__/valid-config';
import { userLoginsTotal } from '../metrics';
import { authHandle, getAuth, makeAuth } from './index';
// Exercise the actual production limiter and origin checks, not test-mode defaults.
vi.hoisted(() => {
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('AUTH_SECRET', 'only-a-test-secret-with-at-least-32-characters');
});
const origin = 'https://when.example.test';
const cfg = {
...validConfig,
auth: {
credentials: { username: 'configured-admin', password: 'configured-admin-password' }
},
user: { ...validConfig.user, email: 'owner@configured.example.test' },
url: { ...validConfig.url, app: origin }
};
const credentials = cfg.auth.credentials!;
let client = 0;
let address: string;
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-29T12:00:00Z'));
address = `192.0.2.${++client}`;
makeAuth(cfg);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllGlobals();
});
afterAll(() => vi.unstubAllEnvs());
function event(path: string, body?: unknown, headers: Record<string, string> = {}) {
const url = new URL(path, origin);
return {
url,
request: new Request(url, {
method: body === undefined ? 'GET' : 'POST',
headers: { origin, 'content-type': 'application/json', 'x-real-ip': address, ...headers },
...(body === undefined ? {} : { body: JSON.stringify(body) })
}),
locals: {}
} as Parameters<Handle>[0]['event'];
}
function request(path: string, body?: unknown, headers: Record<string, string> = {}) {
return authHandle({
event: event(`/auth${path}`, body, headers),
resolve: async () => new Response('not an auth endpoint', { status: 404 })
});
}
function cookies(response: Response) {
return [...parseSetCookieHeader(response.headers.get('set-cookie') ?? '')]
.filter(([, attributes]) => attributes.value)
.map(([name, attributes]) => `${name}=${attributes.value}`)
.join('; ');
}
async function appSession(cookie: string) {
const response = await authHandle({
event: event('/admin', undefined, { cookie }),
resolve: async (ev) => {
const first = ev.locals.auth();
expect(ev.locals.auth()).toBe(first);
return Response.json(await first);
}
});
return response.json();
}
test('YAML HTTP login validates credentials and protects cookie-less first login', async () => {
const loginMetric = vi.spyOn(userLoginsTotal, 'inc');
const bad = await request('/sign-in/credentials', { ...credentials, password: 'wrong' });
expect(bad.status).toBe(401);
expect((await bad.json()).code).toBe('CredentialsSignin');
expect(cookies(bad)).toBe('');
expect(loginMetric).toHaveBeenLastCalledWith({ provider: 'credentials', status: 'failure' });
const crossOrigin = await request('/sign-in/credentials', credentials, {
origin: 'https://another.example.test'
});
expect(crossOrigin.status).toBe(403);
expect(cookies(crossOrigin)).toBe('');
const good = await request('/sign-in/credentials', credentials);
expect(good.status).toBe(200);
expect(await good.json()).toEqual({ success: true });
expect(loginMetric).toHaveBeenLastCalledWith({ provider: 'credentials', status: 'success' });
expect(cookies(good)).toContain('session_data=');
expect(cookies(good)).not.toContain(credentials.password);
expect(cookies(good)).not.toContain('account_data=');
expect(getAuth().options.database).toBeUndefined();
expect(getAuth().options.secondaryStorage).toBeUndefined();
});
test('auth routes accept a configured public URL with a trailing slash', async () => {
makeAuth({ ...cfg, url: { ...cfg.url, app: `${origin}/` } });
const login = await request('/sign-in/credentials', credentials);
expect(login.status).toBe(200);
const logout = await request('/sign-out', {}, { cookie: cookies(login) });
expect(logout.status).toBe(200);
});
test('native throttling uses the trusted X-Real-IP header', async () => {
for (let i = 0; i < 4; i++) {
const response = await request(
'/sign-in/credentials',
{ ...credentials, password: 'wrong' },
{
'x-forwarded-for': `198.51.100.${i + 10}`
}
);
expect(response.status).toBe(i < 3 ? 401 : 429);
}
address = `192.0.2.${++client}`;
expect((await request('/sign-in/credentials', credentials)).status).toBe(200);
});
test('cookies survive a fresh instance, expose only app fields and expire at the original deadline', async () => {
const login = await request('/sign-in/credentials', credentials);
const cookie = cookies(login);
makeAuth(cfg); // Empty memory adapter, as after a restart.
const session = await appSession(cookie);
expect(session).toEqual({
user: { name: credentials.username, email: cfg.user.email },
expires: '2026-11-05T12:00:00.000Z'
});
const damaged = cookie.replace(/(session_data=.{12})/, '$1x');
expect(await appSession(damaged)).toBeNull();
vi.setSystemTime(new Date('2026-11-04T12:00:00Z'));
expect(await appSession(cookie)).toEqual(session);
vi.setSystemTime(new Date('2026-11-05T12:00:01Z'));
expect(await appSession(cookie)).toBeNull();
});
test('bypassing the cache cannot renew the underlying session past seven days', async () => {
const login = await request('/sign-in/credentials', credentials);
const cookie = cookies(login);
vi.setSystemTime(new Date('2026-10-31T12:00:00Z'));
const response = await request('/get-session?disableCookieCache=true', undefined, { cookie });
expect((await response.json()).session.expiresAt).toBe('2026-11-05T12:00:00.000Z');
vi.setSystemTime(new Date('2026-11-05T12:00:01Z'));
const expired = await request('/get-session?disableCookieCache=true', undefined, { cookie });
expect(await expired.json()).toBeNull();
});
test('native logout clears cookies and unrelated native strategies cannot create sessions', async () => {
const login = await request('/sign-in/credentials', credentials);
const cookie = cookies(login);
const logout = await request('/sign-out', {}, { cookie });
expect(logout.status).toBe(200);
const cleared = parseSetCookieHeader(logout.headers.get('set-cookie') ?? '');
expect([...cleared].filter(([name]) => name.includes('session_')).length).toBeGreaterThanOrEqual(
2
);
for (const [name, attributes] of cleared) {
if (name.includes('session_')) expect(attributes.value).toBe('');
}
const signup = await request('/sign-up/email', {
name: 'Someone',
email: 'someone@example.test',
password: 'another-password'
});
expect((await signup.json()).code).toBe('EMAIL_PASSWORD_SIGN_UP_DISABLED');
expect(cookies(signup)).toBe('');
const passwordLogin = await request('/sign-in/email', {
email: cfg.user.email,
password: credentials.password
});
expect((await passwordLogin.json()).code).toBe('EMAIL_PASSWORD_DISABLED');
expect(cookies(passwordLogin)).toBe('');
const social = await request('/sign-in/social', { provider: 'oidc', callbackURL: '/admin' });
expect(social.ok).toBe(false);
expect(cookies(social)).not.toContain('session_data=');
});
test('OIDC uses discovery, verified tokens, PKCE, the existing callback and a global safe error return', async () => {
const loginMetric = vi.spyOn(userLoginsTotal, 'inc');
const issuer = 'https://idp.example.test/tenant';
const fetch = vi.fn(async (input: RequestInfo | URL) => {
expect(String(input)).toBe(`${issuer}/.well-known/openid-configuration`);
return Response.json({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
id_token_signing_alg_values_supported: ['RS256']
});
});
vi.stubGlobal('fetch', fetch);
makeAuth({
...cfg,
auth: {
oidc: {
issuer: `${issuer}/`,
client_id: 'configured-client',
client_secret: 'configured-secret',
name: 'Our SSO'
}
}
});
const auth = getAuth();
const response = await request('/sign-in/social', { provider: 'oidc', callbackURL: '/admin' });
expect(response.status).toBe(200);
expect(fetch).toHaveBeenCalledOnce();
const authorization = new URL((await response.json()).url);
expect(authorization.origin + authorization.pathname).toBe(`${issuer}/authorize`);
expect(authorization.searchParams.get('redirect_uri')).toBe(`${origin}/auth/callback/oidc`);
expect(authorization.searchParams.get('code_challenge_method')).toBe('S256');
expect(authorization.searchParams.get('nonce')).toBeTruthy();
expect(loginMetric).not.toHaveBeenCalled();
const oauth = auth.options.plugins?.find((plugin) => plugin.id === 'generic-oauth');
expect(oauth?.options?.config[0]).toMatchObject({
name: 'Our SSO',
clientId: 'configured-client',
clientSecret: 'configured-secret',
requireIdTokenVerification: true,
tokenEndpointAuth: { method: 'client_secret_basic' },
disableProviderLogout: true
});
const failedCallback = await request('/callback/oidc?code=unmatched');
expect(failedCallback.status).toBe(302);
const location = new URL(failedCallback.headers.get('location')!, origin);
expect(location.origin + location.pathname).toBe(`${origin}/signin`);
const errors = location.searchParams.getAll('error');
expect(errors).toHaveLength(1);
expect(errors[0]).toBeTruthy();
expect(errors[0]).not.toBe('oidc');
expect(loginMetric).toHaveBeenLastCalledWith({ provider: 'oidc', status: 'failure' });
expect(cookies(failedCallback)).not.toContain('session_data=');
});
+61 -22
View File
@@ -1,39 +1,78 @@
import { SvelteKitAuth } from '@auth/sveltekit';
import type { RequestEvent } from '@sveltejs/kit';
import { building } from '$app/environment';
import type { Handle } from '@sveltejs/kit';
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import type { WhenConfiguration } from '@when/config';
import { buildProviders } from './providers';
import { logger } from '../logger';
import { buildAuthOptions } from './providers';
import { requireAuthSecret } from './secret';
export { requireAuthSecret } from './secret';
const WEEK_SECONDS = 7 * 24 * 60 * 60;
type AuthModule = ReturnType<typeof SvelteKitAuth>;
export interface WhenSession {
user?: { name: string; email: string; image?: string | null };
expires: string;
}
let instance: AuthModule | null = null;
let instance: ReturnType<typeof betterAuth> | null = null;
export function makeAuth(cfg: WhenConfiguration): AuthModule {
const secret = requireAuthSecret();
instance = SvelteKitAuth({
secret,
trustHost: true,
session: { strategy: 'jwt', maxAge: WEEK_SECONDS },
providers: buildProviders(cfg),
pages: { signIn: '/signin' }
export function makeAuth(cfg: WhenConfiguration) {
instance = betterAuth<BetterAuthOptions>({
secret: requireAuthSecret(),
baseURL: cfg.url.app.replace(/\/+$/, ''),
basePath: '/auth',
telemetry: { enabled: false },
session: {
expiresIn: WEEK_SECONDS,
disableSessionRefresh: true,
cookieCache: { refreshCache: false }
},
account: {
storeAccountCookie: false,
accountLinking: { enabled: false }
},
advanced: {
disableOriginCheck: false,
ipAddress: { ipAddressHeaders: ['x-real-ip'] }
},
...buildAuthOptions(cfg),
logger: {
// Library messages/arguments can contain credentials, codes or tokens.
log: (level) => logger[level]({ source: 'better-auth' }, 'authentication event')
}
});
return instance;
}
export function getAuth(): AuthModule {
export function getAuth() {
if (!instance) throw new Error('auth not initialized — bootApp() must run first');
return instance;
}
/**
* SvelteKit form actions for sign-in / sign-out. Bound through getAuth()
* at request time so the auth instance is guaranteed to be initialized.
* Auth.js handles CSRF internally when invoked this way (no manual
* `/auth/csrf` fetch required).
*/
export const signInAction = (event: RequestEvent) => getAuth().signIn(event);
export const signOutAction = (event: RequestEvent) => getAuth().signOut(event);
export const authHandle: Handle = async ({ event, resolve }) => {
if (building) {
event.locals.auth = async () => null;
return resolve(event);
}
const auth = getAuth();
async function readSession(): Promise<WhenSession | null> {
const data = await auth.api.getSession({ headers: event.request.headers });
if (!data) return null;
return {
user: { name: data.user.name, email: data.user.email, image: data.user.image },
expires: data.session.expiresAt.toISOString()
};
}
let session: Promise<WhenSession | null> | undefined;
event.locals.auth = () => {
if (!session) session = readSession();
return session;
};
return svelteKitHandler({ event, resolve, auth, building });
};
+9 -69
View File
@@ -1,18 +1,18 @@
import { expect, test, vi } from 'vitest';
import { buildProviders, verifyCredentials } from './providers';
import { expect, test } from 'vitest';
import { buildAuthOptions, verifyCredentials } from './providers';
import { requireAuthSecret } from '$lib/server/auth/secret';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import type { WhenConfiguration } from '@when/config';
import { logger } from '$lib/server/logger';
test('credentials config produces a single Credentials provider', () => {
const providers = buildProviders(validConfig);
expect(providers).toHaveLength(1);
test('credentials config registers only the YAML credentials plugin', () => {
const options = buildAuthOptions(validConfig);
expect(options.plugins?.map((plugin) => plugin.id)).toEqual(['when-credentials']);
});
test('oidc config produces a single OIDC provider with state checks and default name', () => {
test('oidc config registers only the generic OAuth plugin', () => {
const cfg: WhenConfiguration = {
...validConfig,
url: { ...validConfig.url, app: 'https://when.example.test' },
auth: {
oidc: {
issuer: 'https://auth.example.com',
@@ -22,32 +22,8 @@ test('oidc config produces a single OIDC provider with state checks and default
}
}
};
const providers = buildProviders(cfg);
expect(providers).toHaveLength(1);
const p = providers[0] as Record<string, unknown>;
expect(p.id).toBe('oidc');
expect(p.name).toBe('Single Sign-On');
expect(p.type).toBe('oidc');
expect(p.issuer).toBe('https://auth.example.com');
expect(p.checks).toEqual(['state']);
});
test('oidc config with custom name sets provider name', () => {
const cfg: WhenConfiguration = {
...validConfig,
auth: {
oidc: {
issuer: 'https://auth.example.com',
client_id: 'when',
client_secret: 'shh',
name: 'Authelia'
}
}
};
const providers = buildProviders(cfg);
const p = providers[0] as Record<string, unknown>;
expect(p.name).toBe('Authelia');
expect(p.checks).toEqual(['state']);
const options = buildAuthOptions(cfg);
expect(options.plugins?.map((plugin) => plugin.id)).toEqual(['generic-oauth']);
});
test('requireAuthSecret rejects missing secret', () => {
@@ -99,39 +75,3 @@ test('missing or non-string fields are rejected', () => {
expect(verifyCredentials(ADMIN, {})).toBeNull();
expect(verifyCredentials(ADMIN, { username: 1, password: 2 })).toBeNull();
});
function warningsWhileBuilding(env: string, auth: WhenConfiguration['auth']): string[] {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const previous = process.env.NODE_ENV;
process.env.NODE_ENV = env;
try {
buildProviders({ ...validConfig, auth });
return warn.mock.calls.map((call) => JSON.stringify(call));
} finally {
process.env.NODE_ENV = previous;
warn.mockRestore();
}
}
const OIDC = {
oidc: {
issuer: 'https://auth.example.com',
client_id: 'when',
client_secret: 'shh',
name: 'Single Sign-On'
}
};
test('credentials auth in production warns about the missing rate limiting', () => {
expect(warningsWhileBuilding('production', validConfig.auth).join(' ')).toContain(
'rate limiting'
);
});
test('credentials auth outside production says nothing', () => {
expect(warningsWhileBuilding('development', validConfig.auth)).toEqual([]);
});
test('oidc in production says nothing', () => {
expect(warningsWhileBuilding('production', OIDC)).toEqual([]);
});
+99 -34
View File
@@ -1,49 +1,114 @@
import { timingSafeEqual } from 'node:crypto';
import Credentials from '@auth/sveltekit/providers/credentials';
import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth';
import {
APIError,
createAuthEndpoint,
createAuthMiddleware,
formCsrfMiddleware
} from 'better-auth/api';
import { setSessionCookie } from 'better-auth/cookies';
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
import type { CredentialsAuth, OidcAuth, WhenConfiguration } from '@when/config';
import { logger } from '../logger';
import { userLoginsTotal } from '../metrics';
export function buildProviders(cfg: WhenConfiguration) {
if (cfg.auth.credentials) return [credentialsProvider(cfg.auth.credentials)];
if (cfg.auth.oidc) return [oidcProvider(cfg.auth.oidc)];
return [];
type AuthOptions = Pick<BetterAuthOptions, 'plugins' | 'hooks' | 'onAPIError'>;
export function buildAuthOptions(cfg: WhenConfiguration): AuthOptions {
if (cfg.auth.credentials) return credentialsAuth(cfg.auth.credentials, cfg.user.email);
if (cfg.auth.oidc) return oidcAuth(cfg.auth.oidc, cfg.url.app);
throw new Error('an authentication strategy is required');
}
function credentialsProvider(admin: CredentialsAuth) {
if (process.env.NODE_ENV === 'production') {
logger.warn(
'credentials auth is enabled: this app applies no rate limiting or lockout to password attempts. That is fine on a trusted network; for an internet-facing deployment prefer OIDC, or put an authenticating proxy in front.'
);
}
return Credentials({
name: 'Password',
credentials: {
username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' }
},
authorize: async (submitted) => verifyCredentials(admin, submitted)
});
}
function oidcProvider(oidc: OidcAuth) {
function credentialsAuth(admin: CredentialsAuth, email: string): AuthOptions {
return {
id: 'oidc',
name: oidc.name,
type: 'oidc' as const,
issuer: oidc.issuer,
clientId: oidc.client_id,
clientSecret: oidc.client_secret,
checks: ['state'] as ['state']
plugins: [credentialsProvider(admin, email)],
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-in/credentials') return;
const status = ctx.context.newSession ? 'success' : 'failure';
userLoginsTotal.inc({ provider: 'credentials', status });
})
}
};
}
function oidcAuth(oidc: OidcAuth, appUrl: string): AuthOptions {
return {
plugins: [
genericOAuth({
config: [
{
providerId: 'oidc',
name: oidc.name,
discoveryUrl: `${oidc.issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`,
clientId: oidc.client_id,
clientSecret: oidc.client_secret,
tokenEndpointAuth: { method: 'client_secret_basic' },
scopes: ['openid', 'profile', 'email'],
pkce: true,
requireIdTokenVerification: true,
disableProviderLogout: true
}
]
})
],
onAPIError: { errorURL: new URL('/signin', appUrl).href },
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/callback/:id') return;
if (ctx.params?.id !== 'oidc') return;
const status = ctx.context.newSession ? 'success' : 'failure';
userLoginsTotal.inc({ provider: 'oidc', status });
})
}
};
}
function credentialsProvider(admin: CredentialsAuth, email: string): BetterAuthPlugin {
return {
id: 'when-credentials',
endpoints: {
signInCredentials: createAuthEndpoint(
'/sign-in/credentials',
{ method: 'POST', use: [formCsrfMiddleware] },
async (ctx) => {
const identity = verifyCredentials(admin, ctx.body);
if (!identity) {
throw new APIError('UNAUTHORIZED', {
code: 'CredentialsSignin',
message: 'Invalid username or password'
});
}
// Better Auth's default adapter is ephemeral. Neither identity nor
// sessions need persistent rows; the password never enters it.
const adapter = ctx.context.internalAdapter;
const existing = await adapter.findUserByEmail(email);
let user = existing?.user;
if (!user) {
user = await adapter.createUser(
{ name: identity.name, email, emailVerified: true },
{ method: 'when-credentials' }
);
}
const session = await adapter.createSession(user.id);
if (!session) throw new APIError('INTERNAL_SERVER_ERROR');
await setSessionCookie(ctx, { session, user });
return ctx.json({ success: true });
}
)
}
};
}
export function verifyCredentials(
admin: CredentialsAuth,
submitted: Partial<Record<string, unknown>> | undefined
submitted: unknown
): { id: string; name: string } | null {
const username = typeof submitted?.username === 'string' ? submitted.username : '';
const password = typeof submitted?.password === 'string' ? submitted.password : '';
if (typeof submitted !== 'object' || submitted === null) return null;
const fields = submitted as Record<string, unknown>;
const username = typeof fields.username === 'string' ? fields.username : '';
const password = typeof fields.password === 'string' ? fields.password : '';
// Both compares always run: returning early on the username would time it.
const okUsername = secretEquals(username, admin.username);
@@ -6,6 +6,7 @@ function cfg(overrides: Record<string, unknown> = {}): WhenConfiguration {
return {
auth: { credentials: { username: 'admin', password: 'p' } },
database: { app: '/data/app.sqlite', queue: '/data/queue.sqlite' },
url: { app: 'http://localhost:5173', internal: '', worker: 'http://when-worker:9000' },
user: { name: 'A', email: 'a@example.com', timezone: 'UTC' },
...overrides
} as unknown as WhenConfiguration;
@@ -44,6 +45,22 @@ test('restarts when the database section changes', () => {
expect(fx.restart).toHaveBeenCalledOnce();
});
test.for(['app', 'internal', 'worker'] as const)('handles a changed url.%s', (key) => {
const current = cfg();
const fx = effects(current);
const next = cfg({ url: { ...current.url, [key]: 'https://changed.example.com' } });
applyConfig({ ok: true, config: next }, fx);
if (key === 'app') {
expect(fx.restart).toHaveBeenCalledOnce();
expect(fx.swap).not.toHaveBeenCalled();
} else {
expect(fx.swap).toHaveBeenCalledWith(next);
expect(fx.restart).not.toHaveBeenCalled();
}
});
test('keeps the current config when the reload failed', () => {
const fx = effects(cfg());
+6 -3
View File
@@ -7,8 +7,7 @@ export interface ReloadEffects {
restart: () => void;
}
// Changes under these keys can't be hot-applied (auth providers and the DB/queue
// connections are built at boot), so a reboot picks them up instead.
// Auth (including its canonical URL) and DB/queue connections are built at boot.
const RESTART_KEYS: (keyof WhenConfiguration)[] = ['auth', 'database'];
export function applyConfig(result: ReloadResult, fx: ReloadEffects): void {
@@ -16,7 +15,11 @@ export function applyConfig(result: ReloadResult, fx: ReloadEffects): void {
logger.error({ err: result.error }, 'config reload failed; keeping current config');
return;
}
if (configChanged(fx.current(), result.config, RESTART_KEYS)) {
const current = fx.current();
if (
configChanged(current, result.config, RESTART_KEYS) ||
current.url.app !== result.config.url.app
) {
logger.warn('config change requires a restart');
fx.restart();
return;
+2
View File
@@ -14,6 +14,8 @@ test.for([
'javascript:alert(1)',
'http://localhost.evil.com/x',
'http://localhost:9999/other',
`${ORIGIN}//evil.com`,
`${ORIGIN}/admin/..//evil.com`,
'',
null
])('sends %s to the fallback instead', (value) => {
+5 -5
View File
@@ -1,5 +1,3 @@
// Resolving against the origin we require turns every way out of this site — absolute,
// protocol-relative, backslash-smuggled, javascript: — into a different origin.
export function localRedirect(
value: string | null | undefined,
origin: string,
@@ -7,9 +5,11 @@ export function localRedirect(
): string {
if (!value) return fallback;
try {
const url = new URL(value, origin);
if (url.origin !== origin) return fallback;
return url.pathname + url.search + url.hash;
const requested = new URL(value, origin);
const path = requested.pathname + requested.search + requested.hash;
const destination = new URL(path, origin);
if (requested.origin !== origin || destination.origin !== origin) return fallback;
return path;
} catch {
return fallback;
}
@@ -2,11 +2,10 @@ import { systemClock } from '$lib/server/clock';
import { getConfig, getDb } from '$lib/server/state';
import { toAppointmentView } from '$lib/server/appointments';
import { evaluateCalendarStatuses } from '$lib/server/calendar/health';
import { signOutAction } from '$lib/server/auth';
import { countAppointments, listAppointmentsPage, listServiceStatus } from '@when/db';
import { allCalendars } from '@when/config';
import { sql } from 'kysely';
import type { Actions, PageServerLoad } from './$types';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const db = getDb();
@@ -122,7 +121,3 @@ export const load: PageServerLoad = async () => {
purgedCount
};
};
export const actions: Actions = {
signout: signOutAction
};
+1 -43
View File
@@ -1,10 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { isRedirect } from '@sveltejs/kit';
import { signInAction } from '$lib/server/auth';
import { localRedirect } from '$lib/server/redirect';
import { getConfig } from '$lib/server/state';
import { userLoginsTotal } from '$lib/server/metrics';
import type { Actions, PageServerLoad } from './$types';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, locals }) => {
const callbackUrl = localRedirect(url.searchParams.get('callbackUrl'), url.origin, '/admin');
@@ -19,42 +16,3 @@ export const load: PageServerLoad = async ({ url, locals }) => {
return { callbackUrl, auth, errorCode };
};
export const actions: Actions = {
default: async (event) => {
const cfg = getConfig();
const provider = 'credentials' in cfg.auth ? 'credentials' : 'oidc';
try {
const result = await signInAction(event);
userLoginsTotal.inc({ provider, status: 'success' });
return result;
} catch (error) {
if (isRedirect(error)) {
userLoginsTotal.inc({ provider, status: 'success' });
throw error;
}
userLoginsTotal.inc({ provider, status: 'failure' });
const callbackUrl = localRedirect(
event.url.searchParams.get('callbackUrl'),
event.url.origin,
'/admin'
);
let errorType = 'CredentialsSignin';
if (error instanceof Error) {
if ('type' in error && typeof error.type === 'string') {
errorType = error.type;
} else {
errorType = error.name;
}
}
redirect(
303,
`/signin?error=${encodeURIComponent(errorType)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
);
}
}
};
+81 -25
View File
@@ -1,28 +1,70 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authClient } from '$lib/auth-client';
import IconWarningCircle from 'virtual:icons/ph/warning-circle';
import IconLock from 'virtual:icons/ph/lock';
import IconSignIn from 'virtual:icons/ph/sign-in';
let { data } = $props();
// Map error codes to friendly messages
let errorMessage = $derived.by(() => {
if (!data.errorCode) return null;
switch (data.errorCode) {
case 'CredentialsSignin':
return 'Invalid username or password. Please try again.';
case 'SessionRequired':
return 'Please sign in to access this page.';
default:
return 'An authentication error occurred. Please try again.';
let pending = $state(false);
let submitError = $state<string | null>(null);
let errorCode = $derived(submitError ?? data.errorCode);
async function signIn(event: SubmitEvent) {
event.preventDefault();
if (pending) return;
const formData = new FormData(event.currentTarget as HTMLFormElement);
pending = true;
submitError = null;
try {
if (data.auth.type === 'oidc') {
const { error } = await authClient.signIn.social({
provider: 'oidc',
callbackURL: data.callbackUrl,
errorCallbackURL: `/signin?callbackUrl=${encodeURIComponent(data.callbackUrl)}`
});
if (error) {
submitError = error.status === 429 ? 'RateLimited' : 'AuthError';
pending = false;
}
return;
}
const { error } = await authClient.$fetch('/sign-in/credentials', {
method: 'POST',
body: {
username: formData.get('username'),
password: formData.get('password')
}
});
if (error) {
if (error.status === 401) submitError = 'CredentialsSignin';
else if (error.status === 429) submitError = 'RateLimited';
else submitError = 'AuthError';
pending = false;
return;
}
await goto(data.callbackUrl, { invalidateAll: true });
} catch {
submitError = 'NetworkError';
pending = false;
}
});
}
</script>
<svelte:head>
<title>Sign in — When</title>
</svelte:head>
<svelte:window
onpageshow={(event) => {
if (event.persisted) pending = false;
}}
/>
<div class="signin-container">
<main class="signin-card">
<header class="signin-header">
@@ -31,18 +73,27 @@
<p class="signin-subtitle">Appointment Scheduling Portal</p>
</header>
{#if errorMessage}
{#if errorCode}
<div class="error-banner" role="alert">
<span class="error-icon"><IconWarningCircle aria-hidden="true" /></span>
<span class="error-text">{errorMessage}</span>
<span class="error-text">
{#if errorCode === 'CredentialsSignin'}
Invalid username or password. Please try again.
{:else if errorCode === 'SessionRequired'}
Please sign in to access this page.
{:else if errorCode === 'RateLimited'}
Too many sign-in attempts. Please wait and try again.
{:else if errorCode === 'NetworkError'}
Unable to connect. Please try again.
{:else}
An authentication error occurred. Please try again.
{/if}
</span>
</div>
{/if}
{#if data.auth.type === 'credentials'}
<form method="POST" class="signin-form">
<input type="hidden" name="providerId" value="credentials" />
<input type="hidden" name="redirectTo" value={data.callbackUrl} />
<form method="POST" class="signin-form" onsubmit={signIn} aria-busy={pending}>
<div class="form-group">
<label for="username" class="form-label">Username</label>
<div class="input-wrapper">
@@ -75,24 +126,29 @@
</div>
</div>
<button type="submit" class="submit-btn">
<button type="submit" class="submit-btn" disabled={pending}>
<span class="btn-icon"><IconLock aria-hidden="true" /></span>
Sign in
{#if pending}
Signing in…
{:else}
Sign in
{/if}
</button>
</form>
{:else if data.auth.type === 'oidc'}
<form method="POST" class="signin-form">
<input type="hidden" name="providerId" value="oidc" />
<input type="hidden" name="redirectTo" value={data.callbackUrl} />
<form method="POST" class="signin-form" onsubmit={signIn} aria-busy={pending}>
<p class="sso-desc">
Single sign-on via {data.auth.name}.
</p>
<!-- svelte-ignore a11y_autofocus -->
<button type="submit" class="submit-btn" autofocus>
<button type="submit" class="submit-btn" autofocus disabled={pending}>
<span class="btn-icon"><IconSignIn aria-hidden="true" /></span>
Sign in
{#if pending}
Signing in…
{:else}
Sign in
{/if}
</button>
</form>
{/if}
+2 -28
View File
@@ -1,17 +1,9 @@
import { describe, expect, test, vi } from 'vitest';
import { load, actions } from './+page.server';
import { load } from './+page.server';
import { setState, type AppState } from '$lib/server/state';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
vi.mock('$lib/server/auth', () => {
return {
signInAction: vi.fn(),
getAuth: vi.fn()
};
});
import { signInAction } from '$lib/server/auth';
describe('/signin server load and actions', () => {
describe('/signin server load', () => {
test('load function returns callbackUrl, auth, and errorCode', async () => {
setState({
config: validConfig,
@@ -115,22 +107,4 @@ describe('/signin server load and actions', () => {
} as unknown as Parameters<typeof load>[0])
).rejects.toThrow();
});
test('action catches AuthError and redirects back with query parameters', async () => {
const mockError = Object.assign(new Error('CredentialsSignin'), {
type: 'CredentialsSignin'
});
vi.mocked(signInAction).mockRejectedValueOnce(mockError);
const url = new URL('http://localhost/signin?callbackUrl=/custom-path');
const mockEvent = {
url,
request: {}
};
await expect(
actions.default(mockEvent as unknown as Parameters<typeof actions.default>[0])
).rejects.toThrow();
});
});
+273 -77
View File
@@ -125,9 +125,6 @@ importers:
apps/web:
dependencies:
'@auth/sveltekit':
specifier: 1.11.3
version: 1.11.3(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(nodemailer@9.0.3)(svelte@5.56.0(@typescript-eslint/types@8.60.0))
'@dicebear/core':
specifier: 10.4.0
version: 10.4.0
@@ -149,6 +146,9 @@ importers:
'@when/video-chat':
specifier: workspace:*
version: link:../../packages/video-chat
better-auth:
specifier: 1.7.14
version: 1.7.14(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vitest@4.1.7)
bits-ui:
specifier: 2.18.1
version: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))
@@ -555,36 +555,6 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@auth/core@0.41.3':
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: 9.0.3
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@auth/sveltekit@1.11.3':
resolution: {integrity: sha512-nfI/CFHD9hpJG4W+u+xtMrw9XSrA1k6kbgbGc9PvbiEf67oPMuzLAvLDj6/3yd98kFz+51Rq04JLBckKp8T5Kg==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.3
'@sveltejs/kit': ^1.0.0 || ^2.0.0
nodemailer: 9.0.3
svelte: ^3.54.0 || ^4.0.0 || ^5.0.0-0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@@ -606,6 +576,82 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@better-auth/core@1.7.14':
resolution: {integrity: sha512-ByhdrGVwp/el+hoTYKSVOHIH23/lQj9TQ1txdGcWOg22tnfr3hJ6ukzySooeYj/R36XsQgBhkJqIp8mKsNeErA==}
peerDependencies:
'@better-auth/utils': ^0.4.2 || ^0.5.0
'@better-fetch/fetch': 1.3.3
'@opentelemetry/api': ^1.9.0
better-call: 1.4.4
jose: ^6.1.0
kysely: ^0.28.5 || ^0.29.0
nanostores: ^1.0.1
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@better-auth/drizzle-adapter@1.7.14':
resolution: {integrity: sha512-5RC8Ec5Ur15FWsS4As9/VrlRbB3+9EfShxIpA5A1FcD6IgPxFvzQNkAm2+FYx9LUSqx47NPf9hBTG2GUCMP9Ig==}
peerDependencies:
'@better-auth/core': ^1.7.14
'@better-auth/utils': ^0.4.2 || ^0.5.0
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
peerDependenciesMeta:
drizzle-orm:
optional: true
'@better-auth/kysely-adapter@1.7.14':
resolution: {integrity: sha512-4wkqj8a8JVpWDYLlgzfbk7eOaWCFkL6HHQd6CLamFKaC8vNsXew1x4v5dE5O6dGr8j4g5qMISS+BAvOuIvBevg==}
peerDependencies:
'@better-auth/core': ^1.7.14
'@better-auth/utils': ^0.4.2 || ^0.5.0
kysely: ^0.28.17 || ^0.29.0
peerDependenciesMeta:
kysely:
optional: true
'@better-auth/memory-adapter@1.7.14':
resolution: {integrity: sha512-Ojv9BcmFyHuTHha9ZM/NUW1ut9J84wLngta+07Es0Ejn8kekqP5aHBpZd5Z8AvADs5iJUOWvQGFmGlPWAKJgBg==}
peerDependencies:
'@better-auth/core': ^1.7.14
'@better-auth/utils': ^0.4.2 || ^0.5.0
'@better-auth/mongo-adapter@1.7.14':
resolution: {integrity: sha512-6IA0RSPZFMdRwwnLZ9oWnDIiD0y0o0azTHUfa01BNU2AFJgMDkeiASUV3bKcJBskzQoyQrff74eFYTsTSeeVUQ==}
peerDependencies:
'@better-auth/core': ^1.7.14
'@better-auth/utils': ^0.4.2 || ^0.5.0
mongodb: ^6.0.0 || ^7.0.0
peerDependenciesMeta:
mongodb:
optional: true
'@better-auth/prisma-adapter@1.7.14':
resolution: {integrity: sha512-mF6t5xqeZNWzUe0ErmrSdzQmtslJscmu9X8Mp5QmROlpnnAzvSc70RILu8lHms8CePt7N34+KRH1QvYjGFrVGQ==}
peerDependencies:
'@better-auth/core': ^1.7.14
'@better-auth/utils': ^0.4.2 || ^0.5.0
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
peerDependenciesMeta:
'@prisma/client':
optional: true
prisma:
optional: true
'@better-auth/telemetry@1.7.14':
resolution: {integrity: sha512-PtKFzsH7gje/WJuUPDjckOoNFazsq8NMt3AdiXIPzHl7tZULcNGTKAX8K7+yn0rG6cO/4mtMgpOjhtilElOnmg==}
peerDependencies:
'@better-auth/core': ^1.7.14
'@better-auth/utils': ^0.4.2 || ^0.5.0
'@better-fetch/fetch': 1.3.3
'@better-auth/utils@0.5.1':
resolution: {integrity: sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg==}
'@better-fetch/fetch@1.3.3':
resolution: {integrity: sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg==}
'@changesets/apply-release-plan@8.0.0':
resolution: {integrity: sha512-kUd2pbf1w5/AYmBMb0Tt+rkIPCjFJdT0SZMrkOjJT/WV/QbtmvkyB5jkV0oaNPheavprZk+SfUiozUti7TIL2w==}
engines: {node: ^22.11 || ^24 || >=26}
@@ -969,6 +1015,14 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@noble/ciphers@2.4.0':
resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.4.0':
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
engines: {node: '>= 20.19.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
@@ -976,9 +1030,6 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -1563,6 +1614,73 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
better-auth@1.7.14:
resolution: {integrity: sha512-QxDCI0pOCCHDYmruisKhKyrvOJ6Icv9xg0RA6ZUysfKFXENYwhNNpwUlkE/NmViH2nDMgkKcymmLr55pnpR9vw==}
peerDependencies:
'@lynx-js/react': '*'
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
'@sveltejs/kit': ^2.0.0 || ^3.0.0
'@tanstack/react-start': ^1.0.0
'@tanstack/solid-start': ^1.0.0
drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
mongodb: ^6.0.0 || ^7.0.0
mysql2: ^3.0.0
next: ^14.0.0 || ^15.0.0 || ^16.0.0
pg: ^8.0.0
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
solid-js: ^1.0.0
svelte: ^4.0.0 || ^5.0.0
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0
vue: ^3.0.0
peerDependenciesMeta:
'@lynx-js/react':
optional: true
'@prisma/client':
optional: true
'@sveltejs/kit':
optional: true
'@tanstack/react-start':
optional: true
'@tanstack/solid-start':
optional: true
drizzle-kit:
optional: true
drizzle-orm:
optional: true
mongodb:
optional: true
mysql2:
optional: true
next:
optional: true
pg:
optional: true
prisma:
optional: true
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vitest:
optional: true
vue:
optional: true
better-call@1.4.4:
resolution: {integrity: sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw==}
peerDependencies:
zod: ^4.0.0
peerDependenciesMeta:
zod:
optional: true
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
@@ -1644,6 +1762,9 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -2100,6 +2221,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanostores@1.5.5:
resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==}
engines: {node: ^20.0.0 || >=22.0.0}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -2107,9 +2232,6 @@ packages:
resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
engines: {node: '>=6.0.0'}
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@@ -2233,11 +2355,6 @@ packages:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
@@ -2302,6 +2419,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rou3@0.9.2:
resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
runed@0.35.1:
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
peerDependencies:
@@ -2327,12 +2447,12 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
set-cookie-parser@3.2.1:
resolution: {integrity: sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -2649,6 +2769,9 @@ packages:
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zod@4.6.7:
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
snapshots:
'@antfu/install-pkg@1.1.0':
@@ -2656,25 +2779,6 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.2.3
'@auth/core@0.41.3(nodemailer@9.0.3)':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.2.3
oauth4webapi: 3.8.6
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
optionalDependencies:
nodemailer: 9.0.3
'@auth/sveltekit@1.11.3(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(nodemailer@9.0.3)(svelte@5.56.0(@typescript-eslint/types@8.60.0))':
dependencies:
'@auth/core': 0.41.3(nodemailer@9.0.3)
'@sveltejs/kit': 2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0))
set-cookie-parser: 2.7.2
svelte: 5.56.0(@typescript-eslint/types@8.60.0)
optionalDependencies:
nodemailer: 9.0.3
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
@@ -2690,6 +2794,58 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5)':
dependencies:
'@better-auth/utils': 0.5.1
'@better-fetch/fetch': 1.3.3
'@standard-schema/spec': 1.1.0
better-call: 1.4.4(zod@4.6.7)
jose: 6.2.3
kysely: 0.28.17
nanostores: 1.5.5
zod: 4.6.7
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@better-auth/drizzle-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)':
dependencies:
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5)
'@better-auth/utils': 0.5.1
'@better-auth/kysely-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(kysely@0.28.17)':
dependencies:
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5)
'@better-auth/utils': 0.5.1
optionalDependencies:
kysely: 0.28.17
'@better-auth/memory-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)':
dependencies:
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5)
'@better-auth/utils': 0.5.1
'@better-auth/mongo-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)':
dependencies:
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5)
'@better-auth/utils': 0.5.1
'@better-auth/prisma-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)':
dependencies:
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5)
'@better-auth/utils': 0.5.1
'@better-auth/telemetry@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)':
dependencies:
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5)
'@better-auth/utils': 0.5.1
'@better-fetch/fetch': 1.3.3
'@better-auth/utils@0.5.1':
dependencies:
'@noble/hashes': 2.4.0
'@better-fetch/fetch@1.3.3': {}
'@changesets/apply-release-plan@8.0.0':
dependencies:
'@changesets/config': 4.0.0
@@ -3035,12 +3191,14 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@noble/ciphers@2.4.0': {}
'@noble/hashes@2.4.0': {}
'@opentelemetry/api@1.9.1': {}
'@oxc-project/types@0.133.0': {}
'@panva/hkdf@1.2.1': {}
'@pinojs/redact@0.4.0': {}
'@playwright/test@1.61.1':
@@ -3525,6 +3683,41 @@ snapshots:
balanced-match@4.0.4: {}
better-auth@1.7.14(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vitest@4.1.7):
dependencies:
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5)
'@better-auth/drizzle-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)
'@better-auth/kysely-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(kysely@0.28.17)
'@better-auth/memory-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)
'@better-auth/mongo-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)
'@better-auth/prisma-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)
'@better-auth/telemetry': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.4(zod@4.6.7))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)
'@better-auth/utils': 0.5.1
'@better-fetch/fetch': 1.3.3
'@noble/ciphers': 2.4.0
'@noble/hashes': 2.4.0
better-call: 1.4.4(zod@4.6.7)
defu: 6.1.7
jose: 6.2.3
kysely: 0.28.17
nanostores: 1.5.5
zod: 4.6.7
optionalDependencies:
'@sveltejs/kit': 2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0))
svelte: 5.56.0(@typescript-eslint/types@8.60.0)
vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0))
transitivePeerDependencies:
- '@opentelemetry/api'
better-call@1.4.4(zod@4.6.7):
dependencies:
'@better-auth/utils': 0.5.1
'@better-fetch/fetch': 1.3.3
rou3: 0.9.2
set-cookie-parser: 3.2.1
optionalDependencies:
zod: 4.6.7
bintrees@1.0.2: {}
bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0)):
@@ -3588,6 +3781,8 @@ snapshots:
deepmerge@4.3.1: {}
defu@6.1.7: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
@@ -4004,12 +4199,12 @@ snapshots:
nanoid@3.3.16: {}
nanostores@1.5.5: {}
natural-compare@1.4.0: {}
nodemailer@9.0.3: {}
oauth4webapi@3.8.6: {}
obug@2.1.1: {}
on-exit-leak-free@2.1.2: {}
@@ -4139,11 +4334,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
preact@10.24.3:
optional: true
prelude-ls@1.2.1: {}
@@ -4237,6 +4429,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.4
fsevents: 2.3.3
rou3@0.9.2: {}
runed@0.35.1(@sveltejs/kit@2.70.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(yaml@2.9.0)))(svelte@5.56.0(@typescript-eslint/types@8.60.0)):
dependencies:
dequal: 2.0.3
@@ -4256,10 +4450,10 @@ snapshots:
semver@7.8.1: {}
set-cookie-parser@2.7.2: {}
set-cookie-parser@3.1.0: {}
set-cookie-parser@3.2.1: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -4522,3 +4716,5 @@ snapshots:
yocto-queue@0.1.0: {}
zimmerframe@1.1.4: {}
zod@4.6.7: {}