diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts
index 3307e5e..1a697e9 100644
--- a/apps/web/e2e/login.spec.ts
+++ b/apps/web/e2e/login.spec.ts
@@ -21,8 +21,8 @@ test.describe('login', () => {
await page.getByLabel('Password').fill(E2E_LOGIN.password);
await page.getByRole('button', { name: 'Login' }).click();
- await expect(page).toHaveURL(/\/app(\/|$)/);
- await expect(page.getByRole('heading', { name: 'Full Page' })).toBeVisible();
+ await expect(page).toHaveURL(/\/app\/timeline\/index/);
+ await expect(page.getByPlaceholder('Search activities')).toBeVisible();
});
test('shows an error and stays on login when credentials are rejected', async ({ page }) => {
diff --git a/apps/web/src/apps/auth/index.tsx b/apps/web/src/apps/auth/index.tsx
index 63edda4..e9162c1 100644
--- a/apps/web/src/apps/auth/index.tsx
+++ b/apps/web/src/apps/auth/index.tsx
@@ -1,5 +1,6 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
+import { WEB_URL } from '../../core/constants/web-url';
import { AuthPageGuard } from '../../core/lib/auth-guard';
const LoginPage = lazy(() => import('./login'));
@@ -9,7 +10,7 @@ export default function AuthModule() {
} />
- } />
+ } />
} />
diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx
index 101d80d..a8470f6 100644
--- a/apps/web/src/apps/index.tsx
+++ b/apps/web/src/apps/index.tsx
@@ -5,8 +5,10 @@ import { StatusPage, AgGridProvider } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
import { LoadingScreen } from '../core/components/loading-screen';
import { ComingSoonPage } from '../core/components/coming-soon-page';
+import { WEB_URL } from '../core/constants/web-url';
+import { registerClientNavigate, registerSessionLifecycle } from '../core/lib/client-navigation';
import { useThemeStore } from '../core/stores/theme.store';
-import { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker';
+import { initializeAndPurgeHistoryBackground, resetHistoryCache } from './main/layouts/hooks/useHistoryTracker';
const AuthModule = lazy(() => import('./auth'));
const AppModule = lazy(() => import('./main'));
@@ -20,7 +22,7 @@ function NotFoundPage() {
description={t('common:systemPages.notFound.description')}
showActionsBack
showActionsHome
- homeUrl="/app"
+ homeUrl={WEB_URL.APP}
backButtonLabel={t('common:systemPages.actions.goBack')}
homeButtonLabel={t('common:systemPages.actions.backToHome')}
/>
@@ -36,7 +38,7 @@ function ForbiddenPage() {
description={t('common:systemPages.forbidden.description')}
showActionsBack
showActionsHome
- homeUrl="/app"
+ homeUrl={WEB_URL.APP}
backButtonLabel={t('common:systemPages.actions.goBack')}
homeButtonLabel={t('common:systemPages.actions.backToHome')}
/>
@@ -61,10 +63,16 @@ const router = createBrowserRouter([
{ path: '/403', element: },
{ path: '/maintenance', element: },
{ path: '/coming-soon', element: },
- { path: '/', element: },
+ { path: '/', element: },
{ path: '*', element: },
]);
+registerClientNavigate((to, options) => router.navigate(to, options));
+registerSessionLifecycle({
+ onEnd: resetHistoryCache,
+ onStart: () => initializeAndPurgeHistoryBackground(),
+});
+
export default function App() {
const colorScheme = useThemeStore((s) => s.colorScheme);
diff --git a/apps/web/src/apps/main/app-home-redirect.tsx b/apps/web/src/apps/main/app-home-redirect.tsx
new file mode 100644
index 0000000..5a2d8a7
--- /dev/null
+++ b/apps/web/src/apps/main/app-home-redirect.tsx
@@ -0,0 +1,43 @@
+import { useEffect, useState } from 'react';
+import { Navigate } from 'react-router-dom';
+import type { PrivilegeEntity } from '@repo/ui/foundations';
+import { WEB_URL } from '../../core/constants/web-url';
+import { filterMenuByViewPrivilege } from '../../core/lib/filter-menu-by-view-privilege';
+import { firstProductPath } from '../../core/lib/first-product-path';
+import { appDatabase, AppDatabaseKey } from '../../core/storage/local';
+import { MENU_ITEMS } from './layouts/data/menu.data';
+
+export function AppHomeRedirect() {
+ const [to, setTo] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ const token = await appDatabase.getItem(AppDatabaseKey.ACCESS_TOKEN);
+ if (!token || cancelled) {
+ return;
+ }
+
+ const profile = await appDatabase.getItem<{ isSuperadmin?: boolean }>(AppDatabaseKey.USER_PROFILE);
+ const privileges = await appDatabase.getItem>(AppDatabaseKey.USER_PRIVILEGE);
+ if (cancelled) {
+ return;
+ }
+
+ const filtered = filterMenuByViewPrivilege(MENU_ITEMS, privileges ?? {}, Boolean(profile?.isSuperadmin));
+ setTo(firstProductPath(filtered, WEB_URL.APP_HOME));
+ }
+
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (!to) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/apps/web/src/apps/main/index.tsx b/apps/web/src/apps/main/index.tsx
index 26bd453..7b1e023 100644
--- a/apps/web/src/apps/main/index.tsx
+++ b/apps/web/src/apps/main/index.tsx
@@ -1,7 +1,9 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
-import ModuleLayout from './layouts/module.layout';
+import { EmbeddedComingSoonPage } from '../../core/components/coming-soon-page';
import { GlobalCredentialChecker } from '../../core/lib/auth-guard';
+import { AppHomeRedirect } from './app-home-redirect';
+import ModuleLayout from './layouts/module.layout';
const ExampleModule = lazy(() => import('./modules/example'));
const SystemSetting = lazy(() => import('./modules/system/setting'));
@@ -29,7 +31,8 @@ export default function AppModule() {
} />
} />
} />
- } />
+ } />
+ } />
} />
diff --git a/apps/web/src/apps/main/layouts/hooks/useHistoryTracker.ts b/apps/web/src/apps/main/layouts/hooks/useHistoryTracker.ts
index 0717acf..baed516 100644
--- a/apps/web/src/apps/main/layouts/hooks/useHistoryTracker.ts
+++ b/apps/web/src/apps/main/layouts/hooks/useHistoryTracker.ts
@@ -29,6 +29,11 @@ if (historyChannel) {
// ---------------------------------------------------------------------------
// 2. Background Task: Initialization & Purge (Remain the same)
// ---------------------------------------------------------------------------
+export function resetHistoryCache(): void {
+ memoryHistoryCache = [];
+ isHistoryReady = false;
+}
+
export async function initializeAndPurgeHistoryBackground() {
if (isHistoryReady) return;
diff --git a/apps/web/src/core/components/coming-soon-page.tsx b/apps/web/src/core/components/coming-soon-page.tsx
index ca1fdbf..ece7c5d 100644
--- a/apps/web/src/core/components/coming-soon-page.tsx
+++ b/apps/web/src/core/components/coming-soon-page.tsx
@@ -1,5 +1,6 @@
import { StatusPage } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
+import { WEB_URL } from '../constants/web-url';
interface ComingSoonPageProps {
/** Use inside ModuleLayout so the page fits under the app chrome */
@@ -16,7 +17,7 @@ export function ComingSoonPage({ embedded = false }: ComingSoonPageProps) {
description={t('common:systemPages.comingSoon.description')}
showActionsBack={!embedded}
showActionsHome
- homeUrl="/app"
+ homeUrl={WEB_URL.APP}
backButtonLabel={t('common:systemPages.actions.goBack')}
homeButtonLabel={t('common:systemPages.actions.backToHome')}
height={embedded ? 500 : undefined}
diff --git a/apps/web/src/core/constants/web-url.test.ts b/apps/web/src/core/constants/web-url.test.ts
new file mode 100644
index 0000000..01da7bd
--- /dev/null
+++ b/apps/web/src/core/constants/web-url.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { WEB_URL } from './web-url';
+
+describe('WEB_URL', () => {
+ it('uses an ungated fallback home instead of the example module', () => {
+ expect(WEB_URL.APP_HOME).toBe('/app/dashboard');
+ expect(WEB_URL.APP_HOME).not.toContain('/example/');
+ expect(WEB_URL.LOGIN).toBe('/auth/login');
+ });
+});
diff --git a/apps/web/src/core/constants/web-url.ts b/apps/web/src/core/constants/web-url.ts
index 45d2d26..ab5e95e 100644
--- a/apps/web/src/core/constants/web-url.ts
+++ b/apps/web/src/core/constants/web-url.ts
@@ -1,4 +1,9 @@
export const WEB_URL = {
+ LOGIN: '/auth/login',
+ APP: '/app',
+ APP_HOME: '/app/dashboard',
+ DASHBOARD: '/app/dashboard',
+ TIMELINE: '/app/timeline',
USERS: '/app/system/users',
DIVISIONS: '/app/configuration/divisions',
BRANCHES: '/app/configuration/branches',
diff --git a/apps/web/src/core/lib/auth-guard.tsx b/apps/web/src/core/lib/auth-guard.tsx
index fcb91db..de77e55 100644
--- a/apps/web/src/core/lib/auth-guard.tsx
+++ b/apps/web/src/core/lib/auth-guard.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { appDatabase, AppDatabaseKey } from '../storage/local';
+import { WEB_URL } from '../constants/web-url';
import { refreshAuthSession, terminateAuthSession } from './auth.helper';
/**
@@ -46,12 +47,12 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
try {
const token = await appDatabase.getItem(AppDatabaseKey.ACCESS_TOKEN);
if (token && !isTokenExpired(token)) {
- navigate('/app', { replace: true });
+ navigate(WEB_URL.APP, { replace: true });
return;
}
if (await restoreSessionWithRefresh()) {
- navigate('/app', { replace: true });
+ navigate(WEB_URL.APP, { replace: true });
return;
}
@@ -80,8 +81,6 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
*/
export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
useEffect(() => {
- let intervalId: ReturnType;
-
async function checkCredential() {
try {
const token = await appDatabase.getItem(AppDatabaseKey.ACCESS_TOKEN);
@@ -102,7 +101,7 @@ export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
checkCredential();
- intervalId = setInterval(checkCredential, 60000);
+ const intervalId = setInterval(checkCredential, 60000);
return () => clearInterval(intervalId);
}, []);
diff --git a/apps/web/src/core/lib/auth.helper.test.ts b/apps/web/src/core/lib/auth.helper.test.ts
index 8d7124c..8d13de0 100644
--- a/apps/web/src/core/lib/auth.helper.test.ts
+++ b/apps/web/src/core/lib/auth.helper.test.ts
@@ -30,6 +30,7 @@ vi.mock('./api-client', () => ({
import { appDatabase, appStorage } from '../storage/local';
import { apiClient } from './api-client';
import { initiateAuthSession, logoutAuthSession, persistTokenPair, terminateAuthSession } from './auth.helper';
+import { resetClientNavigate } from './client-navigation';
const tokens = { accessToken: 'access-1', refreshToken: 'refresh-1' };
const me = {
@@ -45,7 +46,7 @@ const me = {
function stubLocation(pathname: string, search = '') {
const replace = vi.fn();
vi.stubGlobal('window', {
- location: { pathname, search, replace },
+ location: { pathname, search, replace, origin: 'https://admin.trackgo.eigen.co.id' },
});
return replace;
}
@@ -53,6 +54,7 @@ function stubLocation(pathname: string, search = '') {
describe('auth.helper', () => {
beforeEach(() => {
vi.clearAllMocks();
+ resetClientNavigate();
});
it('persists the token pair', async () => {
@@ -89,6 +91,18 @@ describe('auth.helper', () => {
expect(replace).toHaveBeenCalledWith('/app');
});
+ it('uses the SPA navigator after login so nginx is not asked for a missing file', async () => {
+ const { registerClientNavigate } = await import('./client-navigation');
+ const navigate = vi.fn();
+ const replace = stubLocation('/auth/login', '');
+ registerClientNavigate(navigate);
+
+ await initiateAuthSession(tokens, me);
+
+ expect(navigate).toHaveBeenCalledWith('/app', { replace: true });
+ expect(replace).not.toHaveBeenCalled();
+ });
+
it('honours the redirect query param after login', async () => {
const replace = stubLocation('/auth/login', '?redirect=%2Fapp%2Fcustomers');
@@ -97,6 +111,14 @@ describe('auth.helper', () => {
expect(replace).toHaveBeenCalledWith('/app/customers');
});
+ it('ignores an external redirect query param', async () => {
+ const replace = stubLocation('/auth/login', '?redirect=https%3A%2F%2Fevil.example');
+
+ await initiateAuthSession(tokens, me);
+
+ expect(replace).toHaveBeenCalledWith('/app');
+ });
+
it('clears the refresh token when terminating a session', async () => {
const replace = stubLocation('/app/customers');
diff --git a/apps/web/src/core/lib/auth.helper.ts b/apps/web/src/core/lib/auth.helper.ts
index 8332cea..cd40f60 100644
--- a/apps/web/src/core/lib/auth.helper.ts
+++ b/apps/web/src/core/lib/auth.helper.ts
@@ -1,6 +1,9 @@
import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local';
import { API_URL } from '../constants/api-url';
+import { WEB_URL } from '../constants/web-url';
+import { clientReplace, notifySessionEnd, notifySessionStart } from './client-navigation';
import { mapUserPrivileges } from './map-user-privileges';
+import { safeInternalPath } from './safe-internal-path';
import type { AuthUser, TokenPair } from './auth.types';
interface TerminateOptions {
@@ -39,16 +42,17 @@ export async function terminateAuthSession(options: TerminateOptions = {}): Prom
await appDatabase.removeItem(AppDatabaseKey.REFRESH_TOKEN);
await appStorage.removeItem(AppStorageKey.USER_ID);
- let loginUrl = '/auth/login';
- const isNotLoginPage = !window.location.pathname.includes('/auth/login');
+ let loginUrl = WEB_URL.LOGIN;
+ const isLoginPage =
+ window.location.pathname === WEB_URL.LOGIN || window.location.pathname.startsWith(`${WEB_URL.LOGIN}/`);
- if (isNotLoginPage) {
+ if (!isLoginPage) {
if (preserveRedirect) {
- const currentPath = window.location.pathname + window.location.search;
- loginUrl += `?redirect=${encodeURIComponent(currentPath)}`;
+ loginUrl += `?redirect=${encodeURIComponent(window.location.pathname)}`;
}
- window.location.replace(loginUrl);
+ notifySessionEnd();
+ clientReplace(loginUrl);
}
}
@@ -59,10 +63,10 @@ export async function initiateAuthSession(tokens: TokenPair, me: AuthUser): Prom
await persistTokenPair(tokens);
await persistUserSession(me);
+ notifySessionStart();
const params = new URLSearchParams(window.location.search);
- const redirectTo = params.get('redirect');
- window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app');
+ clientReplace(safeInternalPath(params.get('redirect'), WEB_URL.APP));
}
let refreshInFlight: Promise | null = null;
diff --git a/apps/web/src/core/lib/client-navigation.test.ts b/apps/web/src/core/lib/client-navigation.test.ts
new file mode 100644
index 0000000..d8a1176
--- /dev/null
+++ b/apps/web/src/core/lib/client-navigation.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ clientReplace,
+ notifySessionEnd,
+ registerClientNavigate,
+ registerSessionLifecycle,
+ resetClientNavigate,
+} from './client-navigation';
+
+describe('clientReplace', () => {
+ afterEach(() => {
+ resetClientNavigate();
+ vi.unstubAllGlobals();
+ });
+
+ it('uses the registered SPA navigator instead of a full page load', () => {
+ const navigate = vi.fn();
+ const replace = vi.fn();
+ vi.stubGlobal('window', { location: { replace } });
+ registerClientNavigate(navigate);
+
+ clientReplace('/auth/login');
+
+ expect(navigate).toHaveBeenCalledWith('/auth/login', { replace: true });
+ expect(replace).not.toHaveBeenCalled();
+ });
+
+ it('falls back to location.replace when no navigator is registered', () => {
+ const replace = vi.fn();
+ vi.stubGlobal('window', { location: { replace } });
+
+ clientReplace('/auth/login');
+
+ expect(replace).toHaveBeenCalledWith('/auth/login');
+ });
+
+ it('runs onEnd when the session is torn down', () => {
+ const onEnd = vi.fn();
+ registerSessionLifecycle({ onEnd });
+
+ notifySessionEnd();
+
+ expect(onEnd).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/src/core/lib/client-navigation.ts b/apps/web/src/core/lib/client-navigation.ts
new file mode 100644
index 0000000..e4f05fe
--- /dev/null
+++ b/apps/web/src/core/lib/client-navigation.ts
@@ -0,0 +1,45 @@
+type ClientNavigate = (to: string, options: { replace: boolean }) => void | Promise;
+
+interface SessionLifecycle {
+ onStart?: () => void | Promise;
+ onEnd?: () => void;
+}
+
+let navigateFn: ClientNavigate | null = null;
+let sessionLifecycle: SessionLifecycle = {};
+
+export function registerClientNavigate(fn: ClientNavigate): void {
+ navigateFn = fn;
+}
+
+export function registerSessionLifecycle(lifecycle: SessionLifecycle): void {
+ sessionLifecycle = lifecycle;
+}
+
+export function resetClientNavigate(): void {
+ navigateFn = null;
+ sessionLifecycle = {};
+}
+
+export function notifySessionStart(): void {
+ void sessionLifecycle.onStart?.();
+}
+
+export function notifySessionEnd(): void {
+ sessionLifecycle.onEnd?.();
+}
+
+/**
+ * Stay inside the already-loaded SPA when possible.
+ * A full `location.replace` makes nginx look up a real file (e.g. `/auth/login`) and 404.
+ */
+export function clientReplace(to: string): void {
+ if (!navigateFn) {
+ window.location.replace(to);
+ return;
+ }
+
+ void Promise.resolve(navigateFn(to, { replace: true })).catch(() => {
+ window.location.replace(to);
+ });
+}
diff --git a/apps/web/src/core/lib/first-product-path.test.ts b/apps/web/src/core/lib/first-product-path.test.ts
new file mode 100644
index 0000000..121ec8c
--- /dev/null
+++ b/apps/web/src/core/lib/first-product-path.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { firstProductPath } from './first-product-path';
+
+describe('firstProductPath', () => {
+ it('skips ungated placeholders and returns the first privileged leaf', () => {
+ const path = firstProductPath(
+ [
+ { path: '/app/dashboard' },
+ { path: '/app/timeline/index', moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE' },
+ {
+ path: '/app/sales',
+ children: [{ path: '/app/sales/orders/index', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER' }],
+ },
+ ],
+ '/app/dashboard',
+ );
+
+ expect(path).toBe('/app/timeline/index');
+ });
+
+ it('walks nested children when the first privileged item is nested', () => {
+ const path = firstProductPath(
+ [
+ { path: '/app/dashboard' },
+ {
+ path: '/app/logistics',
+ children: [{ path: '/app/logistics/plans/index', moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN' }],
+ },
+ ],
+ '/app/dashboard',
+ );
+
+ expect(path).toBe('/app/logistics/plans/index');
+ });
+
+ it('returns the fallback when no privileged leaf remains', () => {
+ expect(firstProductPath([{ path: '/app/dashboard' }], '/app/dashboard')).toBe('/app/dashboard');
+ });
+});
diff --git a/apps/web/src/core/lib/first-product-path.ts b/apps/web/src/core/lib/first-product-path.ts
new file mode 100644
index 0000000..8fe195e
--- /dev/null
+++ b/apps/web/src/core/lib/first-product-path.ts
@@ -0,0 +1,20 @@
+export function firstProductPath(
+ items: T[],
+ fallback: string,
+): string {
+ for (const item of items) {
+ if (item.children?.length) {
+ const nested = firstProductPath(item.children, '');
+ if (nested) {
+ return nested;
+ }
+ continue;
+ }
+
+ if (item.moduleKey && item.path) {
+ return item.path;
+ }
+ }
+
+ return fallback;
+}
diff --git a/apps/web/src/core/lib/safe-internal-path.test.ts b/apps/web/src/core/lib/safe-internal-path.test.ts
new file mode 100644
index 0000000..6048d38
--- /dev/null
+++ b/apps/web/src/core/lib/safe-internal-path.test.ts
@@ -0,0 +1,36 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { safeInternalPath } from './safe-internal-path';
+
+const ORIGIN = 'https://admin.trackgo.eigen.co.id';
+
+describe('safeInternalPath', () => {
+ beforeEach(() => {
+ vi.stubGlobal('window', { location: { origin: ORIGIN } });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('returns the fallback when the value is missing', () => {
+ expect(safeInternalPath(null, '/app')).toBe('/app');
+ expect(safeInternalPath(undefined, '/app')).toBe('/app');
+ expect(safeInternalPath('', '/app')).toBe('/app');
+ });
+
+ it('accepts an in-app absolute path', () => {
+ expect(safeInternalPath('/app/timeline/index', '/app')).toBe('/app/timeline/index');
+ expect(safeInternalPath('/app/timeline/index?tab=map', '/app')).toBe('/app/timeline/index?tab=map');
+ });
+
+ it('rejects open redirects', () => {
+ expect(safeInternalPath('https://evil.example', '/app')).toBe('/app');
+ expect(safeInternalPath('//evil.example/phish', '/app')).toBe('/app');
+ expect(safeInternalPath('auth/login', '/app')).toBe('/app');
+ expect(safeInternalPath('/auth/login', '/app')).toBe('/app');
+ expect(safeInternalPath('/\\evil.example', '/app')).toBe('/app');
+ expect(safeInternalPath('/\tevil.example', '/app')).toBe('/app');
+ expect(safeInternalPath('/\n/evil.example', '/app')).toBe('/app');
+ expect(safeInternalPath('/\r/evil.example', '/app')).toBe('/app');
+ });
+});
diff --git a/apps/web/src/core/lib/safe-internal-path.ts b/apps/web/src/core/lib/safe-internal-path.ts
new file mode 100644
index 0000000..c8fca0f
--- /dev/null
+++ b/apps/web/src/core/lib/safe-internal-path.ts
@@ -0,0 +1,35 @@
+import { WEB_URL } from '../constants/web-url';
+
+const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
+
+function resolveOrigin(): string {
+ if (typeof window !== 'undefined' && window.location?.origin) {
+ return window.location.origin;
+ }
+ return 'http://localhost';
+}
+
+/** Allows in-app paths only (`/app` or `/app/...`). Rejects open redirects. */
+export function safeInternalPath(raw: string | null | undefined, fallback: string): string {
+ if (!raw || raw.includes('\\') || CONTROL_CHARS.test(raw)) {
+ return fallback;
+ }
+
+ let candidate: URL;
+ try {
+ candidate = new URL(raw, resolveOrigin());
+ } catch {
+ return fallback;
+ }
+
+ if (candidate.origin !== resolveOrigin()) {
+ return fallback;
+ }
+
+ const path = `${candidate.pathname}${candidate.search}${candidate.hash}`;
+ if (path !== WEB_URL.APP && !path.startsWith(`${WEB_URL.APP}/`)) {
+ return fallback;
+ }
+
+ return path;
+}