refactor(web): replace Auth.js with stateless Better Auth #106
No files matched your search
@@ -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.
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Vendored
+8
-2
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createAuthClient } from 'better-auth/svelte';
|
||||
|
||||
export const authClient = createAuthClient({ basePath: '/auth' });
|
||||
@@ -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;
|
||||
|
||||
@@ -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=');
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Generated
+273
-77
@@ -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: {}
|
||||
Reference in new issue
Block a user