From 0fbd510d1e1b0cded72833ecda44549e2c6f708e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 26 May 2026 12:24:06 -0300 Subject: [PATCH 1/9] fix(security): redirect /home paths to login when unauthenticated (#2712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Next.js middleware matcher in src/proxy.ts omitted /home, so runAuthzPipeline never executed for /home requests — every visit reached the page directly, exposing UI content without auth on REQUIRE_LOGIN deployments. Adding /home and /home/:path* to the matcher invokes the pipeline; extending isDashboardPath() so /home shares the same redirect-to-login behavior as /dashboard closes the bypass. Reported in #2712. --- src/proxy.ts | 2 ++ src/server/authz/pipeline.ts | 7 ++++++- tests/unit/authz/pipeline.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/proxy.ts b/src/proxy.ts index cfce41dd4726..eaa312e0911c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -9,6 +9,8 @@ export const config = { matcher: [ "/", "/dashboard/:path*", + "/home", + "/home/:path*", "/api/:path*", "/v1/:path*", "/v1", diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 137d4458c708..36f0a96759c6 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -61,7 +61,12 @@ function rejectionResponse( } function isDashboardPath(pathname: string): boolean { - return pathname === "/dashboard" || pathname.startsWith("/dashboard/"); + return ( + pathname === "/dashboard" || + pathname.startsWith("/dashboard/") || + pathname === "/home" || + pathname.startsWith("/home/") + ); } function isManagementDashboardRoute( diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 079dd836f8e1..814ebb2fe886 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -84,6 +84,30 @@ test("runAuthzPipeline redirects unauthenticated dashboard pages to login", asyn assert.ok(response.headers.get("x-request-id")); }); +test("runAuthzPipeline redirects unauthenticated /home to login (#2712)", async () => { + await forceAuthRequired(); + + const response = await pipeline.runAuthzPipeline(request("http://localhost/home"), { + enforce: true, + }); + + assert.equal(response.status, 307); + assert.equal(response.headers.get("location"), "http://localhost/login"); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); +}); + +test("runAuthzPipeline redirects unauthenticated /home/* nested paths to login (#2712)", async () => { + await forceAuthRequired(); + + const response = await pipeline.runAuthzPipeline(request("http://localhost/home/settings"), { + enforce: true, + }); + + assert.equal(response.status, 307); + assert.equal(response.headers.get("location"), "http://localhost/login"); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); +}); + test("runAuthzPipeline allows onboarding when login is required but no password exists", async () => { delete process.env.INITIAL_PASSWORD; await settingsDb.updateSettings({ From 8efc3c109bbbc209ba5ef1ea8e7daa9ea0dfcfbe Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 26 May 2026 12:32:12 -0300 Subject: [PATCH 2/9] fix(deepseek): guard PoW solver Web Worker handler for Node.js strict mode (#2724) open-sse/lib/deepseek-pow-solver.cjs declared a bare `onmessage = ...` intended for a Web Worker global scope. When the bundle is loaded via require() in Node.js 22+/24+ strict mode, the assignment throws `ReferenceError: onmessage is not defined`, aborting every DeepSeek request before the PoW computation runs. Wrap the assignment so it only fires when a Worker-like global is present (typeof self !== "undefined" && typeof postMessage !== "undefined"), and assign through globalThis.onmessage so the path also works in older Node sloppy mode if the bundle is ever revived in a Worker. Reported in #2724. --- open-sse/lib/deepseek-pow-solver.cjs | 2 +- tests/unit/deepseek-pow-solver-strict.test.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tests/unit/deepseek-pow-solver-strict.test.ts diff --git a/open-sse/lib/deepseek-pow-solver.cjs b/open-sse/lib/deepseek-pow-solver.cjs index 9318ec513d14..4b4ab62722af 100644 --- a/open-sse/lib/deepseek-pow-solver.cjs +++ b/open-sse/lib/deepseek-pow-solver.cjs @@ -1,3 +1,3 @@ const r=(id)=>{if(id===46743)return{Buffer};return{}};const t={},e={}; -"use strict";let n,i;r(42551),r(40966),r(70968),r(76966),r(35399),r(36279),r(87801),r(16389),r(36073),r(27448),r(10681),r(32014),r(46596),r(39008),r(71),r(85540);var o=r(46743),f=Object.create,u=Object.defineProperty,s=Object.getOwnPropertyDescriptor,a=Object.getOwnPropertyNames,c=Object.getPrototypeOf,h=Object.prototype.hasOwnProperty,l=(t,e,r)=>(r=null!=t?f(c(t)):{},((t,e,r,n)=>{if(e&&"object"==typeof e||"function"==typeof e)for(let i of a(e))h.call(t,i)||i===r||u(t,i,{get:()=>e[i],enumerable:!(n=s(e,i))||n.enumerable});return t})(!e&&t&&t.__esModule?r:u(r,"default",{value:t,enumerable:!0}),t)),p=(n=(t,e)=>{e.exports=(t,e)=>(r,n)=>{let i=2*n,o=2*e;r[i]=t[o],r[i+1]=t[o+1]}},()=>(i||n((i={exports:{}}).exports,i),i.exports)),g=l(p()),y=t=>{let{A:e,C:r}=t;for(let t=0;t<25;t+=5){for(let n=0;n<5;n++)(0,g.default)(e,t+n)(r,n);for(let n=0;n<5;n++){let i=(t+n)*2,o=(n+1)%5*2,f=(n+2)%5*2;e[i]^=~r[o]&r[f],e[i+1]^=~r[o+1]&r[f+1]}}},d=new Uint32Array([0,1,0,32898,0x80000000,32906,0x80000000,0x80008000,0,32907,0,0x80000001,0x80000000,0x80008081,0x80000000,32777,0,138,0,136,0,0x80008009,0,0x8000000a,0,0x8000808b,0x80000000,139,0x80000000,32905,0x80000000,32771,0x80000000,32770,0x80000000,128,0,32778,0x80000000,0x8000000a,0x80000000,0x80008081,0x80000000,32896,0,0x80000001,0x80000000,0x80008008]),b=t=>{let{A:e,I:r}=t,n=2*r;e[0]^=d[n],e[1]^=d[n+1]},v=[10,7,11,17,18,3,5,16,8,21,24,4,15,23,19,13,12,2,20,14,22,9,6,1],w=[1,3,6,10,15,21,28,36,45,55,2,14,27,41,56,8,25,43,62,18,39,61,20,44],x=l(p()),E=t=>{let{A:e,C:r,W:n}=t,i=0;(0,x.default)(e,i+1)(n,i);let o=0,f=0,u=0,s=32;for(;i<24;i++){let t=v[i],a=w[i];(0,x.default)(e,t)(r,0),o=n[0],f=n[1],s=32-a,n[u=a<32?0:1]=o<>>s,n[(u+1)%2]=f<>>s,(0,x.default)(n,0)(e,t),(0,x.default)(r,0)(n,0)}},m=l(p()),B=t=>{let{A:e,C:r,D:n,W:i}=t,o=0,f=0;for(let t=0;t<5;t++){let n=2*t,i=(t+5)*2,o=(t+10)*2,f=(t+15)*2,u=(t+20)*2;r[n]=e[n]^e[i]^e[o]^e[f]^e[u],r[n+1]=e[n+1]^e[i+1]^e[o+1]^e[f+1]^e[u+1]}for(let t=0;t<5;t++){(0,m.default)(r,(t+1)%5)(i,0),o=i[0],f=i[1],i[0]=o<<1|f>>>31,i[1]=f<<1|o>>>31,n[2*t]=r[(t+4)%5*2]^i[0],n[2*t+1]=r[(t+4)%5*2+1]^i[1];for(let r=0;r<25;r+=5)e[(r+t)*2]^=n[2*t],e[(r+t)*2+1]^=n[2*t+1]}},I=(t,e)=>{for(let r=0;r{for(let r=0;r>>8,e[r+2]=t[n+1]>>>16,e[r+3]=t[n+1]>>>24,e[r+4]=t[n],e[r+5]=t[n]>>>8,e[r+6]=t[n]>>>16,e[r+7]=t[n]>>>24}return e},U=function(t){let e,r,n,{capacity:i,padding:f}=t,u=i/8,s=200-i/4,a={keccak:(e=new Uint32Array(10),r=new Uint32Array(10),n=new Uint32Array(2),t=>{for(let i=1;i<24;i++)B({A:t,C:e,D:r,W:n}),E({A:t,C:e,W:n}),y({A:t,C:e}),b({A:t,I:i});e.fill(0),r.fill(0),n.fill(0)}),state:new Uint32Array(50),queue:o.Buffer.allocUnsafe(s),queueOffset:0};return this.getState=()=>a,this.setState=t=>{a.keccak=t.keccak,a.state.set(t.state.slice()),t.queue.copy(a.queue),a.queueOffset=t.queueOffset},this.absorb=t=>{for(let e=0;e=s&&(I(a.queue,a.state),a.keccak(a.state),a.queueOffset=0);return this},this.squeeze=t=>{let e={buffer:o.Buffer.allocUnsafe(u),padding:t,queue:o.Buffer.allocUnsafe(a.queue.length),state:new Uint32Array(a.state.length)};a.queue.copy(e.queue);for(let t=0;t(a.queue.fill(0),a.state.fill(0),a.queueOffset=0,this),this.copy=()=>{let t=new U({capacity:i,padding:f});return t.setState(this.getState()),t},this};onmessage=t=>{if("pow-challenge"!==t.data.type)return;let{algorithm:e,challenge:r,salt:n,difficulty:i,signature:f,expireAt:u}=t.data.challenge;try{let t=((t,e,r,n,i)=>{if("DeepSeekHashV1"!==t)throw Error("Unsupported algorithm: "+t);let f="".concat(r,"_").concat(i,"_"),u=function(t,e,r){if(t.length%2!=0)throw RangeError("c.length");if(r<=0||!Number.isSafeInteger(r))throw RangeError("d");for(var n=(function t(){var e=this;return this&&this.constructor===t?(this._sponge=new U({capacity:256}),this.update=t=>{if("string"==typeof t)return this._sponge.absorb(o.Buffer.from(t,"utf8")),this;throw TypeError("input not a string")},this.digest=function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"hex";return e._sponge.squeeze(6).toString(t)},this.copy=()=>{let e=new t;return e._sponge=this._sponge.copy(),e},this):new t})(256).update(e),i=0;i(r=null!=t?f(c(t)):{},((t,e,r,n)=>{if(e&&"object"==typeof e||"function"==typeof e)for(let i of a(e))h.call(t,i)||i===r||u(t,i,{get:()=>e[i],enumerable:!(n=s(e,i))||n.enumerable});return t})(!e&&t&&t.__esModule?r:u(r,"default",{value:t,enumerable:!0}),t)),p=(n=(t,e)=>{e.exports=(t,e)=>(r,n)=>{let i=2*n,o=2*e;r[i]=t[o],r[i+1]=t[o+1]}},()=>(i||n((i={exports:{}}).exports,i),i.exports)),g=l(p()),y=t=>{let{A:e,C:r}=t;for(let t=0;t<25;t+=5){for(let n=0;n<5;n++)(0,g.default)(e,t+n)(r,n);for(let n=0;n<5;n++){let i=(t+n)*2,o=(n+1)%5*2,f=(n+2)%5*2;e[i]^=~r[o]&r[f],e[i+1]^=~r[o+1]&r[f+1]}}},d=new Uint32Array([0,1,0,32898,0x80000000,32906,0x80000000,0x80008000,0,32907,0,0x80000001,0x80000000,0x80008081,0x80000000,32777,0,138,0,136,0,0x80008009,0,0x8000000a,0,0x8000808b,0x80000000,139,0x80000000,32905,0x80000000,32771,0x80000000,32770,0x80000000,128,0,32778,0x80000000,0x8000000a,0x80000000,0x80008081,0x80000000,32896,0,0x80000001,0x80000000,0x80008008]),b=t=>{let{A:e,I:r}=t,n=2*r;e[0]^=d[n],e[1]^=d[n+1]},v=[10,7,11,17,18,3,5,16,8,21,24,4,15,23,19,13,12,2,20,14,22,9,6,1],w=[1,3,6,10,15,21,28,36,45,55,2,14,27,41,56,8,25,43,62,18,39,61,20,44],x=l(p()),E=t=>{let{A:e,C:r,W:n}=t,i=0;(0,x.default)(e,i+1)(n,i);let o=0,f=0,u=0,s=32;for(;i<24;i++){let t=v[i],a=w[i];(0,x.default)(e,t)(r,0),o=n[0],f=n[1],s=32-a,n[u=a<32?0:1]=o<>>s,n[(u+1)%2]=f<>>s,(0,x.default)(n,0)(e,t),(0,x.default)(r,0)(n,0)}},m=l(p()),B=t=>{let{A:e,C:r,D:n,W:i}=t,o=0,f=0;for(let t=0;t<5;t++){let n=2*t,i=(t+5)*2,o=(t+10)*2,f=(t+15)*2,u=(t+20)*2;r[n]=e[n]^e[i]^e[o]^e[f]^e[u],r[n+1]=e[n+1]^e[i+1]^e[o+1]^e[f+1]^e[u+1]}for(let t=0;t<5;t++){(0,m.default)(r,(t+1)%5)(i,0),o=i[0],f=i[1],i[0]=o<<1|f>>>31,i[1]=f<<1|o>>>31,n[2*t]=r[(t+4)%5*2]^i[0],n[2*t+1]=r[(t+4)%5*2+1]^i[1];for(let r=0;r<25;r+=5)e[(r+t)*2]^=n[2*t],e[(r+t)*2+1]^=n[2*t+1]}},I=(t,e)=>{for(let r=0;r{for(let r=0;r>>8,e[r+2]=t[n+1]>>>16,e[r+3]=t[n+1]>>>24,e[r+4]=t[n],e[r+5]=t[n]>>>8,e[r+6]=t[n]>>>16,e[r+7]=t[n]>>>24}return e},U=function(t){let e,r,n,{capacity:i,padding:f}=t,u=i/8,s=200-i/4,a={keccak:(e=new Uint32Array(10),r=new Uint32Array(10),n=new Uint32Array(2),t=>{for(let i=1;i<24;i++)B({A:t,C:e,D:r,W:n}),E({A:t,C:e,W:n}),y({A:t,C:e}),b({A:t,I:i});e.fill(0),r.fill(0),n.fill(0)}),state:new Uint32Array(50),queue:o.Buffer.allocUnsafe(s),queueOffset:0};return this.getState=()=>a,this.setState=t=>{a.keccak=t.keccak,a.state.set(t.state.slice()),t.queue.copy(a.queue),a.queueOffset=t.queueOffset},this.absorb=t=>{for(let e=0;e=s&&(I(a.queue,a.state),a.keccak(a.state),a.queueOffset=0);return this},this.squeeze=t=>{let e={buffer:o.Buffer.allocUnsafe(u),padding:t,queue:o.Buffer.allocUnsafe(a.queue.length),state:new Uint32Array(a.state.length)};a.queue.copy(e.queue);for(let t=0;t(a.queue.fill(0),a.state.fill(0),a.queueOffset=0,this),this.copy=()=>{let t=new U({capacity:i,padding:f});return t.setState(this.getState()),t},this};/* #2724: guard Web Worker handler — Node.js require() loads this CJS in strict mode where bare `onmessage = ...` throws ReferenceError */typeof self!=="undefined"&&typeof postMessage!=="undefined"&&(globalThis.onmessage=t=>{if("pow-challenge"!==t.data.type)return;let{algorithm:e,challenge:r,salt:n,difficulty:i,signature:f,expireAt:u}=t.data.challenge;try{let t=((t,e,r,n,i)=>{if("DeepSeekHashV1"!==t)throw Error("Unsupported algorithm: "+t);let f="".concat(r,"_").concat(i,"_"),u=function(t,e,r){if(t.length%2!=0)throw RangeError("c.length");if(r<=0||!Number.isSafeInteger(r))throw RangeError("d");for(var n=(function t(){var e=this;return this&&this.constructor===t?(this._sponge=new U({capacity:256}),this.update=t=>{if("string"==typeof t)return this._sponge.absorb(o.Buffer.from(t,"utf8")),this;throw TypeError("input not a string")},this.digest=function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"hex";return e._sponge.squeeze(6).toString(t)},this.copy=()=>{let e=new t;return e._sponge=this._sponge.copy(),e},this):new t})(256).update(e),i=0;i { + let mod: { U?: unknown }; + assert.doesNotThrow(() => { + mod = requireCJS("../../open-sse/lib/deepseek-pow-solver.cjs"); + }); + assert.ok(mod!, "module loaded"); + assert.equal(typeof mod!.U, "function", "expected exported constructor U"); +}); + +test("deepseek-pow-solver.cjs does not pollute globalThis.onmessage in Node (#2724)", () => { + const before = (globalThis as { onmessage?: unknown }).onmessage; + requireCJS("../../open-sse/lib/deepseek-pow-solver.cjs"); + const after = (globalThis as { onmessage?: unknown }).onmessage; + assert.equal(after, before, "onmessage must remain unchanged outside Worker context"); +}); From 1d766732d285b8ed5814883eab725f7aa72820d9 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 26 May 2026 12:35:58 -0300 Subject: [PATCH 3/9] fix(combos): include no-auth providers in combo builder options (#2737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getComboBuilderOptions() previously iterated only providers that had rows in provider_connections. No-auth providers (e.g. opencode and others flagged with noAuth: true) never get a connection row, so they were missing from the combo builder picker — users could not add them to combos. Add a supplementary loop after the main connectionsByProvider pass that iterates NOAUTH_PROVIDERS from src/shared/constants/providers.ts. For each provider not already covered, build the same model option set (synced, built-in, custom, fallback) and emit a ComboBuilderProviderOption with connectionCount: 0 and an empty connections array. Reported in #2737. --- src/lib/combos/builderOptions.ts | 126 ++++++++++++++++++ .../unit/combo-builder-options-route.test.ts | 22 +++ 2 files changed, 148 insertions(+) diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 6bb17a8a7181..12d2add45033 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -13,6 +13,7 @@ import { getSyncedCapabilities } from "@/lib/modelsDevSync"; import { getModelsByProviderId } from "@/shared/constants/models"; import { AI_PROVIDERS, + NOAUTH_PROVIDERS, isAnthropicCompatibleProvider, isClaudeCodeCompatibleProvider, isOpenAICompatibleProvider, @@ -514,6 +515,131 @@ export async function getComboBuilderOptions(): Promise(); + const builtInModels = getModelsByProviderId(providerId); + const syncedModels = normalizeSyncedModels( + (syncedModelsMap as Record)[providerId] + ); + const customModels = normalizeCustomModels( + (customModelsMap as Record)[providerId] + ); + const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels); + const acceptsArbitraryModel = + Boolean((AI_PROVIDERS[providerId] as JsonRecord | undefined)?.passthroughModels) || + isOpenAICompatibleProvider(providerId) || + isAnthropicCompatibleProvider(providerId) || + isClaudeCodeCompatibleProvider(providerId); + + for (const model of syncedModels) { + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: toStringOrNull(model.id), + }); + addModelOption(modelMap, providerId, { + id: toStringOrNull(model.id), + name: toStringOrNull(model.name), + source: "imported", + supportedEndpoints: toStringArray(model.supportedEndpoints), + contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow, + outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens, + supportsThinking: + typeof model.supportsThinking === "boolean" + ? model.supportsThinking + : (resolved.supportsThinking ?? undefined), + }); + } + + for (const model of builtInModels as RegistryModel[]) { + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: toStringOrNull(model.id), + }); + addModelOption(modelMap, providerId, { + id: toStringOrNull(model.id), + name: toStringOrNull(model.name), + source: "system", + contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow, + outputTokenLimit: resolved.maxOutputTokens, + supportsThinking: resolved.supportsThinking ?? undefined, + }); + } + + for (const model of customModels) { + if (model.isHidden === true) continue; + const source = ["api-sync", "auto-sync", "imported"].includes( + toStringOrNull(model.source)?.toLowerCase() || "" + ) + ? "imported" + : ("custom" as BuilderModelSource); + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: toStringOrNull(model.id), + }); + addModelOption(modelMap, providerId, { + id: toStringOrNull(model.id), + name: toStringOrNull(model.name), + source, + supportedEndpoints: toStringArray(model.supportedEndpoints), + apiFormat: toStringOrNull(model.apiFormat), + contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow, + outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens, + supportsThinking: + typeof model.supportsThinking === "boolean" + ? model.supportsThinking + : (resolved.supportsThinking ?? undefined), + }); + } + + if (Array.isArray(fallbackModels)) { + for (const model of fallbackModels) { + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: toStringOrNull(model.id), + }); + addModelOption(modelMap, providerId, { + id: toStringOrNull(model.id), + name: toStringOrNull(model.name), + source: "fallback", + contextLength: + typeof (model as { contextLength?: number }).contextLength === "number" + ? (model as { contextLength?: number }).contextLength || null + : resolved.contextWindow, + outputTokenLimit: resolved.maxOutputTokens, + supportsThinking: resolved.supportsThinking ?? undefined, + }); + } + } + + const displayName = (providerEntryName(providerId) || + getProviderDisplayName(providerId, null) || + providerId) as string; + + providers.push({ + providerId, + providerType: providerVisual.providerType, + displayName, + alias: providerVisual.alias, + prefix: null, + icon: providerVisual.icon, + color: providerVisual.color, + source: providerVisual.source, + acceptsArbitraryModel, + connectionCount: 0, + activeConnectionCount: 0, + modelCount: modelMap.size, + connections: [], + models: Array.from(modelMap.values()).sort(compareModels), + }); + } + const comboRefs = (combos as JsonRecord[]) .filter((combo) => combo.isHidden !== true && combo.isActive !== false) .map((combo) => ({ diff --git a/tests/unit/combo-builder-options-route.test.ts b/tests/unit/combo-builder-options-route.test.ts index fc104b717baa..c2e02b2d2baf 100644 --- a/tests/unit/combo-builder-options-route.test.ts +++ b/tests/unit/combo-builder-options-route.test.ts @@ -194,6 +194,28 @@ test("combo builder options route aggregates providers, connections, models and ); }); +test("combo builder options route includes no-auth provider (opencode) even without provider_connections rows", async () => { + // No connections seeded — opencode has noAuth: true and never gets a provider_connections row. + const response = await route.GET(); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.providers), "providers should be an array"); + + const opencode = body.providers.find((p: any) => p.providerId === "opencode"); + assert.ok(opencode, "opencode should appear in combo builder options even without connections"); + assert.equal(opencode.connectionCount, 0, "opencode should have 0 connections"); + assert.equal(opencode.activeConnectionCount, 0, "opencode should have 0 active connections"); + assert.ok(opencode.models.length > 0, "opencode should expose its built-in models"); + // Spot-check a known built-in model from providerRegistry.ts + assert.ok( + opencode.models.some((m: any) => m.id === "big-pickle"), + "big-pickle should be among opencode models" + ); + assert.equal(opencode.models[0].qualifiedModel.startsWith("opencode/"), true); + assert.equal(opencode.source, "system", "opencode should have source=system"); +}); + test("combo builder options route exposes compatible provider nodes with node metadata", async () => { await providersDb.createProviderNode({ id: "openai-compatible-demo", From c223b1a592e363555effc6b65213bb6b879ae383 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 26 May 2026 12:54:03 -0300 Subject: [PATCH 4/9] =?UTF-8?q?fix(mcp):=20break=20callLogs=E2=86=94compli?= =?UTF-8?q?ance=20ESM=20cycle=20that=20deadlocks=20MCP=20server=20on=20Nod?= =?UTF-8?q?e.js=2024=20(#2650)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled MCP server hung indefinitely on Node.js 24 because the esbuild output produced a circular __esm init: callLogs.ts imported isNoLog from ../compliance, and compliance/index.ts imported deleteCallLogsBefore/trimCallLogsToMaxRows from ../usage/callLogs. Node 20/22 silently short-circuited the cycle; Node 24's stricter ESM evaluation correctly waits for both inits to complete, deadlocking. Extract the no-log opt-out state (noLogKeys, noLogDbCache, isNoLog, setNoLog, etc.) to src/lib/compliance/noLog.ts — a leaf module that imports only from db/core and adapter types. Switch the callers that were closing the cycle (usage/callLogs.ts, db/detailedLogs.ts, db/apiKeys.ts) to import directly from compliance/noLog. compliance/ index.ts re-exports isNoLog/setNoLog so external callers keep their imports unchanged. Add tests/unit/compliance-noLog-cycle.test.ts with a 5s race that fails fast if the cycle regresses, plus an assertion that state written via compliance/index is visible via compliance/noLog. Reported in #2650. --- src/lib/compliance/index.ts | 88 ++--------------------- src/lib/compliance/noLog.ts | 86 ++++++++++++++++++++++ src/lib/db/apiKeys.ts | 2 +- src/lib/db/detailedLogs.ts | 2 +- src/lib/usage/callLogs.ts | 2 +- tests/unit/compliance-noLog-cycle.test.ts | 45 ++++++++++++ 6 files changed, 138 insertions(+), 87 deletions(-) create mode 100644 src/lib/compliance/noLog.ts create mode 100644 tests/unit/compliance-noLog-cycle.test.ts diff --git a/src/lib/compliance/index.ts b/src/lib/compliance/index.ts index b966813c8144..29b848500838 100644 --- a/src/lib/compliance/index.ts +++ b/src/lib/compliance/index.ts @@ -413,90 +413,10 @@ export function countAuditLog(filter: AuditLogFilter = {}) { } // ─── No-Log Opt-Out ──────────────── - -/** @type {Set} API key IDs with logging disabled */ -const noLogKeys = new Set(); -const noLogDbCache = new Map(); -let noLogColumnVerified = false; -let hasNoLogColumn = false; -const NO_LOG_CACHE_TTL_MS = 30_000; -const noLogIdsFromEnv = (process.env.NO_LOG_API_KEY_IDS || "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); -for (const id of noLogIdsFromEnv) { - noLogKeys.add(id); -} - -/** - * Set whether an API key opts out of request logging. - * - * @param {string} apiKeyId - * @param {boolean} noLog - */ -export function setNoLog(apiKeyId: string, noLog: boolean) { - if (noLog) { - noLogKeys.add(apiKeyId); - } else { - noLogKeys.delete(apiKeyId); - } - noLogDbCache.set(apiKeyId, { value: noLog, timestamp: Date.now() }); -} - -function ensureNoLogColumn(db: SqliteAdapter) { - if (noLogColumnVerified) { - return hasNoLogColumn; - } - - try { - const columns = db.prepare("PRAGMA table_info(api_keys)").all() as Array<{ name: string }>; - hasNoLogColumn = columns.some((column) => column.name === "no_log"); - } catch { - hasNoLogColumn = false; - } - - noLogColumnVerified = true; - return hasNoLogColumn; -} - -function readNoLogFromDb(apiKeyId: string): boolean { - const db = getDb(); - if (!db || !apiKeyId) return false; - if (!ensureNoLogColumn(db)) return false; - - const cached = noLogDbCache.get(apiKeyId); - if (cached && Date.now() - cached.timestamp < NO_LOG_CACHE_TTL_MS) { - return cached.value; - } - - try { - const row = db.prepare("SELECT no_log FROM api_keys WHERE id = ?").get(apiKeyId) as - | { no_log?: number } - | undefined; - const value = Boolean(row && Number(row.no_log) === 1); - noLogDbCache.set(apiKeyId, { value, timestamp: Date.now() }); - return value; - } catch { - return false; - } -} - -/** - * Check if an API key has opted out of logging. - * - * @param {string} apiKeyId - * @returns {boolean} - */ -export function isNoLog(apiKeyId: string) { - if (!apiKeyId) return false; - if (noLogKeys.has(apiKeyId)) return true; - - const persistedNoLog = readNoLogFromDb(apiKeyId); - if (persistedNoLog) { - noLogKeys.add(apiKeyId); - } - return persistedNoLog; -} +// Moved to ./noLog.ts to break the callLogs → compliance → callLogs ESM cycle +// that deadlocks the bundled MCP server under Node.js 24 (#2650). Re-exported +// here so downstream callers that import from "../compliance" keep working. +export { isNoLog, setNoLog } from "./noLog"; // ─── Log Retention / Cleanup ──────────────── diff --git a/src/lib/compliance/noLog.ts b/src/lib/compliance/noLog.ts new file mode 100644 index 000000000000..6b04cfd3195c --- /dev/null +++ b/src/lib/compliance/noLog.ts @@ -0,0 +1,86 @@ +import type { SqliteAdapter } from "@/lib/db/adapters/types"; +import { getDbInstance } from "../db/core"; + +// #2650: extracted from compliance/index.ts to break the +// callLogs.ts → compliance/index.ts → callLogs.ts cycle that deadlocks +// the bundled MCP server under Node.js 24's stricter ESM evaluation. + +function getDb(): SqliteAdapter | null { + try { + return getDbInstance(); + } catch { + return null; + } +} + +const noLogKeys = new Set(); +const noLogDbCache = new Map(); +let noLogColumnVerified = false; +let hasNoLogColumn = false; +const NO_LOG_CACHE_TTL_MS = 30_000; + +const noLogIdsFromEnv = (process.env.NO_LOG_API_KEY_IDS || "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +for (const id of noLogIdsFromEnv) { + noLogKeys.add(id); +} + +export function setNoLog(apiKeyId: string, noLog: boolean): void { + if (noLog) { + noLogKeys.add(apiKeyId); + } else { + noLogKeys.delete(apiKeyId); + } + noLogDbCache.set(apiKeyId, { value: noLog, timestamp: Date.now() }); +} + +function ensureNoLogColumn(db: SqliteAdapter): boolean { + if (noLogColumnVerified) { + return hasNoLogColumn; + } + + try { + const columns = db.prepare("PRAGMA table_info(api_keys)").all() as Array<{ name: string }>; + hasNoLogColumn = columns.some((column) => column.name === "no_log"); + } catch { + hasNoLogColumn = false; + } + + noLogColumnVerified = true; + return hasNoLogColumn; +} + +function readNoLogFromDb(apiKeyId: string): boolean { + const db = getDb(); + if (!db || !apiKeyId) return false; + if (!ensureNoLogColumn(db)) return false; + + const cached = noLogDbCache.get(apiKeyId); + if (cached && Date.now() - cached.timestamp < NO_LOG_CACHE_TTL_MS) { + return cached.value; + } + + try { + const row = db.prepare("SELECT no_log FROM api_keys WHERE id = ?").get(apiKeyId) as + | { no_log?: number } + | undefined; + const value = Boolean(row && Number(row.no_log) === 1); + noLogDbCache.set(apiKeyId, { value, timestamp: Date.now() }); + return value; + } catch { + return false; + } +} + +export function isNoLog(apiKeyId: string): boolean { + if (!apiKeyId) return false; + if (noLogKeys.has(apiKeyId)) return true; + + const persistedNoLog = readNoLogFromDb(apiKeyId); + if (persistedNoLog) { + noLogKeys.add(apiKeyId); + } + return persistedNoLog; +} diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 9f44fab27114..4fd42fcb3b38 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -8,7 +8,7 @@ import { getDbInstance, rowToCamel } from "./core"; import { backupDbFile } from "./backup"; import { registerDbStateResetter } from "./stateReset"; import { getKeyGroupsForApiKey, checkKeyModelAccess } from "./apiKeyGroups"; -import { setNoLog } from "../compliance"; +import { setNoLog } from "../compliance/noLog"; // ──────────────── Performance Optimizations ──────────────── diff --git a/src/lib/db/detailedLogs.ts b/src/lib/db/detailedLogs.ts index f811e751a082..7fdab54e1548 100644 --- a/src/lib/db/detailedLogs.ts +++ b/src/lib/db/detailedLogs.ts @@ -8,7 +8,7 @@ import { v4 as uuidv4 } from "uuid"; import { getDbInstance } from "./core"; import { getSettings } from "./settings"; -import { isNoLog } from "../compliance"; +import { isNoLog } from "../compliance/noLog"; import { protectPayloadForLog, serializePayloadForStorage, diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index ca3329110abc..b636ea7e44f0 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -18,7 +18,7 @@ import { getPromptCacheCreationTokensOrNull, getReasoningTokensOrNull, } from "./tokenAccounting"; -import { isNoLog } from "../compliance"; +import { isNoLog } from "../compliance/noLog"; import { sanitizePII } from "../piiSanitizer"; import { protectPayloadForLog, parseStoredPayload } from "../logPayloads"; import { getCallLogMaxEntries, getCallLogRetentionDays, getCallLogsTableMaxRows } from "../logEnv"; diff --git a/tests/unit/compliance-noLog-cycle.test.ts b/tests/unit/compliance-noLog-cycle.test.ts new file mode 100644 index 000000000000..14f9f9947179 --- /dev/null +++ b/tests/unit/compliance-noLog-cycle.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #2650: callLogs.ts ↔ compliance/index.ts had a circular import that +// deadlocked the bundled MCP server under Node.js 24. The cycle was broken +// by extracting noLog state to compliance/noLog.ts. These tests guard the +// new boundary so the cycle does not regress. + +test("compliance/noLog can be imported without pulling callLogs (#2650)", async () => { + const noLog = await import("../../src/lib/compliance/noLog.ts"); + assert.equal(typeof noLog.isNoLog, "function"); + assert.equal(typeof noLog.setNoLog, "function"); +}); + +test("compliance/index re-exports isNoLog and setNoLog (backwards compat)", async () => { + const compliance = await import("../../src/lib/compliance/index.ts"); + assert.equal(typeof compliance.isNoLog, "function"); + assert.equal(typeof compliance.setNoLog, "function"); +}); + +test("callLogs and compliance can both be loaded sequentially without deadlock (#2650)", async () => { + // If a circular __esm init returns, this would hang. Wrap in a timeout + // race so the test fails fast on regression instead of timing out the + // whole suite. + const loadBoth = (async () => { + await import("../../src/lib/usage/callLogs.ts"); + await import("../../src/lib/compliance/index.ts"); + return true; + })(); + + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("module load timed out — circular init regression")), 5000) + ); + + const ok = await Promise.race([loadBoth, timeout]); + assert.equal(ok, true); +}); + +test("noLog state set via compliance/index is visible via compliance/noLog", async () => { + const compliance = await import("../../src/lib/compliance/index.ts"); + const noLog = await import("../../src/lib/compliance/noLog.ts"); + compliance.setNoLog("__test-cycle-key__", true); + assert.equal(noLog.isNoLog("__test-cycle-key__"), true); + compliance.setNoLog("__test-cycle-key__", false); +}); From fe5ec8cc9f8c39d2414d589148bc76e6755f0c1c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 26 May 2026 13:02:07 -0300 Subject: [PATCH 5/9] fix(cli): raise CLI server-ready timeout to 60s with TCP fallback for Windows cold start (#2460) Windows users reported `omniroute serve` reporting a phantom timeout while the server was actually still booting. Next.js cold start on Windows can take well over 20s (filesystem watchers, Defender, spawn-on-write behavior), so the 20s wait in serve.mjs and the 15s default in waitForServer often fired before /api/monitoring/health was mounted. - Raise the default waitForServer timeout from 15s to 60s. - Update serve.mjs to wait up to 60s (was 20s). - Add an isPortListening() TCP probe: when the HTTP health endpoint fails but the port has been listening for >= 3s consecutively, treat the server as ready. Handles the window where the process has bound the port but the App Router middleware has not finished compiling. Add tests/unit/cli-waitForServer.test.mjs covering the timeout exit path and the TCP fallback path. Reported in #2460. --- bin/cli/commands/serve.mjs | 2 +- bin/cli/utils/pid.mjs | 38 ++++++++++++++++++-- tests/unit/cli-waitForServer.test.mjs | 50 +++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 tests/unit/cli-waitForServer.test.mjs diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 17b58e30c6b4..24fa344d4a89 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -269,7 +269,7 @@ async function runWithSupervisor( }); if (!showLog) { - waitForServer(dashboardPort, 20000).then(async (up) => { + waitForServer(dashboardPort, 60000).then(async (up) => { if (up) { if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor); onReady(dashboardPort, apiPort, noOpen); diff --git a/bin/cli/utils/pid.mjs b/bin/cli/utils/pid.mjs index 1f3ff526dfe6..6c94437e372b 100644 --- a/bin/cli/utils/pid.mjs +++ b/bin/cli/utils/pid.mjs @@ -61,16 +61,50 @@ export function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -export async function waitForServer(port, timeout = 15000) { +// #2460: Default raised from 15s to 60s so Windows users (slower Next.js +// cold start due to filesystem watchers, antivirus, etc.) get a working +// "server ready" signal instead of a phantom timeout while the server is +// still booting. TCP fallback marks the server as ready when the port +// has been listening for >= 3s consecutively but /api/monitoring/health +// has not yet been mounted — common during dev cold start. +export async function waitForServer(port, timeout = 60000) { const start = Date.now(); + let tcpListeningSince = null; while (Date.now() - start < timeout) { try { const res = await fetch(`http://localhost:${port}/api/monitoring/health`, { signal: AbortSignal.timeout(2000), }); if (res.ok) return true; - } catch {} + // Server responded but health endpoint is not ready yet — keep + // polling, but the fact that we got a response means TCP is open. + if (tcpListeningSince === null) tcpListeningSince = Date.now(); + } catch { + const listening = await isPortListening(port).catch(() => false); + if (listening) { + if (tcpListeningSince === null) tcpListeningSince = Date.now(); + if (Date.now() - tcpListeningSince >= 3000) return true; + } else { + tcpListeningSince = null; + } + } await sleep(500); } return false; } + +async function isPortListening(port) { + const net = await import("node:net"); + return new Promise((resolve) => { + const socket = net.connect({ host: "127.0.0.1", port, timeout: 1000 }); + const finish = (ok) => { + try { + socket.destroy(); + } catch {} + resolve(ok); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.once("timeout", () => finish(false)); + }); +} diff --git a/tests/unit/cli-waitForServer.test.mjs b/tests/unit/cli-waitForServer.test.mjs new file mode 100644 index 000000000000..716127057d9f --- /dev/null +++ b/tests/unit/cli-waitForServer.test.mjs @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; + +import { waitForServer } from "../../bin/cli/utils/pid.mjs"; + +// #2460: waitForServer must (a) respect a 60s default timeout, (b) return +// true when the port is listening for >= 3s even if /api/monitoring/health +// is not yet mounted (common on Windows during slow Next.js cold start), +// and (c) return false cleanly when nothing is listening. + +async function freePort() { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.listen(0, () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +test("waitForServer returns false on a closed port within the given timeout (#2460)", async () => { + const port = await freePort(); + const start = Date.now(); + const result = await waitForServer(port, 1200); + const elapsed = Date.now() - start; + assert.equal(result, false); + assert.ok(elapsed >= 1200 && elapsed < 4000, `elapsed ${elapsed}ms outside expected range`); +}); + +test("waitForServer returns true via TCP fallback when port listens but health endpoint is absent (#2460)", async () => { + const port = await freePort(); + const server = net.createServer((socket) => { + // Accept the connection but never respond — simulates a Node process + // that has bound the port but not yet mounted HTTP routes. + socket.on("data", () => {}); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); + + try { + const result = await waitForServer(port, 8000); + assert.equal(result, true, "expected TCP fallback to mark the server ready"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); From f2056183fb967e108107854923efb09138bbcf7e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 26 May 2026 13:05:51 -0300 Subject: [PATCH 6/9] fix(translator): allow web_search server tool family in Responses API (#2695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code and Codex CLI send `web_search_20250305` (and future versioned variants like `web_search_20250101`) in the tools array when hitting /v1/responses. The translator's tool-type validation rejected anything that wasn't `function`/`custom`/`command`/`namespace`, so the request failed with `unsupported_feature` before reaching the upstream. - Accept any tool type matching `^web_search` (forward-compatible for future versioned names). - Preserve the original versioned name in the translated output instead of mapping to plain `web_search` — Anthropic upstreams validate the exact versioned string, and OpenAI-compatible upstreams accept unknown fields gracefully. Mapping would have broken Anthropic. Update the existing "rejects unsupported built-in tools" test to use `file_search` (which is still rejected), and add 5 new tests covering `web_search_20250305` pass-through, plain `web_search`, no regression for `function` tools, and no regression for unknown tool types. Reported in #2695. --- .../translator/request/openai-responses.ts | 14 ++- .../dashboard/providers/[id]/page.tsx | 7 +- .../components/KiroSocialOAuthModal.tsx | 3 +- src/sse/services/model.ts | 33 ++++- .../model-alias-provider-resolution.test.ts | 118 ++++++++++++++++++ tests/unit/shared-clipboard.test.tsx | 63 ++++++++++ .../translator-openai-responses-req.test.ts | 113 ++++++++++++++++- 7 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 tests/unit/model-alias-provider-resolution.test.ts create mode 100644 tests/unit/shared-clipboard.test.tsx diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 17be2152f984..eadb2e8811ca 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -13,6 +13,9 @@ type JsonRecord = Record; const RESPONSES_STORE_MARKER = "_omnirouteResponsesStore"; const COPILOT_REASONING_SUMMARY_MARKER = "_omnirouteCopilotReasoningSummary"; +// Forward-compatible regex: matches web_search, web_search_20250305, and any future versioned names. +const WEB_SEARCH_TOOL_TYPES = /^web_search/; + function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -67,13 +70,15 @@ export function openaiResponsesToOpenAIRequest( const tool = toRecord(toolValue); const toolType = toString(tool.type); // Allow: function tools, tools already in Chat format (have .function property), CLI subagent tools, - // and namespace tools (MCP tool groups used by Codex/OpenAI Responses API). + // namespace tools (MCP tool groups used by Codex/OpenAI Responses API), and web_search server tools + // (Anthropic versioned: web_search_20250305, web_search_20250101, etc. — or plain web_search). if ( toolType && toolType !== "function" && toolType !== "custom" && toolType !== "command" && toolType !== "namespace" && + !WEB_SEARCH_TOOL_TYPES.test(toolType) && !tool.function ) { throw unsupportedFeature( @@ -255,6 +260,13 @@ export function openaiResponsesToOpenAIRequest( result.tools = root.tools.map((toolValue) => { const tool = toRecord(toolValue); if (tool.function) return toolValue; + const toolType = toString(tool.type); + // Pass web_search server tools through with their original type (versioned or plain). + // These have no Chat Completions equivalent; preserve as-is so upstreams that understand + // Anthropic-style web_search_YYYYMMDD naming receive the exact name they expect. + if (WEB_SEARCH_TOOL_TYPES.test(toolType)) { + return toolValue; + } return { type: "function", function: { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 0d02606f2471..57986ef9b9f1 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -61,6 +61,7 @@ import { normalizeModelCatalogSource, } from "@/shared/utils/modelCatalogSearch"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { copyToClipboard } from "@/shared/utils/clipboard"; import { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey, @@ -7343,11 +7344,11 @@ function AddApiKeyModal({ const copyCommandCodeValue = async (value: string | undefined, key: string) => { if (!value) return; - try { - await navigator.clipboard.writeText(value); + const ok = await copyToClipboard(value); + if (ok) { setCopiedCommandCodeField(key); window.setTimeout(() => setCopiedCommandCodeField(null), 1500); - } catch { + } else { setSaveError("Copy failed. Select the text and copy it manually."); } }; diff --git a/src/shared/components/KiroSocialOAuthModal.tsx b/src/shared/components/KiroSocialOAuthModal.tsx index 3af87e72e603..ae31b6d1f738 100644 --- a/src/shared/components/KiroSocialOAuthModal.tsx +++ b/src/shared/components/KiroSocialOAuthModal.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import Modal from "./Modal"; import Button from "./Button"; +import { copyToClipboard } from "@/shared/utils/clipboard"; type KiroSocialOAuthModalProps = { isOpen: boolean; @@ -133,7 +134,7 @@ export default function KiroSocialOAuthModal({ {authUrl.length > 80 ? authUrl.slice(0, 80) + "..." : authUrl}