diff --git a/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md b/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md
new file mode 100644
index 0000000000..af2a0d6b4c
--- /dev/null
+++ b/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md
@@ -0,0 +1 @@
+- **fix(providers):** zed-hosted OAuth now redirects the browser back to the dashboard's own loopback port (auto-completing the login), and the manual paste path accepts Zed's user_id/access_token callback URL instead of erroring with "No authorization code found" ([#10517](https://github.com/diegosouzapw/OmniRoute/pull/10517)) - thanks @phatchau036
\ No newline at end of file
diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json
index d688cfe70d..05323c8002 100644
--- a/config/quality/file-size-baseline.json
+++ b/config/quality/file-size-baseline.json
@@ -1,4 +1,5 @@
{
+ "_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).",
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
"_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)",
"_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.",
@@ -582,7 +583,7 @@
"src/lib/memory/retrieval.ts": "1073",
"src/lib/tailscaleTunnel.ts": "1202",
"src/lib/usage/providerLimits.ts": "1013",
- "src/shared/components/OAuthModal.tsx": "1134",
+ "src/shared/components/OAuthModal.tsx": "1146",
"src/shared/components/RequestLoggerV2.tsx": "1629",
"src/shared/components/analytics/charts.tsx": "1035",
"src/shared/services/cliRuntime.ts": "1122",
diff --git a/src/app/callback/page.tsx b/src/app/callback/page.tsx
index 3f5dd678a1..3be2d56b29 100644
--- a/src/app/callback/page.tsx
+++ b/src/app/callback/page.tsx
@@ -23,11 +23,21 @@ export default function CallbackPage() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
- const code = params.get("code");
+ let code = params.get("code");
const state = params.get("state");
const error = params.get("error");
const errorDescription = params.get("error_description");
+ // Zed native-app sign-in: the redirect carries user_id + access_token and no
+ // ?code= — the FULL URL is the exchange payload (zed-hosted's exchangeToken
+ // parses and RSA-decrypts it server-side). Rewritten here from `/` by the
+ // root page handler so the waiting OAuth modal receives it via the same
+ // postMessage/BroadcastChannel/localStorage relay as every other provider.
+ const zedAccessToken = params.get("access_token") || params.get("accessToken");
+ if (!code && zedAccessToken && (params.get("user_id") || params.get("userId"))) {
+ code = window.location.href;
+ }
+
const callbackData = {
code,
state,
@@ -63,6 +73,13 @@ export default function CallbackPage() {
// same-origin fallback when the opener was severed by COOP.
const trustedTargetOrigins = [
window.location.origin, // Same origin (dashboard popup mode).
+ // Loopback hostname variants of the same port: the dashboard may be open
+ // on 127.0.0.1:PORT while Zed's redirect (or vice versa) lands on
+ // localhost:PORT — both names are the operator's own machine, so the
+ // callback may be delivered to either. Same rationale as the 1455 entries.
+ ...(window.location.port
+ ? [`http://localhost:${window.location.port}`, `http://127.0.0.1:${window.location.port}`]
+ : []),
"http://localhost:1455", // Codex helper (fixed loopback port).
"http://127.0.0.1:1455", // Same Codex helper, IPv4 literal form.
];
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d594875903..cbf119edb8 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,5 +1,31 @@
import { redirect } from "next/navigation";
-export default function InitPage() {
+/**
+ * Root entry. Zed's native-app sign-in always redirects the browser to the
+ * loopback ROOT (`http://127.0.0.1:/?user_id=...&access_token=...`),
+ * ignoring any path — when the dashboard port is reused as native_app_port
+ * (see zed-hosted.ts), that redirect lands HERE. Forward the payload to the
+ * /callback relay (which postMessages it to the waiting OAuth modal) instead of
+ * the plain /dashboard redirect below, which would drop the query string.
+ */
+export default async function InitPage({
+ searchParams,
+}: {
+ searchParams?: Promise>;
+}) {
+ const params = (await searchParams) || {};
+ const query = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (typeof value === "string") {
+ query.set(key, value);
+ } else if (Array.isArray(value)) {
+ for (const item of value) {
+ if (typeof item === "string") query.append(key, item);
+ }
+ }
+ }
+ if (query.get("user_id") && query.get("access_token")) {
+ redirect(`/callback?${query.toString()}`);
+ }
redirect("/dashboard");
}
diff --git a/src/lib/oauth/providers/zed-hosted.ts b/src/lib/oauth/providers/zed-hosted.ts
index db263a509f..46a4c1b320 100644
--- a/src/lib/oauth/providers/zed-hosted.ts
+++ b/src/lib/oauth/providers/zed-hosted.ts
@@ -1,4 +1,5 @@
import { ZED_HOSTED_CONFIG } from "../constants/oauth";
+import { getRuntimePorts } from "../../runtime/ports";
import {
createZedNativeAuthData,
parseZedCallbackPayload,
@@ -21,15 +22,55 @@ import {
*
* `code` at exchange time is the pasted native-app callback URL/query string
* (`http://127.0.0.1:/?user_id=...&access_token=...`) — Zed always
- * redirects to loopback + native_app_port, ignoring any `redirect_uri` we'd
- * send, so `redirectUri` here is unused by exchangeToken (kept only to
- * satisfy OAuthModal's generic "session must have a redirectUri" guard).
+ * redirects to loopback + native_app_port, ignoring any path we'd send. When
+ * the dashboard itself listens on a loopback port, `buildAuthUrl` reuses it as
+ * native_app_port so the redirect lands back on OmniRoute (auto-completed via
+ * the /callback relay); otherwise the dead default port is used and the user
+ * completes the flow by pasting the browser's full URL.
*/
+/**
+ * Extract the dashboard's loopback port so Zed's browser redirect can land back
+ * on OmniRoute itself. Zed always redirects to `http://127.0.0.1:/`
+ * — it ignores any path/redirect_uri — so reusing the dashboard's own loopback
+ * port (e.g. 20128) turns the dead "site can't be reached" page into a loadable
+ * `/callback` relay (the root page forwards ?user_id=...&access_token=... there).
+ *
+ * The redirect URI only tells us WHICH HOSTNAME the browser used (loopback vs.
+ * LAN/remote) — its scheme and port reflect what the *browser* sees, which can
+ * differ from what the OmniRoute Node process actually listens on (e.g. a local
+ * TLS-terminating reverse proxy fronting the dashboard on 443 while the real
+ * process listens on 20128 in plain HTTP). Trusting the browser-supplied port
+ * previously produced `http://127.0.0.1:443/` redirects that nothing serves in
+ * plain HTTP. This runs server-side, so once the hostname is confirmed loopback
+ * (any scheme — Zed's own redirect is always plain http regardless of how the
+ * dashboard was reached), use the server's own authoritative listening port
+ * (`getRuntimePorts()`, sourced from OMNIROUTE_PORT/PORT/DASHBOARD_PORT) instead
+ * of re-deriving it from the client-observed scheme/port. Non-loopback redirect
+ * URIs (remote/LAN deployments) return null → keep the default port and rely on
+ * the manual paste flow.
+ */
+function resolveDashboardLoopbackPort(redirectUri: unknown): number | null {
+ try {
+ const url = new URL(String(redirectUri));
+ if (!/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(url.hostname)) return null;
+ const { dashboardPort } = getRuntimePorts();
+ return Number.isInteger(dashboardPort) && dashboardPort > 0 ? dashboardPort : null;
+ } catch {
+ return null;
+ }
+}
+
+// Exported for direct unit coverage of the port-derivation logic without
+// exercising the live Zed OAuth handshake (see resolveDashboardLoopbackPort.test.ts).
+export const __test__ = { resolveDashboardLoopbackPort };
+
export const zedHosted = {
config: ZED_HOSTED_CONFIG,
flowType: "authorization_code",
- buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG) => {
- const authData = createZedNativeAuthData(config);
+ buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG, redirectUri?: string) => {
+ const nativeAppPort =
+ resolveDashboardLoopbackPort(redirectUri) || config.defaultNativeAppPort || 58443;
+ const authData = createZedNativeAuthData(config, { nativeAppPort });
return {
authUrl: authData.authUrl,
codeVerifier: authData.privateKeyVerifier,
diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx
index aab8007e2d..5cc190c5f8 100644
--- a/src/shared/components/OAuthModal.tsx
+++ b/src/shared/components/OAuthModal.tsx
@@ -456,11 +456,17 @@ export default function OAuthModal({
// Claude Code and Cline OAuth flows can finish on provider-hosted pages that
// show an auth code instead of redirecting back to OmniRoute.
// Start directly in manual mode so users always have an input to paste code/url.
- // zed-hosted's native-app sign-in always redirects the browser to a local
- // 127.0.0.1: callback that OmniRoute never listens on (the port is
- // arbitrary and unrelated to the dashboard's own port) — nothing can
- // auto-close the popup, so always show the manual paste-URL input.
- if (provider === "claude" || provider === "cline" || provider === "zed-hosted") {
+ // zed-hosted's native-app sign-in redirects the browser to a local
+ // 127.0.0.1: callback. On true localhost that port IS the
+ // dashboard's own (buildAuthUrl reuses it), so the redirect lands on the
+ // /callback relay and the popup flow auto-completes. Elsewhere (LAN/remote)
+ // the port is unreachable — nothing can auto-close the popup, so always
+ // show the manual paste-URL input.
+ if (
+ provider === "claude" ||
+ provider === "cline" ||
+ (provider === "zed-hosted" && !isTrueLocalhost)
+ ) {
forceManual = true;
}
@@ -880,6 +886,17 @@ export default function OAuthModal({
}
const input = callbackUrl.trim();
+
+ // zed-hosted: the native-app callback (http://127.0.0.1:/?user_id=...&access_token=...)
+ // carries no ?code= param — the FULL pasted URL (or JSON/query blob) is the
+ // payload. zed-hosted's exchangeToken parses user_id/access_token out of it
+ // and RSA-decrypts the token with the private key held in codeVerifier, so
+ // skip the generic code/state extraction below.
+ if (provider === "zed-hosted") {
+ await exchangeTokens(input, authData?.state || null);
+ return;
+ }
+
let code = null;
let state = authData?.state || null;
let errorParam = null;
diff --git a/src/shared/components/OAuthModalPanels.tsx b/src/shared/components/OAuthModalPanels.tsx
index ea203b5607..a691a20a39 100644
--- a/src/shared/components/OAuthModalPanels.tsx
+++ b/src/shared/components/OAuthModalPanels.tsx
@@ -364,13 +364,23 @@ export function OAuthManualInputPanel({
code: (chunks) => {chunks},
})}
+ {provider === "zed-hosted" && (
+
+ After signing in, Zed redirects to a local address like{" "}
+ http://127.0.0.1:<port>/?user_id=... which the
+ browser may show as unreachable — that is expected. Copy the FULL URL from the
+ browser address bar (the access token is inside it) and paste it above.
+
+ )}
onCallbackUrlChange(event.target.value)}
placeholder={
provider === "claude" || provider === "cline"
? "code#state or /callback?code=..."
- : placeholderUrl
+ : provider === "zed-hosted"
+ ? "http://127.0.0.1:/?user_id=...&access_token=..."
+ : placeholderUrl
}
className="font-mono text-xs"
/>
diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts
index d08dd840fa..3d0f26266e 100644
--- a/tests/unit/modelsDevSync-extended.test.ts
+++ b/tests/unit/modelsDevSync-extended.test.ts
@@ -261,6 +261,45 @@ test.describe("modelsDevSync-extended", { concurrency: 1 }, async () => {
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
});
+ test("getModelsDevPricing memoizes until save/clear (#9685)", async () => {
+ const modelsDev = await importFresh("pricing-memo");
+ const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA);
+ modelsDev.saveModelsDevPricing(pricing);
+
+ const first = modelsDev.getModelsDevPricing();
+ const second = modelsDev.getModelsDevPricing();
+ assert.equal(first, second, "repeated reads must return the same memoized object");
+
+ // Mutating DB under the cache must not be visible until invalidation.
+ const db = core.getDbInstance();
+ db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run();
+ assert.equal(
+ modelsDev.getModelsDevPricing(),
+ first,
+ "raw SQL without save/clear must not bypass the memo"
+ );
+
+ modelsDev.clearModelsDevPricing();
+ assert.deepEqual(modelsDev.getModelsDevPricing(), {});
+
+ modelsDev.saveModelsDevPricing(pricing);
+ const afterSave = modelsDev.getModelsDevPricing();
+ assert.notEqual(afterSave, first, "save must invalidate the memo");
+ assert.equal(afterSave.openai["gpt-4o"].input, 2.5);
+
+ // Copilot review: DB reset must invalidate the memo so import/restore doesn't serve stale pricing.
+ const beforeReset = modelsDev.getModelsDevPricing();
+ core.resetDbInstance();
+ const afterReset = modelsDev.getModelsDevPricing();
+ assert.notEqual(
+ afterReset,
+ beforeReset,
+ "resetDbInstance must invalidate the memo (Copilot #10055)"
+ );
+ // Data is still on disk after resetDbInstance(), but the cache was cleared and re-read from fresh DB.
+ assert.equal(afterReset.openai["gpt-4o"].input, 2.5, "DB reset re-reads from fresh connection");
+ });
+
test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => {
const modelsDev = await importFresh("capabilities-storage");
const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA);
diff --git a/tests/unit/ui/oauth-callback-postmessage-scope.test.tsx b/tests/unit/ui/oauth-callback-postmessage-scope.test.tsx
index 0fcab8e857..19cb6fd8f3 100644
--- a/tests/unit/ui/oauth-callback-postmessage-scope.test.tsx
+++ b/tests/unit/ui/oauth-callback-postmessage-scope.test.tsx
@@ -15,8 +15,11 @@ import CallbackPage from "@/app/callback/page";
* Regression guard for ported upstream PR decolua/9router#998 (security):
* the OAuth callback page must never relay {code, state} to a wildcard
* postMessage target ("*"), as a hostile opener can read the code/state and
- * complete the OAuth flow as the user. Only the same-origin parent and
- * Codex's fixed loopback helper (127.0.0.1:1455) are trusted targets.
+ * complete the OAuth flow as the user. Trusted targets are the same-origin
+ * parent, the loopback hostname variants of the same port (localhost vs
+ * 127.0.0.1 — Zed native-app redirects may land on the other spelling than the
+ * dashboard the modal was opened from; same port means the same OmniRoute
+ * server), and Codex's fixed loopback helper (127.0.0.1:1455).
*/
describe("OAuth callback page — postMessage target origin scope (#998)", () => {
let container: HTMLDivElement;
@@ -81,7 +84,15 @@ describe("OAuth callback page — postMessage target origin scope (#998)", () =>
await Promise.resolve();
});
- const trusted = new Set([window.location.origin, "http://localhost:1455", "http://127.0.0.1:1455"]);
+ const loopbackSamePort = window.location.port
+ ? [`http://localhost:${window.location.port}`, `http://127.0.0.1:${window.location.port}`]
+ : [];
+ const trusted = new Set([
+ window.location.origin,
+ ...loopbackSamePort,
+ "http://localhost:1455",
+ "http://127.0.0.1:1455",
+ ]);
const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
expect(targetOrigins.length).toBeGreaterThan(0);
for (const origin of targetOrigins) {
diff --git a/tests/unit/zed-hosted-loopback-port-derivation.test.ts b/tests/unit/zed-hosted-loopback-port-derivation.test.ts
new file mode 100644
index 0000000000..b9cc1b8c94
--- /dev/null
+++ b/tests/unit/zed-hosted-loopback-port-derivation.test.ts
@@ -0,0 +1,128 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+/**
+ * Regression coverage for #10517.
+ *
+ * Zed's native-app sign-in always redirects the browser to
+ * `http://127.0.0.1:/`, ignoring any path/redirect_uri we send.
+ * `zed-hosted.ts::buildAuthUrl` reuses the dashboard's own loopback port as
+ * native_app_port so that redirect lands back on OmniRoute instead of a dead
+ * "site can't be reached" page.
+ *
+ * Before this fix, the port was re-derived from the browser-supplied
+ * `redirectUri` string (`OAuthModal.tsx`'s `window.location.port ||
+ * (protocol === "https:" ? "443" : "80")` fallback), which produced
+ * `http://127.0.0.1:443/` when the dashboard was reached over HTTPS on its
+ * default port (e.g. behind a local TLS-terminating reverse proxy) — a scheme
+ * mismatch, since nothing serves plain HTTP on 443 and Zed's redirect is
+ * always plain http regardless of how the browser reached the dashboard.
+ *
+ * The fix runs server-side (this code executes in the Next.js API route, not
+ * the browser) and derives the port from the OmniRoute process's own
+ * authoritative listening port (`getRuntimePorts()`, sourced from
+ * OMNIROUTE_PORT/PORT/DASHBOARD_PORT) once the redirect URI's hostname is
+ * confirmed loopback — no longer trusting the browser-observed scheme/port.
+ */
+
+const originalEnv = {
+ OMNIROUTE_PORT: process.env.OMNIROUTE_PORT,
+ PORT: process.env.PORT,
+ DASHBOARD_PORT: process.env.DASHBOARD_PORT,
+};
+
+function resetPortEnv() {
+ delete process.env.OMNIROUTE_PORT;
+ delete process.env.PORT;
+ delete process.env.DASHBOARD_PORT;
+}
+
+test.after(() => {
+ resetPortEnv();
+ for (const [key, value] of Object.entries(originalEnv)) {
+ if (value !== undefined) process.env[key] = value;
+ }
+});
+
+const { __test__ } = await import("../../src/lib/oauth/providers/zed-hosted.ts");
+const { resolveDashboardLoopbackPort } = __test__;
+
+test("resolveDashboardLoopbackPort: loopback hostname over HTTPS on the default port resolves via server config, not a guessed 443", () => {
+ resetPortEnv();
+ process.env.OMNIROUTE_PORT = "20128";
+
+ // This is the exact shape OAuthModal.tsx's buggy fallback used to produce
+ // for the true-localhost + default-port case (scheme hardcoded to "http"
+ // regardless of the real protocol, port guessed from the protocol default).
+ // Even with a scheme/port combination that does not reflect reality, the
+ // hostname alone is enough — the real port comes from server config.
+ const port = resolveDashboardLoopbackPort("http://localhost:443/callback");
+ assert.equal(port, 20128, "must use the server's own configured port, never the guessed 443");
+});
+
+test("resolveDashboardLoopbackPort: respects OMNIROUTE_PORT override", () => {
+ resetPortEnv();
+ process.env.OMNIROUTE_PORT = "31415";
+
+ assert.equal(resolveDashboardLoopbackPort("http://127.0.0.1:20128/callback"), 31415);
+ assert.equal(resolveDashboardLoopbackPort("http://localhost/callback"), 31415);
+});
+
+test("resolveDashboardLoopbackPort: falls back to PORT then DASHBOARD_PORT precedence like getRuntimePorts", () => {
+ resetPortEnv();
+ process.env.PORT = "9000";
+ assert.equal(resolveDashboardLoopbackPort("http://localhost:20128/callback"), 9000);
+
+ resetPortEnv();
+ process.env.DASHBOARD_PORT = "9500";
+ assert.equal(resolveDashboardLoopbackPort("http://127.0.0.1:20128/callback"), 9500);
+});
+
+test("resolveDashboardLoopbackPort: IPv6 loopback literal resolves to the server port", () => {
+ resetPortEnv();
+ process.env.OMNIROUTE_PORT = "20128";
+ assert.equal(resolveDashboardLoopbackPort("http://[::1]:20128/callback"), 20128);
+});
+
+test("resolveDashboardLoopbackPort: non-loopback (remote/LAN) redirect URIs return null", () => {
+ resetPortEnv();
+ process.env.OMNIROUTE_PORT = "20128";
+
+ assert.equal(resolveDashboardLoopbackPort("https://omniroute.example.com/callback"), null);
+ assert.equal(resolveDashboardLoopbackPort("http://192.168.1.50:20128/callback"), null);
+});
+
+test("resolveDashboardLoopbackPort: malformed/missing redirect URIs return null", () => {
+ resetPortEnv();
+ assert.equal(resolveDashboardLoopbackPort(undefined), null);
+ assert.equal(resolveDashboardLoopbackPort("not a url"), null);
+});
+
+test("zedHosted.buildAuthUrl: reuses the server's configured port as native_app_port for a loopback redirect, regardless of the browser-observed scheme", async () => {
+ resetPortEnv();
+ process.env.OMNIROUTE_PORT = "20128";
+
+ const { zedHosted } = await import("../../src/lib/oauth/providers/zed-hosted.ts");
+ const { ZED_HOSTED_CONFIG } = await import("../../src/lib/oauth/constants/oauth.ts");
+
+ // Simulate the redirect URI OAuthModal.tsx sends when the dashboard is
+ // reached over HTTPS on its implicit default port (window.location.port is
+ // empty): hostname is loopback, but scheme/port do not reflect the real
+ // OmniRoute listener.
+ const built = zedHosted.buildAuthUrl(ZED_HOSTED_CONFIG, "http://localhost:443/callback");
+ assert.equal(built.redirectUri, "http://127.0.0.1:20128/");
+
+ const url = new URL(built.authUrl);
+ assert.equal(url.searchParams.get("native_app_port"), "20128");
+});
+
+test("zedHosted.buildAuthUrl: remote/LAN redirect URIs keep the configured default native app port", async () => {
+ resetPortEnv();
+ process.env.OMNIROUTE_PORT = "20128";
+
+ const { zedHosted } = await import("../../src/lib/oauth/providers/zed-hosted.ts");
+ const { ZED_HOSTED_CONFIG } = await import("../../src/lib/oauth/constants/oauth.ts");
+
+ const built = zedHosted.buildAuthUrl(ZED_HOSTED_CONFIG, "https://omniroute.example.com/callback");
+ assert.equal(built.redirectUri, `http://127.0.0.1:${ZED_HOSTED_CONFIG.defaultNativeAppPort}/`);
+});