diff --git a/.env.example b/.env.example index 531c8dc..fda3b23 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ BETTER_AUTH_SECRET=replace-with-a-random-secret-at-least-32-characters DATABASE_URL=postgres://root:root123@localhost:5432/Knowhere # Optional. Defaults to DATABASE_URL when unset. NEWSLETTER_DATABASE_URL= +# Per-instance PostgreSQL pool limits. The defaults are sized for Vercel and staging. +DATABASE_POOL_MAX=2 +NEWSLETTER_DATABASE_POOL_MAX=1 +DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS=10000 +DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS=5000 # Set this to true only for local/self-hosted PostgreSQL without SSL. # UNSAFE_DB_SSL_ENABLED=true BILLING_ENABLED=false diff --git a/README.md b/README.md index 41d7aee..cdf2278 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ Required for startup: | `BETTER_AUTH_SECRET` | Random secret with at least 32 characters. | | `DATABASE_URL` | PostgreSQL connection URL for dashboard auth/account data. | | `NEWSLETTER_DATABASE_URL` | Optional PostgreSQL connection URL for newsletter subscription data. Falls back to `DATABASE_URL` when unset. | +| `DATABASE_POOL_MAX` | Maximum dashboard auth/account database connections per app instance. Defaults to `2`. | +| `NEWSLETTER_DATABASE_POOL_MAX` | Maximum newsletter database connections per app instance. Defaults to `1`. | +| `DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS` | Time before an idle database connection is closed. Defaults to `10000`. | +| `DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS` | Time allowed to acquire a new database connection. Defaults to `5000`. | | `UNSAFE_DB_SSL_ENABLED` | Optional escape hatch for local/self-hosted PostgreSQL without SSL. Set to `true` only when the database does not support SSL. Defaults to `false`, so hosted SaaS keeps SSL enabled without extra config. | Email/password registration is enabled for self-hosted deployments. The login page defaults to SSO plus Resend-backed email links; set `PASSWORD_LOGIN_ENABLED=true` only when you want to expose the password-login entry point. OAuth and Resend-backed magic-link login are optional add-ons. Password reset emails also use Resend; signed-in OAuth users can set a password from dashboard settings. @@ -88,6 +92,8 @@ Optional: Do not commit `.env.local`, `.env.production`, or any other real environment file. +Both database pools are bounded per application instance. On Vercel, the app registers each pool with the Vercel Functions lifecycle so idle connections can be released before a function instance is suspended. + ## Quality Commands ```bash diff --git a/lib/db/database-pool.test.ts b/lib/db/database-pool.test.ts new file mode 100644 index 0000000..b5f0ad1 --- /dev/null +++ b/lib/db/database-pool.test.ts @@ -0,0 +1,52 @@ +import { createDatabasePool } from "@lib/db/database-pool"; +import { attachDatabasePool } from "@vercel/functions"; +import type { Pool } from "pg"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@vercel/functions", (): { attachDatabasePool: ReturnType } => ({ + attachDatabasePool: vi.fn(), +})); + +afterEach((): void => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("createDatabasePool", (): void => { + it("creates a bounded pool without Vercel lifecycle registration outside Vercel", async (): Promise => { + vi.stubEnv("VERCEL", undefined); + + const pool: Pool = createDatabasePool({ + connectionString: "postgres://user:pass@example.com:5432/knowhere", + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, + max: 2, + ssl: false, + }); + + expect(pool.options).toMatchObject({ + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, + max: 2, + }); + expect(attachDatabasePool).not.toHaveBeenCalled(); + await pool.end(); + }); + + it("registers the bounded pool with the Vercel lifecycle", async (): Promise => { + vi.stubEnv("VERCEL", "1"); + + const pool: Pool = createDatabasePool({ + connectionString: "postgres://user:pass@example.com:5432/knowhere", + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, + max: 1, + ssl: false, + }); + + expect(attachDatabasePool).toHaveBeenCalledOnce(); + expect(attachDatabasePool).toHaveBeenCalledWith(pool); + expect(pool.options.max).toBe(1); + await pool.end(); + }); +}); diff --git a/lib/db/database-pool.ts b/lib/db/database-pool.ts new file mode 100644 index 0000000..20cd62f --- /dev/null +++ b/lib/db/database-pool.ts @@ -0,0 +1,12 @@ +import { attachDatabasePool } from "@vercel/functions"; +import { Pool, type PoolConfig } from "pg"; + +export function createDatabasePool(configuration: PoolConfig): Pool { + const pool: Pool = new Pool(configuration); + + if (process.env.VERCEL === "1") { + attachDatabasePool(pool); + } + + return pool; +} diff --git a/lib/db/index.test.ts b/lib/db/index.test.ts new file mode 100644 index 0000000..b49c49e --- /dev/null +++ b/lib/db/index.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +interface PoolStub { + readonly pool: string; +} + +interface DatabaseStub { + readonly database: string; +} + +const createDatabasePool: Mock<() => PoolStub> = vi.fn((): PoolStub => ({ pool: "main" })); +const drizzle: Mock<() => DatabaseStub> = vi.fn((): DatabaseStub => ({ database: "main" })); + +vi.mock("@lib/db/database-pool", (): { createDatabasePool: typeof createDatabasePool } => ({ + createDatabasePool, +})); +vi.mock("@lib/db/database-ssl", (): { getDatabaseSslConfig: () => boolean } => ({ + getDatabaseSslConfig: (): boolean => false, +})); +vi.mock("@lib/db/auth-schema", (): { user: string } => ({ user: "auth-schema" })); +vi.mock("@lib/db/schema", (): { account: string } => ({ account: "app-schema" })); +vi.mock("drizzle-orm/node-postgres", (): { drizzle: typeof drizzle } => ({ drizzle })); +vi.mock("@lib/env", (): { env: Readonly> } => ({ + env: { + DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: 5_000, + DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: 10_000, + DATABASE_POOL_MAX: 2, + DATABASE_URL: "postgres://main.example/knowhere", + UNSAFE_DB_SSL_ENABLED: "false", + }, +})); + +beforeEach((): void => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +describe("main database", (): void => { + it("uses the bounded main pool configuration", async (): Promise => { + const databaseModule: typeof import("@lib/db") = await import("@lib/db"); + + expect(createDatabasePool).toHaveBeenCalledWith({ + connectionString: "postgres://main.example/knowhere", + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, + max: 2, + ssl: false, + }); + expect(drizzle).toHaveBeenCalledWith({ + client: { pool: "main" }, + schema: { + account: "app-schema", + user: "auth-schema", + }, + }); + expect(databaseModule.db).toEqual({ database: "main" }); + }); +}); diff --git a/lib/db/index.ts b/lib/db/index.ts index 0e46be1..fe003c1 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,13 +1,16 @@ import * as authSchema from "@lib/db/auth-schema"; +import { createDatabasePool } from "@lib/db/database-pool"; import { getDatabaseSslConfig } from "@lib/db/database-ssl"; import * as appSchema from "@lib/db/schema"; import { env } from "@lib/env"; import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; // Connection pool for dashboard auth/account data. -const pool = new Pool({ +const pool: ReturnType = createDatabasePool({ connectionString: env.DATABASE_URL, + connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS, + idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS, + max: env.DATABASE_POOL_MAX, ssl: getDatabaseSslConfig(env.UNSAFE_DB_SSL_ENABLED), }); diff --git a/lib/db/newsletter.test.ts b/lib/db/newsletter.test.ts new file mode 100644 index 0000000..720f33b --- /dev/null +++ b/lib/db/newsletter.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +interface PoolStub { + readonly pool: string; +} + +interface DatabaseStub { + readonly database: string; +} + +const createDatabasePool: Mock<() => PoolStub> = vi.fn((): PoolStub => ({ pool: "newsletter" })); +const drizzle: Mock<() => DatabaseStub> = vi.fn((): DatabaseStub => ({ database: "newsletter" })); + +vi.mock("@lib/db/database-pool", (): { createDatabasePool: typeof createDatabasePool } => ({ + createDatabasePool, +})); +vi.mock("@lib/db/database-ssl", (): { getDatabaseSslConfig: () => boolean } => ({ + getDatabaseSslConfig: (): boolean => false, +})); +vi.mock("@lib/db/newsletter-schema", (): { subscription: string } => ({ + subscription: "newsletter-schema", +})); +vi.mock("drizzle-orm/node-postgres", (): { drizzle: typeof drizzle } => ({ drizzle })); +vi.mock("@lib/env", (): { env: Readonly> } => ({ + env: { + DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: 5_000, + DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: 10_000, + DATABASE_URL: "postgres://main.example/knowhere", + NEWSLETTER_DATABASE_POOL_MAX: 1, + NEWSLETTER_DATABASE_URL: "postgres://newsletter.example/knowhere", + UNSAFE_DB_SSL_ENABLED: "false", + }, +})); + +beforeEach((): void => { + vi.clearAllMocks(); + vi.resetModules(); +}); + +describe("newsletter database", (): void => { + it("uses the independent bounded newsletter pool", async (): Promise => { + const newsletterModule: typeof import("@lib/db/newsletter") = await import( + "@lib/db/newsletter" + ); + + expect(createDatabasePool).toHaveBeenCalledWith({ + connectionString: "postgres://newsletter.example/knowhere", + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, + max: 1, + ssl: false, + }); + expect(drizzle).toHaveBeenCalledWith({ + client: { pool: "newsletter" }, + schema: { + subscription: "newsletter-schema", + }, + }); + expect(newsletterModule.newsletterDb).toEqual({ database: "newsletter" }); + }); +}); diff --git a/lib/db/newsletter.ts b/lib/db/newsletter.ts index 13ac6c3..6b56d92 100644 --- a/lib/db/newsletter.ts +++ b/lib/db/newsletter.ts @@ -1,15 +1,18 @@ +import { createDatabasePool } from "@lib/db/database-pool"; import { getDatabaseSslConfig } from "@lib/db/database-ssl"; import * as newsletterSchema from "@lib/db/newsletter-schema"; import { env } from "@lib/env"; import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; function getNewsletterDatabaseUrl(): string { return env.NEWSLETTER_DATABASE_URL ?? env.DATABASE_URL; } -const newsletterPool = new Pool({ +const newsletterPool: ReturnType = createDatabasePool({ connectionString: getNewsletterDatabaseUrl(), + connectionTimeoutMillis: env.DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS, + idleTimeoutMillis: env.DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS, + max: env.NEWSLETTER_DATABASE_POOL_MAX, ssl: getDatabaseSslConfig(env.UNSAFE_DB_SSL_ENABLED), }); diff --git a/lib/env.test.ts b/lib/env.test.ts index c12b1e3..b60d857 100644 --- a/lib/env.test.ts +++ b/lib/env.test.ts @@ -52,6 +52,35 @@ describe("env.AUTH_COOKIE_PREFIX", () => { }); }); +describe("env database pools", (): void => { + it("uses the bounded staging and Vercel pool defaults", async (): Promise => { + const envModule: EnvModule = await loadEnv({ + DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: undefined, + DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: undefined, + DATABASE_POOL_MAX: undefined, + NEWSLETTER_DATABASE_POOL_MAX: undefined, + }); + + expect(envModule.env.DATABASE_POOL_MAX).toBe(2); + expect(envModule.env.NEWSLETTER_DATABASE_POOL_MAX).toBe(1); + expect(envModule.env.DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS).toBe(10_000); + expect(envModule.env.DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS).toBe(5_000); + }); + + it("rejects non-positive database pool values", async (): Promise => { + const consoleError: ReturnType = vi + .spyOn(console, "error") + .mockImplementation((): void => {}); + + await expect( + loadEnv({ + DATABASE_POOL_MAX: "0", + }) + ).rejects.toThrow("Invalid environment variables"); + expect(consoleError).toHaveBeenCalled(); + }); +}); + describe("env.OPENAI_ADS", () => { it("normalizes blank OpenAI Ads values to undefined", async () => { const { env } = await loadEnv({ diff --git a/lib/env.ts b/lib/env.ts index e5410f4..e562316 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -15,12 +15,18 @@ function normalizeOptionalUrl(value: unknown): unknown { return normalizeOptionalString(value); } +const POSITIVE_INTEGER: z.ZodCoercedNumber = z.coerce.number().int().positive(); + export const env = createEnv({ server: { BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.url(), DATABASE_URL: z.url(), NEWSLETTER_DATABASE_URL: z.preprocess(normalizeOptionalUrl, z.url().optional()), + DATABASE_POOL_MAX: POSITIVE_INTEGER.default(2), + NEWSLETTER_DATABASE_POOL_MAX: POSITIVE_INTEGER.default(1), + DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: POSITIVE_INTEGER.default(10_000), + DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: POSITIVE_INTEGER.default(5_000), UNSAFE_DB_SSL_ENABLED: z.string().default("false"), GA_MEASUREMENT_ID: z .string() @@ -91,6 +97,11 @@ export const env = createEnv({ BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, DATABASE_URL: process.env.DATABASE_URL, NEWSLETTER_DATABASE_URL: process.env.NEWSLETTER_DATABASE_URL, + DATABASE_POOL_MAX: process.env.DATABASE_POOL_MAX, + NEWSLETTER_DATABASE_POOL_MAX: process.env.NEWSLETTER_DATABASE_POOL_MAX, + DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS: process.env.DATABASE_POOL_IDLE_TIMEOUT_MILLISECONDS, + DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS: + process.env.DATABASE_POOL_CONNECTION_TIMEOUT_MILLISECONDS, UNSAFE_DB_SSL_ENABLED: process.env.UNSAFE_DB_SSL_ENABLED, GA_MEASUREMENT_ID: process.env.GA_MEASUREMENT_ID, OPENAI_ADS_PIXEL_ID: process.env.OPENAI_ADS_PIXEL_ID, diff --git a/package.json b/package.json index 551d588..bdc6905 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@tanstack/react-query": "^5.62.8", "@tanstack/react-query-devtools": "^5.62.8", "@tanstack/react-table": "^8.21.3", + "@vercel/functions": "3.9.3", "better-auth": "^1.4.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a80dbd0..aec4744 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@vercel/functions': + specifier: 3.9.3 + version: 3.9.3(@aws-sdk/credential-provider-web-identity@3.958.0) better-auth: specifier: ^1.4.7 version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.17.2))(next@16.1.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.5(@types/node@20.19.27)(vite@8.0.10(@types/node@20.19.27)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.2))) @@ -2465,6 +2468,29 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@vercel/cli-config@0.2.3': + resolution: {integrity: sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A==} + + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} + + '@vercel/functions@3.9.3': + resolution: {integrity: sha512-cbzTdASCZDnufrABc8oO00e/FqlqFFSdld+iGZPhrWBDHP4Pu8ESKKYIzASqYvbYYUIWujBvEa5LLCcZzm7WEw==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + ws: '>=8' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + ws: + optional: true + + '@vercel/oidc@3.8.4': + resolution: {integrity: sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA==} + engines: {node: '>= 20'} + '@vitest/expect@4.1.5': resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} @@ -2946,6 +2972,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3021,6 +3051,10 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -3043,6 +3077,10 @@ packages: resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==} engines: {node: '>=16.9.0'} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -3091,6 +3129,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -3102,6 +3144,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -3228,6 +3273,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3236,6 +3284,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -3323,6 +3375,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + nuqs@2.8.6: resolution: {integrity: sha512-aRxeX68b4ULmhio8AADL2be1FWDy0EPqaByPvIYWrA7Pm07UjlrICp/VPlSnXJNAG0+3MQwv3OporO2sOXMVGA==} peerDependencies: @@ -3355,6 +3411,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -3365,6 +3425,10 @@ packages: openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3711,6 +3775,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3762,6 +3829,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} @@ -4035,6 +4106,14 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xdg-app-paths@5.5.1: + resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==} + engines: {node: '>= 6.0'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4044,6 +4123,9 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.3.4: resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} @@ -6421,6 +6503,27 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@vercel/cli-config@0.2.3': + dependencies: + xdg-app-paths: 5.5.1 + zod: 4.1.11 + + '@vercel/cli-exec@1.0.1': + dependencies: + execa: 5.1.1 + + '@vercel/functions@3.9.3(@aws-sdk/credential-provider-web-identity@3.958.0)': + dependencies: + '@vercel/oidc': 3.8.4 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.958.0 + + '@vercel/oidc@3.8.4': + dependencies: + '@vercel/cli-config': 0.2.3 + '@vercel/cli-exec': 1.0.1 + jose: 5.10.0 + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 @@ -6796,6 +6899,18 @@ snapshots: eventemitter3@5.0.4: {} + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + expect-type@1.3.0: {} fast-content-type-parse@2.0.1: {} @@ -6855,6 +6970,8 @@ snapshots: get-nonce@1.0.1: {} + get-stream@6.0.1: {} + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -6875,6 +6992,8 @@ snapshots: hono@4.11.5: {} + human-signals@2.1.0: {} + husky@9.1.7: {} immer@10.2.0: {} @@ -6915,12 +7034,16 @@ snapshots: is-number@7.0.0: {} + is-stream@2.0.1: {} + is-what@5.5.0: {} isexe@2.0.0: {} jiti@1.21.7: {} + jose@5.10.0: {} + jose@6.1.3: {} json-schema-typed@8.0.2: {} @@ -7019,6 +7142,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + merge-stream@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -7026,6 +7151,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} motion-dom@12.26.2: @@ -7105,6 +7232,10 @@ snapshots: normalize-path@3.0.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + nuqs@2.8.6(next@16.1.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 @@ -7118,6 +7249,10 @@ snapshots: obug@2.1.1: {} + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -7128,6 +7263,8 @@ snapshots: dependencies: yaml: 2.8.2 + os-paths@4.4.0: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -7471,6 +7608,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} slice-ansi@7.1.2: @@ -7515,6 +7654,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@2.0.0: {} + strnum@2.1.2: {} styled-jsx@5.1.6(react@19.2.3): @@ -7762,10 +7903,21 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + xdg-app-paths@5.5.1: + dependencies: + os-paths: 4.4.0 + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xtend@4.0.2: {} yaml@2.8.2: {} + zod@4.1.11: {} + zod@4.3.4: {} zustand@5.0.10(@types/react@19.2.9)(immer@11.1.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):