diff --git a/docs/content/docs/concepts/rate-limit.mdx b/docs/content/docs/concepts/rate-limit.mdx index 736c754b3b..64c8912445 100644 --- a/docs/content/docs/concepts/rate-limit.mdx +++ b/docs/content/docs/concepts/rate-limit.mdx @@ -52,7 +52,7 @@ Rate limiting uses the connecting IP address to track the number of requests mad default header checked is `x-forwarded-for`, which is commonly used in production environments. If you are using a different header to track the user's IP address, you'll need to specify it. -```ts title="auth.ts" +```ts title="auth.ts" export const auth = betterAuth({ //...other options advanced: { @@ -68,6 +68,44 @@ export const auth = betterAuth({ }) ``` +#### IPv6 Address Support + +Better Auth automatically normalizes IPv6 addresses to prevent bypass attacks where attackers use different representations of the same IPv6 address (e.g., `2001:db8::1` vs `2001:0db8:0000:0000:0000:0000:0000:0001`). This ensures that all representations of the same IPv6 address are treated as the same for rate limiting purposes. + +Additionally, IPv4-mapped IPv6 addresses (e.g., `::ffff:192.0.2.1`) are automatically converted to their IPv4 form (`192.0.2.1`) to prevent attackers from bypassing rate limits by switching between IPv4 and IPv6 representations. + +#### IPv6 Subnet Rate Limiting + +By default, IPv6 addresses are rate limited individually (using the full /128 address). However, since IPv6 typically allocates large address blocks to single users, attackers could potentially bypass rate limits by rotating through multiple IPv6 addresses from their allocation. + +To prevent this, you can configure rate limiting to apply to IPv6 subnets instead of individual addresses: + +```ts title="auth.ts" +export const auth = betterAuth({ + //...other options + advanced: { + ipAddress: { + ipv6Subnet: 64, // Rate limit by /64 subnet instead of individual addresses + }, + }, + rateLimit: { + enabled: true, + window: 60, + max: 100, + }, +}) +``` + +Common IPv6 subnet prefix lengths: +- `128` (default): Individual IPv6 address - most restrictive +- `64`: /64 subnet - typical home/business allocation +- `48`: /48 subnet - larger network allocation +- `32`: /32 subnet - ISP-level allocation + + +IPv6 subnet configuration only affects IPv6 addresses. IPv4 addresses are always rate limited individually. + + ### Rate Limit Window ```ts title="auth.ts" diff --git a/e2e/smoke/test/fixtures/ipv6/index.ts b/e2e/smoke/test/fixtures/ipv6/index.ts new file mode 100644 index 0000000000..0496035e0c --- /dev/null +++ b/e2e/smoke/test/fixtures/ipv6/index.ts @@ -0,0 +1,46 @@ +import { DatabaseSync } from "node:sqlite"; +import { serve } from "@hono/node-server"; +import { betterAuth } from "better-auth"; +import { getMigrations } from "better-auth/db"; +import { Hono } from "hono"; + +const database = new DatabaseSync(":memory:"); + +export const auth = betterAuth({ + baseURL: "http://localhost:3000", + database, + emailAndPassword: { + enabled: true, + }, + trustedOrigins: [ + "http://localhost:*", // Allow any localhost port for smoke tests + ], + rateLimit: { + enabled: true, + window: 60, + max: 3, + }, + advanced: { + ipAddress: { + ipv6Subnet: 64, // Group IPv6 addresses by /64 subnet + }, + }, +}); + +const { runMigrations } = await getMigrations(auth.options); + +await runMigrations(); + +const app = new Hono(); + +app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)); + +serve( + { + fetch: app.fetch, + port: 0, + }, + (info) => { + console.log(info.port); + }, +); diff --git a/e2e/smoke/test/fixtures/ipv6/package.json b/e2e/smoke/test/fixtures/ipv6/package.json new file mode 100644 index 0000000000..fdfce65043 --- /dev/null +++ b/e2e/smoke/test/fixtures/ipv6/package.json @@ -0,0 +1,10 @@ +{ + "name": "fixtures-ipv6", + "private": true, + "type": "module", + "dependencies": { + "@hono/node-server": "^1.19.9", + "better-auth": "workspace:*", + "hono": "^4.11.4" + } +} diff --git a/e2e/smoke/test/fixtures/ipv6/tsconfig.json b/e2e/smoke/test/fixtures/ipv6/tsconfig.json new file mode 100644 index 0000000000..6e3473bbab --- /dev/null +++ b/e2e/smoke/test/fixtures/ipv6/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "module": "esnext", + "moduleResolution": "bundler" + } +} diff --git a/e2e/smoke/test/ipv6.spec.ts b/e2e/smoke/test/ipv6.spec.ts new file mode 100644 index 0000000000..82d47d0dbb --- /dev/null +++ b/e2e/smoke/test/ipv6.spec.ts @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url)); + +const nodejsWarnings = ["ExperimentalWarning"]; + +describe("IPv6 rate limiting", () => { + const entryFile = join(fixturesDir, "ipv6", "index.ts"); + it("should group IPv6 addresses from same /64 subnet", async (t) => { + const cp = spawn("node", [entryFile], { + stdio: "pipe", + }); + t.after(() => { + cp.kill("SIGINT"); + }); + cp.stderr.on("data", (data) => { + console.error(data.toString()); + }); + const port = await new Promise((resolve) => { + cp.stdout.on("data", (data) => { + const output = data.toString().replace(/\u001b\[[0-9;]*m/g, ""); + if (nodejsWarnings.some((warning) => output.includes(warning))) { + return; + } + const port = +output; + assert.ok(port > 0); + assert.ok(!Number.isNaN(port)); + assert.ok(Number.isFinite(port)); + resolve(port); + }); + }); + + // Different IPv6 addresses from the same /64 subnet + // 2001:db8:abcd:1234::/64 prefix + const ipv6Addresses = [ + "2001:db8:abcd:1234:0000:0000:0000:0001", + "2001:db8:abcd:1234:1111:2222:3333:4444", + "2001:db8:abcd:1234:ffff:ffff:ffff:ffff", + ]; + + // Make requests with different IPv6 addresses from same /64 + // Rate limit is max 3 requests, so 4th should be blocked + for (let i = 0; i < 4; i++) { + const ipv6 = ipv6Addresses[i % ipv6Addresses.length]!; + const response = await fetch( + `http://localhost:${port}/api/auth/sign-in/email`, + { + method: "POST", + body: JSON.stringify({ + email: "test@test.com", + password: "password", + }), + headers: { + "content-type": "application/json", + origin: `http://localhost:${port}`, + "x-forwarded-for": ipv6, + }, + }, + ); + + if (i >= 3) { + assert.equal( + response.status, + 429, + `Request ${i + 1} with IP ${ipv6} should be rate limited (429)`, + ); + } else { + assert.notEqual( + response.status, + 429, + `Request ${i + 1} with IP ${ipv6} should not be rate limited`, + ); + } + } + }); + + it("should not group IPv6 addresses from different /64 subnets", async (t) => { + const cp = spawn("node", [entryFile], { + stdio: "pipe", + }); + t.after(() => { + cp.kill("SIGINT"); + }); + cp.stderr.on("data", (data) => { + console.error(data.toString()); + }); + const port = await new Promise((resolve) => { + cp.stdout.on("data", (data) => { + const output = data.toString().replace(/\u001b\[[0-9;]*m/g, ""); + if (nodejsWarnings.some((warning) => output.includes(warning))) { + return; + } + const port = +output; + assert.ok(port > 0); + assert.ok(!Number.isNaN(port)); + assert.ok(Number.isFinite(port)); + resolve(port); + }); + }); + + // Different /64 subnets + const differentSubnets = [ + "2001:db8:abcd:1111:0000:0000:0000:0001", // /64 subnet 1 + "2001:db8:abcd:2222:0000:0000:0000:0001", // /64 subnet 2 + "2001:db8:abcd:3333:0000:0000:0000:0001", // /64 subnet 3 + ]; + + // Each subnet should have its own rate limit counter + // So 3 requests from 3 different subnets should all succeed + for (const ipv6 of differentSubnets) { + const response = await fetch( + `http://localhost:${port}/api/auth/sign-in/email`, + { + method: "POST", + body: JSON.stringify({ + email: "test@test.com", + password: "password", + }), + headers: { + "content-type": "application/json", + origin: `http://localhost:${port}`, + "x-forwarded-for": ipv6, + }, + }, + ); + + assert.notEqual( + response.status, + 429, + `Request with IP ${ipv6} should not be rate limited (different subnet)`, + ); + } + }); + + it("should normalize different IPv6 representations", async (t) => { + const cp = spawn("node", [entryFile], { + stdio: "pipe", + }); + t.after(() => { + cp.kill("SIGINT"); + }); + cp.stderr.on("data", (data) => { + console.error(data.toString()); + }); + const port = await new Promise((resolve) => { + cp.stdout.on("data", (data) => { + const output = data.toString().replace(/\u001b\[[0-9;]*m/g, ""); + if (nodejsWarnings.some((warning) => output.includes(warning))) { + return; + } + const port = +output; + assert.ok(port > 0); + assert.ok(!Number.isNaN(port)); + assert.ok(Number.isFinite(port)); + resolve(port); + }); + }); + + // Different representations of the same IPv6 address + const sameAddressDifferentFormats = [ + "2001:db8::1", + "2001:DB8::1", // uppercase + "2001:0db8::1", // leading zeros + "2001:db8:0:0:0:0:0:1", // expanded + ]; + + // All should be normalized to the same address and share rate limit + for (let i = 0; i < 4; i++) { + const ipv6 = sameAddressDifferentFormats[i]!; + const response = await fetch( + `http://localhost:${port}/api/auth/sign-in/email`, + { + method: "POST", + body: JSON.stringify({ + email: "test@test.com", + password: "password", + }), + headers: { + "content-type": "application/json", + origin: `http://localhost:${port}`, + "x-forwarded-for": ipv6, + }, + }, + ); + + if (i >= 3) { + assert.equal( + response.status, + 429, + `Request ${i + 1} with IP ${ipv6} should be rate limited (same address)`, + ); + } else { + assert.notEqual( + response.status, + 429, + `Request ${i + 1} with IP ${ipv6} should not be rate limited`, + ); + } + } + }); +}); diff --git a/packages/better-auth/src/api/rate-limiter/index.ts b/packages/better-auth/src/api/rate-limiter/index.ts index f57be41998..a739afe311 100644 --- a/packages/better-auth/src/api/rate-limiter/index.ts +++ b/packages/better-auth/src/api/rate-limiter/index.ts @@ -1,5 +1,5 @@ import type { AuthContext } from "@better-auth/core"; -import { safeJSONParse } from "@better-auth/core/utils"; +import { createRateLimitKey, safeJSONParse } from "@better-auth/core/utils"; import type { RateLimit } from "../../types"; import { getIp } from "../../utils/get-request-ip"; import { wildcardMatch } from "../../utils/wildcard"; @@ -164,7 +164,7 @@ export async function onRequestRateLimit(req: Request, ctx: AuthContext) { if (!ip) { return; } - const key = ip + path; + const key = createRateLimitKey(ip, path); const specialRules = getDefaultSpecialRules(); const specialRule = specialRules.find((rule) => rule.pathMatcher(path)); diff --git a/packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts b/packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts index bd96f0f4ed..911da7e351 100644 --- a/packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts +++ b/packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts @@ -1,110 +1,105 @@ +import { normalizeIP } from "@better-auth/core/utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import type { RateLimit } from "../../types"; -describe( - "rate-limiter", - { - timeout: 10000, - }, - async () => { - const { client, testUser } = await getTestInstance({ - rateLimit: { - enabled: true, - window: 10, - max: 20, - }, - }); +describe("rate-limiter", async () => { + const { client, testUser } = await getTestInstance({ + rateLimit: { + enabled: true, + window: 10, + max: 20, + }, + }); - it("should return 429 after 3 request for sign-in", async () => { - for (let i = 0; i < 5; i++) { - const response = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - }); - if (i >= 3) { - expect(response.error?.status).toBe(429); - } else { - expect(response.error).toBeNull(); - } - } - }); - - it("should reset the limit after the window period", async () => { - vi.useFakeTimers(); - vi.advanceTimersByTime(11000); - for (let i = 0; i < 5; i++) { - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - }); - if (i >= 3) { - expect(res.error?.status).toBe(429); - } else { - expect(res.error).toBeNull(); - } - } - }); - - it("should respond the correct retry-after header", async () => { - vi.useFakeTimers(); - vi.advanceTimersByTime(3000); - let retryAfter = ""; - await client.signIn.email( - { - email: testUser.email, - password: testUser.password, - }, - { - onError(context) { - retryAfter = context.response.headers.get("X-Retry-After") ?? ""; - }, - }, - ); - expect(retryAfter).toBe("7"); - }); - - it("should rate limit based on the path", async () => { - const signInRes = await client.signIn.email({ + it("should return 429 after 3 request for sign-in", async () => { + for (let i = 0; i < 5; i++) { + const response = await client.signIn.email({ email: testUser.email, password: testUser.password, }); - expect(signInRes.error?.status).toBe(429); + if (i >= 3) { + expect(response.error?.status).toBe(429); + } else { + expect(response.error).toBeNull(); + } + } + }); - const signUpRes = await client.signUp.email({ - email: "new-test@email.com", + it("should reset the limit after the window period", async () => { + vi.useFakeTimers(); + vi.advanceTimersByTime(11000); + for (let i = 0; i < 5; i++) { + const res = await client.signIn.email({ + email: testUser.email, password: testUser.password, - name: "test", }); - expect(signUpRes.error).toBeNull(); - }); - - it("non-special-rules limits", async () => { - for (let i = 0; i < 25; i++) { - const response = await client.getSession(); - expect(response.error?.status).toBe(i >= 20 ? 429 : undefined); + if (i >= 3) { + expect(res.error?.status).toBe(429); + } else { + expect(res.error).toBeNull(); } - }); + } + }); - it("query params should be ignored", async () => { - for (let i = 0; i < 25; i++) { - const response = await client.listSessions({ - fetchOptions: { - query: { - "test-query": Math.random().toString(), - }, + it("should respond the correct retry-after header", async () => { + vi.useFakeTimers(); + vi.advanceTimersByTime(3000); + let retryAfter = ""; + await client.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + onError(context) { + retryAfter = context.response.headers.get("X-Retry-After") ?? ""; + }, + }, + ); + expect(retryAfter).toBe("7"); + }); + + it("should rate limit based on the path", async () => { + const signInRes = await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + expect(signInRes.error?.status).toBe(429); + + const signUpRes = await client.signUp.email({ + email: "new-test@email.com", + password: testUser.password, + name: "test", + }); + expect(signUpRes.error).toBeNull(); + }); + + it("non-special-rules limits", async () => { + for (let i = 0; i < 25; i++) { + const response = await client.getSession(); + expect(response.error?.status).toBe(i >= 20 ? 429 : undefined); + } + }); + + it("query params should be ignored", async () => { + for (let i = 0; i < 25; i++) { + const response = await client.listSessions({ + fetchOptions: { + query: { + "test-query": Math.random().toString(), }, - }); + }, + }); - if (i >= 20) { - expect(response.error?.status).toBe(429); - } else { - expect(response.error?.status).toBe(401); - } + if (i >= 20) { + expect(response.error?.status).toBe(429); + } else { + expect(response.error?.status).toBe(401); } - }); - }, -); + } + }); +}); describe("custom rate limiting storage", async () => { const store = new Map(); @@ -138,7 +133,7 @@ describe("custom rate limiting storage", async () => { password: testUser.password, }); const rateLimitData: RateLimit = JSON.parse( - store.get("127.0.0.1/sign-in/email") ?? "{}", + store.get("127.0.0.1|/sign-in/email") ?? "{}", ); expect(rateLimitData.lastRequest).toBeGreaterThanOrEqual(lastRequest); lastRequest = rateLimitData.lastRequest; @@ -149,7 +144,7 @@ describe("custom rate limiting storage", async () => { expect(response.error).toBeNull(); expect(rateLimitData.count).toBe(i + 1); } - const rateLimitExp = expirationMap.get("127.0.0.1/sign-in/email"); + const rateLimitExp = expirationMap.get("127.0.0.1|/sign-in/email"); expect(rateLimitExp).toBe(10); } }); @@ -277,7 +272,7 @@ describe("should work in development/test environment", () => { ); expect(signInKeys.length).toBeGreaterThan(0); - expect(signInKeys[0]).toBe(`${LOCALHOST_IP}${REQUEST_PATH}`); + expect(signInKeys[0]).toBe(`${LOCALHOST_IP}|${REQUEST_PATH}`); }); it("should work in test environment", async () => { @@ -321,6 +316,94 @@ describe("should work in development/test environment", () => { ); expect(signInKeys.length).toBeGreaterThan(0); - expect(signInKeys[0]).toBe(`${LOCALHOST_IP}${REQUEST_PATH}`); + expect(signInKeys[0]).toBe(`${LOCALHOST_IP}|${REQUEST_PATH}`); + }); +}); + +describe("IPv6 address normalization and rate limiting", () => { + it("should normalize IPv6 addresses to canonical form", () => { + // All these representations of the same IPv6 address should normalize to the same value + const representations = [ + "2001:db8::1", + "2001:DB8::1", + "2001:0db8::1", + "2001:db8:0::1", + "2001:0db8:0:0:0:0:0:1", + ]; + + const normalized = representations.map((ip) => normalizeIP(ip)); + const uniqueValues = new Set(normalized); + + // All should normalize to the same value + expect(uniqueValues.size).toBe(1); + expect(normalized[0]).toBe("2001:0db8:0000:0000:0000:0000:0000:0001"); + }); + + it("should convert IPv4-mapped IPv6 to IPv4", () => { + const ipv4Mapped = [ + "::ffff:192.0.2.1", + "::FFFF:192.0.2.1", + "::ffff:c000:0201", // hex-encoded + ]; + + const normalized = ipv4Mapped.map((ip) => normalizeIP(ip)); + + // All should normalize to the same IPv4 address + normalized.forEach((ip) => { + expect(ip).toBe("192.0.2.1"); + }); + }); + + it("should support IPv6 subnet rate limiting", () => { + // Simulate attacker rotating through IPv6 addresses in their /64 allocation + const attackIPs = [ + "2001:db8:abcd:1234:0000:0000:0000:0001", + "2001:db8:abcd:1234:1111:2222:3333:4444", + "2001:db8:abcd:1234:ffff:ffff:ffff:ffff", + ]; + + const normalized = attackIPs.map((ip) => + normalizeIP(ip, { ipv6Subnet: 64 }), + ); + + // All should map to same /64 subnet + const uniqueValues = new Set(normalized); + expect(uniqueValues.size).toBe(1); + expect(normalized[0]).toBe("2001:0db8:abcd:1234:0000:0000:0000:0000"); + }); + + it("should rate limit different IPv6 subnets separately", () => { + // Different /64 subnets should have separate rate limits + const subnet1IPs = ["2001:db8:abcd:1111::1", "2001:db8:abcd:1111::2"]; + const subnet2IPs = ["2001:db8:abcd:2222::1", "2001:db8:abcd:2222::2"]; + + const normalized1 = subnet1IPs.map((ip) => + normalizeIP(ip, { ipv6Subnet: 64 }), + ); + const normalized2 = subnet2IPs.map((ip) => + normalizeIP(ip, { ipv6Subnet: 64 }), + ); + + // Same subnet should normalize to same value + expect(normalized1[0]).toBe(normalized1[1]); + expect(normalized2[0]).toBe(normalized2[1]); + + // Different subnets should normalize to different values + expect(normalized1[0]).not.toBe(normalized2[0]); + }); + + it("should handle localhost IPv6 addresses", () => { + expect(normalizeIP("::1")).toBe("0000:0000:0000:0000:0000:0000:0000:0001"); + }); + + it("should handle link-local IPv6 addresses", () => { + const linkLocal = normalizeIP("fe80::1"); + expect(linkLocal).toBe("fe80:0000:0000:0000:0000:0000:0000:0001"); + }); + + it("IPv6 subnet should not affect IPv4 addresses", () => { + const ipv4 = "192.168.1.1"; + const normalized = normalizeIP(ipv4, { ipv6Subnet: 64 }); + expect(normalized).toBe(ipv4); }); }); diff --git a/packages/better-auth/src/utils/get-request-ip.ts b/packages/better-auth/src/utils/get-request-ip.ts index e9d9b28af7..2bb02c7fc1 100644 --- a/packages/better-auth/src/utils/get-request-ip.ts +++ b/packages/better-auth/src/utils/get-request-ip.ts @@ -1,6 +1,6 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { isDevelopment, isTest } from "@better-auth/core/env"; -import * as z from "zod"; +import { isValidIP, normalizeIP } from "@better-auth/core/utils"; // Localhost IP used for test and development environments const LOCALHOST_IP = "127.0.0.1"; @@ -25,7 +25,9 @@ export function getIp( if (typeof value === "string") { const ip = value.split(",")[0]!.trim(); if (isValidIP(ip)) { - return ip; + return normalizeIP(ip, { + ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet, + }); } } } @@ -37,18 +39,3 @@ export function getIp( return null; } - -function isValidIP(ip: string): boolean { - const ipv4 = z.ipv4().safeParse(ip); - - if (ipv4.success) { - return true; - } - - const ipv6 = z.ipv6().safeParse(ip); - if (ipv6.success) { - return true; - } - - return false; -} diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index b356e68664..42c7e1bb1b 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -142,6 +142,25 @@ export type BetterAuthAdvancedOptions = { * ⚠︎ This is a security risk and it may expose your application to abuse */ disableIpTracking?: boolean; + /** + * IPv6 subnet prefix length for rate limiting. + * + * IPv6 addresses can be grouped by subnet to prevent attackers from + * bypassing rate limits by rotating through multiple addresses in + * their allocation. + * + * Common values: + * - 128 (default): Individual IPv6 address + * - 64: /64 subnet (typical home/business allocation) + * - 48: /48 subnet (larger network allocation) + * - 32: /32 subnet (ISP allocation) + * + * Note: This only affects IPv6 addresses. IPv4 addresses are always + * rate limited individually. + * + * @default 128 (individual address) + */ + ipv6Subnet?: 128 | 64 | 48 | 32 | undefined; } | undefined; /** diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 4b10aec1e2..0752500a49 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,5 +1,7 @@ export { deprecate } from "./deprecate"; export { defineErrorCodes } from "./error-codes"; export { generateId } from "./id"; +export { createRateLimitKey, isValidIP, normalizeIP } from "./ip"; export { safeJSONParse } from "./json"; export { capitalizeFirstLetter } from "./string"; +export { normalizePathname } from "./url"; diff --git a/packages/core/src/utils/ip.test.ts b/packages/core/src/utils/ip.test.ts new file mode 100644 index 0000000000..191a51ec29 --- /dev/null +++ b/packages/core/src/utils/ip.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; +import { createRateLimitKey, isValidIP, normalizeIP } from "./ip"; + +describe("IP Normalization", () => { + describe("isValidIP", () => { + it("should validate IPv4 addresses", () => { + expect(isValidIP("192.168.1.1")).toBe(true); + expect(isValidIP("127.0.0.1")).toBe(true); + expect(isValidIP("0.0.0.0")).toBe(true); + expect(isValidIP("255.255.255.255")).toBe(true); + }); + + it("should validate IPv6 addresses", () => { + expect(isValidIP("2001:db8::1")).toBe(true); + expect(isValidIP("::1")).toBe(true); + expect(isValidIP("::")).toBe(true); + expect(isValidIP("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe(true); + }); + + it("should reject invalid IPs", () => { + expect(isValidIP("not-an-ip")).toBe(false); + expect(isValidIP("999.999.999.999")).toBe(false); + expect(isValidIP("gggg::1")).toBe(false); + }); + }); + + describe("IPv4 Normalization", () => { + it("should return IPv4 addresses unchanged", () => { + expect(normalizeIP("192.168.1.1")).toBe("192.168.1.1"); + expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1"); + expect(normalizeIP("10.0.0.1")).toBe("10.0.0.1"); + }); + }); + + describe("IPv6 Normalization", () => { + it("should normalize compressed IPv6 to full form", () => { + expect(normalizeIP("2001:db8::1")).toBe( + "2001:0db8:0000:0000:0000:0000:0000:0001", + ); + expect(normalizeIP("::1")).toBe( + "0000:0000:0000:0000:0000:0000:0000:0001", + ); + expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000"); + }); + + it("should normalize uppercase to lowercase", () => { + expect(normalizeIP("2001:DB8::1")).toBe( + "2001:0db8:0000:0000:0000:0000:0000:0001", + ); + expect(normalizeIP("2001:0DB8:ABCD:EF00::1")).toBe( + "2001:0db8:abcd:ef00:0000:0000:0000:0001", + ); + }); + + it("should handle various IPv6 formats consistently", () => { + // All these represent the same address + const normalized = "2001:0db8:0000:0000:0000:0000:0000:0001"; + expect(normalizeIP("2001:db8::1")).toBe(normalized); + expect(normalizeIP("2001:0db8:0:0:0:0:0:1")).toBe(normalized); + expect(normalizeIP("2001:db8:0::1")).toBe(normalized); + expect(normalizeIP("2001:0db8::0:0:0:1")).toBe(normalized); + }); + + it("should handle IPv6 with :: at different positions", () => { + expect(normalizeIP("2001:db8:85a3::8a2e:370:7334")).toBe( + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + ); + expect(normalizeIP("::ffff:192.0.2.1")).not.toContain("::"); + }); + }); + + describe("IPv4-mapped IPv6 Conversion", () => { + it("should convert IPv4-mapped IPv6 to IPv4", () => { + expect(normalizeIP("::ffff:192.0.2.1")).toBe("192.0.2.1"); + expect(normalizeIP("::ffff:127.0.0.1")).toBe("127.0.0.1"); + expect(normalizeIP("::FFFF:10.0.0.1")).toBe("10.0.0.1"); + }); + + it("should handle hex-encoded IPv4 in mapped addresses", () => { + // ::ffff:c000:0201 = ::ffff:192.0.2.1 = 192.0.2.1 + expect(normalizeIP("::ffff:c000:0201")).toBe("192.0.2.1"); + // ::ffff:7f00:0001 = ::ffff:127.0.0.1 = 127.0.0.1 + expect(normalizeIP("::ffff:7f00:0001")).toBe("127.0.0.1"); + }); + + it("should handle full form IPv4-mapped IPv6", () => { + expect(normalizeIP("0:0:0:0:0:ffff:192.0.2.1")).toBe("192.0.2.1"); + }); + }); + + describe("IPv6 Subnet Support", () => { + it("should extract /64 subnet", () => { + /* cspell:disable-next-line */ + const ip1 = normalizeIP("2001:db8:0:0:1234:5678:90ab:cdef", { + ipv6Subnet: 64, + }); + const ip2 = normalizeIP("2001:db8:0:0:ffff:ffff:ffff:ffff", { + ipv6Subnet: 64, + }); + // Both should have same /64 prefix + expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); + expect(ip2).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); + expect(ip1).toBe(ip2); + }); + + it("should extract /48 subnet", () => { + /* cspell:disable-next-line */ + const ip1 = normalizeIP("2001:db8:1234:5678:90ab:cdef:1234:5678", { + ipv6Subnet: 48, + }); + const ip2 = normalizeIP("2001:db8:1234:ffff:ffff:ffff:ffff:ffff", { + ipv6Subnet: 48, + }); + // Both should have same /48 prefix + expect(ip1).toBe("2001:0db8:1234:0000:0000:0000:0000:0000"); + expect(ip2).toBe("2001:0db8:1234:0000:0000:0000:0000:0000"); + expect(ip1).toBe(ip2); + }); + + it("should extract /32 subnet", () => { + /* cspell:disable-next-line */ + const ip1 = normalizeIP("2001:db8:1234:5678:90ab:cdef:1234:5678", { + ipv6Subnet: 32, + }); + const ip2 = normalizeIP("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", { + ipv6Subnet: 32, + }); + // Both should have same /32 prefix + expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); + expect(ip2).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); + expect(ip1).toBe(ip2); + }); + + it("should handle /128 (full address) by default", () => { + const ip1 = normalizeIP("2001:db8::1"); + const ip2 = normalizeIP("2001:db8::1", { ipv6Subnet: 128 }); + expect(ip1).toBe(ip2); + expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0001"); + }); + + it("should not affect IPv4 addresses when ipv6Subnet is set", () => { + expect(normalizeIP("192.168.1.1", { ipv6Subnet: 64 })).toBe( + "192.168.1.1", + ); + }); + }); + + describe("Rate Limit Key Creation", () => { + it("should create keys with separator", () => { + expect(createRateLimitKey("192.168.1.1", "/sign-in")).toBe( + "192.168.1.1|/sign-in", + ); + expect(createRateLimitKey("2001:db8::1", "/api/auth")).toBe( + "2001:db8::1|/api/auth", + ); + }); + + it("should prevent collision attacks", () => { + // Without separator: "192.0.2.1" + "/sign-in" = "192.0.2.1/sign-in" + // "192.0.2" + ".1/sign-in" = "192.0.2.1/sign-in" + // With separator: they're different + const key1 = createRateLimitKey("192.0.2.1", "/sign-in"); + const key2 = createRateLimitKey("192.0.2", ".1/sign-in"); + expect(key1).not.toBe(key2); + expect(key1).toBe("192.0.2.1|/sign-in"); + expect(key2).toBe("192.0.2|.1/sign-in"); + }); + }); + + describe("Security: Bypass Prevention", () => { + it("should prevent IPv6 representation bypass", () => { + // Attacker tries different representations of same address + const representations = [ + "2001:db8::1", + "2001:DB8::1", + "2001:0db8::1", + "2001:db8:0::1", + "2001:0db8:0:0:0:0:0:1", + "2001:db8::0:1", + ]; + + const normalized = representations.map((ip) => normalizeIP(ip)); + // All should normalize to the same value + const uniqueValues = new Set(normalized); + expect(uniqueValues.size).toBe(1); + expect(normalized[0]).toBe("2001:0db8:0000:0000:0000:0000:0000:0001"); + }); + + it("should prevent IPv4-mapped bypass", () => { + // Attacker switches between IPv4 and IPv4-mapped IPv6 + const ip1 = normalizeIP("192.0.2.1"); + const ip2 = normalizeIP("::ffff:192.0.2.1"); + const ip3 = normalizeIP("::FFFF:192.0.2.1"); + const ip4 = normalizeIP("::ffff:c000:0201"); + + // All should normalize to the same IPv4 + expect(ip1).toBe("192.0.2.1"); + expect(ip2).toBe("192.0.2.1"); + expect(ip3).toBe("192.0.2.1"); + expect(ip4).toBe("192.0.2.1"); + }); + + it("should group IPv6 subnet attacks", () => { + // Attacker rotates through addresses in their /64 allocation + const attackIPs = [ + "2001:db8:abcd:1234:0000:0000:0000:0001", + "2001:db8:abcd:1234:1111:2222:3333:4444", + "2001:db8:abcd:1234:ffff:ffff:ffff:ffff", + "2001:db8:abcd:1234:aaaa:bbbb:cccc:dddd", + ]; + + const normalized = attackIPs.map((ip) => + normalizeIP(ip, { ipv6Subnet: 64 }), + ); + + // All should map to same /64 subnet + const uniqueValues = new Set(normalized); + expect(uniqueValues.size).toBe(1); + expect(normalized[0]).toBe("2001:0db8:abcd:1234:0000:0000:0000:0000"); + }); + }); + + describe("Edge Cases", () => { + it("should handle localhost addresses", () => { + expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1"); + expect(normalizeIP("::1")).toBe( + "0000:0000:0000:0000:0000:0000:0000:0001", + ); + }); + + it("should handle all-zeros address", () => { + expect(normalizeIP("0.0.0.0")).toBe("0.0.0.0"); + expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000"); + }); + + it("should handle link-local addresses", () => { + expect(normalizeIP("169.254.0.1")).toBe("169.254.0.1"); + expect(normalizeIP("fe80::1")).toBe( + "fe80:0000:0000:0000:0000:0000:0000:0001", + ); + }); + }); +}); diff --git a/packages/core/src/utils/ip.ts b/packages/core/src/utils/ip.ts new file mode 100644 index 0000000000..4f2ac230bf --- /dev/null +++ b/packages/core/src/utils/ip.ts @@ -0,0 +1,211 @@ +import * as z from "zod"; + +/** + * Normalizes an IP address for consistent rate limiting. + * + * Features: + * - Normalizes IPv6 to canonical lowercase form + * - Converts IPv4-mapped IPv6 to IPv4 + * - Supports IPv6 subnet extraction + * - Handles all edge cases (::1, ::, etc.) + */ + +interface NormalizeIPOptions { + /** + * For IPv6 addresses, extract the subnet prefix instead of full address. + * Common values: 32, 48, 64, 128 (default: 128 = full address) + * + * @default 128 + */ + ipv6Subnet?: 128 | 64 | 48 | 32; +} + +/** + * Checks if an IP is valid IPv4 or IPv6 + */ +export function isValidIP(ip: string): boolean { + return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success; +} + +/** + * Checks if an IP is IPv6 + */ +function isIPv6(ip: string): boolean { + return z.ipv6().safeParse(ip).success; +} + +/** + * Converts IPv4-mapped IPv6 address to IPv4 + * e.g., "::ffff:192.0.2.1" -> "192.0.2.1" + */ +function extractIPv4FromMapped(ipv6: string): string | null { + const lower = ipv6.toLowerCase(); + + // Handle ::ffff:192.0.2.1 format + if (lower.startsWith("::ffff:")) { + const ipv4Part = lower.substring(7); + // Check if it's a valid IPv4 + if (z.ipv4().safeParse(ipv4Part).success) { + return ipv4Part; + } + } + + // Handle full form: 0:0:0:0:0:ffff:192.0.2.1 + const parts = ipv6.split(":"); + if (parts.length === 7 && parts[5]?.toLowerCase() === "ffff") { + const ipv4Part = parts[6]; + if (ipv4Part && z.ipv4().safeParse(ipv4Part).success) { + return ipv4Part; + } + } + + // Handle hex-encoded IPv4 in mapped address + // e.g., ::ffff:c000:0201 -> 192.0.2.1 + if (lower.includes("::ffff:") || lower.includes(":ffff:")) { + const groups = expandIPv6(ipv6); + if ( + groups.length === 8 && + groups[0] === "0000" && + groups[1] === "0000" && + groups[2] === "0000" && + groups[3] === "0000" && + groups[4] === "0000" && + groups[5] === "ffff" && + groups[6] && + groups[7] + ) { + // Convert last two groups to IPv4 + const byte1 = Number.parseInt(groups[6].substring(0, 2), 16); + const byte2 = Number.parseInt(groups[6].substring(2, 4), 16); + const byte3 = Number.parseInt(groups[7].substring(0, 2), 16); + const byte4 = Number.parseInt(groups[7].substring(2, 4), 16); + return `${byte1}.${byte2}.${byte3}.${byte4}`; + } + } + + return null; +} + +/** + * Expands a compressed IPv6 address to full form + * e.g., "2001:db8::1" -> ["2001", "0db8", "0000", "0000", "0000", "0000", "0000", "0001"] + */ +function expandIPv6(ipv6: string): string[] { + // Handle :: notation (zero compression) + if (ipv6.includes("::")) { + const sides = ipv6.split("::"); + const left = sides[0] ? sides[0].split(":") : []; + const right = sides[1] ? sides[1].split(":") : []; + + // Calculate missing groups + const totalGroups = 8; + const missingGroups = totalGroups - left.length - right.length; + const zeros = Array(missingGroups).fill("0000"); + + // Pad existing groups to 4 digits + const paddedLeft = left.map((g) => g.padStart(4, "0")); + const paddedRight = right.map((g) => g.padStart(4, "0")); + + return [...paddedLeft, ...zeros, ...paddedRight]; + } + + // No compression, just pad each group + return ipv6.split(":").map((g) => g.padStart(4, "0")); +} + +/** + * Normalizes an IPv6 address to canonical form + * e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001" + */ +function normalizeIPv6( + ipv6: string, + subnetPrefix?: 128 | 32 | 48 | 64, +): string { + const groups = expandIPv6(ipv6); + + if (subnetPrefix && subnetPrefix < 128) { + // Apply subnet mask + const prefix = subnetPrefix; + let bitsRemaining: number = prefix; + + const maskedGroups = groups.map((group) => { + if (bitsRemaining <= 0) { + return "0000"; + } + if (bitsRemaining >= 16) { + bitsRemaining -= 16; + return group; + } + + // Partial mask for this group + const value = Number.parseInt(group, 16); + const mask = (0xffff << (16 - bitsRemaining)) & 0xffff; + const masked = value & mask; + bitsRemaining = 0; + return masked.toString(16).padStart(4, "0"); + }); + + return maskedGroups.join(":").toLowerCase(); + } + + return groups.join(":").toLowerCase(); +} + +/** + * Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting. + * + * @param ip - The IP address to normalize + * @param options - Normalization options + * @returns Normalized IP address + * + * @example + * normalizeIP("2001:DB8::1") + * // -> "2001:0db8:0000:0000:0000:0000:0000:0001" + * + * @example + * normalizeIP("::ffff:192.0.2.1") + * // -> "192.0.2.1" (converted to IPv4) + * + * @example + * normalizeIP("2001:db8::1", { ipv6Subnet: 64 }) + * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64) + */ +export function normalizeIP( + ip: string, + options: NormalizeIPOptions = {}, +): string { + // IPv4 addresses are already normalized + if (z.ipv4().safeParse(ip).success) { + return ip.toLowerCase(); + } + + // Check if it's IPv6 + if (!isIPv6(ip)) { + // Return as-is if not valid (shouldn't happen due to prior validation) + return ip.toLowerCase(); + } + + // Check for IPv4-mapped IPv6 + const ipv4 = extractIPv4FromMapped(ip); + if (ipv4) { + return ipv4.toLowerCase(); + } + + // Normalize IPv6 + const subnetPrefix = options.ipv6Subnet || 128; + return normalizeIPv6(ip, subnetPrefix); +} + +/** + * Creates a rate limit key from IP and path + * Uses a separator to prevent collision attacks + * + * @param ip - The IP address (should be normalized) + * @param path - The request path + * @returns Rate limit key + */ +export function createRateLimitKey(ip: string, path: string): string { + // Use | as separator to prevent collision attacks + // e.g., "192.0.2.1" + "/sign-in" vs "192.0.2" + ".1/sign-in" + return `${ip}|${path}`; +} diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts new file mode 100644 index 0000000000..a895ef4ccf --- /dev/null +++ b/packages/core/src/utils/url.ts @@ -0,0 +1,43 @@ +/** + * Normalizes a request pathname by removing the basePath prefix and trailing slashes. + * This is useful for matching paths against configured path lists. + * + * @param requestUrl - The full request URL + * @param basePath - The base path of the auth API (e.g., "/api/auth") + * @returns The normalized path without basePath prefix or trailing slashes, + * or "/" if URL parsing fails + * + * @example + * normalizePathname("http://localhost:3000/api/auth/sso/saml2/callback/provider1", "/api/auth") + * // Returns: "/sso/saml2/callback/provider1" + * + * normalizePathname("http://localhost:3000/sso/saml2/callback/provider1/", "/") + * // Returns: "/sso/saml2/callback/provider1" + */ +export function normalizePathname( + requestUrl: string, + basePath: string, +): string { + let pathname: string; + try { + pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/"; + } catch { + return "/"; + } + + if (basePath === "/" || basePath === "") { + return pathname; + } + + // Check for exact match or proper path boundary (basePath followed by "/" or end) + // This prevents "/api/auth" from matching "/api/authevil/..." + if (pathname === basePath) { + return "/"; + } + + if (pathname.startsWith(basePath + "/")) { + return pathname.slice(basePath.length).replace(/\/+$/, "") || "/"; + } + + return pathname; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44d75b5321..8a1a25a904 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,7 +410,7 @@ importers: version: 12.23.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3) geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -522,7 +522,7 @@ importers: version: 2.1.1 geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) lucide-react: specifier: ^0.542.0 version: 0.542.0(react@19.2.3) @@ -764,7 +764,7 @@ importers: version: 16.4.3(@oramacloud/client@2.1.4)(@tanstack/react-router@1.151.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(algoliasearch@5.46.2)(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.4) geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -994,7 +994,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.8.69 - version: 0.8.69(@cloudflare/workers-types@4.20260103.0)(@vitest/runner@4.0.17)(@vitest/snapshot@4.0.17)(vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.6)(happy-dom@20.0.11)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.8.69(@cloudflare/workers-types@4.20260103.0)(@vitest/runner@4.0.17)(@vitest/snapshot@4.0.17)(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.6)(happy-dom@20.0.11)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@cloudflare/workers-types': specifier: ^4.20250903.0 version: 4.20260103.0 @@ -1005,6 +1005,18 @@ importers: specifier: 4.33.2 version: 4.33.2(@cloudflare/workers-types@4.20260103.0) + e2e/smoke/test/fixtures/ipv6: + dependencies: + '@hono/node-server': + specifier: ^1.19.9 + version: 1.19.9(hono@4.11.4) + better-auth: + specifier: workspace:* + version: link:../../../../../packages/better-auth + hono: + specifier: ^4.11.4 + version: 4.11.4 + e2e/smoke/test/fixtures/tsconfig-declaration: dependencies: '@better-auth/oauth-provider': @@ -3077,9 +3089,6 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -4040,6 +4049,12 @@ packages: peerDependencies: hono: ^4 + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -6632,111 +6647,56 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.54.0': - resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.55.1': resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.54.0': - resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} - cpu: [arm64] - os: [android] - '@rollup/rollup-android-arm64@4.55.1': resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.54.0': - resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} - cpu: [arm64] - os: [darwin] - '@rollup/rollup-darwin-arm64@4.55.1': resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.54.0': - resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} - cpu: [x64] - os: [darwin] - '@rollup/rollup-darwin-x64@4.55.1': resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.54.0': - resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} - cpu: [arm64] - os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.55.1': resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.54.0': - resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} - cpu: [x64] - os: [freebsd] - '@rollup/rollup-freebsd-x64@4.55.1': resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.54.0': - resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} - cpu: [arm] - os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.55.1': resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.54.0': - resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} - cpu: [arm] - os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.54.0': - resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} - cpu: [arm64] - os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.54.0': - resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} - cpu: [arm64] - os: [linux] - '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.54.0': - resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} - cpu: [loong64] - os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] @@ -6747,11 +6707,6 @@ packages: cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.54.0': - resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} - cpu: [ppc64] - os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] @@ -6762,51 +6717,26 @@ packages: cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.54.0': - resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} - cpu: [riscv64] - os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.54.0': - resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} - cpu: [riscv64] - os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.54.0': - resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} - cpu: [s390x] - os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.54.0': - resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} - cpu: [x64] - os: [linux] - '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.54.0': - resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} - cpu: [x64] - os: [linux] - '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] @@ -6817,51 +6747,26 @@ packages: cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.54.0': - resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} - cpu: [arm64] - os: [openharmony] - '@rollup/rollup-openharmony-arm64@4.55.1': resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.54.0': - resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} - cpu: [arm64] - os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.55.1': resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.54.0': - resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} - cpu: [ia32] - os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.55.1': resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.54.0': - resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-gnu@4.55.1': resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.54.0': - resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-msvc@4.55.1': resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} cpu: [x64] @@ -7785,6 +7690,9 @@ packages: '@vitest/expect@4.0.16': resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + '@vitest/expect@4.0.17': + resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} + '@vitest/mocker@4.0.16': resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} peerDependencies: @@ -7796,6 +7704,17 @@ packages: vite: optional: true + '@vitest/mocker@4.0.17': + resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@4.0.16': resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} @@ -7817,6 +7736,9 @@ packages: '@vitest/spy@4.0.16': resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/spy@4.0.17': + resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} + '@vitest/utils@4.0.16': resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} @@ -13639,11 +13561,6 @@ packages: rollup: optional: true - rollup@4.54.0: - resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rollup@4.55.1: resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -13925,10 +13842,6 @@ packages: smob@1.5.0: resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} - smol-toml@1.5.2: - resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} - engines: {node: '>= 18'} - smol-toml@1.6.0: resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} @@ -15013,6 +14926,40 @@ packages: jsdom: optional: true + vitest@4.0.17: + resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.17 + '@vitest/browser-preview': 4.0.17 + '@vitest/browser-webdriverio': 4.0.17 + '@vitest/ui': 4.0.17 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} @@ -15764,7 +15711,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -15914,7 +15861,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -16953,7 +16900,7 @@ snapshots: optionalDependencies: workerd: 1.20250829.0 - '@cloudflare/vitest-pool-workers@0.8.69(@cloudflare/workers-types@4.20260103.0)(@vitest/runner@4.0.17)(@vitest/snapshot@4.0.17)(vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.6)(happy-dom@20.0.11)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@cloudflare/vitest-pool-workers@0.8.69(@cloudflare/workers-types@4.20260103.0)(@vitest/runner@4.0.17)(@vitest/snapshot@4.0.17)(vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.6)(happy-dom@20.0.11)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/runner': 4.0.17 '@vitest/snapshot': 4.0.17 @@ -16962,7 +16909,7 @@ snapshots: devalue: 5.6.1 miniflare: 4.20250829.0 semver: 7.7.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.6)(happy-dom@20.0.11)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.6)(happy-dom@20.0.11)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) wrangler: 4.33.2(@cloudflare/workers-types@4.20260103.0) zod: 3.25.76 transitivePeerDependencies: @@ -17258,11 +17205,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -18196,6 +18138,10 @@ snapshots: dependencies: hono: 4.11.4 + '@hono/node-server@1.19.9(hono@4.11.4)': + dependencies: + hono: 4.11.4 + '@hookform/resolvers@5.2.2(react-hook-form@7.68.0(react@19.2.3))': dependencies: '@standard-schema/utils': 0.3.0 @@ -18350,12 +18296,12 @@ snapshots: '@img/sharp-wasm32@0.33.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -18874,12 +18820,12 @@ snapshots: write-file-atomic: 5.0.1 optional: true - '@netlify/functions@3.1.10(rollup@4.54.0)': + '@netlify/functions@3.1.10(rollup@4.55.1)': dependencies: '@netlify/blobs': 9.1.2 '@netlify/dev-utils': 2.2.0 '@netlify/serverless-functions-api': 1.41.2 - '@netlify/zip-it-and-ship-it': 12.2.1(rollup@4.54.0) + '@netlify/zip-it-and-ship-it': 12.2.1(rollup@4.55.1) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -18915,13 +18861,13 @@ snapshots: '@netlify/serverless-functions-api@2.2.1': {} - '@netlify/zip-it-and-ship-it@12.2.1(rollup@4.54.0)': + '@netlify/zip-it-and-ship-it@12.2.1(rollup@4.55.1)': dependencies: '@babel/parser': 7.28.6 '@babel/types': 7.28.0 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.2.1 - '@vercel/nft': 0.29.4(rollup@4.54.0) + '@vercel/nft': 0.29.4(rollup@4.55.1) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -21091,13 +21037,13 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.59': {} - '@rollup/plugin-alias@5.1.1(rollup@4.54.0)': + '@rollup/plugin-alias@5.1.1(rollup@4.55.1)': optionalDependencies: - rollup: 4.54.0 + rollup: 4.55.1 - '@rollup/plugin-commonjs@28.0.6(rollup@4.54.0)': + '@rollup/plugin-commonjs@28.0.6(rollup@4.55.1)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.54.0) + '@rollup/pluginutils': 5.2.0(rollup@4.55.1) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.3) @@ -21105,193 +21051,127 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.3 optionalDependencies: - rollup: 4.54.0 + rollup: 4.55.1 - '@rollup/plugin-inject@5.0.5(rollup@4.54.0)': + '@rollup/plugin-inject@5.0.5(rollup@4.55.1)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.54.0) + '@rollup/pluginutils': 5.2.0(rollup@4.55.1) estree-walker: 2.0.2 magic-string: 0.30.21 optionalDependencies: - rollup: 4.54.0 + rollup: 4.55.1 - '@rollup/plugin-json@6.1.0(rollup@4.54.0)': + '@rollup/plugin-json@6.1.0(rollup@4.55.1)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.54.0) + '@rollup/pluginutils': 5.2.0(rollup@4.55.1) optionalDependencies: - rollup: 4.54.0 + rollup: 4.55.1 - '@rollup/plugin-node-resolve@16.0.1(rollup@4.54.0)': + '@rollup/plugin-node-resolve@16.0.1(rollup@4.55.1)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.54.0) + '@rollup/pluginutils': 5.2.0(rollup@4.55.1) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.11 optionalDependencies: - rollup: 4.54.0 + rollup: 4.55.1 - '@rollup/plugin-replace@6.0.2(rollup@4.54.0)': + '@rollup/plugin-replace@6.0.2(rollup@4.55.1)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.54.0) + '@rollup/pluginutils': 5.2.0(rollup@4.55.1) magic-string: 0.30.21 optionalDependencies: - rollup: 4.54.0 + rollup: 4.55.1 - '@rollup/plugin-terser@0.4.4(rollup@4.54.0)': + '@rollup/plugin-terser@0.4.4(rollup@4.55.1)': dependencies: serialize-javascript: 6.0.2 smob: 1.5.0 terser: 5.44.1 optionalDependencies: - rollup: 4.54.0 + rollup: 4.55.1 - '@rollup/pluginutils@5.2.0(rollup@4.54.0)': + '@rollup/pluginutils@5.2.0(rollup@4.55.1)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.54.0 - - '@rollup/rollup-android-arm-eabi@4.54.0': - optional: true + rollup: 4.55.1 '@rollup/rollup-android-arm-eabi@4.55.1': optional: true - '@rollup/rollup-android-arm64@4.54.0': - optional: true - '@rollup/rollup-android-arm64@4.55.1': optional: true - '@rollup/rollup-darwin-arm64@4.54.0': - optional: true - '@rollup/rollup-darwin-arm64@4.55.1': optional: true - '@rollup/rollup-darwin-x64@4.54.0': - optional: true - '@rollup/rollup-darwin-x64@4.55.1': optional: true - '@rollup/rollup-freebsd-arm64@4.54.0': - optional: true - '@rollup/rollup-freebsd-arm64@4.55.1': optional: true - '@rollup/rollup-freebsd-x64@4.54.0': - optional: true - '@rollup/rollup-freebsd-x64@4.55.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.54.0': - optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.55.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.54.0': - optional: true - '@rollup/rollup-linux-arm-musleabihf@4.55.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.54.0': - optional: true - '@rollup/rollup-linux-arm64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.54.0': - optional: true - '@rollup/rollup-linux-arm64-musl@4.55.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.54.0': - optional: true - '@rollup/rollup-linux-loong64-gnu@4.55.1': optional: true '@rollup/rollup-linux-loong64-musl@4.55.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.54.0': - optional: true - '@rollup/rollup-linux-ppc64-gnu@4.55.1': optional: true '@rollup/rollup-linux-ppc64-musl@4.55.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.54.0': - optional: true - '@rollup/rollup-linux-riscv64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.54.0': - optional: true - '@rollup/rollup-linux-riscv64-musl@4.55.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.54.0': - optional: true - '@rollup/rollup-linux-s390x-gnu@4.55.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.54.0': - optional: true - '@rollup/rollup-linux-x64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-x64-musl@4.54.0': - optional: true - '@rollup/rollup-linux-x64-musl@4.55.1': optional: true '@rollup/rollup-openbsd-x64@4.55.1': optional: true - '@rollup/rollup-openharmony-arm64@4.54.0': - optional: true - '@rollup/rollup-openharmony-arm64@4.55.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.54.0': - optional: true - '@rollup/rollup-win32-arm64-msvc@4.55.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.54.0': - optional: true - '@rollup/rollup-win32-ia32-msvc@4.55.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.54.0': - optional: true - '@rollup/rollup-win32-x64-gnu@4.55.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.54.0': - optional: true - '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true @@ -22383,10 +22263,10 @@ snapshots: vue: 3.5.26(typescript@5.9.3) vue-router: 4.6.4(vue@3.5.26(typescript@5.9.3)) - '@vercel/nft@0.29.4(rollup@4.54.0)': + '@vercel/nft@0.29.4(rollup@4.55.1)': dependencies: '@mapbox/node-pre-gyp': 2.0.0 - '@rollup/pluginutils': 5.2.0(rollup@4.54.0) + '@rollup/pluginutils': 5.2.0(rollup@4.55.1) acorn: 8.15.0 acorn-import-attributes: 1.9.5(acorn@8.15.0) async-sema: 3.1.1 @@ -22487,6 +22367,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/expect@4.0.17': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 + chai: 6.2.2 + tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.16(msw@2.12.7(@types/node@24.10.1)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.1)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.16 @@ -22505,6 +22394,15 @@ snapshots: msw: 2.12.7(@types/node@25.0.6)(typescript@5.9.3) vite: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.17(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.17 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.7(@types/node@25.0.6)(typescript@5.9.3) + vite: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@4.0.16': dependencies: tinyrainbow: 3.0.3 @@ -22537,6 +22435,8 @@ snapshots: '@vitest/spy@4.0.16': {} + '@vitest/spy@4.0.17': {} + '@vitest/utils@4.0.16': dependencies: '@vitest/pretty-format': 4.0.16 @@ -22960,7 +22860,7 @@ snapshots: babel-plugin-react-compiler@1.0.0: dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 babel-plugin-react-native-web@0.21.2: {} @@ -23846,7 +23746,7 @@ snapshots: dependencies: '@cspell/cspell-types': 9.4.0 comment-json: 4.4.1 - smol-toml: 1.5.2 + smol-toml: 1.6.0 yaml: 2.8.2 cspell-dictionary@9.4.0: @@ -25569,7 +25469,7 @@ snapshots: function-bind@1.1.2: {} - geist@1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)): + geist@1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)): dependencies: next: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1) @@ -26227,7 +26127,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/parser': 7.28.6 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.3 @@ -26839,8 +26739,8 @@ snapshots: magicast@0.5.1: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 source-map-js: 1.2.1 mailchecker@6.0.18: {} @@ -27300,7 +27200,7 @@ snapshots: metro-transform-plugins@0.83.3: dependencies: '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 + '@babel/generator': 7.28.6 '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 flow-enums-runtime: 0.0.6 @@ -27331,9 +27231,9 @@ snapshots: metro-transform-worker@0.83.3: dependencies: '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/generator': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 flow-enums-runtime: 0.0.6 metro: 0.83.3 metro-babel-transformer: 0.83.3 @@ -27399,11 +27299,11 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/generator': 7.28.6 + '@babel/parser': 7.28.6 '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 accepts: 1.3.8 chalk: 4.1.2 ci-info: 2.0.0 @@ -28018,15 +27918,15 @@ snapshots: nitropack@2.12.4(@azure/identity@4.13.0)(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(@netlify/blobs@10.5.0)(better-sqlite3@12.5.0)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260103.0)(@electric-sql/pglite@0.3.14)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.2.0(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(magicast@0.3.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.5.0)(bun-types@1.3.5)(kysely@0.28.9)(mysql2@3.16.0)(pg@8.16.3)(postgres@3.4.7)(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(magicast@0.3.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.16.0)(rolldown@1.0.0-beta.59): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 - '@netlify/functions': 3.1.10(rollup@4.54.0) - '@rollup/plugin-alias': 5.1.1(rollup@4.54.0) - '@rollup/plugin-commonjs': 28.0.6(rollup@4.54.0) - '@rollup/plugin-inject': 5.0.5(rollup@4.54.0) - '@rollup/plugin-json': 6.1.0(rollup@4.54.0) - '@rollup/plugin-node-resolve': 16.0.1(rollup@4.54.0) - '@rollup/plugin-replace': 6.0.2(rollup@4.54.0) - '@rollup/plugin-terser': 0.4.4(rollup@4.54.0) - '@vercel/nft': 0.29.4(rollup@4.54.0) + '@netlify/functions': 3.1.10(rollup@4.55.1) + '@rollup/plugin-alias': 5.1.1(rollup@4.55.1) + '@rollup/plugin-commonjs': 28.0.6(rollup@4.55.1) + '@rollup/plugin-inject': 5.0.5(rollup@4.55.1) + '@rollup/plugin-json': 6.1.0(rollup@4.55.1) + '@rollup/plugin-node-resolve': 16.0.1(rollup@4.55.1) + '@rollup/plugin-replace': 6.0.2(rollup@4.55.1) + '@rollup/plugin-terser': 0.4.4(rollup@4.55.1) + '@vercel/nft': 0.29.4(rollup@4.55.1) archiver: 7.0.1 c12: 3.3.2(magicast@0.3.5) chokidar: 4.0.3 @@ -28068,8 +27968,8 @@ snapshots: pkg-types: 2.3.0 pretty-bytes: 6.1.1 radix3: 1.1.2 - rollup: 4.54.0 - rollup-plugin-visualizer: 6.0.3(rolldown@1.0.0-beta.59)(rollup@4.54.0) + rollup: 4.55.1 + rollup-plugin-visualizer: 6.0.3(rolldown@1.0.0-beta.59)(rollup@4.55.1) scule: 1.3.0 semver: 7.7.3 serve-placeholder: 2.0.2 @@ -29803,7 +29703,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.59 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.59 - rollup-plugin-visualizer@6.0.3(rolldown@1.0.0-beta.59)(rollup@4.54.0): + rollup-plugin-visualizer@6.0.3(rolldown@1.0.0-beta.59)(rollup@4.55.1): dependencies: open: 8.4.2 picomatch: 4.0.3 @@ -29811,35 +29711,7 @@ snapshots: yargs: 17.7.2 optionalDependencies: rolldown: 1.0.0-beta.59 - rollup: 4.54.0 - - rollup@4.54.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.54.0 - '@rollup/rollup-android-arm64': 4.54.0 - '@rollup/rollup-darwin-arm64': 4.54.0 - '@rollup/rollup-darwin-x64': 4.54.0 - '@rollup/rollup-freebsd-arm64': 4.54.0 - '@rollup/rollup-freebsd-x64': 4.54.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 - '@rollup/rollup-linux-arm-musleabihf': 4.54.0 - '@rollup/rollup-linux-arm64-gnu': 4.54.0 - '@rollup/rollup-linux-arm64-musl': 4.54.0 - '@rollup/rollup-linux-loong64-gnu': 4.54.0 - '@rollup/rollup-linux-ppc64-gnu': 4.54.0 - '@rollup/rollup-linux-riscv64-gnu': 4.54.0 - '@rollup/rollup-linux-riscv64-musl': 4.54.0 - '@rollup/rollup-linux-s390x-gnu': 4.54.0 - '@rollup/rollup-linux-x64-gnu': 4.54.0 - '@rollup/rollup-linux-x64-musl': 4.54.0 - '@rollup/rollup-openharmony-arm64': 4.54.0 - '@rollup/rollup-win32-arm64-msvc': 4.54.0 - '@rollup/rollup-win32-ia32-msvc': 4.54.0 - '@rollup/rollup-win32-x64-gnu': 4.54.0 - '@rollup/rollup-win32-x64-msvc': 4.54.0 - fsevents: 2.3.3 + rollup: 4.55.1 rollup@4.55.1: dependencies: @@ -30285,8 +30157,6 @@ snapshots: smob@1.5.0: {} - smol-toml@1.5.2: {} - smol-toml@1.6.0: {} solid-js@1.9.9: @@ -31312,7 +31182,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.54.0 + rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.0.6 @@ -31445,6 +31315,45 @@ snapshots: - tsx - yaml + vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.6)(happy-dom@20.0.11)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.17 + '@vitest/mocker': 4.0.17(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.17 + '@vitest/runner': 4.0.17 + '@vitest/snapshot': 4.0.17 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.0.6 + happy-dom: 20.0.11 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vlq@1.0.1: {} vscode-languageserver-textdocument@1.0.12: {}