diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 447920c..7d541ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 env: - DATABASE_URL: postgresql://user:password@example.com/neondb?sslmode=require - NEON_AUTH_BASE_URL: https://example.neon.tech/neondb/auth - NEXT_PUBLIC_NEON_AUTH_BASE_URL: https://example.neon.tech/neondb/auth - NEON_AUTH_COOKIE_SECRET: test-test-test-test-test-test-test-test + DATABASE_URL: postgresql://user:password@example.com/clawproxy + ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' steps: - name: Checkout uses: actions/checkout@v4 @@ -30,7 +28,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22 - cache: yarn - name: Enable Corepack (Yarn from packageManager) run: corepack enable @@ -63,9 +60,6 @@ jobs: context: . push: false build-args: | - NEXT_PUBLIC_NEON_AUTH_BASE_URL=https://example.neon.tech/neondb/auth - DATABASE_URL=postgresql://user:password@example.com/neondb?sslmode=require - NEON_AUTH_BASE_URL=https://example.neon.tech/neondb/auth - NEON_AUTH_COOKIE_SECRET=test-test-test-test-test-test-test-test + DATABASE_URL=postgresql://user:password@example.com/clawproxy cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 9903c35..c8ff00a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,23 +16,13 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# Build-time env (Next inlines NEXT_PUBLIC_*; Neon Auth and DB modules load during `next build`). -# NEON_AUTH_COOKIE_SECRET gets a placeholder because @neondatabase/auth validates -# the 32-char minimum at import time during page-data collection; the real secret -# is supplied at runtime via the container environment. -ARG NEXT_PUBLIC_NEON_AUTH_BASE_URL +# Build-time env. The app reads DATABASE_URL at runtime, but Next may evaluate server +# modules during `next build`, so CI/Coolify builds should provide the same Postgres URL. ARG DATABASE_URL -ARG NEON_AUTH_BASE_URL -ARG NEON_AUTH_COOKIE_SECRET=build-time-placeholder-not-a-real-secret +ENV DATABASE_URL=$DATABASE_URL -ENV NEXT_PUBLIC_NEON_AUTH_BASE_URL=$NEXT_PUBLIC_NEON_AUTH_BASE_URL \ - DATABASE_URL=$DATABASE_URL \ - NEON_AUTH_BASE_URL=$NEON_AUTH_BASE_URL \ - NEON_AUTH_COOKIE_SECRET=$NEON_AUTH_COOKIE_SECRET - -RUN test -n "$NEXT_PUBLIC_NEON_AUTH_BASE_URL" && test -n "$DATABASE_URL" && \ - test -n "$NEON_AUTH_BASE_URL" || \ - (echo "ERROR: Docker build requires --build-arg NEXT_PUBLIC_NEON_AUTH_BASE_URL, DATABASE_URL, NEON_AUTH_BASE_URL" >&2; exit 1) +RUN test -n "$DATABASE_URL" || \ + (echo "ERROR: Docker build requires --build-arg DATABASE_URL" >&2; exit 1) ENV NEXT_TELEMETRY_DISABLED=1 @@ -44,13 +34,13 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -# DATABASE_URL must be supplied at container runtime (Neon dashboard connection string). -# The build-time DATABASE_URL is only for `next build`; the app reads the env var when the server runs. +# DATABASE_URL must be supplied at container runtime (Coolify Postgres connection string). RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public +COPY --from=builder /app/db/migrations ./db/migrations COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static diff --git a/PLAN.md b/PLAN.md index bbf2e64..d3828e3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,7 +6,7 @@ clawproxy is a **public webhook relay for private OpenClaw nodes**. The core engine works: -- ✅ **Auth:** Neon Auth for human admins, hashed bearer tokens for machine nodes +- ✅ **Auth:** App-owned email/password auth in Postgres, hashed bearer tokens for machine nodes - ✅ **Database:** Postgres schema with nodes, routes, events (Drizzle ORM) - ✅ **Ingress:** `POST /api/ingress/:routeSlug` accepts and persists webhooks - ✅ **Delivery:** Pull-based polling with lease/ack, retry on expiry, TTL enforcement diff --git a/README.md b/README.md index 121576e..c9f0636 100644 --- a/README.md +++ b/README.md @@ -72,17 +72,13 @@ Nodes connect to `wss:///api/nodes/ws` and authenticate imm ### Prerequisites - Node.js 22+ -- A [Neon](https://neon.tech) Postgres database -- A [Neon Auth](https://neon.tech/docs/guides/neon-auth) project +- A Postgres database (Coolify Postgres is the recommended production default) ### Environment variables | Variable | Required | Description | |---|---|---| -| `DATABASE_URL` | Yes | Neon Postgres connection string | -| `NEON_AUTH_BASE_URL` | Yes | Neon Auth base URL (server-side) | -| `NEON_AUTH_COOKIE_SECRET` | Yes | Secret used to sign session cookies (min 32 chars) | -| `NEXT_PUBLIC_NEON_AUTH_BASE_URL` | Yes | Neon Auth base URL (embedded in the client bundle at build time) | +| `DATABASE_URL` | Yes | Postgres connection string (Coolify Postgres recommended) | | `ENCRYPTION_KEY` | Yes | 64-character hex string (32 bytes) used for AES-256-GCM encryption of sensitive fields at rest. Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` | Copy `.env.example` (if present) to `.env.local` and fill in the values before running locally. @@ -138,11 +134,11 @@ The project ships with a multi-stage `Dockerfile` that produces a minimal produc **Build the image** -`NEXT_PUBLIC_NEON_AUTH_BASE_URL` is baked into the client bundle at build time, so it must be passed as a build argument: +Pass the database URL as a build argument because Next may evaluate server modules during `next build`: ```bash docker build \ - --build-arg NEXT_PUBLIC_NEON_AUTH_BASE_URL=https:// \ + --build-arg DATABASE_URL="postgresql://..." \ -t clawproxy . ``` @@ -153,8 +149,7 @@ Pass runtime secrets as environment variables: ```bash docker run -p 3000:3000 \ -e DATABASE_URL="postgresql://..." \ - -e NEON_AUTH_BASE_URL="https://..." \ - -e NEON_AUTH_COOKIE_SECRET="..." \ + -e ENCRYPTION_KEY="$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")" \ clawproxy ``` @@ -168,10 +163,8 @@ Set the following environment variables in your platform's dashboard before depl | Variable | Notes | |---|---| -| `DATABASE_URL` | Neon Postgres connection string | -| `NEON_AUTH_BASE_URL` | Neon Auth base URL | -| `NEON_AUTH_COOKIE_SECRET` | Session cookie signing secret | -| `NEXT_PUBLIC_NEON_AUTH_BASE_URL` | Must be set **before** the build runs so it is inlined into the client bundle | +| `DATABASE_URL` | Postgres connection string (Coolify Postgres recommended) | +| `ENCRYPTION_KEY` | 64-character hex encryption key for sensitive payload fields | Deploy with the Nixpacks CLI: diff --git a/app/api/auth/[...path]/route.ts b/app/api/auth/[...path]/route.ts index b910bf3..897e231 100644 --- a/app/api/auth/[...path]/route.ts +++ b/app/api/auth/[...path]/route.ts @@ -1,3 +1,7 @@ -import { auth } from '@/app/lib/auth/server'; +export function GET() { + return Response.json({ ok: false, error: 'Unknown auth endpoint' }, { status: 404 }); +} -export const { GET, POST } = auth.handler(); +export function POST() { + return Response.json({ ok: false, error: 'Unknown auth endpoint' }, { status: 404 }); +} diff --git a/app/api/auth/_shared.ts b/app/api/auth/_shared.ts new file mode 100644 index 0000000..c58a31d --- /dev/null +++ b/app/api/auth/_shared.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; + +import { SESSION_COOKIE_NAME } from '@/app/lib/auth/constants'; + +export type JsonBody = Record; + +export async function readJson(request: Request): Promise { + try { + const body = await request.json(); + return typeof body === 'object' && body !== null ? (body as JsonBody) : null; + } catch { + return null; + } +} + +export function stringField(body: JsonBody | null, key: string) { + const value = body?.[key]; + return typeof value === 'string' ? value : ''; +} + +export function okWithSession(data: unknown, token: string, expiresAt: Date) { + const response = NextResponse.json({ ok: true, data }); + response.cookies.set({ + name: SESSION_COOKIE_NAME, + value: token, + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + expires: expiresAt, + }); + return response; +} + +export function clearSessionResponse() { + const response = NextResponse.json({ ok: true }); + response.cookies.set({ + name: SESSION_COOKIE_NAME, + value: '', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: 0, + }); + return response; +} diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..06da77f --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,8 @@ +import { requestPasswordReset } from '@/app/lib/auth/server'; +import { readJson, stringField } from '../_shared'; + +export async function POST(request: Request) { + const body = await readJson(request); + await requestPasswordReset(stringField(body, 'email')); + return Response.json({ ok: true }); +} diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 2cd2556..7676279 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -1,11 +1,3 @@ -import { auth } from '@/app/lib/auth/server'; - -const handler = auth.handler(); - -export async function GET(request: Request) { - return handler.GET(request, { params: Promise.resolve({ path: [] }) }); -} - -export async function POST(request: Request) { - return handler.POST(request, { params: Promise.resolve({ path: [] }) }); +export function GET() { + return Response.json({ ok: true, service: 'clawproxy-auth' }); } diff --git a/app/api/auth/sign-in/route.ts b/app/api/auth/sign-in/route.ts new file mode 100644 index 0000000..60ee0c8 --- /dev/null +++ b/app/api/auth/sign-in/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; + +import { + checkAuthRateLimit, + clearAuthRateLimit, + recordAuthRateLimitAttempt, +} from '@/app/lib/auth/rate-limit'; +import { signInWithPassword } from '@/app/lib/auth/server'; +import { okWithSession, readJson, stringField } from '../_shared'; + +const SIGN_IN_WINDOW_MS = 15 * 60 * 1000; +const SIGN_IN_MAX_FAILED_ATTEMPTS = 5; +const SIGN_IN_KIND = 'sign-in'; + +function signInKey(email: string) { + return `${SIGN_IN_KIND}:${email.trim().toLowerCase()}`; +} + +function tooManyAttemptsResponse(retryAfterSeconds: number) { + return NextResponse.json( + { ok: false, error: 'Too many sign-in attempts' }, + { + status: 429, + headers: { 'retry-after': String(retryAfterSeconds) }, + } + ); +} + +export async function POST(request: Request) { + const body = await readJson(request); + const email = stringField(body, 'email'); + const key = signInKey(email); + + const rateLimit = await checkAuthRateLimit({ + key, + kind: SIGN_IN_KIND, + maxAttempts: SIGN_IN_MAX_FAILED_ATTEMPTS, + windowMs: SIGN_IN_WINDOW_MS, + }); + if (rateLimit.limited) { + return tooManyAttemptsResponse(rateLimit.retryAfterSeconds); + } + + const result = await signInWithPassword(email, stringField(body, 'password')); + + if (!result.ok) { + await recordAuthRateLimitAttempt({ key, kind: SIGN_IN_KIND, windowMs: SIGN_IN_WINDOW_MS }); + return NextResponse.json({ ok: false, error: result.error }, { status: result.status ?? 401 }); + } + + await clearAuthRateLimit(key); + return okWithSession({ user: result.user }, result.token, result.expiresAt); +} diff --git a/app/api/auth/sign-out/route.ts b/app/api/auth/sign-out/route.ts new file mode 100644 index 0000000..cbc0010 --- /dev/null +++ b/app/api/auth/sign-out/route.ts @@ -0,0 +1,15 @@ +import { SESSION_COOKIE_NAME } from '@/app/lib/auth/constants'; +import { signOutSession } from '@/app/lib/auth/server'; +import { clearSessionResponse } from '../_shared'; + +export async function POST(request: Request) { + const token = request.headers + .get('cookie') + ?.split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`${SESSION_COOKIE_NAME}=`)) + ?.slice(SESSION_COOKIE_NAME.length + 1); + + await signOutSession(token); + return clearSessionResponse(); +} diff --git a/app/api/auth/sign-up/route.ts b/app/api/auth/sign-up/route.ts new file mode 100644 index 0000000..893ebd7 --- /dev/null +++ b/app/api/auth/sign-up/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server'; + +import { + checkAuthRateLimit, + recordAuthRateLimitAttempt, +} from '@/app/lib/auth/rate-limit'; +import { signUpWithPassword } from '@/app/lib/auth/server'; +import { okWithSession, readJson, stringField } from '../_shared'; + +const SIGN_UP_WINDOW_MS = 15 * 60 * 1000; +const SIGN_UP_MAX_ATTEMPTS = 5; +const SIGN_UP_KIND = 'sign-up'; +const SIGN_UP_GLOBAL_KEY = `${SIGN_UP_KIND}:global`; + +function tooManyAttemptsResponse(retryAfterSeconds: number) { + return NextResponse.json( + { ok: false, error: 'Too many sign-up attempts' }, + { + status: 429, + headers: { 'retry-after': String(retryAfterSeconds) }, + } + ); +} + +export async function POST(request: Request) { + const body = await readJson(request); + const rateLimit = await checkAuthRateLimit({ + key: SIGN_UP_GLOBAL_KEY, + kind: SIGN_UP_KIND, + maxAttempts: SIGN_UP_MAX_ATTEMPTS, + windowMs: SIGN_UP_WINDOW_MS, + }); + if (rateLimit.limited) { + return tooManyAttemptsResponse(rateLimit.retryAfterSeconds); + } + + await recordAuthRateLimitAttempt({ + key: SIGN_UP_GLOBAL_KEY, + kind: SIGN_UP_KIND, + windowMs: SIGN_UP_WINDOW_MS, + }); + + const result = await signUpWithPassword({ + email: stringField(body, 'email'), + password: stringField(body, 'password'), + name: stringField(body, 'name'), + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: result.status ?? 400 }); + } + + return okWithSession({ user: result.user }, result.token, result.expiresAt); +} diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx index 823b72e..a2b5fb4 100644 --- a/app/auth/forgot-password/page.tsx +++ b/app/auth/forgot-password/page.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { FormEvent, useState } from 'react'; -import { createNeonClientAuth } from '@/app/lib/auth/client'; +import { createClientAuth } from '@/app/lib/auth/client'; import { useRedirect127ToLocalhost } from '@/app/lib/auth/dev-origin'; import { AdminShell } from '@/components/app/admin-shell'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -27,7 +27,7 @@ export default function ForgotPasswordPage() { setSuccess(null); try { - const auth = await createNeonClientAuth(); + const auth = await createClientAuth(); const result = await auth.requestPasswordReset({ email, redirectTo: '/auth/sign-in', diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index d5beb56..39bd589 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { FormEvent, Suspense, useState } from 'react'; import posthog from 'posthog-js'; -import { createNeonClientAuth } from '@/app/lib/auth/client'; +import { createClientAuth } from '@/app/lib/auth/client'; import { useRedirect127ToLocalhost } from '@/app/lib/auth/dev-origin'; import { resolvePostSignInRedirect } from '@/app/lib/auth/post-sign-in-redirect'; import { AdminShell } from '@/components/app/admin-shell'; @@ -39,7 +39,7 @@ function SignInForm() { setError(null); try { - const auth = await createNeonClientAuth(); + const auth = await createClientAuth(); const result = await auth.signIn.email({ email, password, diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index 9993f60..2dce7be 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { FormEvent, useState } from 'react'; import posthog from 'posthog-js'; -import { createNeonClientAuth } from '@/app/lib/auth/client'; +import { createClientAuth } from '@/app/lib/auth/client'; import { useRedirect127ToLocalhost } from '@/app/lib/auth/dev-origin'; import { AdminShell } from '@/components/app/admin-shell'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -32,7 +32,7 @@ export default function SignUpPage() { setSuccess(null); try { - const auth = await createNeonClientAuth(); + const auth = await createClientAuth(); const result = await auth.signUp.email({ email, password, @@ -46,15 +46,6 @@ export default function SignUpPage() { posthog.identify(email, { email, name: name.trim() || email }); posthog.capture('user_signed_up', { email, name: name.trim() || email }); - // Sign the new account straight in: bouncing to the sign-in form made a successful - // sign-up look like a failure. If that doesn't work, say so on the sign-in page. - const signedIn = await auth.signIn.email({ email, password }); - if (signedIn?.error) { - router.push('/auth/sign-in?registered=1'); - router.refresh(); - return; - } - setSuccess('Account created. Taking you to your dashboard…'); router.push('/dashboard'); router.refresh(); diff --git a/app/dashboard/use-sign-out.ts b/app/dashboard/use-sign-out.ts index 84bfa51..289d9ba 100644 --- a/app/dashboard/use-sign-out.ts +++ b/app/dashboard/use-sign-out.ts @@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation'; import { useCallback, useState } from 'react'; -import { createNeonClientAuth } from '@/app/lib/auth/client'; +import { createClientAuth } from '@/app/lib/auth/client'; export function useSignOut() { const router = useRouter(); @@ -15,7 +15,7 @@ export function useSignOut() { setError(null); try { - const auth = await createNeonClientAuth(); + const auth = await createClientAuth(); const result = await auth.signOut(); if (result?.error) { diff --git a/app/lib/auth/client.ts b/app/lib/auth/client.ts index f32099f..5325537 100644 --- a/app/lib/auth/client.ts +++ b/app/lib/auth/client.ts @@ -1,41 +1,46 @@ 'use client'; -function randomUuidFallback() { - return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; -} - -function ensureCryptoRandomUuid() { - const globalCrypto = globalThis.crypto as Crypto | undefined; - - if (!globalCrypto) { - return; +type AuthResult = + | { data: T; error?: never } + | { data?: never; error: { message: string } }; + +async function postJson(path: string, body: Record = {}): Promise> { + const response = await fetch(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + + const payload = (await response.json().catch(() => ({}))) as { + ok?: boolean; + data?: T; + error?: string; + }; + + if (!response.ok || payload.ok === false) { + return { error: { message: payload.error || 'Request failed' } }; } - if (typeof globalCrypto.randomUUID !== 'function') { - Object.defineProperty(globalCrypto, 'randomUUID', { - value: randomUuidFallback, - configurable: true, - }); - } + return { data: payload.data as T }; } -function getAuthBaseUrl() { - if (typeof window === 'undefined') { - const publicBaseUrl = process.env.NEXT_PUBLIC_NEON_AUTH_BASE_URL; - - if (publicBaseUrl) { - return publicBaseUrl; - } - - throw new Error('Neon Auth base URL is unavailable on the server'); - } - - return new URL('/api/auth', window.location.origin).toString(); -} - -export async function createNeonClientAuth() { - ensureCryptoRandomUuid(); - - const { createAuthClient } = await import('@neondatabase/auth'); - return createAuthClient(getAuthBaseUrl()); +export async function createClientAuth() { + return { + signIn: { + email(input: { email: string; password: string }) { + return postJson('/api/auth/sign-in', input); + }, + }, + signUp: { + email(input: { email: string; password: string; name?: string }) { + return postJson('/api/auth/sign-up', input); + }, + }, + requestPasswordReset(input: { email: string; redirectTo?: string }) { + return postJson('/api/auth/forgot-password', input); + }, + signOut() { + return postJson('/api/auth/sign-out'); + }, + }; } diff --git a/app/lib/auth/constants.ts b/app/lib/auth/constants.ts new file mode 100644 index 0000000..750fb1f --- /dev/null +++ b/app/lib/auth/constants.ts @@ -0,0 +1 @@ +export const SESSION_COOKIE_NAME = 'clawproxy_session'; diff --git a/app/lib/auth/password.ts b/app/lib/auth/password.ts new file mode 100644 index 0000000..0ff5cb5 --- /dev/null +++ b/app/lib/auth/password.ts @@ -0,0 +1,64 @@ +import { randomBytes, scrypt as scryptCallback, timingSafeEqual, type ScryptOptions } from 'node:crypto'; + +const KEY_LENGTH = 64; +const SCRYPT_PARAMS = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 } as const; + +function scrypt(password: string, salt: string, keyLength: number, options: ScryptOptions) { + return new Promise((resolve, reject) => { + scryptCallback(password, salt, keyLength, options, (error, derivedKey) => { + if (error) reject(error); + else resolve(derivedKey); + }); + }); +} + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('base64url'); + const derivedKey = (await scrypt(password, salt, KEY_LENGTH, SCRYPT_PARAMS)) as Buffer; + return [ + 'scrypt', + SCRYPT_PARAMS.N, + SCRYPT_PARAMS.r, + SCRYPT_PARAMS.p, + salt, + derivedKey.toString('base64url'), + ].join('$'); +} + +export function passwordHashNeedsUpgrade(storedHash: string): boolean { + const parts = storedHash.split('$'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return true; + + const [, nRaw, rRaw, pRaw] = parts; + return ( + Number(nRaw) < SCRYPT_PARAMS.N || + Number(rRaw) !== SCRYPT_PARAMS.r || + Number(pRaw) !== SCRYPT_PARAMS.p + ); +} + +export async function verifyPassword(password: string, storedHash: string): Promise { + const parts = storedHash.split('$'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return false; + + const [, nRaw, rRaw, pRaw, salt, expectedRaw] = parts; + const N = Number(nRaw); + const r = Number(rRaw); + const p = Number(pRaw); + if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p) || !salt || !expectedRaw) { + return false; + } + + try { + const expected = Buffer.from(expectedRaw, 'base64url'); + const actual = (await scrypt(password, salt, expected.length, { + N, + r, + p, + maxmem: Math.max(SCRYPT_PARAMS.maxmem, 128 * N * r + 1024 * 1024), + })) as Buffer; + return expected.length === actual.length && timingSafeEqual(expected, actual); + } catch { + return false; + } +} diff --git a/app/lib/auth/rate-limit.ts b/app/lib/auth/rate-limit.ts new file mode 100644 index 0000000..882e636 --- /dev/null +++ b/app/lib/auth/rate-limit.ts @@ -0,0 +1,72 @@ +import 'server-only'; + +import { eq, sql } from 'drizzle-orm'; + +import { db } from '@/app/lib/db/client'; +import { authRateLimits } from '@/db/schema'; + +export type AuthRateLimitResult = { + limited: boolean; + retryAfterSeconds: number; +}; + +export type AuthRateLimitOptions = { + key: string; + kind: string; + maxAttempts: number; + windowMs: number; +}; + +function nowMs() { + return Date.now(); +} + +function retryAfter(windowStart: Date, windowMs: number) { + return Math.max(1, Math.ceil((windowStart.getTime() + windowMs - nowMs()) / 1000)); +} + +export async function checkAuthRateLimit({ + key, + maxAttempts, + windowMs, +}: AuthRateLimitOptions): Promise { + const [entry] = await db.select().from(authRateLimits).where(eq(authRateLimits.key, key)).limit(1); + if (!entry) return { limited: false, retryAfterSeconds: 0 }; + + if (nowMs() - entry.windowStart.getTime() >= windowMs) { + await db.delete(authRateLimits).where(eq(authRateLimits.key, key)); + return { limited: false, retryAfterSeconds: 0 }; + } + + if (entry.count >= maxAttempts) { + return { limited: true, retryAfterSeconds: retryAfter(entry.windowStart, windowMs) }; + } + + return { limited: false, retryAfterSeconds: 0 }; +} + +export async function recordAuthRateLimitAttempt({ + key, + kind, + windowMs, +}: Omit) { + const windowStart = new Date(); + const expiredBefore = new Date(windowStart.getTime() - windowMs); + + await db + .insert(authRateLimits) + .values({ key, kind, count: 1, windowStart, updatedAt: windowStart }) + .onConflictDoUpdate({ + target: authRateLimits.key, + set: { + kind, + count: sql`case when ${authRateLimits.windowStart} <= ${expiredBefore} then 1 else ${authRateLimits.count} + 1 end`, + windowStart: sql`case when ${authRateLimits.windowStart} <= ${expiredBefore} then ${windowStart} else ${authRateLimits.windowStart} end`, + updatedAt: windowStart, + }, + }); +} + +export async function clearAuthRateLimit(key: string) { + await db.delete(authRateLimits).where(eq(authRateLimits.key, key)); +} diff --git a/app/lib/auth/server.ts b/app/lib/auth/server.ts index 4c4bc42..f5528ce 100644 --- a/app/lib/auth/server.ts +++ b/app/lib/auth/server.ts @@ -1,10 +1,164 @@ import 'server-only'; -import { createNeonAuth } from '@neondatabase/auth/next/server'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { and, eq, gt } from 'drizzle-orm'; +import { cookies } from 'next/headers'; -export const auth = createNeonAuth({ - baseUrl: process.env.NEON_AUTH_BASE_URL!, - cookies: { - secret: process.env.NEON_AUTH_COOKIE_SECRET!, +import { db } from '@/app/lib/db/client'; +import { SESSION_COOKIE_NAME } from './constants'; +import { authSessions, authUsers, type AuthUser } from '@/db/schema'; +import { hashPassword, passwordHashNeedsUpgrade, verifyPassword } from './password'; + +const SESSION_DAYS = 30; +const DUMMY_PASSWORD_HASH = + 'scrypt$131072$8$1$dummy-salt-for-timing$fDgBd3ILD0ghTJFckvLC1-phtr7co3c23O1kYvq2ANBoDZ5LAmfnYfcPBBbitkQi99ooA9wKzPdRR8yKL7EjYQ'; + +export type AppAuthUser = Pick; + +type AuthSuccess = { + ok: true; + user: AppAuthUser; + token: string; + expiresAt: Date; +}; + +type AuthFailure = { + ok: false; + error: string; + status?: number; +}; + +function normalizeEmail(email: string) { + return email.trim().toLowerCase(); +} + +function publicUser(user: AuthUser): AppAuthUser { + return { id: user.id, email: user.email, name: user.name }; +} + +function hashSessionToken(token: string) { + return createHash('sha256').update(token).digest('hex'); +} + +function newSessionExpiry() { + return new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000); +} + +export function createSessionToken() { + return randomBytes(32).toString('base64url'); +} + +async function createSession(user: AuthUser): Promise { + const token = createSessionToken(); + const expiresAt = newSessionExpiry(); + + await db.insert(authSessions).values({ + id: randomUUID(), + tokenHash: hashSessionToken(token), + userId: user.id, + expiresAt, + }); + + return { ok: true, user: publicUser(user), token, expiresAt }; +} + +export async function signUpWithPassword(input: { + email: string; + password: string; + name?: string; +}): Promise { + const email = normalizeEmail(input.email); + const password = input.password; + const name = input.name?.trim() || email; + + if (!email || !email.includes('@')) return { ok: false, error: 'Valid email is required', status: 400 }; + if (password.length < 8) { + return { ok: false, error: 'Password must be at least 8 characters', status: 400 }; + } + + try { + const [user] = await db + .insert(authUsers) + .values({ + id: randomUUID(), + email, + name, + passwordHash: await hashPassword(password), + }) + .returning(); + + if (!user) return { ok: false, error: 'Could not create account', status: 500 }; + return createSession(user); + } catch (error) { + if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { + return { ok: false, error: 'Email already exists', status: 409 }; + } + throw error; + } +} + +export async function signInWithPassword( + emailInput: string, + password: string +): Promise { + const email = normalizeEmail(emailInput); + const [user] = await db.select().from(authUsers).where(eq(authUsers.email, email)).limit(1); + + if (!user) { + await verifyPassword(password, DUMMY_PASSWORD_HASH); + return { ok: false, error: 'Invalid email or password', status: 401 }; + } + + if (!(await verifyPassword(password, user.passwordHash))) { + return { ok: false, error: 'Invalid email or password', status: 401 }; + } + + if (passwordHashNeedsUpgrade(user.passwordHash)) { + await db + .update(authUsers) + .set({ passwordHash: await hashPassword(password), updatedAt: new Date() }) + .where(eq(authUsers.id, user.id)); + } + + return createSession(user); +} + +export async function getSessionByToken(token: string | undefined | null) { + if (!token) return null; + + const [session] = await db + .select() + .from(authSessions) + .where(and(eq(authSessions.tokenHash, hashSessionToken(token)), gt(authSessions.expiresAt, new Date()))) + .limit(1); + + if (!session) return null; + + const [user] = await db.select().from(authUsers).where(eq(authUsers.id, session.userId)).limit(1); + if (!user) return null; + + return { user: publicUser(user), session }; +} + +export async function signOutSession(token: string | undefined | null) { + if (token) { + await db.delete(authSessions).where(eq(authSessions.tokenHash, hashSessionToken(token))); + } + return { ok: true }; +} + +export async function requestPasswordReset(email: string) { + void email; + // No email provider is wired yet. Keep the endpoint intentionally neutral so callers cannot + // enumerate accounts; a future email provider can enqueue a reset token here. + return { ok: true }; +} + +export const auth = { + async getSession() { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + const session = await getSessionByToken(token); + return { data: session ? { user: session.user } : null }; }, -}); +}; diff --git a/app/lib/db/migrations.ts b/app/lib/db/migrations.ts new file mode 100644 index 0000000..a79be71 --- /dev/null +++ b/app/lib/db/migrations.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type postgres from 'postgres'; + +type Sql = ReturnType; +type Transaction = { + // postgres.js types narrow params aggressively; migration SQL is controlled repo input. + unsafe: (statement: string, params?: never[]) => Promise; +}; + +async function runStatement(tx: Transaction, statement: string) { + await tx.unsafe(statement); +} + +function sqlString(value: string) { + return `'${value.replaceAll("'", "''")}'`; +} + +async function recordMigration(tx: Transaction, name: string, hash: string) { + await tx.unsafe( + `INSERT INTO "__clawproxy_migrations" ("name", "hash") VALUES (${sqlString(name)}, ${sqlString(hash)})` + ); +} + +export function splitMigrationStatements(sql: string) { + return sql + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter(Boolean); +} + +function migrationHash(contents: string) { + return createHash('sha256').update(contents).digest('hex'); +} + +export async function applyCommittedMigrations(sql: Sql, migrationsDir = join(process.cwd(), 'db/migrations')) { + await sql` + CREATE TABLE IF NOT EXISTS "__clawproxy_migrations" ( + "name" text PRIMARY KEY NOT NULL, + "hash" text NOT NULL, + "applied_at" timestamp with time zone DEFAULT now() NOT NULL + ) + `; + + const names = (await readdir(migrationsDir)) + .filter((name) => /^\d+_.+\.sql$/.test(name)) + .sort((a, b) => a.localeCompare(b)); + + for (const name of names) { + const contents = await readFile(join(migrationsDir, name), 'utf8'); + const hash = migrationHash(contents); + const existing = await sql<{ hash: string }[]>` + SELECT hash FROM "__clawproxy_migrations" WHERE name = ${name} LIMIT 1 + `; + + if (existing[0]) { + if (existing[0].hash !== hash) { + throw new Error(`Migration ${name} was already applied with a different hash`); + } + continue; + } + + const statements = splitMigrationStatements(contents); + await sql.begin(async (tx) => { + for (const statement of statements) { + await runStatement(tx, statement); + } + await recordMigration(tx, name, hash); + }); + } +} diff --git a/db/migrations/0004_app_owned_auth.sql b/db/migrations/0004_app_owned_auth.sql new file mode 100644 index 0000000..b13740b --- /dev/null +++ b/db/migrations/0004_app_owned_auth.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS "auth_users" ( + "id" text PRIMARY KEY NOT NULL, + "email" text NOT NULL, + "name" text NOT NULL, + "password_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "auth_users_email_key" ON "auth_users" USING btree ("email");--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "auth_sessions" ( + "id" text PRIMARY KEY NOT NULL, + "token_hash" text NOT NULL, + "user_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "auth_sessions_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE no action +);--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "auth_sessions_token_hash_key" ON "auth_sessions" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "auth_sessions_user_id_idx" ON "auth_sessions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "auth_sessions_expires_at_idx" ON "auth_sessions" USING btree ("expires_at"); diff --git a/db/migrations/0005_auth_rate_limits.sql b/db/migrations/0005_auth_rate_limits.sql new file mode 100644 index 0000000..306039c --- /dev/null +++ b/db/migrations/0005_auth_rate_limits.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS "auth_rate_limits" ( + "key" text PRIMARY KEY NOT NULL, + "kind" text NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "auth_rate_limits_kind_idx" ON "auth_rate_limits" USING btree ("kind"); diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 87940b8..151d649 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1790098916448, "tag": "0003_nodes_user_slug_namespace", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1790123400000, + "tag": "0004_app_owned_auth", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1790125200000, + "tag": "0005_auth_rate_limits", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema.ts b/db/schema.ts index db945a9..6b933ca 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -19,6 +19,49 @@ export const eventStatusEnum = pgEnum('event_status', [ 'expired', ]); +export const authUsers = pgTable( + 'auth_users', + { + id: text('id').primaryKey(), + email: text('email').notNull(), + name: text('name').notNull(), + passwordHash: text('password_hash').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('auth_users_email_key').on(table.email)] +); + +export const authSessions = pgTable( + 'auth_sessions', + { + id: text('id').primaryKey(), + tokenHash: text('token_hash').notNull(), + userId: text('user_id') + .notNull() + .references(() => authUsers.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('auth_sessions_token_hash_key').on(table.tokenHash), + index('auth_sessions_user_id_idx').on(table.userId), + index('auth_sessions_expires_at_idx').on(table.expiresAt), + ] +); + +export const authRateLimits = pgTable( + 'auth_rate_limits', + { + key: text('key').primaryKey(), + kind: text('kind').notNull(), + count: integer('count').notNull().default(0), + windowStart: timestamp('window_start', { withTimezone: true }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index('auth_rate_limits_kind_idx').on(table.kind)] +); + export const nodes = pgTable( 'nodes', { @@ -89,6 +132,12 @@ export const events = pgTable( ] ); +export type AuthUser = typeof authUsers.$inferSelect; +export type NewAuthUser = typeof authUsers.$inferInsert; +export type AuthSession = typeof authSessions.$inferSelect; +export type NewAuthSession = typeof authSessions.$inferInsert; +export type AuthRateLimit = typeof authRateLimits.$inferSelect; +export type NewAuthRateLimit = typeof authRateLimits.$inferInsert; export type Node = typeof nodes.$inferSelect; export type NewNode = typeof nodes.$inferInsert; export type Route = typeof routes.$inferSelect; diff --git a/package.json b/package.json index ab9d79b..b5cb229 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "db:push": "drizzle-kit push" }, "dependencies": { - "@neondatabase/auth": "^0.2.0-beta.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", diff --git a/proxy.ts b/proxy.ts index 55c8482..eb28bad 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,14 +1,20 @@ -import { auth } from '@/app/lib/auth/server'; +import { NextRequest, NextResponse } from 'next/server'; + +import { SESSION_COOKIE_NAME } from '@/app/lib/auth/constants'; /** - * Match dashboard navigations only — not /api/admin. For POST/DELETE to admin APIs, - * Neon Auth's middleware forwards the incoming method/body to upstream get-session when - * the fast cookie-cache path does not apply (GET-only), which breaks JSON APIs and can - * yield HTML responses. Admin routes use requireAdminUser() instead. + * Keep middleware edge-safe: only require that a session cookie exists before dashboard + * navigation. The dashboard layout validates the session against Postgres at runtime. */ -export default auth.middleware({ - loginUrl: '/auth/sign-in', -}); +export default function proxy(request: NextRequest) { + const token = request.cookies.get(SESSION_COOKIE_NAME)?.value; + if (token) return NextResponse.next(); + + const loginUrl = new URL('/auth/sign-in', request.url); + const next = `${request.nextUrl.pathname}${request.nextUrl.search}`; + if (next !== '/dashboard') loginUrl.searchParams.set('next', next); + return NextResponse.redirect(loginUrl); +} export const config = { matcher: ['/dashboard/:path*'], diff --git a/server.ts b/server.ts index cf3ffef..f626f99 100644 --- a/server.ts +++ b/server.ts @@ -4,6 +4,7 @@ import next from 'next'; import postgres from 'postgres'; import { WebSocketServer, type WebSocket } from 'ws'; +import { applyCommittedMigrations } from './app/lib/db/migrations'; import { markNodeSeen } from './app/lib/nodes/mark-seen'; import { addConnection, removeConnection } from './app/lib/ws/connection-manager'; import { createNodeSocketHandler } from './app/lib/ws/node-socket'; @@ -141,7 +142,10 @@ const handleNodeWebSocket = createNodeSocketHandler({ const app = next({ dev, hostname, port }); const handle = app.getRequestHandler(); -app.prepare().then(() => { +async function main() { + await applyCommittedMigrations(db); + await app.prepare(); + const httpServer = createServer((req, res) => { handle(req, res); }); @@ -164,4 +168,9 @@ app.prepare().then(() => { httpServer.listen(port, () => { console.log(`> Ready on http://${hostname}:${port}`); }); +} + +main().catch((error) => { + console.error('[server] failed to start', error); + process.exit(1); }); diff --git a/tests/api/auth/route.test.ts b/tests/api/auth/route.test.ts index 56d5d2b..2a035ef 100644 --- a/tests/api/auth/route.test.ts +++ b/tests/api/auth/route.test.ts @@ -1,57 +1,180 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -const getSpy = vi.hoisted(() => vi.fn()); -const postSpy = vi.hoisted(() => vi.fn()); - -vi.mock('@/app/lib/auth/server', () => ({ - auth: { - handler: vi.fn(() => ({ - GET: getSpy, - POST: postSpy, - })), - }, +const service = vi.hoisted(() => ({ + signInWithPassword: vi.fn(), + signUpWithPassword: vi.fn(), + signOutSession: vi.fn(), + requestPasswordReset: vi.fn(), })); -import { GET as rootGet, POST as rootPost } from '@/app/api/auth/route'; -import { GET as catchAllGet, POST as catchAllPost } from '@/app/api/auth/[...path]/route'; +const rateLimit = vi.hoisted(() => ({ + checkAuthRateLimit: vi.fn(), + recordAuthRateLimitAttempt: vi.fn(), + clearAuthRateLimit: vi.fn(), +})); + +vi.mock('@/app/lib/auth/server', () => service); +vi.mock('@/app/lib/auth/rate-limit', () => rateLimit); -describe('GET/POST /api/auth', () => { +import { POST as forgotPassword } from '@/app/api/auth/forgot-password/route'; +import { POST as signIn } from '@/app/api/auth/sign-in/route'; +import { POST as signOut } from '@/app/api/auth/sign-out/route'; +import { POST as signUp } from '@/app/api/auth/sign-up/route'; + +function jsonRequest( + path: string, + body: Record, + extraHeaders: Record = {} +) { + return new Request(`http://localhost${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...extraHeaders }, + body: JSON.stringify(body), + }); +} + +describe('first-party auth API routes', () => { beforeEach(() => { - getSpy.mockReset(); - postSpy.mockReset(); - getSpy.mockResolvedValue(new Response('ok-get', { status: 200 })); - postSpy.mockResolvedValue(new Response('ok-post', { status: 200 })); + vi.clearAllMocks(); + rateLimit.checkAuthRateLimit.mockResolvedValue({ limited: false, retryAfterSeconds: 0 }); + rateLimit.recordAuthRateLimitAttempt.mockResolvedValue(undefined); + rateLimit.clearAuthRateLimit.mockResolvedValue(undefined); + }); + + test('sign-in creates an httpOnly session cookie on success', async () => { + service.signInWithPassword.mockResolvedValue({ + ok: true, + token: 'session-token', + expiresAt: new Date('2030-01-01T00:00:00Z'), + user: { id: 'user-1', email: 'user@example.com', name: 'User' }, + }); + + const response = await signIn(jsonRequest('/api/auth/sign-in', { + email: 'USER@example.com', + password: 'secret123', + })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ ok: true, data: { user: { id: 'user-1' } } }); + expect(service.signInWithPassword).toHaveBeenCalledWith('USER@example.com', 'secret123'); + expect(response.headers.get('set-cookie')).toContain('clawproxy_session=session-token'); + expect(response.headers.get('set-cookie')).toContain('HttpOnly'); + }); + + test('sign-in returns 401 for bad credentials', async () => { + service.signInWithPassword.mockResolvedValue({ ok: false, error: 'Invalid email or password' }); + + const response = await signIn(jsonRequest('/api/auth/sign-in', { + email: 'user@example.com', + password: 'wrong', + })); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body).toEqual({ ok: false, error: 'Invalid email or password' }); + expect(response.headers.get('set-cookie')).toBeNull(); + }); + + test('rate limits repeated failed sign-in attempts by email', async () => { + rateLimit.checkAuthRateLimit.mockResolvedValue({ limited: true, retryAfterSeconds: 900 }); + + const response = await signIn(jsonRequest('/api/auth/sign-in', { + email: 'rate-limit@example.com', + password: 'wrong', + }, { 'x-forwarded-for': '203.0.113.10' })); + const body = await response.json(); + + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('900'); + expect(body).toEqual({ ok: false, error: 'Too many sign-in attempts' }); + expect(service.signInWithPassword).not.toHaveBeenCalled(); + expect(rateLimit.checkAuthRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + key: 'sign-in:rate-limit@example.com', + kind: 'sign-in', + })); + }); + + test('sign-up creates the user and signs them in', async () => { + service.signUpWithPassword.mockResolvedValue({ + ok: true, + token: 'new-session', + expiresAt: new Date('2030-01-01T00:00:00Z'), + user: { id: 'user-2', email: 'new@example.com', name: 'New User' }, + }); + + const response = await signUp(jsonRequest('/api/auth/sign-up', { + email: 'new@example.com', + password: 'long-enough', + name: 'New User', + })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.data.user.email).toBe('new@example.com'); + expect(service.signUpWithPassword).toHaveBeenCalledWith({ + email: 'new@example.com', + password: 'long-enough', + name: 'New User', + }); + expect(response.headers.get('set-cookie')).toContain('clawproxy_session=new-session'); }); - test('root route delegates to handler.GET with empty path params', async () => { - const req = new Request('http://localhost/api/auth'); - const res = await rootGet(req); + test('sign-up returns 409 when the email already exists', async () => { + service.signUpWithPassword.mockResolvedValue({ ok: false, error: 'Email already exists', status: 409 }); - expect(getSpy).toHaveBeenCalledTimes(1); - const [request, ctx] = getSpy.mock.calls[0]!; - expect(request).toBe(req); - expect(await ctx.params).toEqual({ path: [] }); - expect(res.status).toBe(200); - expect(await res.text()).toBe('ok-get'); + const response = await signUp(jsonRequest('/api/auth/sign-up', { + email: 'taken@example.com', + password: 'long-enough', + })); + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body.error).toBe('Email already exists'); }); - test('root route delegates to handler.POST with empty path params', async () => { - const req = new Request('http://localhost/api/auth', { method: 'POST', body: '{}' }); - const res = await rootPost(req); + test('rate limits repeated sign-up attempts globally', async () => { + rateLimit.checkAuthRateLimit.mockResolvedValue({ limited: true, retryAfterSeconds: 900 }); + + const response = await signUp(jsonRequest('/api/auth/sign-up', { + email: 'blocked@example.com', + password: 'long-enough', + }, { 'x-forwarded-for': '203.0.113.20' })); + const body = await response.json(); - expect(postSpy).toHaveBeenCalledTimes(1); - const [, ctx] = postSpy.mock.calls[0]!; - expect(await ctx.params).toEqual({ path: [] }); - expect(res.status).toBe(200); + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('900'); + expect(body).toEqual({ ok: false, error: 'Too many sign-up attempts' }); + expect(service.signUpWithPassword).not.toHaveBeenCalled(); + expect(rateLimit.checkAuthRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + key: 'sign-up:global', + kind: 'sign-up', + })); }); - test('catch-all route exports the same handler methods', async () => { - const req = new Request('http://localhost/api/auth/callback/foo'); - await catchAllGet(req); - expect(getSpy).toHaveBeenCalled(); + test('forgot-password returns a neutral success response', async () => { + service.requestPasswordReset.mockResolvedValue({ ok: true }); + + const response = await forgotPassword(jsonRequest('/api/auth/forgot-password', { + email: 'maybe@example.com', + })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ ok: true }); + expect(service.requestPasswordReset).toHaveBeenCalledWith('maybe@example.com'); + }); + + test('sign-out clears the session cookie', async () => { + service.signOutSession.mockResolvedValue({ ok: true }); + + const response = await signOut(new Request('http://localhost/api/auth/sign-out', { + method: 'POST', + headers: { cookie: 'clawproxy_session=old-token' }, + })); - const req2 = new Request('http://localhost/api/auth/callback/foo', { method: 'POST' }); - await catchAllPost(req2); - expect(postSpy).toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(service.signOutSession).toHaveBeenCalledWith('old-token'); + expect(response.headers.get('set-cookie')).toContain('clawproxy_session=;'); }); }); diff --git a/tests/auth/client-auth.test.ts b/tests/auth/client-auth.test.ts new file mode 100644 index 0000000..1ce833f --- /dev/null +++ b/tests/auth/client-auth.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +describe('createClientAuth', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn(async () => + Response.json({ ok: true, data: { user: { id: 'user-1' } } }) + ) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.resetModules(); + }); + + test('posts sign-in credentials to the first-party auth endpoint', async () => { + const { createClientAuth } = await import('@/app/lib/auth/client'); + const auth = await createClientAuth(); + + const result = await auth.signIn.email({ email: 'user@example.com', password: 'secret' }); + + expect(result).toEqual({ data: { user: { id: 'user-1' } } }); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/auth/sign-in', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'user@example.com', password: 'secret' }), + }); + }); + + test('maps API failures to the auth-client error shape used by forms', async () => { + globalThis.fetch = vi.fn(async () => + Response.json({ ok: false, error: 'Invalid email or password' }, { status: 401 }) + ) as typeof fetch; + + const { createClientAuth } = await import('@/app/lib/auth/client'); + const auth = await createClientAuth(); + + await expect(auth.signIn.email({ email: 'user@example.com', password: 'bad' })).resolves.toEqual({ + error: { message: 'Invalid email or password' }, + }); + }); + + test('posts sign-out to the first-party endpoint', async () => { + const { createClientAuth } = await import('@/app/lib/auth/client'); + const auth = await createClientAuth(); + + await auth.signOut(); + + expect(globalThis.fetch).toHaveBeenCalledWith('/api/auth/sign-out', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + }); +}); diff --git a/tests/auth/forgot-password-page.test.ts b/tests/auth/forgot-password-page.test.ts index f653721..02efa88 100644 --- a/tests/auth/forgot-password-page.test.ts +++ b/tests/auth/forgot-password-page.test.ts @@ -8,10 +8,10 @@ import { setInputValue } from '../support/set-input-value'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; -const createNeonClientAuthMock = vi.hoisted(() => vi.fn()); +const createClientAuthMock = vi.hoisted(() => vi.fn()); vi.mock('@/app/lib/auth/client', () => ({ - createNeonClientAuth: createNeonClientAuthMock, + createClientAuth: createClientAuthMock, })); vi.mock('@/app/lib/auth/dev-origin', () => ({ @@ -23,7 +23,7 @@ describe('ForgotPasswordPage', () => { let root: Root; beforeEach(() => { - createNeonClientAuthMock.mockReset(); + createClientAuthMock.mockReset(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -64,7 +64,7 @@ describe('ForgotPasswordPage', () => { test('shows success message after requestPasswordReset succeeds', async () => { const requestPasswordReset = vi.fn().mockResolvedValue({}); - createNeonClientAuthMock.mockResolvedValue({ requestPasswordReset }); + createClientAuthMock.mockResolvedValue({ requestPasswordReset }); const { default: ForgotPasswordPage } = await import('@/app/auth/forgot-password/page'); @@ -89,7 +89,7 @@ describe('ForgotPasswordPage', () => { test('shows API error when reset returns error', async () => { const requestPasswordReset = vi.fn().mockResolvedValue({ error: { message: 'Rate limited' } }); - createNeonClientAuthMock.mockResolvedValue({ requestPasswordReset }); + createClientAuthMock.mockResolvedValue({ requestPasswordReset }); const { default: ForgotPasswordPage } = await import('@/app/auth/forgot-password/page'); @@ -110,7 +110,7 @@ describe('ForgotPasswordPage', () => { test('shows message when requestPasswordReset throws', async () => { const requestPasswordReset = vi.fn().mockRejectedValue(new Error('offline')); - createNeonClientAuthMock.mockResolvedValue({ requestPasswordReset }); + createClientAuthMock.mockResolvedValue({ requestPasswordReset }); const { default: ForgotPasswordPage } = await import('@/app/auth/forgot-password/page'); diff --git a/tests/auth/neon-client-auth.test.ts b/tests/auth/neon-client-auth.test.ts deleted file mode 100644 index 1578470..0000000 --- a/tests/auth/neon-client-auth.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -const createAuthClientMock = vi.hoisted(() => vi.fn(() => ({}))); - -vi.mock('@neondatabase/auth', () => ({ - createAuthClient: (...args: unknown[]) => createAuthClientMock(...args), -})); - -describe('createNeonClientAuth', () => { - const originalWindow = globalThis.window; - const originalPublicUrl = process.env.NEXT_PUBLIC_NEON_AUTH_BASE_URL; - - beforeEach(() => { - createAuthClientMock.mockClear(); - createAuthClientMock.mockReturnValue({}); - delete process.env.NEXT_PUBLIC_NEON_AUTH_BASE_URL; - }); - - afterEach(() => { - vi.resetModules(); - if (originalWindow !== undefined) { - globalThis.window = originalWindow; - } else { - Reflect.deleteProperty(globalThis, 'window'); - } - if (originalPublicUrl === undefined) { - delete process.env.NEXT_PUBLIC_NEON_AUTH_BASE_URL; - } else { - process.env.NEXT_PUBLIC_NEON_AUTH_BASE_URL = originalPublicUrl; - } - }); - - test('uses NEXT_PUBLIC_NEON_AUTH_BASE_URL on the server', async () => { - Reflect.deleteProperty(globalThis, 'window'); - process.env.NEXT_PUBLIC_NEON_AUTH_BASE_URL = 'https://auth.example/neon'; - - const { createNeonClientAuth } = await import('@/app/lib/auth/client'); - await createNeonClientAuth(); - - expect(createAuthClientMock).toHaveBeenCalledWith('https://auth.example/neon'); - }); - - test('throws when server has no public auth base URL', async () => { - Reflect.deleteProperty(globalThis, 'window'); - - const { createNeonClientAuth } = await import('@/app/lib/auth/client'); - - await expect(createNeonClientAuth()).rejects.toThrow( - 'Neon Auth base URL is unavailable on the server', - ); - expect(createAuthClientMock).not.toHaveBeenCalled(); - }); - - test('builds base URL from window.location.origin in the browser', async () => { - globalThis.window = { - location: { origin: 'https://app.example' }, - } as Window; - - const { createNeonClientAuth } = await import('@/app/lib/auth/client'); - await createNeonClientAuth(); - - expect(createAuthClientMock).toHaveBeenCalledWith('https://app.example/api/auth'); - }); -}); diff --git a/tests/auth/password.test.ts b/tests/auth/password.test.ts new file mode 100644 index 0000000..ff6c2bb --- /dev/null +++ b/tests/auth/password.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; + +import { hashPassword, passwordHashNeedsUpgrade, verifyPassword } from '@/app/lib/auth/password'; + +describe('password hashing', () => { + test('hashes and verifies a password without storing the plain text', async () => { + const hash = await hashPassword('correct horse battery staple'); + + expect(hash).toMatch(/^scrypt\$131072\$8\$1\$/); + expect(hash).not.toContain('correct horse battery staple'); + await expect(verifyPassword('correct horse battery staple', hash)).resolves.toBe(true); + await expect(verifyPassword('wrong password', hash)).resolves.toBe(false); + }); + + test('detects hashes generated with weaker legacy scrypt parameters', async () => { + const legacyHash = 'scrypt$16384$8$1$salt$derivedkey'; + const currentHash = await hashPassword('correct horse battery staple'); + + expect(passwordHashNeedsUpgrade(legacyHash)).toBe(true); + expect(passwordHashNeedsUpgrade(currentHash)).toBe(false); + }); + + test('rejects malformed password hashes', async () => { + await expect(verifyPassword('anything', 'not-a-real-hash')).resolves.toBe(false); + }); +}); diff --git a/tests/auth/sign-in-copy.test.tsx b/tests/auth/sign-in-copy.test.tsx index 2f7aa49..7272f34 100644 --- a/tests/auth/sign-in-copy.test.tsx +++ b/tests/auth/sign-in-copy.test.tsx @@ -11,7 +11,7 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/app/lib/auth/client', () => ({ - createNeonClientAuth: vi.fn(async () => ({ signIn: { email: vi.fn() } })), + createClientAuth: vi.fn(async () => ({ signIn: { email: vi.fn() } })), })); vi.mock('posthog-js', () => ({ diff --git a/tests/auth/sign-in-page.test.ts b/tests/auth/sign-in-page.test.ts index 660d407..53325f3 100644 --- a/tests/auth/sign-in-page.test.ts +++ b/tests/auth/sign-in-page.test.ts @@ -11,7 +11,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const pushMock = vi.fn(); const refreshMock = vi.fn(); const navState = vi.hoisted(() => ({ searchParams: new URLSearchParams() })); -const createNeonClientAuthMock = vi.hoisted(() => vi.fn()); +const createClientAuthMock = vi.hoisted(() => vi.fn()); vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock, refresh: refreshMock }), @@ -19,7 +19,7 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/app/lib/auth/client', () => ({ - createNeonClientAuth: createNeonClientAuthMock, + createClientAuth: createClientAuthMock, })); vi.mock('@/app/lib/auth/dev-origin', () => ({ @@ -33,7 +33,7 @@ describe('SignInPage', () => { beforeEach(() => { pushMock.mockClear(); refreshMock.mockClear(); - createNeonClientAuthMock.mockReset(); + createClientAuthMock.mockReset(); navState.searchParams = new URLSearchParams(); container = document.createElement('div'); document.body.appendChild(container); @@ -75,7 +75,7 @@ describe('SignInPage', () => { test('submits credentials and redirects to dashboard on success', async () => { const signInEmail = vi.fn().mockResolvedValue({}); - createNeonClientAuthMock.mockResolvedValue({ signIn: { email: signInEmail } }); + createClientAuthMock.mockResolvedValue({ signIn: { email: signInEmail } }); const { default: SignInPage } = await import('@/app/auth/sign-in/page'); @@ -106,7 +106,7 @@ describe('SignInPage', () => { test('respects safe next search param after sign-in', async () => { navState.searchParams = new URLSearchParams('next=/dashboard/nodes'); const signInEmail = vi.fn().mockResolvedValue({}); - createNeonClientAuthMock.mockResolvedValue({ signIn: { email: signInEmail } }); + createClientAuthMock.mockResolvedValue({ signIn: { email: signInEmail } }); const { default: SignInPage } = await import('@/app/auth/sign-in/page'); @@ -130,7 +130,7 @@ describe('SignInPage', () => { const signInEmail = vi .fn() .mockResolvedValue({ error: { message: 'Invalid credentials' } }); - createNeonClientAuthMock.mockResolvedValue({ signIn: { email: signInEmail } }); + createClientAuthMock.mockResolvedValue({ signIn: { email: signInEmail } }); const { default: SignInPage } = await import('@/app/auth/sign-in/page'); @@ -153,7 +153,7 @@ describe('SignInPage', () => { test('shows generic message when signIn throws', async () => { const signInEmail = vi.fn().mockRejectedValue(new Error('network down')); - createNeonClientAuthMock.mockResolvedValue({ signIn: { email: signInEmail } }); + createClientAuthMock.mockResolvedValue({ signIn: { email: signInEmail } }); const { default: SignInPage } = await import('@/app/auth/sign-in/page'); diff --git a/tests/auth/sign-up-flow.test.tsx b/tests/auth/sign-up-flow.test.tsx index e1a4629..1e3ebcb 100644 --- a/tests/auth/sign-up-flow.test.tsx +++ b/tests/auth/sign-up-flow.test.tsx @@ -3,11 +3,10 @@ import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -const { mockPush, mockRefresh, mockSignUp, mockSignIn } = vi.hoisted(() => ({ +const { mockPush, mockRefresh, mockSignUp } = vi.hoisted(() => ({ mockPush: vi.fn(), mockRefresh: vi.fn(), mockSignUp: vi.fn(), - mockSignIn: vi.fn(), })); vi.mock('next/navigation', () => ({ @@ -16,9 +15,8 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/app/lib/auth/client', () => ({ - createNeonClientAuth: vi.fn(async () => ({ + createClientAuth: vi.fn(async () => ({ signUp: { email: mockSignUp }, - signIn: { email: mockSignIn }, })), })); @@ -37,7 +35,6 @@ describe('sign-up', () => { beforeEach(() => { vi.clearAllMocks(); mockSignUp.mockResolvedValue({ data: {} }); - mockSignIn.mockResolvedValue({ data: {} }); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -68,15 +65,7 @@ describe('sign-up', () => { await submit(); expect(mockSignUp).toHaveBeenCalled(); - expect(mockSignIn).toHaveBeenCalled(); expect(mockPush).toHaveBeenCalledWith('/dashboard'); }); - test('falls back to the sign-in page with a confirmation when auto sign-in fails', async () => { - mockSignIn.mockResolvedValue({ error: { message: 'nope' } }); - - await submit(); - - expect(mockPush).toHaveBeenCalledWith('/auth/sign-in?registered=1'); - }); }); diff --git a/tests/auth/sign-up-page.test.ts b/tests/auth/sign-up-page.test.ts index 96a4afe..5506833 100644 --- a/tests/auth/sign-up-page.test.ts +++ b/tests/auth/sign-up-page.test.ts @@ -10,14 +10,14 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const pushMock = vi.fn(); const refreshMock = vi.fn(); -const createNeonClientAuthMock = vi.hoisted(() => vi.fn()); +const createClientAuthMock = vi.hoisted(() => vi.fn()); vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock, refresh: refreshMock }), })); vi.mock('@/app/lib/auth/client', () => ({ - createNeonClientAuth: createNeonClientAuthMock, + createClientAuth: createClientAuthMock, })); vi.mock('@/app/lib/auth/dev-origin', () => ({ @@ -31,7 +31,7 @@ describe('SignUpPage', () => { beforeEach(() => { pushMock.mockClear(); refreshMock.mockClear(); - createNeonClientAuthMock.mockReset(); + createClientAuthMock.mockReset(); vi.useFakeTimers({ shouldAdvanceTime: true }); container = document.createElement('div'); document.body.appendChild(container); @@ -76,10 +76,8 @@ describe('SignUpPage', () => { test('signs in and navigates to the dashboard on successful sign-up', async () => { const signUpEmail = vi.fn().mockResolvedValue({}); - const signInEmail = vi.fn().mockResolvedValue({}); - createNeonClientAuthMock.mockResolvedValue({ + createClientAuthMock.mockResolvedValue({ signUp: { email: signUpEmail }, - signIn: { email: signInEmail }, }); const { default: SignUpPage } = await import('@/app/auth/sign-up/page'); @@ -103,17 +101,14 @@ describe('SignUpPage', () => { password: 'pw', name: 'Ada', }); - // Bouncing a new account to the sign-in form read as failure; sign it in instead. - expect(signInEmail).toHaveBeenCalledWith({ email: 'ada@e.com', password: 'pw' }); expect(pushMock).toHaveBeenCalledWith('/dashboard'); expect(refreshMock).toHaveBeenCalled(); }); test('uses email as name when name is blank', async () => { const signUpEmail = vi.fn().mockResolvedValue({}); - createNeonClientAuthMock.mockResolvedValue({ + createClientAuthMock.mockResolvedValue({ signUp: { email: signUpEmail }, - signIn: { email: vi.fn().mockResolvedValue({}) }, }); const { default: SignUpPage } = await import('@/app/auth/sign-up/page'); @@ -140,9 +135,8 @@ describe('SignUpPage', () => { test('shows API error when signUp returns error', async () => { const signUpEmail = vi.fn().mockResolvedValue({ error: { message: 'Email taken' } }); - createNeonClientAuthMock.mockResolvedValue({ + createClientAuthMock.mockResolvedValue({ signUp: { email: signUpEmail }, - signIn: { email: vi.fn().mockResolvedValue({}) }, }); const { default: SignUpPage } = await import('@/app/auth/sign-up/page'); @@ -166,9 +160,8 @@ describe('SignUpPage', () => { test('shows message when signUp throws', async () => { const signUpEmail = vi.fn().mockRejectedValue(new Error('boom')); - createNeonClientAuthMock.mockResolvedValue({ + createClientAuthMock.mockResolvedValue({ signUp: { email: signUpEmail }, - signIn: { email: vi.fn().mockResolvedValue({}) }, }); const { default: SignUpPage } = await import('@/app/auth/sign-up/page'); diff --git a/tests/dashboard/sign-out-button.test.ts b/tests/dashboard/sign-out-button.test.ts index b98821f..d58083e 100644 --- a/tests/dashboard/sign-out-button.test.ts +++ b/tests/dashboard/sign-out-button.test.ts @@ -19,7 +19,7 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/app/lib/auth/client', () => ({ - createNeonClientAuth: () => ({ + createClientAuth: () => ({ signOut: signOutMock, }), })); diff --git a/tests/lib/db/migrations.test.ts b/tests/lib/db/migrations.test.ts new file mode 100644 index 0000000..559b595 --- /dev/null +++ b/tests/lib/db/migrations.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest'; + +import { splitMigrationStatements } from '@/app/lib/db/migrations'; + +describe('splitMigrationStatements', () => { + test('splits drizzle statement breakpoints and drops whitespace-only chunks', () => { + expect( + splitMigrationStatements(` + CREATE TABLE example (id text);--> statement-breakpoint + + CREATE INDEX example_id_idx ON example (id); + --> statement-breakpoint + `) + ).toEqual([ + 'CREATE TABLE example (id text);', + 'CREATE INDEX example_id_idx ON example (id);', + ]); + }); +}); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index 363ffde..9e76181 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -1,25 +1,29 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { describe, expect, test } from 'vitest'; -const returnedMiddleware = { kind: 'neon-auth-middleware' as const }; -const middlewareSpy = vi.hoisted(() => vi.fn(() => returnedMiddleware)); +import proxy, { config } from '../proxy'; -vi.mock('@/app/lib/auth/server', () => ({ - auth: { - middleware: (config: unknown) => middlewareSpy(config), - }, -})); +describe('proxy dashboard guard', () => { + test('redirects dashboard requests without a session cookie', () => { + const request = new NextRequest('http://localhost/dashboard/nodes?tab=active'); -describe('proxy (Neon Auth middleware export)', () => { - afterEach(() => { - middlewareSpy.mockClear(); + const response = proxy(request); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'http://localhost/auth/sign-in?next=%2Fdashboard%2Fnodes%3Ftab%3Dactive' + ); + expect(config.matcher).toEqual(['/dashboard/:path*']); }); - test('configures middleware with dashboard login URL and matcher', async () => { - vi.resetModules(); - const mod = await import('../proxy'); + test('allows dashboard requests with a session cookie', () => { + const request = new NextRequest('http://localhost/dashboard', { + headers: { cookie: 'clawproxy_session=token' }, + }); + + const response = proxy(request); - expect(middlewareSpy).toHaveBeenCalledWith({ loginUrl: '/auth/sign-in' }); - expect(mod.default).toBe(returnedMiddleware); - expect(mod.config.matcher).toEqual(['/dashboard/:path*']); + expect(response.status).toBe(200); + expect(response.headers.get('location')).toBeNull(); }); }); diff --git a/yarn.lock b/yarn.lock index 73561f5..d93fd17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -323,13 +323,6 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.17.9": - version: 7.29.2 - resolution: "@babel/runtime@npm:7.29.2" - checksum: 10c0/30b80a0140d16467792e1bbeb06f655b0dab70407da38dfac7fedae9c859f9ae9d846ef14ad77bd3814c064295fe9b1bc551f1541ea14646ae9f22b71a8bc17a - languageName: node - linkType: hard - "@babel/template@npm:^7.28.6": version: 7.28.6 resolution: "@babel/template@npm:7.28.6" @@ -373,63 +366,6 @@ __metadata: languageName: node linkType: hard -"@better-auth/core@npm:1.4.6": - version: 1.4.6 - resolution: "@better-auth/core@npm:1.4.6" - dependencies: - "@standard-schema/spec": "npm:^1.0.0" - zod: "npm:^4.1.12" - peerDependencies: - "@better-auth/utils": 0.3.0 - "@better-fetch/fetch": 1.1.18 - better-call: 1.1.5 - jose: ^6.1.0 - kysely: ^0.28.5 - nanostores: ^1.0.1 - checksum: 10c0/f54d4452b90140fa01b2896a3ff0b768894186a5ded6ace37a505c6dae2ed8f3a3880f18bc69f809d9eb3c6989287c54e20b8481921c59b6ed609df7bee2f53f - languageName: node - linkType: hard - -"@better-auth/telemetry@npm:1.4.6": - version: 1.4.6 - resolution: "@better-auth/telemetry@npm:1.4.6" - dependencies: - "@better-auth/utils": "npm:0.3.0" - "@better-fetch/fetch": "npm:1.1.18" - peerDependencies: - "@better-auth/core": 1.4.6 - checksum: 10c0/b3408a16f53b3e287b94ae02fde3622fbb8effe1be4b38c1ec7e13271e19011f2d63780b9630dee478ae33d47b7792a14689751a68db6b9ee1361ab51bc77c91 - languageName: node - linkType: hard - -"@better-auth/utils@npm:0.3.0": - version: 0.3.0 - resolution: "@better-auth/utils@npm:0.3.0" - checksum: 10c0/f0ab0c49e1f1e6e7b03dd86e0211ecd25026029378ee0008d5e6b156bcb4d198c4604615ad4242a89fc9290644d73adb485b3f8b3976796113db35c8f1a1953c - languageName: node - linkType: hard - -"@better-auth/utils@npm:^0.3.0": - version: 0.3.1 - resolution: "@better-auth/utils@npm:0.3.1" - checksum: 10c0/cd3d36f28ab5a9afce5b582fbd8a52000c5ef43bef1b4d6586df6aa4c6d8b8757cb6b9b83370d5913713bce3a64c12694e8eb3b109e3ec34768e0e0904b3bf58 - languageName: node - linkType: hard - -"@better-fetch/fetch@npm:1.1.18, @better-fetch/fetch@npm:^1.1.4": - version: 1.1.18 - resolution: "@better-fetch/fetch@npm:1.1.18" - checksum: 10c0/b8202e4d3dbcd52fd8b5b2f255817606c300f37c60b1d9876f560372b77f66cb6d730e72e2c9b35bca06a1e364ed9d08977c0e8c2ce0ee30fdb4c6f43aae9433 - languageName: node - linkType: hard - -"@better-fetch/fetch@npm:^1.1.21": - version: 1.1.21 - resolution: "@better-fetch/fetch@npm:1.1.21" - checksum: 10c0/b23355b9eb397a4e6d3b269b027f19c44e3348b42640fa8f133ea4ca9c07e1d289781d2b18477f56883040e7401fcc751c52a6f0b56ea619bdd3132fff3cbb43 - languageName: node - linkType: hard - "@bramus/specificity@npm:^2.4.2": version: 2.4.2 resolution: "@bramus/specificity@npm:2.4.2" @@ -441,25 +377,6 @@ __metadata: languageName: node linkType: hard -"@captchafox/react@npm:^1.10.0": - version: 1.11.0 - resolution: "@captchafox/react@npm:1.11.0" - dependencies: - "@captchafox/types": "npm:^1.4.0" - peerDependencies: - react: ">=16.8.0" - react-dom: ">=16.8.0" - checksum: 10c0/a5905a02f76d9c5017003c59f762625434ce02d3eab575bc6f301aab81d7c12e45ca00c9f333cc487702c1e809948dca19ff12af1214df510db46e3c83de5d3c - languageName: node - linkType: hard - -"@captchafox/types@npm:^1.4.0": - version: 1.4.0 - resolution: "@captchafox/types@npm:1.4.0" - checksum: 10c0/10f2ec88947079e5baafc3b29027cb88de78af33ca804ab53c240fe8a74be66d5477e347d81a431e6344dd14164bb4bf846204b93b2a9950bf6f297d9e2ddfb5 - languageName: node - linkType: hard - "@csstools/color-helpers@npm:^6.0.2": version: 6.0.2 resolution: "@csstools/color-helpers@npm:6.0.2" @@ -518,58 +435,6 @@ __metadata: languageName: node linkType: hard -"@daveyplate/better-auth-ui@npm:3.3.9": - version: 3.3.9 - resolution: "@daveyplate/better-auth-ui@npm:3.3.9" - dependencies: - "@better-fetch/fetch": "npm:^1.1.21" - "@hcaptcha/react-hcaptcha": "npm:^1.17.1" - "@noble/hashes": "npm:^2.0.1" - "@react-email/components": "npm:^1.0.1" - "@wojtekmaj/react-recaptcha-v3": "npm:^0.1.4" - react-google-recaptcha: "npm:^3.1.0" - react-qr-code: "npm:^2.0.18" - ua-parser-js: "npm:^2.0.7" - vaul: "npm:^1.1.2" - peerDependencies: - "@better-auth/passkey": ">=1.4.6" - "@captchafox/react": ^1.10.0 - "@daveyplate/better-auth-tanstack": ^1.3.6 - "@hookform/resolvers": ">=5.2.0" - "@instantdb/react": ">=0.18.0" - "@marsidev/react-turnstile": ">=1.1.0" - "@radix-ui/react-avatar": ">=1.1.0" - "@radix-ui/react-checkbox": ">=1.1.0" - "@radix-ui/react-context": ">=1.1.0" - "@radix-ui/react-dialog": ">=1.1.0" - "@radix-ui/react-dropdown-menu": ">=2.1.0" - "@radix-ui/react-label": ">=2.1.0" - "@radix-ui/react-primitive": ">=2.0.0" - "@radix-ui/react-select": ">=2.2.0" - "@radix-ui/react-separator": ">=1.1.0" - "@radix-ui/react-slot": ">=1.1.0" - "@radix-ui/react-tabs": ">=1.1.0" - "@radix-ui/react-use-callback-ref": ">=1.1.0" - "@radix-ui/react-use-layout-effect": ">=1.1.0" - "@tanstack/react-query": ">=5.66.0" - "@triplit/client": ">=1.0.0" - "@triplit/react": ">=1.0.0" - better-auth: ^1.4.6 - class-variance-authority: ">=0.7.0" - clsx: ">=2.1.0" - input-otp: ">=1.4.0" - lucide-react: ">=0.469.0" - react: ">=18.0.0" - react-dom: ">=18.0.0" - react-hook-form: ">=7.55.0" - sonner: ">=1.7.0" - tailwind-merge: ">=2.6.0" - tailwindcss: ">=3.0.0" - zod: ">=3.0.0" - checksum: 10c0/6d911ebba9c3c8296a6fae32eb108912165940d17de3ac77efe4e0288773dd0b826f28263018bc27c0eff273207c59a3e4b51d3cc58043b57197c4bec3d012af - languageName: node - linkType: hard - "@dotenvx/dotenvx@npm:^1.48.4": version: 1.57.1 resolution: "@dotenvx/dotenvx@npm:1.57.1" @@ -1316,26 +1181,6 @@ __metadata: languageName: node linkType: hard -"@hcaptcha/loader@npm:^2.3.0": - version: 2.3.0 - resolution: "@hcaptcha/loader@npm:2.3.0" - checksum: 10c0/2273c0de35968bad169ba1102085930da52e6a5d3b74bfdc5c00a8959066f9671ddf2f1d25dddbc7544f290268439559cb49b42009d88c5a7e8481dcb9c67981 - languageName: node - linkType: hard - -"@hcaptcha/react-hcaptcha@npm:^1.12.1, @hcaptcha/react-hcaptcha@npm:^1.17.1": - version: 1.17.4 - resolution: "@hcaptcha/react-hcaptcha@npm:1.17.4" - dependencies: - "@babel/runtime": "npm:^7.17.9" - "@hcaptcha/loader": "npm:^2.3.0" - peerDependencies: - react: ">= 16.3.0" - react-dom: ">= 16.3.0" - checksum: 10c0/b3064e6a3c7c9e1be56dddf78d39192ad59e591a037514e068dd2da768588c641a277ba9034864244ed02cf134624968f50d1583aeda1a79cdf909d6035488b2 - languageName: node - linkType: hard - "@hono/node-server@npm:^1.19.9": version: 1.19.11 resolution: "@hono/node-server@npm:1.19.11" @@ -1345,17 +1190,6 @@ __metadata: languageName: node linkType: hard -"@hookform/resolvers@npm:^5.2.2": - version: 5.2.2 - resolution: "@hookform/resolvers@npm:5.2.2" - dependencies: - "@standard-schema/utils": "npm:^0.3.0" - peerDependencies: - react-hook-form: ^7.55.0 - checksum: 10c0/0692cd61dcc2a70cbb27b88a37f733c39e97f555c036ba04a81bd42b0467461cfb6bafacb46c16f173672f9c8a216bd7928a2330d4e49c700d130622bf1defaf - languageName: node - linkType: hard - "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -1729,16 +1563,6 @@ __metadata: languageName: node linkType: hard -"@marsidev/react-turnstile@npm:>=1.1.0": - version: 1.4.2 - resolution: "@marsidev/react-turnstile@npm:1.4.2" - peerDependencies: - react: ^17.0.2 || ^18.0.0 || ^19.0 - react-dom: ^17.0.2 || ^18.0.0 || ^19.0 - checksum: 10c0/3cb72435076f65eb1bf965ad2aaa15900df032bf32609246591a8810f633d508f98561b9a2376c9b3c15f36de639b885df6bf90d3001f3ffb086f29e3396945a - languageName: node - linkType: hard - "@modelcontextprotocol/sdk@npm:^1.26.0": version: 1.27.1 resolution: "@modelcontextprotocol/sdk@npm:1.27.1" @@ -1808,83 +1632,6 @@ __metadata: languageName: node linkType: hard -"@neondatabase/auth-ui@npm:0.1.0-alpha.11": - version: 0.1.0-alpha.11 - resolution: "@neondatabase/auth-ui@npm:0.1.0-alpha.11" - dependencies: - "@captchafox/react": "npm:^1.10.0" - "@daveyplate/better-auth-ui": "npm:3.3.9" - "@hcaptcha/react-hcaptcha": "npm:^1.12.1" - "@hookform/resolvers": "npm:^5.2.2" - "@marsidev/react-turnstile": "npm:>=1.1.0" - "@radix-ui/react-avatar": "npm:^1.1.11" - "@radix-ui/react-checkbox": "npm:^1.3.3" - "@radix-ui/react-context": "npm:^1.1.3" - "@radix-ui/react-dialog": "npm:^1.1.15" - "@radix-ui/react-dropdown-menu": "npm:^2.1.16" - "@radix-ui/react-label": "npm:^2.1.8" - "@radix-ui/react-primitive": "npm:^2.1.4" - "@radix-ui/react-select": "npm:^2.2.6" - "@radix-ui/react-separator": "npm:^1.1.8" - "@radix-ui/react-slot": "npm:^1.2.4" - "@radix-ui/react-tabs": "npm:^1.1.13" - "@radix-ui/react-use-callback-ref": "npm:^1.1.1" - "@radix-ui/react-use-layout-effect": "npm:^1.1.1" - "@wojtekmaj/react-recaptcha-v3": "npm:^0.1.4" - class-variance-authority: "npm:^0.7.1" - clsx: "npm:^2.1.1" - input-otp: "npm:^1.4.2" - lucide-react: "npm:^0.555.0" - next-themes: "npm:^0.4.6" - react-google-recaptcha: "npm:^3.1.0" - react-hook-form: "npm:^7.66.1" - react-qr-code: "npm:^2.0.18" - sonner: "npm:^2.0.7" - tailwind-merge: "npm:^3.4.0" - tailwindcss: "npm:^4.1.17" - tw-animate-css: "npm:^1.4.0" - ua-parser-js: "npm:^2.0.5" - vaul: "npm:^1.1.2" - zod: "npm:^4.1.12" - peerDependencies: - "@neondatabase/auth": ">=0.1.0-alpha.8" - react: ">=18.0.0" - react-dom: ">=18.0.0" - peerDependenciesMeta: - "@neondatabase/auth": - optional: false - react: - optional: false - react-dom: - optional: false - checksum: 10c0/1217d32287d9b3f21ad77e93faff2935022e93750868e10843c4dfd7659d804bfa9e243f4922f160f969512af688b45da8f267067d358aabd25b6a109c83714a - languageName: node - linkType: hard - -"@neondatabase/auth@npm:^0.2.0-beta.1": - version: 0.2.0-beta.1 - resolution: "@neondatabase/auth@npm:0.2.0-beta.1" - dependencies: - "@neondatabase/auth-ui": "npm:0.1.0-alpha.11" - "@supabase/auth-js": "npm:2.79.0" - better-auth: "npm:1.4.6" - jose: "npm:6.1.2" - zod: "npm:4.1.12" - peerDependencies: - next: ">=16.0.6" - react: ">=18.0.0" - react-dom: ">=18.0.0" - peerDependenciesMeta: - next: - optional: true - react: - optional: true - react-dom: - optional: true - checksum: 10c0/f732ed5f521b8202198476b15ac49f08c2c4377f74094b3b98ca41a3288b1796713a2191e0dc89f7cf83501f9ca2448b97246025e059d30e2983fb16e410f0de - languageName: node - linkType: hard - "@next/env@npm:16.2.1": version: 16.2.1 resolution: "@next/env@npm:16.2.1" @@ -1964,13 +1711,6 @@ __metadata: languageName: node linkType: hard -"@noble/ciphers@npm:^2.0.0": - version: 2.1.1 - resolution: "@noble/ciphers@npm:2.1.1" - checksum: 10c0/f02662723475a9785a2f7bcd6e8e12d4b5f77537154f5e21edb0fed398051fb1ae5057eb444987e1c65342f8632474f0143ea612a2dad49593d3358eb730a079 - languageName: node - linkType: hard - "@noble/curves@npm:^1.9.7": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" @@ -1987,13 +1727,6 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:^2.0.0, @noble/hashes@npm:^2.0.1": - version: 2.0.1 - resolution: "@noble/hashes@npm:2.0.1" - checksum: 10c0/e81769ce21c3b1c80141a3b99bd001f17edea09879aa936692ae39525477386d696101cd573928a304806efb2b9fa751e1dd83241c67d0c84d30091e85c79bdb - languageName: node - linkType: hard - "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -2480,30 +2213,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-avatar@npm:^1.1.11": - version: 1.1.11 - resolution: "@radix-ui/react-avatar@npm:1.1.11" - dependencies: - "@radix-ui/react-context": "npm:1.1.3" - "@radix-ui/react-primitive": "npm:2.1.4" - "@radix-ui/react-use-callback-ref": "npm:1.1.1" - "@radix-ui/react-use-is-hydrated": "npm:0.1.0" - "@radix-ui/react-use-layout-effect": "npm:1.1.1" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/b1b3d4b11a8e05a8479d2410fb4e7b1bf825135c4cd42f7e5152568a54a55a3073bd87d50325150417a29306e7b1b371289dc3c4f11739af8a2a7bb8dd7c38c9 - languageName: node - linkType: hard - -"@radix-ui/react-checkbox@npm:1.3.3, @radix-ui/react-checkbox@npm:^1.3.3": +"@radix-ui/react-checkbox@npm:1.3.3": version: 1.3.3 resolution: "@radix-ui/react-checkbox@npm:1.3.3" dependencies: @@ -2627,20 +2337,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-context@npm:1.1.3, @radix-ui/react-context@npm:^1.1.3": - version: 1.1.3 - resolution: "@radix-ui/react-context@npm:1.1.3" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/0f271b4100dbb007ad2675f2529453f07454f214b7ce796d72680bf2dff050d0719083ee6e8962919a74048ff853eff2e50de07d8f8c674d6be91bfa76204cc2 - languageName: node - linkType: hard - -"@radix-ui/react-dialog@npm:1.1.15, @radix-ui/react-dialog@npm:^1.1.1, @radix-ui/react-dialog@npm:^1.1.15": +"@radix-ui/react-dialog@npm:1.1.15": version: 1.1.15 resolution: "@radix-ui/react-dialog@npm:1.1.15" dependencies: @@ -2708,7 +2405,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dropdown-menu@npm:2.1.16, @radix-ui/react-dropdown-menu@npm:^2.1.16": +"@radix-ui/react-dropdown-menu@npm:2.1.16": version: 2.1.16 resolution: "@radix-ui/react-dropdown-menu@npm:2.1.16" dependencies: @@ -2852,25 +2549,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-label@npm:^2.1.8": - version: 2.1.8 - resolution: "@radix-ui/react-label@npm:2.1.8" - dependencies: - "@radix-ui/react-primitive": "npm:2.1.4" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/8b130380bd54bafb0dc652270c8cf035ceeb78faab82f78c0a76fc33cc0718e8455ff880e0db1b6c10f203ff342bf1f941544eb258c1fd85ae5b49b53cdf1a3d - languageName: node - linkType: hard - "@radix-ui/react-menu@npm:2.1.16": version: 2.1.16 resolution: "@radix-ui/react-menu@npm:2.1.16" @@ -3143,25 +2821,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-primitive@npm:2.1.4, @radix-ui/react-primitive@npm:^2.1.4": - version: 2.1.4 - resolution: "@radix-ui/react-primitive@npm:2.1.4" - dependencies: - "@radix-ui/react-slot": "npm:1.2.4" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/90d687b222a25975371ed1f9f08648d75237214b8dec4cbaf09ec9ac951339b17421278f1aff2fb7c5672ba8bd03774a94904efdba73805dd5cc947ce5be8c4a - languageName: node - linkType: hard - "@radix-ui/react-progress@npm:1.1.7": version: 1.1.7 resolution: "@radix-ui/react-progress@npm:1.1.7" @@ -3264,7 +2923,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-select@npm:2.2.6, @radix-ui/react-select@npm:^2.2.6": +"@radix-ui/react-select@npm:2.2.6": version: 2.2.6 resolution: "@radix-ui/react-select@npm:2.2.6" dependencies: @@ -3322,25 +2981,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-separator@npm:^1.1.8": - version: 1.1.8 - resolution: "@radix-ui/react-separator@npm:1.1.8" - dependencies: - "@radix-ui/react-primitive": "npm:2.1.4" - peerDependencies: - "@types/react": "*" - "@types/react-dom": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10c0/92e1353f696a22167c90f2c610b440be1fae3c05128287560914f124eef83d74c06ad25431923f3595032e6d89c23d479c95434390f4c0d9d4a68ec8d563ae0c - languageName: node - linkType: hard - "@radix-ui/react-slider@npm:1.3.6": version: 1.3.6 resolution: "@radix-ui/react-slider@npm:1.3.6" @@ -3385,21 +3025,6 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-slot@npm:1.2.4, @radix-ui/react-slot@npm:^1.2.4": - version: 1.2.4 - resolution: "@radix-ui/react-slot@npm:1.2.4" - dependencies: - "@radix-ui/react-compose-refs": "npm:1.1.2" - peerDependencies: - "@types/react": "*" - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/8b719bb934f1ae5ac0e37214783085c17c2f1080217caf514c1c6cc3d9ca56c7e19d25470b26da79aa6e605ab36589edaade149b76f5fc0666f1063e2fc0a0dc - languageName: node - linkType: hard - "@radix-ui/react-switch@npm:1.2.6": version: 1.2.6 resolution: "@radix-ui/react-switch@npm:1.2.6" @@ -3425,7 +3050,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-tabs@npm:1.1.13, @radix-ui/react-tabs@npm:^1.1.13": +"@radix-ui/react-tabs@npm:1.1.13": version: 1.1.13 resolution: "@radix-ui/react-tabs@npm:1.1.13" dependencies: @@ -3582,7 +3207,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-use-callback-ref@npm:1.1.1, @radix-ui/react-use-callback-ref@npm:^1.1.1": +"@radix-ui/react-use-callback-ref@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-use-callback-ref@npm:1.1.1" peerDependencies: @@ -3656,7 +3281,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-use-layout-effect@npm:1.1.1, @radix-ui/react-use-layout-effect@npm:^1.1.1": +"@radix-ui/react-use-layout-effect@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1" peerDependencies: @@ -3738,258 +3363,6 @@ __metadata: languageName: node linkType: hard -"@react-email/body@npm:0.3.0": - version: 0.3.0 - resolution: "@react-email/body@npm:0.3.0" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/81796d310f1c404eab035964cead670902d4de047b088218589a8ad6be1953d6494bbd5d586ec09c871484779bd944fa3637afa232ec1e7850c995e94b642c81 - languageName: node - linkType: hard - -"@react-email/button@npm:0.2.1": - version: 0.2.1 - resolution: "@react-email/button@npm:0.2.1" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/1efbf7f7d34555982b5f6a797aab2f5443a9c135a4a28b87145786a50a1d79ed1fcdaa203d03d72148b9a21e19f2adb05661e0b1fec9a89bb5db18520908b252 - languageName: node - linkType: hard - -"@react-email/code-block@npm:0.2.1": - version: 0.2.1 - resolution: "@react-email/code-block@npm:0.2.1" - dependencies: - prismjs: "npm:^1.30.0" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/aa513e6e1023293f6bb319cb0877b927eccb6bd47dd2cc26a69c9ff8eca2322f7efd8800026fe4a609d65a699953ed48385f53b8039b0e3e91fa5ae8dff6de4b - languageName: node - linkType: hard - -"@react-email/code-inline@npm:0.0.6": - version: 0.0.6 - resolution: "@react-email/code-inline@npm:0.0.6" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/e4fba8d168162b0e514876d98809e3baea24fa1822a4770f62276b8a953209b4ca8efc5e8c8663dd5d790eb1e6f9b0b8774f4b08768db421155b6d0afce5ffa1 - languageName: node - linkType: hard - -"@react-email/column@npm:0.0.14": - version: 0.0.14 - resolution: "@react-email/column@npm:0.0.14" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/9cee2fa427ebc62ac144f73f1cac4cc8aebac7fdad850af6246b59522f478f8ecb65bbe9b2a8a799e65fa2719f1305a498b5eaf8b135a36b95c9b440bb8f35aa - languageName: node - linkType: hard - -"@react-email/components@npm:^1.0.1": - version: 1.0.10 - resolution: "@react-email/components@npm:1.0.10" - dependencies: - "@react-email/body": "npm:0.3.0" - "@react-email/button": "npm:0.2.1" - "@react-email/code-block": "npm:0.2.1" - "@react-email/code-inline": "npm:0.0.6" - "@react-email/column": "npm:0.0.14" - "@react-email/container": "npm:0.0.16" - "@react-email/font": "npm:0.0.10" - "@react-email/head": "npm:0.0.13" - "@react-email/heading": "npm:0.0.16" - "@react-email/hr": "npm:0.0.12" - "@react-email/html": "npm:0.0.12" - "@react-email/img": "npm:0.0.12" - "@react-email/link": "npm:0.0.13" - "@react-email/markdown": "npm:0.0.18" - "@react-email/preview": "npm:0.0.14" - "@react-email/render": "npm:2.0.4" - "@react-email/row": "npm:0.0.13" - "@react-email/section": "npm:0.0.17" - "@react-email/tailwind": "npm:2.0.6" - "@react-email/text": "npm:0.1.6" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/5c0e7908f8ade6b7429e9620f839ecbb714f65deea82e038ea1d96f751b36c29f26085b70d911a5d5369a8c84fcfe9cc5b631ccd72c3f8d62595ac2e7510e43b - languageName: node - linkType: hard - -"@react-email/container@npm:0.0.16": - version: 0.0.16 - resolution: "@react-email/container@npm:0.0.16" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/6b86a25885e2a825082aeaa2d0339ccd863b4b657aa41c542d83ee989f63906506f29a5ecd261aa72f57d6be3f43d45c8cb0bc3415961c1d108156437825d95a - languageName: node - linkType: hard - -"@react-email/font@npm:0.0.10": - version: 0.0.10 - resolution: "@react-email/font@npm:0.0.10" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/b49f36cdc99bf7459d8fa56f079bccd7dff2ad168fd4acff80919dbfaa5bef03a2e85b8bd4aa01876f22facf2f24e3a73800a3fd1b0702f64f7121ed776ad4ff - languageName: node - linkType: hard - -"@react-email/head@npm:0.0.13": - version: 0.0.13 - resolution: "@react-email/head@npm:0.0.13" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/cab456a7748b96fb842f520f7e0340f5503a7027b9e676c32cda22ea44bdde5b2e2ad218a466a8fd699abd70dae315619b8b558cc8c07454371b9df12a21282f - languageName: node - linkType: hard - -"@react-email/heading@npm:0.0.16": - version: 0.0.16 - resolution: "@react-email/heading@npm:0.0.16" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/843993d978d8e1937a3ce92cd88004d554d83dc9b1a8cde8ad9b4b169da95dfd00cc5e2db7b161459026cb5e167bc208d2c2982c83600a6d9c288531fdb69903 - languageName: node - linkType: hard - -"@react-email/hr@npm:0.0.12": - version: 0.0.12 - resolution: "@react-email/hr@npm:0.0.12" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/e656f61e881cee1757bac75eee5b46861602b291038464d9f65d024884b9e22b35171173332482aa822c1c667129e7869f5b98583d57fc50445008aad08470eb - languageName: node - linkType: hard - -"@react-email/html@npm:0.0.12": - version: 0.0.12 - resolution: "@react-email/html@npm:0.0.12" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/664cf81508b788280ca54f9c36f6bdf9b7b4000c69aba2b78f3ec7ff366dacf03daf6e1dbbb668b8769646175fa22a97a7909c19c995fa6525648ae45ce6be0a - languageName: node - linkType: hard - -"@react-email/img@npm:0.0.12": - version: 0.0.12 - resolution: "@react-email/img@npm:0.0.12" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/9c390e7a329a30a2792ed5c9d7c53195541df8bf0c125f08cd2ac5df5c54ac210998de8bc201016e17f1b0e4eac5209675c699caa4505339254c825c39b62678 - languageName: node - linkType: hard - -"@react-email/link@npm:0.0.13": - version: 0.0.13 - resolution: "@react-email/link@npm:0.0.13" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/c083bc94c8b8854e9d44830f82f49e732c698b55967d94d45e51fd522a525bcf9f7093d3bb89e959da3232fba34f7f5c98e0ae9359940c81f850a72da20961e8 - languageName: node - linkType: hard - -"@react-email/markdown@npm:0.0.18": - version: 0.0.18 - resolution: "@react-email/markdown@npm:0.0.18" - dependencies: - marked: "npm:^15.0.12" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/d8023f87d95ab80df9ce72b38fa52ef4eac4337569309fb6155f8b16e82bb280c5704e1a6b971e08d1aaf736205411e52291d257b06d344d0901580ae7222b74 - languageName: node - linkType: hard - -"@react-email/preview@npm:0.0.14": - version: 0.0.14 - resolution: "@react-email/preview@npm:0.0.14" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/af9b8b618121af3f28699a240851dcc8f25448fa4bc47c04b5931d25330052a5bcbf45d7cd4da845e64bf91db5dc32108f471553fe20c54b1410443de850ef71 - languageName: node - linkType: hard - -"@react-email/render@npm:2.0.4": - version: 2.0.4 - resolution: "@react-email/render@npm:2.0.4" - dependencies: - html-to-text: "npm:^9.0.5" - prettier: "npm:^3.5.3" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/47bb26bc31c7ccaf7995ce39742e22a72e32af7c7d03d2e39f3b6c8b19079c575646c34aa099ce73f7d755062b87d60320b54bbc89135f80a0bf54d069b77c9c - languageName: node - linkType: hard - -"@react-email/row@npm:0.0.13": - version: 0.0.13 - resolution: "@react-email/row@npm:0.0.13" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/74b075fb85cca2e4ed7bf2b62e0544e92ee4155160f5a7cf253e809f1fb136380ace73f8bd954efa5ed927a2bbcf7448d13b065133becc172e0bd8b071610310 - languageName: node - linkType: hard - -"@react-email/section@npm:0.0.17": - version: 0.0.17 - resolution: "@react-email/section@npm:0.0.17" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/93c16a1a0ae3abccd92244d51413e561ee4305990984eeceeb61dd8d08e7b4a3fbc3f6e381a9e0eed480f907485ab5f8a1e5914a4d6741376e74df46fb8bf02d - languageName: node - linkType: hard - -"@react-email/tailwind@npm:2.0.6": - version: 2.0.6 - resolution: "@react-email/tailwind@npm:2.0.6" - dependencies: - tailwindcss: "npm:4.1.18" - peerDependencies: - "@react-email/body": ">=0" - "@react-email/button": ">=0" - "@react-email/code-block": ">=0" - "@react-email/code-inline": ">=0" - "@react-email/container": ">=0" - "@react-email/heading": ">=0" - "@react-email/hr": ">=0" - "@react-email/img": ">=0" - "@react-email/link": ">=0" - "@react-email/preview": ">=0" - "@react-email/text": ">=0" - react: ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - "@react-email/body": - optional: true - "@react-email/button": - optional: true - "@react-email/code-block": - optional: true - "@react-email/code-inline": - optional: true - "@react-email/container": - optional: true - "@react-email/heading": - optional: true - "@react-email/hr": - optional: true - "@react-email/img": - optional: true - "@react-email/link": - optional: true - "@react-email/preview": - optional: true - checksum: 10c0/d9cbb9d496ef85b306baceb70f9f462a40c3a45d49bd0fb571bd2c0bbcc59db6264e71ea94011e3dc186737c7f5c97228749cc58fd4b69c652201d5956b9b950 - languageName: node - linkType: hard - -"@react-email/text@npm:0.1.6": - version: 0.1.6 - resolution: "@react-email/text@npm:0.1.6" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/f40ef83746670ac1672069119b23f3c553bc4b42b102b106b8ff9f6ce0136b9c06c09bfaf917b7144d9ec0f33eeb52d93b093a7d86629b26fdc64f2c7cc4042c - languageName: node - linkType: hard - "@reduxjs/toolkit@npm:^1.9.0 || 2.x.x": version: 2.11.2 resolution: "@reduxjs/toolkit@npm:2.11.2" @@ -4140,16 +3513,6 @@ __metadata: languageName: node linkType: hard -"@selderee/plugin-htmlparser2@npm:^0.11.0": - version: 0.11.0 - resolution: "@selderee/plugin-htmlparser2@npm:0.11.0" - dependencies: - domhandler: "npm:^5.0.3" - selderee: "npm:^0.11.0" - checksum: 10c0/e938ba9aeb31a9cf30dcb2977ef41685c598bf744bedc88c57aa9e8b7e71b51781695cf99c08aac50773fd7714eba670bd2a079e46db0788abe40c6d220084eb - languageName: node - linkType: hard - "@sindresorhus/merge-streams@npm:^4.0.0": version: 4.0.0 resolution: "@sindresorhus/merge-streams@npm:4.0.0" @@ -4171,15 +3534,6 @@ __metadata: languageName: node linkType: hard -"@supabase/auth-js@npm:2.79.0": - version: 2.79.0 - resolution: "@supabase/auth-js@npm:2.79.0" - dependencies: - tslib: "npm:2.8.1" - checksum: 10c0/f5357985c1289226534a5077c29f6ae4ff1013afe06cd912c225f8a739b0f61915f8c5d98a01d7492bad3c397784891473d0431796af04c23ecfe4bb6b673344 - languageName: node - linkType: hard - "@swc/helpers@npm:0.5.15": version: 0.5.15 resolution: "@swc/helpers@npm:0.5.15" @@ -4946,22 +4300,6 @@ __metadata: languageName: node linkType: hard -"@wojtekmaj/react-recaptcha-v3@npm:^0.1.4": - version: 0.1.4 - resolution: "@wojtekmaj/react-recaptcha-v3@npm:0.1.4" - dependencies: - warning: "npm:^4.0.0" - peerDependencies: - "@types/react": ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/efa23374f396563237bd2e91757da3637637e1cf1493b171a3657de06e1990b56fde1f2856c557dc97fa964d36a31fe488489508d16dfa737ec5543264a4ccef - languageName: node - linkType: hard - "abbrev@npm:^4.0.0": version: 4.0.0 resolution: "abbrev@npm:4.0.0" @@ -5282,73 +4620,6 @@ __metadata: languageName: node linkType: hard -"better-auth@npm:1.4.6": - version: 1.4.6 - resolution: "better-auth@npm:1.4.6" - dependencies: - "@better-auth/core": "npm:1.4.6" - "@better-auth/telemetry": "npm:1.4.6" - "@better-auth/utils": "npm:0.3.0" - "@better-fetch/fetch": "npm:1.1.18" - "@noble/ciphers": "npm:^2.0.0" - "@noble/hashes": "npm:^2.0.0" - better-call: "npm:1.1.5" - defu: "npm:^6.1.4" - jose: "npm:^6.1.0" - kysely: "npm:^0.28.5" - ms: "npm:4.0.0-nightly.202508271359" - nanostores: "npm:^1.0.1" - zod: "npm:^4.1.12" - peerDependencies: - "@lynx-js/react": "*" - "@sveltejs/kit": ^2.0.0 - "@tanstack/react-start": ^1.0.0 - next: ^14.0.0 || ^15.0.0 || ^16.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 - vue: ^3.0.0 - peerDependenciesMeta: - "@lynx-js/react": - optional: true - "@sveltejs/kit": - optional: true - "@tanstack/react-start": - optional: true - next: - optional: true - react: - optional: true - react-dom: - optional: true - solid-js: - optional: true - svelte: - optional: true - vue: - optional: true - checksum: 10c0/146f386885559079f814ce693af71ca193fc2f40d660ace8442062504f1b602d1c41cde984026e5020f984059195e635a9953c1f22cfaefa76a5bc909524aa2c - languageName: node - linkType: hard - -"better-call@npm:1.1.5": - version: 1.1.5 - resolution: "better-call@npm:1.1.5" - dependencies: - "@better-auth/utils": "npm:^0.3.0" - "@better-fetch/fetch": "npm:^1.1.4" - rou3: "npm:^0.7.10" - set-cookie-parser: "npm:^2.7.1" - peerDependencies: - zod: ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - checksum: 10c0/c9329a7cfc2f2d47488df1ac758a0c0c77c5a789ac00917ee990bec797227fb23a9c1199f5a4551ea0242cfe76acf4528cca35bc8b081fc98b33b3a08a7eb583 - languageName: node - linkType: hard - "bidi-js@npm:^1.0.3": version: 1.0.3 resolution: "bidi-js@npm:1.0.3" @@ -5549,7 +4820,6 @@ __metadata: version: 0.0.0-use.local resolution: "clawproxy@workspace:." dependencies: - "@neondatabase/auth": "npm:^0.2.0-beta.1" "@tailwindcss/postcss": "npm:^4" "@types/node": "npm:^20" "@types/react": "npm:^19" @@ -6048,13 +5318,6 @@ __metadata: languageName: node linkType: hard -"defu@npm:^6.1.4": - version: 6.1.4 - resolution: "defu@npm:6.1.4" - checksum: 10c0/2d6cc366262dc0cb8096e429368e44052fdf43ed48e53ad84cc7c9407f890301aa5fcb80d0995abaaf842b3949f154d060be4160f7a46cb2bc2f7726c81526f5 - languageName: node - linkType: hard - "depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -6062,13 +5325,6 @@ __metadata: languageName: node linkType: hard -"detect-europe-js@npm:^0.1.2": - version: 0.1.2 - resolution: "detect-europe-js@npm:0.1.2" - checksum: 10c0/46fccf2a9ddeec8ecb7a369be1452f84856cbd0ccdeb3c47e41fe6c9d69f63d6bd75f91b5aa1ae310e56d8fbcbff0e802b1504cdcc3a92f3327c94408e6d07a2 - languageName: node - linkType: hard - "detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" @@ -6099,33 +5355,6 @@ __metadata: languageName: node linkType: hard -"dom-serializer@npm:^2.0.0": - version: 2.0.0 - resolution: "dom-serializer@npm:2.0.0" - dependencies: - domelementtype: "npm:^2.3.0" - domhandler: "npm:^5.0.2" - entities: "npm:^4.2.0" - checksum: 10c0/d5ae2b7110ca3746b3643d3ef60ef823f5f078667baf530cec096433f1627ec4b6fa8c072f09d079d7cda915fd2c7bc1b7b935681e9b09e591e1e15f4040b8e2 - languageName: node - linkType: hard - -"domelementtype@npm:^2.3.0": - version: 2.3.0 - resolution: "domelementtype@npm:2.3.0" - checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9 - languageName: node - linkType: hard - -"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": - version: 5.0.3 - resolution: "domhandler@npm:5.0.3" - dependencies: - domelementtype: "npm:^2.3.0" - checksum: 10c0/bba1e5932b3e196ad6862286d76adc89a0dbf0c773e5ced1eb01f9af930c50093a084eff14b8de5ea60b895c56a04d5de8bbc4930c5543d029091916770b2d2a - languageName: node - linkType: hard - "dompurify@npm:^3.3.2": version: 3.3.3 resolution: "dompurify@npm:3.3.3" @@ -6138,17 +5367,6 @@ __metadata: languageName: node linkType: hard -"domutils@npm:^3.0.1": - version: 3.2.2 - resolution: "domutils@npm:3.2.2" - dependencies: - dom-serializer: "npm:^2.0.0" - domelementtype: "npm:^2.3.0" - domhandler: "npm:^5.0.3" - checksum: 10c0/47938f473b987ea71cd59e59626eb8666d3aa8feba5266e45527f3b636c7883cca7e582d901531961f742c519d7514636b7973353b648762b2e3bedbf235fada - languageName: node - linkType: hard - "dotenv@npm:^17.2.1, dotenv@npm:^17.3.1": version: 17.3.1 resolution: "dotenv@npm:17.3.1" @@ -6340,13 +5558,6 @@ __metadata: languageName: node linkType: hard -"entities@npm:^4.2.0, entities@npm:^4.4.0": - version: 4.5.0 - resolution: "entities@npm:4.5.0" - checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 - languageName: node - linkType: hard - "entities@npm:^6.0.0": version: 6.0.1 resolution: "entities@npm:6.0.1" @@ -7778,15 +6989,6 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.0": - version: 3.3.2 - resolution: "hoist-non-react-statics@npm:3.3.2" - dependencies: - react-is: "npm:^16.7.0" - checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74 - languageName: node - linkType: hard - "hono@npm:^4.11.4": version: 4.12.8 resolution: "hono@npm:4.12.8" @@ -7810,31 +7012,6 @@ __metadata: languageName: node linkType: hard -"html-to-text@npm:^9.0.5": - version: 9.0.5 - resolution: "html-to-text@npm:9.0.5" - dependencies: - "@selderee/plugin-htmlparser2": "npm:^0.11.0" - deepmerge: "npm:^4.3.1" - dom-serializer: "npm:^2.0.0" - htmlparser2: "npm:^8.0.2" - selderee: "npm:^0.11.0" - checksum: 10c0/5d2c77b798cf88a81b1da2fc1ea1a3b3e2ff49fe5a3d812392f802fff18ec315cf0969bd7846ef2eb7df8c37f463bc63e8cbdcf84e42696c6f3e15dfa61cdf4f - languageName: node - linkType: hard - -"htmlparser2@npm:^8.0.2": - version: 8.0.2 - resolution: "htmlparser2@npm:8.0.2" - dependencies: - domelementtype: "npm:^2.3.0" - domhandler: "npm:^5.0.3" - domutils: "npm:^3.0.1" - entities: "npm:^4.4.0" - checksum: 10c0/609cca85886d0bf2c9a5db8c6926a89f3764596877492e2caa7a25a789af4065bc6ee2cdc81807fe6b1d03a87bf8a373b5a754528a4cc05146b713c20575aab4 - languageName: node - linkType: hard - "http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" @@ -7950,16 +7127,6 @@ __metadata: languageName: node linkType: hard -"input-otp@npm:^1.4.2": - version: 1.4.2 - resolution: "input-otp@npm:1.4.2" - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - checksum: 10c0/d3a3216a75ed832993f3f2852edd7a85c5bae30ea6d251182119120488bbf9fed7cfdd91819bcee6daff57b3cfcbca94fd16d6a7c92cee4d806c0d4fa6ff1128 - languageName: node - linkType: hard - "internal-slot@npm:^1.1.0": version: 1.1.0 resolution: "internal-slot@npm:1.1.0" @@ -8268,13 +7435,6 @@ __metadata: languageName: node linkType: hard -"is-standalone-pwa@npm:^0.1.1": - version: 0.1.1 - resolution: "is-standalone-pwa@npm:0.1.1" - checksum: 10c0/af1a11edef4a8f00c24014576285070fb73eebb3583cae790c0a825e338e19f05048a26e5fd5ef10f6056032d6a4520054ff824532c15df784933c0fcc5e13fd - languageName: node - linkType: hard - "is-stream@npm:^2.0.0": version: 2.0.1 resolution: "is-stream@npm:2.0.1" @@ -8447,13 +7607,6 @@ __metadata: languageName: node linkType: hard -"jose@npm:6.1.2, jose@npm:^6.1.0": - version: 6.1.2 - resolution: "jose@npm:6.1.2" - checksum: 10c0/55f79426f43e652ed6d5de938d50f66bb0a10dcae078db81a23f8d3303e889ce226f000e815f3211f9956bb84badce10da892d130d40fe2eca658045a6f1778e - languageName: node - linkType: hard - "jose@npm:^6.1.3": version: 6.2.2 resolution: "jose@npm:6.2.2" @@ -8639,13 +7792,6 @@ __metadata: languageName: node linkType: hard -"kysely@npm:^0.28.5": - version: 0.28.14 - resolution: "kysely@npm:0.28.14" - checksum: 10c0/0d6aad119660ddf7dc12ecbfc0a33ab0333af53aea350aabf55a0b7978e6b69aba246727738f5462f4633aedbed2106f13e58acc2bdb490845f7b7c869871e97 - languageName: node - linkType: hard - "language-subtag-registry@npm:^0.3.20": version: 0.3.23 resolution: "language-subtag-registry@npm:0.3.23" @@ -8662,13 +7808,6 @@ __metadata: languageName: node linkType: hard -"leac@npm:^0.6.0": - version: 0.6.0 - resolution: "leac@npm:0.6.0" - checksum: 10c0/5257781e10791ef8462eb1cbe5e48e3cda7692486f2a775265d6f5216cc088960c62f138163b8df0dcf2119d18673bfe7b050d6b41543d92a7b7ac90e4eb1e8b - languageName: node - linkType: hard - "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -8839,7 +7978,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.0.0, loose-envify@npm:^1.4.0": +"loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -8866,15 +8005,6 @@ __metadata: languageName: node linkType: hard -"lucide-react@npm:^0.555.0": - version: 0.555.0 - resolution: "lucide-react@npm:0.555.0" - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/18f0870307fe2a37cf62473de88845a78107295de1efcf599a8662be3af4239c0b661a57f8e29815e424773e4aad04d199ad4d94d7ae977b7ce7a61be7f64e04 - languageName: node - linkType: hard - "lucide-react@npm:^0.577.0": version: 0.577.0 resolution: "lucide-react@npm:0.577.0" @@ -8933,15 +8063,6 @@ __metadata: languageName: node linkType: hard -"marked@npm:^15.0.12": - version: 15.0.12 - resolution: "marked@npm:15.0.12" - bin: - marked: bin/marked.js - checksum: 10c0/e09da211544b787ecfb25fed07af206060bf7cd6d9de6cb123f15c496a57f83b7aabea93340aaa94dae9c94e097ae129377cad6310abc16009590972e85f4212 - languageName: node - linkType: hard - "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -9132,13 +8253,6 @@ __metadata: languageName: node linkType: hard -"ms@npm:4.0.0-nightly.202508271359": - version: 4.0.0-nightly.202508271359 - resolution: "ms@npm:4.0.0-nightly.202508271359" - checksum: 10c0/924aadb4a82c2938d72ecc8a80dc0c59ead03467fce20e7d5ef73849bbee126c566a0602b95af2885dbfc4b846a25fe62ec1b0add9cdc1b0ad009f551a042845 - languageName: node - linkType: hard - "ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -9195,13 +8309,6 @@ __metadata: languageName: node linkType: hard -"nanostores@npm:^1.0.1": - version: 1.2.0 - resolution: "nanostores@npm:1.2.0" - checksum: 10c0/282ca01dacc3679358268fcd3ef6b9eb3ad76fd8b0f476789573b3215dd8af7a83191b29819c4d489c20891eb47493c67a49a05aca938c3ad215c14452179be5 - languageName: node - linkType: hard - "napi-postinstall@npm:^0.3.0": version: 0.3.4 resolution: "napi-postinstall@npm:0.3.4" @@ -9225,16 +8332,6 @@ __metadata: languageName: node linkType: hard -"next-themes@npm:^0.4.6": - version: 0.4.6 - resolution: "next-themes@npm:0.4.6" - peerDependencies: - react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - checksum: 10c0/83590c11d359ce7e4ced14f6ea9dd7a691d5ce6843fe2dc520fc27e29ae1c535118478d03e7f172609c41b1ef1b8da6b8dd2d2acd6cd79cac1abbdbd5b99f2c4 - languageName: node - linkType: hard - "next@npm:16.2.1": version: 16.2.1 resolution: "next@npm:16.2.1" @@ -9639,16 +8736,6 @@ __metadata: languageName: node linkType: hard -"parseley@npm:^0.12.0": - version: 0.12.1 - resolution: "parseley@npm:0.12.1" - dependencies: - leac: "npm:^0.6.0" - peberminta: "npm:^0.9.0" - checksum: 10c0/df3de74172b72305b867298a71e5882c413df75d30f2bafb5fb70779dfd349c5e4db03441fbf8ca83da8e4aa72bd0ef2b5c73086c4825d27d1c649d61bc0bcc0 - languageName: node - linkType: hard - "parseurl@npm:^1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -9722,13 +8809,6 @@ __metadata: languageName: node linkType: hard -"peberminta@npm:^0.9.0": - version: 0.9.0 - resolution: "peberminta@npm:0.9.0" - checksum: 10c0/59c2c39269d9f7f559cf44582f1c0503524c6a9bc3478e0309adba2b41c71ab98745a239a4e6f98f46105291256e6d8f12ae9860d9f016b1c9a6f52c0b63bfe7 - languageName: node - linkType: hard - "picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -9859,15 +8939,6 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.5.3": - version: 3.8.1 - resolution: "prettier@npm:3.8.1" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42 - languageName: node - linkType: hard - "pretty-ms@npm:^9.2.0": version: 9.3.0 resolution: "pretty-ms@npm:9.3.0" @@ -9877,13 +8948,6 @@ __metadata: languageName: node linkType: hard -"prismjs@npm:^1.30.0": - version: 1.30.0 - resolution: "prismjs@npm:1.30.0" - checksum: 10c0/f56205bfd58ef71ccfcbcb691fd0eb84adc96c6ff21b0b69fc6fdcf02be42d6ef972ba4aed60466310de3d67733f6a746f89f2fb79c00bf217406d465b3e8f23 - languageName: node - linkType: hard - "proc-log@npm:^6.0.0": version: 6.1.0 resolution: "proc-log@npm:6.1.0" @@ -9901,7 +8965,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.5.0, prop-types@npm:^15.8.1": +"prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -9949,13 +9013,6 @@ __metadata: languageName: node linkType: hard -"qr.js@npm:0.0.0": - version: 0.0.0 - resolution: "qr.js@npm:0.0.0" - checksum: 10c0/1c6a4c7a58d04e52ec2fee99e39b680fdc5b2a510a981df42c36b716a8eac6634d130fc4d65af8f030f2a07dbf5fa046b97cdfa7456c250ebb50a73916efdcb5 - languageName: node - linkType: hard - "qs@npm:^6.14.0, qs@npm:^6.14.1": version: 6.15.0 resolution: "qs@npm:6.15.0" @@ -10071,18 +9128,6 @@ __metadata: languageName: node linkType: hard -"react-async-script@npm:^1.2.0": - version: 1.2.0 - resolution: "react-async-script@npm:1.2.0" - dependencies: - hoist-non-react-statics: "npm:^3.3.0" - prop-types: "npm:^15.5.0" - peerDependencies: - react: ">=16.4.1" - checksum: 10c0/89450912110c380abc08258ce17d2fb18d31d6b7179a74f6bc504c0761a4ca271edb671e402fa8e5ea4250b5c17fa953af80a9f1c4ebb26c9e81caee8476c903 - languageName: node - linkType: hard - "react-dom@npm:19.2.4": version: 19.2.4 resolution: "react-dom@npm:19.2.4" @@ -10094,46 +9139,13 @@ __metadata: languageName: node linkType: hard -"react-google-recaptcha@npm:^3.1.0": - version: 3.1.0 - resolution: "react-google-recaptcha@npm:3.1.0" - dependencies: - prop-types: "npm:^15.5.0" - react-async-script: "npm:^1.2.0" - peerDependencies: - react: ">=16.4.1" - checksum: 10c0/5ecaa6b88f238defd939012cb2671b4cbda59fe03f059158994b8c5215db482412e905eae6a67d23cef220d77cfe0430ab91ce7849d476515cda72f3a8a0a746 - languageName: node - linkType: hard - -"react-hook-form@npm:^7.66.1": - version: 7.72.0 - resolution: "react-hook-form@npm:7.72.0" - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - checksum: 10c0/c689992ef95fcf6a7e622aba447756c5d770b8f89efbd700e9432e127185cff687f8dd7439e235c177371abf5595a7c8dae6c6220b9c3ab29ccfdd543b0b5b9e - languageName: node - linkType: hard - -"react-is@npm:^16.13.1, react-is@npm:^16.7.0": +"react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 languageName: node linkType: hard -"react-qr-code@npm:^2.0.18": - version: 2.0.18 - resolution: "react-qr-code@npm:2.0.18" - dependencies: - prop-types: "npm:^15.8.1" - qr.js: "npm:0.0.0" - peerDependencies: - react: "*" - checksum: 10c0/4e13b795cbb10f1dcf0e39d682bb59851e4c84010ba2be7225b2ad9d5c1ffea52d2d38f884ee26235b7002b8ca99e83b805f55e877663c39d67496764d975cf1 - languageName: node - linkType: hard - "react-redux@npm:8.x.x || 9.x.x": version: 9.2.0 resolution: "react-redux@npm:9.2.0" @@ -10468,13 +9480,6 @@ __metadata: languageName: node linkType: hard -"rou3@npm:^0.7.10": - version: 0.7.12 - resolution: "rou3@npm:0.7.12" - checksum: 10c0/2ea87ddd91a5d0f9d9671fa2bb714f57566eae33ecb24e8a61bb298aca8c226483e59cafe1c60297ac9aa58b2b6ad506447374cdbff4e99cf0f9f72a9dae09dc - languageName: node - linkType: hard - "router@npm:^2.2.0": version: 2.2.0 resolution: "router@npm:2.2.0" @@ -10561,15 +9566,6 @@ __metadata: languageName: node linkType: hard -"selderee@npm:^0.11.0": - version: 0.11.0 - resolution: "selderee@npm:0.11.0" - dependencies: - parseley: "npm:^0.12.0" - checksum: 10c0/c2ad8313a0dbf3c0b74752a8d03cfbc0931ae77a36679cdb64733eb732c1762f95a5174249bf7e8b8103874cb0e013a030f9c8b72f5d41e62f1d847d4a845d39 - languageName: node - linkType: hard - "semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -10626,13 +9622,6 @@ __metadata: languageName: node linkType: hard -"set-cookie-parser@npm:^2.7.1": - version: 2.7.2 - resolution: "set-cookie-parser@npm:2.7.2" - checksum: 10c0/4381a9eb7ee951dfe393fe7aacf76b9a3b4e93a684d2162ab35594fa4053cc82a4d7d7582bf397718012c9adcf839b8cd8f57c6c42901ea9effe33c752da4a45 - languageName: node - linkType: hard - "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -10936,16 +9925,6 @@ __metadata: languageName: node linkType: hard -"sonner@npm:^2.0.7": - version: 2.0.7 - resolution: "sonner@npm:2.0.7" - peerDependencies: - react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - checksum: 10c0/6966ab5e892ed6aab579a175e4a24f3b48747f0fc21cb68c3e33cb41caa7a0eebeb098c210545395e47a18d585eb8734ae7dd12d2bd18c8a3294a1ee73f997d9 - languageName: node - linkType: hard - "source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" @@ -11236,21 +10215,14 @@ __metadata: languageName: node linkType: hard -"tailwind-merge@npm:^3.0.1, tailwind-merge@npm:^3.4.0, tailwind-merge@npm:^3.5.0": +"tailwind-merge@npm:^3.0.1, tailwind-merge@npm:^3.5.0": version: 3.5.0 resolution: "tailwind-merge@npm:3.5.0" checksum: 10c0/4dc588f5b5296ba3f38e1ebb41f02b6d24a8c5bb45e44b33748c118fb4b5767dd0efc464431ca3e75404056b618b5f67bec3708158baa65fed8a2fc9201e0c53 languageName: node linkType: hard -"tailwindcss@npm:4.1.18": - version: 4.1.18 - resolution: "tailwindcss@npm:4.1.18" - checksum: 10c0/c79263cea0b2c577859b02f28284caa4eb3844e4d0f563686726ca97817c045c5c395a55bc776daaa351ba9e4aefa9a75bfbb43c22d86f3c573eecc2b87d6bf1 - languageName: node - linkType: hard - -"tailwindcss@npm:4.2.2, tailwindcss@npm:^4, tailwindcss@npm:^4.1.17": +"tailwindcss@npm:4.2.2, tailwindcss@npm:^4": version: 4.2.2 resolution: "tailwindcss@npm:4.2.2" checksum: 10c0/6eae8a125c35d504ba6c518d26ec64fba694ff4a9ab9b9cd9883050128e0b7afdf491388c472d9bed2624664c1c7d4a133d19b653151a6b52e6ce6953168a857 @@ -11416,7 +10388,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -11563,26 +10535,6 @@ __metadata: languageName: node linkType: hard -"ua-is-frozen@npm:^0.1.2": - version: 0.1.2 - resolution: "ua-is-frozen@npm:0.1.2" - checksum: 10c0/fb41929bd4924c9676391e6d7197d30580749a96697a83c8b6e77fb137e3ff8492dcd1e6f871f6a9517848651a93af37bfefb25385f16d90e19678ade22d30b7 - languageName: node - linkType: hard - -"ua-parser-js@npm:^2.0.5, ua-parser-js@npm:^2.0.7": - version: 2.0.9 - resolution: "ua-parser-js@npm:2.0.9" - dependencies: - detect-europe-js: "npm:^0.1.2" - is-standalone-pwa: "npm:^0.1.1" - ua-is-frozen: "npm:^0.1.2" - bin: - ua-parser-js: script/cli.js - checksum: 10c0/6287fb9e0d332b139bd039cbd606f909bdc3337681b7e6ec2ce9c427d1f39a040a65e03620b82bd3bc4a7d7681be70dd2fa75125fd8c72c01bd9cedb729e5683 - languageName: node - linkType: hard - "unbox-primitive@npm:^1.1.0": version: 1.1.0 resolution: "unbox-primitive@npm:1.1.0" @@ -11795,18 +10747,6 @@ __metadata: languageName: node linkType: hard -"vaul@npm:^1.1.2": - version: 1.1.2 - resolution: "vaul@npm:1.1.2" - dependencies: - "@radix-ui/react-dialog": "npm:^1.1.1" - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - checksum: 10c0/a6da539eb5576c0004a6b17e3673ea1db2c34e80355860131183abf53279ce025bbd016d542c345d1cc8464ad12f9dc9860949c751055d8a84961e8472a53707 - languageName: node - linkType: hard - "victory-vendor@npm:^37.0.2": version: 37.3.6 resolution: "victory-vendor@npm:37.3.6" @@ -11957,15 +10897,6 @@ __metadata: languageName: node linkType: hard -"warning@npm:^4.0.0": - version: 4.0.3 - resolution: "warning@npm:4.0.3" - dependencies: - loose-envify: "npm:^1.0.0" - checksum: 10c0/aebab445129f3e104c271f1637fa38e55eb25f968593e3825bd2f7a12bd58dc3738bb70dc8ec85826621d80b4acfed5a29ebc9da17397c6125864d72301b937e - languageName: node - linkType: hard - "web-streams-polyfill@npm:^3.0.3": version: 3.3.3 resolution: "web-streams-polyfill@npm:3.3.3" @@ -12275,16 +11206,16 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.1.12, zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.1.12": - version: 4.1.12 - resolution: "zod@npm:4.1.12" - checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774 - languageName: node - linkType: hard - "zod@npm:^3.24.1": version: 3.25.76 resolution: "zod@npm:3.25.76" checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c languageName: node linkType: hard + +"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0": + version: 4.1.12 + resolution: "zod@npm:4.1.12" + checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774 + languageName: node + linkType: hard