From e1d0d68c53db0232993a504d1983e191eb6c472f Mon Sep 17 00:00:00 2001 From: ZIce <39822906+vicksiyi@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:11:21 +0800 Subject: [PATCH] Fix public host root redirect (#5363) Co-authored-by: multica-agent --- .../components/redirect-if-authenticated.tsx | 18 ++++++---- apps/web/lib/public-host.test.ts | 19 ++++++++++ apps/web/lib/public-host.ts | 6 ++++ apps/web/proxy.test.ts | 35 ++++++++++++++++--- apps/web/proxy.ts | 12 ++++++- 5 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 apps/web/lib/public-host.test.ts create mode 100644 apps/web/lib/public-host.ts diff --git a/apps/web/features/landing/components/redirect-if-authenticated.tsx b/apps/web/features/landing/components/redirect-if-authenticated.tsx index 2337e8ca46..3438b9a25e 100644 --- a/apps/web/features/landing/components/redirect-if-authenticated.tsx +++ b/apps/web/features/landing/components/redirect-if-authenticated.tsx @@ -6,17 +6,20 @@ import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@multica/core/auth"; import { workspaceListOptions } from "@multica/core/workspace"; import { resolvePostAuthDestination, useHasOnboarded } from "@multica/core/paths"; +import { isOfficialMarketingHost } from "@/lib/public-host"; /** * Client-side fallback redirect for authenticated visitors on the landing page. * - * The primary path for logged-in users hitting `/` is a server-side redirect - * in the Next.js proxy/middleware, driven by the `last_workspace_slug` cookie. - * That cookie is set by the workspace layout on every visit. But on *first - * login* — before the user has ever visited a workspace — the cookie is - * absent, so the proxy falls through to the landing page. This component - * covers that gap: once auth is resolved and the workspace list has loaded, - * push the user into their workspace (or /onboarding if they have none). + * The primary path for logged-in users hitting an app host's `/` is a + * server-side redirect in the Next.js proxy/middleware, driven by the + * `last_workspace_slug` cookie. That cookie is set by the workspace layout on + * every visit. But on *first login* — before the user has ever visited a + * workspace — the cookie is absent, so the proxy falls through to the landing + * page. This component covers that gap on app/self-host origins. + * + * On the official marketing origins, `/` must remain public even for logged-in + * users. Explicit workspace routes still open the app. * * Renders nothing. Uses `router.replace` so the landing page never enters * browser history for authenticated users. @@ -34,6 +37,7 @@ export function RedirectIfAuthenticated() { useEffect(() => { if (isLoading || !user || !isFetched) return; + if (isOfficialMarketingHost(window.location.hostname)) return; router.replace(resolvePostAuthDestination(list, hasOnboarded)); }, [isLoading, user, isFetched, list, hasOnboarded, router]); diff --git a/apps/web/lib/public-host.test.ts b/apps/web/lib/public-host.test.ts new file mode 100644 index 0000000000..2a2f42e830 --- /dev/null +++ b/apps/web/lib/public-host.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { isOfficialMarketingHost } from "./public-host"; + +describe("isOfficialMarketingHost", () => { + it.each(["multica.ai", "www.multica.ai", "MULTICA.AI", "multica.ai."])( + "recognizes %s as an official marketing host", + (host) => { + expect(isOfficialMarketingHost(host)).toBe(true); + }, + ); + + it.each(["app.multica.ai", "api.multica.ai", "localhost", "multica.test"])( + "does not treat %s as the public marketing host", + (host) => { + expect(isOfficialMarketingHost(host)).toBe(false); + }, + ); +}); diff --git a/apps/web/lib/public-host.ts b/apps/web/lib/public-host.ts new file mode 100644 index 0000000000..888d0db98d --- /dev/null +++ b/apps/web/lib/public-host.ts @@ -0,0 +1,6 @@ +const OFFICIAL_MARKETING_HOSTS = new Set(["multica.ai", "www.multica.ai"]); + +export function isOfficialMarketingHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + return OFFICIAL_MARKETING_HOSTS.has(normalized); +} diff --git a/apps/web/proxy.test.ts b/apps/web/proxy.test.ts index ad39eec67b..f335d6782b 100644 --- a/apps/web/proxy.test.ts +++ b/apps/web/proxy.test.ts @@ -2,18 +2,26 @@ import { describe, expect, it } from "vitest"; import { NextRequest } from "next/server"; import { proxy } from "./proxy"; -function makeRequest(path: string, cookies: Record = {}) { +function makeRequest( + path: string, + cookies: Record = {}, + host = "app.multica.test", +) { const cookieHeader = Object.entries(cookies) .map(([key, value]) => `${key}=${value}`) .join("; "); - return new NextRequest(`https://app.multica.test${path}`, { + return new NextRequest(`https://${host}${path}`, { headers: cookieHeader ? { cookie: cookieHeader } : undefined, }); } -function redirectLocation(path: string, cookies: Record = {}) { - return proxy(makeRequest(path, cookies)).headers.get("location"); +function redirectLocation( + path: string, + cookies: Record = {}, + host?: string, +) { + return proxy(makeRequest(path, cookies, host)).headers.get("location"); } describe("proxy legacy workspace route redirects", () => { @@ -61,4 +69,23 @@ describe("proxy legacy workspace route redirects", () => { it("does not redirect workspace-scoped URLs whose first segment is already a slug", () => { expect(redirectLocation("/acme/squads", sessionCookies)).toBeNull(); }); + + it("redirects app-host root URLs to the last workspace", () => { + expect(redirectLocation("/", sessionCookies)).toBe( + "https://app.multica.test/acme/issues", + ); + }); + + it.each(["multica.ai", "www.multica.ai"])( + "does not redirect public marketing root on %s", + (host) => { + expect(redirectLocation("/", sessionCookies, host)).toBeNull(); + }, + ); + + it("still redirects explicit legacy app routes on the public marketing host", () => { + expect(redirectLocation("/issues/ABC-123", sessionCookies, "multica.ai")).toBe( + "https://multica.ai/acme/issues/ABC-123", + ); + }); }); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 7764614931..22eb52eec4 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -4,6 +4,7 @@ import { MULTICA_LOCALE_HEADER, resolveLocaleFromSignals, } from "./lib/locale-routing"; +import { isOfficialMarketingHost } from "./lib/public-host"; // Old workspace-scoped route segments that existed before the URL refactor // (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that @@ -75,7 +76,16 @@ export function proxy(req: NextRequest) { } // --- Root path: redirect logged-in users to their last workspace --- - if (pathname === "/" && hasSession && lastSlug) { + // The official cloud host also serves the public marketing site. Visiting + // https://multica.ai/ must remain a public-site navigation even when a local + // desktop/runtime session has fresh auth cookies; explicit app routes such + // as /acme/issues and legacy /issues still route to the workspace app. + if ( + pathname === "/" && + hasSession && + lastSlug && + !isOfficialMarketingHost(req.nextUrl.hostname) + ) { const url = req.nextUrl.clone(); url.pathname = `/${lastSlug}/issues`; return NextResponse.redirect(url);