fix(rate-limit): support IPv6 address normalization and subnet (#7470)

This commit is contained in:
Alex Yang
2026-01-19 15:30:38 -08:00
parent feb2084f58
commit ae0e280587
14 changed files with 1208 additions and 402 deletions

View File

@@ -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
<Callout type="info">
IPv6 subnet configuration only affects IPv6 addresses. IPv4 addresses are always rate limited individually.
</Callout>
### Rate Limit Window
```ts title="auth.ts"

46
e2e/smoke/test/fixtures/ipv6/index.ts vendored Normal file
View File

@@ -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);
},
);

View File

@@ -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"
}
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"skipLibCheck": true,
"strict": true,
"module": "esnext",
"moduleResolution": "bundler"
}
}

205
e2e/smoke/test/ipv6.spec.ts Normal file
View File

@@ -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<number>((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<number>((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<number>((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`,
);
}
}
});
});

View File

@@ -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));

View File

@@ -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<string, string>();
@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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;
/**

View File

@@ -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";

View File

@@ -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",
);
});
});
});

View File

@@ -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}`;
}

View File

@@ -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;
}

483
pnpm-lock.yaml generated
View File

@@ -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: {}