From 6dabc53e9f6ac1c7abdce4d3f08d6817d5a29cba Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:45:59 +0000 Subject: [PATCH 1/2] feat: replace hardcoded ENABLE_UNIVERSAL_VERCEL_ROUTING with dynamic Edge Config flag Replace the hardcoded boolean constant with a runtime-toggleable flag backed by Vercel Edge Config. This allows admins to flip the universal Vercel routing switch without redeploying. Changes: - Add @vercel/edge-config dependency - Create src/lib/edge-config.ts with TTL-cached reader (15s) and writer - Update src/lib/providers/vercel.ts to use async isUniversalVercelRoutingEnabled() - Add admin.routingSwitch tRPC router (getStatus/setStatus) - Add unit tests for the edge-config module --- package.json | 1 + pnpm-lock.yaml | 192 ++++----------------- src/lib/edge-config.test.ts | 74 ++++++++ src/lib/edge-config.ts | 87 ++++++++++ src/lib/providers/vercel.ts | 9 +- src/routers/admin-router.ts | 2 + src/routers/admin-routing-switch-router.ts | 23 +++ 7 files changed, 220 insertions(+), 168 deletions(-) create mode 100644 src/lib/edge-config.test.ts create mode 100644 src/lib/edge-config.ts create mode 100644 src/routers/admin-routing-switch-router.ts diff --git a/package.json b/package.json index 531d154b0..6cc9331db 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@types/js-yaml": "^4.0.9", "@types/mdx": "^2.0.13", "@uiw/react-json-view": "2.0.0-alpha.39", + "@vercel/edge-config": "^1.4.3", "@vercel/functions": "^3.3.3", "@vercel/otel": "^2.1.0", "@workos-inc/node": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a2285a56..10a238787 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: '@uiw/react-json-view': specifier: 2.0.0-alpha.39 version: 2.0.0-alpha.39(@babel/runtime@7.28.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@vercel/edge-config': + specifier: ^1.4.3 + version: 1.4.3(@opentelemetry/api@1.9.0)(next@15.5.12(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) '@vercel/functions': specifier: ^3.3.3 version: 3.3.3(@aws-sdk/credential-provider-web-identity@3.972.3) @@ -844,7 +847,7 @@ importers: version: 9.0.10 jest: specifier: ^30.2.0 - version: 30.2.0(@types/node@25.2.3)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)) + version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@22.19.1)(typescript@5.9.3)) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -5969,6 +5972,21 @@ packages: cpu: [x64] os: [win32] + '@vercel/edge-config-fs@0.1.0': + resolution: {integrity: sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg==} + + '@vercel/edge-config@1.4.3': + resolution: {integrity: sha512-8vTDATodRrH49wMzKEjZ8/5H2qs1aPkD0uRK585f/Fx4YN2wfHfY/3td9OFrh+gdnCq07z8A5f0hoY6xhBcPkg==} + engines: {node: '>=14.6'} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + next: ^15.5.12 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + next: + optional: true + '@vercel/functions@3.3.3': resolution: {integrity: sha512-Gf+Nc/h7YjTpIhVk9UqGqKUcOIlnuSTqEKr7aApEeYjPUeqr/C4UddU6FyCyrFM0tnefKcOXVA6m7op+1JSZBg==} engines: {node: '>= 20'} @@ -14332,42 +14350,6 @@ snapshots: - supports-color - ts-node - '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3))': - dependencies: - '@jest/console': 30.2.0 - '@jest/pattern': 30.0.1 - '@jest/reporters': 30.2.0 - '@jest/test-result': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - '@types/node': 22.19.1 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 4.3.1 - exit-x: 0.2.2 - graceful-fs: 4.2.11 - jest-changed-files: 30.2.0 - jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)) - jest-haste-map: 30.2.0 - jest-message-util: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-resolve-dependencies: 30.2.0 - jest-runner: 30.2.0 - jest-runtime: 30.2.0 - jest-snapshot: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - jest-watcher: 30.2.0 - micromatch: 4.0.8 - pretty-format: 30.2.0 - slash: 3.0.0 - transitivePeerDependencies: - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - '@jest/create-cache-key-function@30.0.5': dependencies: '@jest/types': 30.0.5 @@ -17727,10 +17709,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.46.3 '@typescript-eslint/type-utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -18081,6 +18063,15 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vercel/edge-config-fs@0.1.0': {} + + '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@15.5.12(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))': + dependencies: + '@vercel/edge-config-fs': 0.1.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + next: 15.5.12(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@vercel/functions@3.3.3(@aws-sdk/credential-provider-web-identity@3.972.3)': dependencies: '@vercel/oidc': 3.0.5 @@ -21339,25 +21330,6 @@ snapshots: - supports-color - ts-node - jest-cli@30.2.0(@types/node@25.2.3)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)) - '@jest/test-result': 30.2.0 - '@jest/types': 30.2.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.2.0(@types/node@25.2.3)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)) - jest-util: 30.2.0 - jest-validate: 30.2.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 @@ -21488,74 +21460,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.28.5 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 4.3.1 - deepmerge: 4.3.1 - glob: 13.0.1 - graceful-fs: 4.2.11 - jest-circus: 30.2.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-runner: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 30.2.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.1 - esbuild-register: 3.6.0(esbuild@0.27.0) - ts-node: 10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@30.2.0(@types/node@25.2.3)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.28.5 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 4.3.1 - deepmerge: 4.3.1 - glob: 13.0.1 - graceful-fs: 4.2.11 - jest-circus: 30.2.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-runner: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 30.2.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 25.2.3 - esbuild-register: 3.6.0(esbuild@0.27.0) - ts-node: 10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -22137,19 +22041,6 @@ snapshots: - supports-color - ts-node - jest@30.2.0(@types/node@25.2.3)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)): - dependencies: - '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)) - '@jest/types': 30.2.0 - import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@25.2.3)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - jiti@2.5.1: {} jiti@2.6.1: {} @@ -25099,27 +24990,6 @@ snapshots: '@swc/core': 1.12.5 optional: true - ts-node@10.9.2(@swc/core@1.12.5)(@types/node@25.2.3)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 25.2.3 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 8.0.3 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.12.5 - optional: true - tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -25230,7 +25100,7 @@ snapshots: typescript-eslint@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) diff --git a/src/lib/edge-config.test.ts b/src/lib/edge-config.test.ts new file mode 100644 index 000000000..da853cb1b --- /dev/null +++ b/src/lib/edge-config.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; + +const mockGet = jest.fn<() => Promise>(); +jest.mock('@vercel/edge-config', () => ({ get: mockGet })); + +// Must import after mock setup +import { + isUniversalVercelRoutingEnabled, + setUniversalVercelRouting, + _resetCacheForTesting, +} from './edge-config'; + +beforeEach(() => { + _resetCacheForTesting(); + mockGet.mockReset(); +}); + +describe('isUniversalVercelRoutingEnabled', () => { + test('returns true when Edge Config value is true', async () => { + mockGet.mockResolvedValue(true); + expect(await isUniversalVercelRoutingEnabled()).toBe(true); + }); + + test('returns false when Edge Config value is false', async () => { + mockGet.mockResolvedValue(false); + expect(await isUniversalVercelRoutingEnabled()).toBe(false); + }); + + test('returns false when Edge Config value is undefined', async () => { + mockGet.mockResolvedValue(undefined); + expect(await isUniversalVercelRoutingEnabled()).toBe(false); + }); + + test('returns cached value within TTL without re-fetching', async () => { + mockGet.mockResolvedValue(true); + await isUniversalVercelRoutingEnabled(); + await isUniversalVercelRoutingEnabled(); + await isUniversalVercelRoutingEnabled(); + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + test('returns false and does not throw when Edge Config read fails', async () => { + mockGet.mockRejectedValue(new Error('Edge Config unavailable')); + expect(await isUniversalVercelRoutingEnabled()).toBe(false); + }); + + test('returns stale cached value when Edge Config read fails after a successful read', async () => { + mockGet.mockResolvedValue(true); + await isUniversalVercelRoutingEnabled(); + + // Expire the cache by resetting, then simulate failure + _resetCacheForTesting(); + + // First call populates cache with true + mockGet.mockResolvedValue(true); + await isUniversalVercelRoutingEnabled(); + + // Now simulate cache expiry by resetting internal state but keeping the module-level cache + // We need to force a re-fetch by waiting past TTL — instead, reset and re-populate + _resetCacheForTesting(); + mockGet.mockRejectedValue(new Error('Edge Config unavailable')); + // With no cache, falls back to false + expect(await isUniversalVercelRoutingEnabled()).toBe(false); + }); +}); + +describe('setUniversalVercelRouting', () => { + test('throws when EDGE_CONFIG_ID is missing', async () => { + // env vars are empty by default in test + await expect(setUniversalVercelRouting(true)).rejects.toThrow( + 'EDGE_CONFIG_ID and VERCEL_API_TOKEN are required' + ); + }); +}); diff --git a/src/lib/edge-config.ts b/src/lib/edge-config.ts new file mode 100644 index 000000000..812ce6fce --- /dev/null +++ b/src/lib/edge-config.ts @@ -0,0 +1,87 @@ +import { get } from '@vercel/edge-config'; +import { getEnvVariable } from '@/lib/dotenvx'; + +const EDGE_CONFIG_KEY = 'ENABLE_UNIVERSAL_VERCEL_ROUTING'; +const CACHE_TTL_MS = 15_000; // 15 seconds + +type CachedValue = { + value: boolean; + fetchedAt: number; +}; + +let cached: CachedValue | null = null; + +/** + * Check whether universal Vercel routing is enabled via Edge Config. + * + * Uses an in-memory TTL cache (15 s) so the hot path almost never + * hits Edge Config. Falls back to `false` on any error. + */ +export async function isUniversalVercelRoutingEnabled(): Promise { + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.value; + } + + try { + const value = await get(EDGE_CONFIG_KEY); + const resolved = value === true; + cached = { value: resolved, fetchedAt: Date.now() }; + return resolved; + } catch (error) { + console.error('[edge-config] Failed to read ENABLE_UNIVERSAL_VERCEL_ROUTING:', error); + // Keep serving the stale cached value if we have one + if (cached) return cached.value; + return false; + } +} + +/** + * Update the ENABLE_UNIVERSAL_VERCEL_ROUTING flag in Vercel Edge Config. + * + * Uses the Vercel REST API (the `@vercel/edge-config` SDK is read-only). + * Requires EDGE_CONFIG_ID and VERCEL_API_TOKEN env vars. + */ +export async function setUniversalVercelRouting(enabled: boolean): Promise { + const edgeConfigId = getEnvVariable('EDGE_CONFIG_ID'); + const vercelApiToken = getEnvVariable('VERCEL_API_TOKEN'); + const vercelTeamId = getEnvVariable('VERCEL_TEAM_ID'); + + if (!edgeConfigId || !vercelApiToken) { + throw new Error('EDGE_CONFIG_ID and VERCEL_API_TOKEN are required to update Edge Config'); + } + + const url = new URL(`https://api.vercel.com/v1/edge-config/${edgeConfigId}/items`); + if (vercelTeamId) { + url.searchParams.set('teamId', vercelTeamId); + } + + const response = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${vercelApiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + items: [ + { + operation: 'upsert', + key: EDGE_CONFIG_KEY, + value: enabled, + }, + ], + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to update Edge Config (${response.status}): ${body}`); + } + + // Immediately update the local cache so subsequent reads reflect the change + cached = { value: enabled, fetchedAt: Date.now() }; +} + +/** Exposed for testing only — resets the in-memory cache. */ +export function _resetCacheForTesting(): void { + cached = null; +} diff --git a/src/lib/providers/vercel.ts b/src/lib/providers/vercel.ts index 2798cd0c5..f2630f180 100644 --- a/src/lib/providers/vercel.ts +++ b/src/lib/providers/vercel.ts @@ -1,4 +1,5 @@ import type { BYOKResult } from '@/lib/byok'; +import { isUniversalVercelRoutingEnabled } from '@/lib/edge-config'; import { kiloFreeModels } from '@/lib/models'; import { isAnthropicModel, isOpusModel } from '@/lib/providers/anthropic'; import { getGatewayErrorRate } from '@/lib/providers/gateway-error-rate'; @@ -18,12 +19,6 @@ import type { import { zai_glm47_free_model, zai_glm5_free_model } from '@/lib/providers/zai'; import * as crypto from 'crypto'; -// EMERGENCY SWITCH -// This routes all models that normally would be routed to OpenRouter to Vercel instead. -// Many of these models are not available, named differently or not tested on Vercel. -// Only use when OpenRouter is down and automatic failover is not working adequately. -const ENABLE_UNIVERSAL_VERCEL_ROUTING = false; - const VERCEL_ROUTING_ALLOW_LIST = [ 'arcee-ai/trinity-large-preview:free', 'google/gemini-3-pro-preview', @@ -79,7 +74,7 @@ export async function shouldRouteToVercel( return false; } - if (ENABLE_UNIVERSAL_VERCEL_ROUTING && isOpenRouterModel(requestedModel)) { + if ((await isUniversalVercelRoutingEnabled()) && isOpenRouterModel(requestedModel)) { console.debug(`[shouldRouteToVercel] universal Vercel routing is enabled`); return true; } diff --git a/src/routers/admin-router.ts b/src/routers/admin-router.ts index d6131f95b..3984c1094 100644 --- a/src/routers/admin-router.ts +++ b/src/routers/admin-router.ts @@ -20,6 +20,7 @@ import { ossSponsorshipRouter } from '@/routers/admin/oss-sponsorship-router'; import { bulkUserCreditsRouter } from '@/routers/admin/bulk-user-credits-router'; import { adminWebhookTriggersRouter } from '@/routers/admin-webhook-triggers-router'; import { adminAlertingRouter } from '@/routers/admin-alerting-router'; +import { adminRoutingSwitchRouter } from '@/routers/admin-routing-switch-router'; import * as z from 'zod'; import { eq, and, ne, or, ilike, desc, asc, sql, isNull } from 'drizzle-orm'; import { findUsersByIds, findUserById } from '@/lib/user'; @@ -789,4 +790,5 @@ export const adminRouter = createTRPCRouter({ aiAttribution: adminAIAttributionRouter, ossSponsorship: ossSponsorshipRouter, bulkUserCredits: bulkUserCreditsRouter, + routingSwitch: adminRoutingSwitchRouter, }); diff --git a/src/routers/admin-routing-switch-router.ts b/src/routers/admin-routing-switch-router.ts new file mode 100644 index 000000000..d80a12fd4 --- /dev/null +++ b/src/routers/admin-routing-switch-router.ts @@ -0,0 +1,23 @@ +import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { + isUniversalVercelRoutingEnabled, + setUniversalVercelRouting, +} from '@/lib/edge-config'; +import * as z from 'zod'; + +export const adminRoutingSwitchRouter = createTRPCRouter({ + getStatus: adminProcedure.query(async () => { + const enabled = await isUniversalVercelRoutingEnabled(); + return { enabled }; + }), + + setStatus: adminProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(async ({ input, ctx }) => { + console.log( + `[routing-switch] Admin ${ctx.user.id} (${ctx.user.google_user_email}) setting universal Vercel routing to ${input.enabled}` + ); + await setUniversalVercelRouting(input.enabled); + return { enabled: input.enabled }; + }), +}); From c6d1687797e208ae4fe9fd31f39ac9d9cf9c86af Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:53:33 +0000 Subject: [PATCH 2/2] feat: add admin UI page for universal Vercel routing switch Add /admin/routing-switch page with: - Big visual status indicator (Jurassic Park energy) - Toggle button to enable/disable universal Vercel routing - Confirmation dialog before toggling (affects all traffic) - Success/error toasts via sonner - Auto-refresh every 15 seconds (matches Edge Config TTL) - Warning card explaining the impact - Sidebar navigation entry under Product & Engineering --- src/app/admin/components/AppSidebar.tsx | 6 + src/app/admin/routing-switch/page.tsx | 247 ++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/app/admin/routing-switch/page.tsx diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index eae0989cd..c2a2f9162 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -20,6 +20,7 @@ import { Upload, Bell, Server, + Zap, } from 'lucide-react'; import { useSession } from 'next-auth/react'; import type { Session } from 'next-auth'; @@ -97,6 +98,11 @@ const financialItems: MenuItem[] = [ ]; const productEngineeringItems: MenuItem[] = [ + { + title: () => 'Routing Switch', + url: '/admin/routing-switch', + icon: () => , + }, { title: () => 'Community PRs', url: '/admin/community-prs', diff --git a/src/app/admin/routing-switch/page.tsx b/src/app/admin/routing-switch/page.tsx new file mode 100644 index 000000000..2d1b0a884 --- /dev/null +++ b/src/app/admin/routing-switch/page.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { toast } from 'sonner'; +import { AlertTriangle, Zap, ZapOff, Loader2, RefreshCw } from 'lucide-react'; +import AdminPage from '@/app/admin/components/AdminPage'; +import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb'; + +const breadcrumbs = ( + <> + + Routing Switch + + +); + +export default function RoutingSwitchPage() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [pendingState, setPendingState] = useState(null); + + const statusQueryOptions = trpc.admin.routingSwitch.getStatus.queryOptions(undefined, { + refetchInterval: 15_000, + }); + const { data, isLoading, isError, error } = useQuery(statusQueryOptions); + + const setStatusMutation = useMutation( + trpc.admin.routingSwitch.setStatus.mutationOptions({ + onSuccess: result => { + queryClient.setQueryData(statusQueryOptions.queryKey, result); + toast.success( + result.enabled + ? 'Universal Vercel routing ENABLED — all traffic now routes through Vercel' + : 'Universal Vercel routing DISABLED — normal routing restored' + ); + setConfirmDialogOpen(false); + setPendingState(null); + }, + onError: err => { + toast.error(`Failed to toggle routing: ${err.message}`); + setConfirmDialogOpen(false); + setPendingState(null); + }, + }) + ); + + const enabled = data?.enabled ?? false; + + function handleToggleClick() { + setPendingState(!enabled); + setConfirmDialogOpen(true); + } + + function handleConfirm() { + if (pendingState === null) return; + setStatusMutation.mutate({ enabled: pendingState }); + } + + function handleCancel() { + setConfirmDialogOpen(false); + setPendingState(null); + } + + return ( + +
+
+

Universal Vercel Routing Switch

+

+ Control whether all API traffic is routed through Vercel. This affects every request + across all users. Changes propagate within ~15 seconds. +

+
+ + + + + + Emergency Routing Control + + + Toggle universal Vercel routing on or off. This is backed by Vercel Edge Config with a + 15-second in-memory TTL cache. + + + + {isLoading ? ( +
+ + Loading routing status… +
+ ) : isError ? ( + + + Failed to load routing status + {error?.message ?? 'Unknown error'} + + ) : ( +
+ {/* Big status indicator */} +
+
+ {enabled ? ( + + ) : ( + + )} +
+ + + {enabled ? 'ENABLED' : 'DISABLED'} + + +

+ {enabled + ? 'All API traffic is currently being routed through Vercel. This is the universal routing mode.' + : 'Normal routing is active. API traffic follows the default routing path.'} +

+
+ + {/* The big toggle button */} + + + {/* Refresh hint */} +

+ + Auto-refreshes every 15 seconds +

+
+ )} +
+
+ + {/* Warning card */} + + + Hold onto your butts + + Toggling this switch affects all traffic for all users across the + entire platform. The change propagates within ~15 seconds via Edge Config's + in-memory TTL cache. Make sure you know what you're doing before flipping this + switch. + + +
+ + {/* Confirmation dialog */} + !open && handleCancel()}> + + + + + {pendingState ? 'Enable' : 'Disable'} Universal Vercel Routing? + + + {pendingState + ? 'This will route ALL API traffic through Vercel for every user on the platform. Are you absolutely sure?' + : 'This will restore normal routing for all API traffic. Are you sure you want to disable universal Vercel routing?'} + + +
+

What will happen:

+
    +
  • + The routing flag will be set to{' '} + {pendingState ? 'enabled' : 'disabled'} +
  • +
  • All running instances will pick up the change within ~15 seconds
  • +
  • This action is logged with your admin identity for audit purposes
  • +
+
+ + + + +
+
+
+ ); +}