diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md
index 38e7b0e2c3..b9e7ef773b 100644
--- a/src/vertex/app/changelog.md
+++ b/src/vertex/app/changelog.md
@@ -4,6 +4,45 @@
---
+## Version 2.0.3
+**Released:** June 11, 2026
+
+### Resilient API Routing & Social Auth Safety Fixes
+
+This release aligns the Vertex application's social login and API routing behavior with the more robust patterns used in the platform project, creating a significant safety net around our authentication flow and API communication.
+
+
+Resilient API Routing (2)
+
+- **Multi-Fallback Routing**: Replaced single-variable URL concatenation (`getApiBaseUrl()`) with `resolveApiOrigin()` and `resolveVersionedApiPath()`. The app now automatically scans multiple environment variables (`API_BASE_URL`, `NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_BASE_URL`) before failing.
+- **Suffix Stripping**: Implemented automatic `/api/v2` suffix stripping to prevent duplicate path segments caused by slight CI/CD misconfigurations.
+
+
+
+
+Social Authentication Fixes (3)
+
+- **Sign-Out Safety Flag**: Added the `OAUTH_SIGNED_OUT_FLAG` pattern to prevent "phantom token" bootstrapping if a user signs out and uses the browser back button to navigate to an old OAuth callback URL. The flag is explicitly cleared on the next intentional login to prevent redirect loops.
+- **Google Account Picker**: Explicitly injected `prompt=select_account` into the Google OAuth initialization query parameters to prevent Google from silently logging users back into their active browser session.
+- **Fail-Open Render Guard**: Removed the silent render guard from the Social Auth component. The buttons will now always render and gracefully handle URL resolution errors by displaying a UI banner rather than mysteriously vanishing.
+
+
+
+
+Files Modified (7)
+
+- `lib/envConstants.ts` [MODIFIED]
+- `lib/api-routing.ts` [MODIFIED]
+- `vertex.config.ts` [MODIFIED]
+- `components/features/auth/social-auth-section.tsx` [MODIFIED]
+- `core/auth/oauth-session.ts` [MODIFIED]
+- `core/auth/authProvider.tsx` [MODIFIED]
+- `core/hooks/useLogout.ts` [MODIFIED]
+
+
+
+---
+
## Version 2.0.2
**Released:** June 10, 2026
diff --git a/src/vertex/components/features/auth/social-auth-section.tsx b/src/vertex/components/features/auth/social-auth-section.tsx
index f366850929..f4259a9cec 100644
--- a/src/vertex/components/features/auth/social-auth-section.tsx
+++ b/src/vertex/components/features/auth/social-auth-section.tsx
@@ -9,6 +9,7 @@ import {
getLastUsedOAuthProvider,
resolveOAuthRedirectAfterUrl,
setLastUsedOAuthProvider,
+ clearBackendOAuthSignedOutFlag,
type SupportedSocialAuthProvider,
} from '@/core/auth/oauth-session';
import { cn } from '@/lib/utils';
@@ -93,9 +94,14 @@ export default function SocialAuthSection({
queryParams.redirect_after = redirectAfter;
}
+ if (provider === 'google') {
+ queryParams.prompt = 'select_account';
+ }
+
try {
setLastUsedOAuthProvider(provider);
+ clearBackendOAuthSignedOutFlag();
window.location.replace(buildOAuthInitiationUrl(provider, queryParams));
} catch (error) {
showBanner({
@@ -109,11 +115,6 @@ export default function SocialAuthSection({
[disabled, redirectPath, showBanner]
);
- // Hide social auth completely if the required API URL environment variable is missing
- // to prevent runtime crashes when getApiBaseUrl() throws an error during OAuth initiation.
- if (!process.env.NEXT_PUBLIC_API_URL) {
- return null;
- }
return (
diff --git a/src/vertex/core/auth/authProvider.tsx b/src/vertex/core/auth/authProvider.tsx
index 9629016362..b4eeb2073e 100644
--- a/src/vertex/core/auth/authProvider.tsx
+++ b/src/vertex/core/auth/authProvider.tsx
@@ -35,7 +35,7 @@ import type {
import { ExtendedSession } from '../utils/secureApiProxyClient';
import { useLogout, CROSS_TAB_LOGOUT_KEY, CROSS_TAB_LOGIN_KEY } from '@/core/hooks/useLogout';
import logger from '@/lib/logger';
-import { consumeOAuthTokenHandoffFromUrl } from './oauth-session';
+import { consumeOAuthTokenHandoffFromUrl, shouldSkipBackendOAuthBootstrap, clearBackendOAuthSignedOutFlag } from './oauth-session';
// --- Helper Functions ---
@@ -715,6 +715,7 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) {
const isHandlingOAuthRef = useRef(
typeof window !== 'undefined' && window.location.hash.includes('token=')
);
+ const hasInitiatedBootstrapRef = useRef(false);
const [isBootstrapping, setIsBootstrapping] = useState(true);
const router = useRouter();
const pathname = usePathname();
@@ -747,11 +748,21 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) {
}, []);
useEffect(() => {
+ if (hasInitiatedBootstrapRef.current) return;
+ hasInitiatedBootstrapRef.current = true;
+
let shouldUnblock = true;
const bootstrap = async () => {
try {
const handoff = consumeOAuthTokenHandoffFromUrl();
if (handoff?.token) {
+ if (shouldSkipBackendOAuthBootstrap()) {
+ logger.debug('[TokenHandoffHandler] OAuth token present but signed-out flag set, ignoring token handoff');
+ isHandlingOAuthRef.current = false;
+ return;
+ }
+ // Fresh OAuth token indicates explicit sign-in, clear any stale flag
+ clearBackendOAuthSignedOutFlag();
logger.info('[TokenHandoffHandler] OAuth token detected, signing in...');
const result = await signIn('credentials', {
@@ -803,6 +814,12 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) {
router.push(`/auth-error?error=${encodeURIComponent(result?.error || 'OAuthSignin')}`);
}
} else {
+ if (shouldSkipBackendOAuthBootstrap()) {
+ logger.debug('[TokenHandoffHandler] No OAuth token and signed-out flag set, skipping bootstrap');
+ isHandlingOAuthRef.current = false;
+ shouldUnblock = true;
+ return;
+ }
isHandlingOAuthRef.current = false;
}
} catch (error) {
diff --git a/src/vertex/core/auth/oauth-session.ts b/src/vertex/core/auth/oauth-session.ts
index 8612cace3b..8e68743e72 100644
--- a/src/vertex/core/auth/oauth-session.ts
+++ b/src/vertex/core/auth/oauth-session.ts
@@ -3,6 +3,7 @@ import { buildServerApiUrl } from "@/lib/api-routing";
const OAUTH_FRAGMENT_TOKEN_KEY = 'token';
const OAUTH_SUCCESS_PROVIDER_KEY = 'success';
const LAST_USED_OAUTH_PROVIDER_KEY = 'vertex:last-oauth-provider';
+const OAUTH_SIGNED_OUT_FLAG = 'vertex:oauth-signed-out';
export const SUPPORTED_SOCIAL_AUTH_PROVIDERS = [
'google',
@@ -68,6 +69,30 @@ export const setLastUsedOAuthProvider = (
localStorage.setItem(LAST_USED_OAUTH_PROVIDER_KEY, provider);
};
+export const shouldSkipBackendOAuthBootstrap = (): boolean => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ return localStorage.getItem(OAUTH_SIGNED_OUT_FLAG) === 'true';
+};
+
+export const clearBackendOAuthSignedOutFlag = (): void => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ localStorage.removeItem(OAUTH_SIGNED_OUT_FLAG);
+};
+
+export const setBackendOAuthSignedOutFlag = (): void => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ localStorage.setItem(OAUTH_SIGNED_OUT_FLAG, 'true');
+};
+
export interface OAuthTokenHandoff {
token: string;
provider: string | null;
diff --git a/src/vertex/core/hooks/useLogout.ts b/src/vertex/core/hooks/useLogout.ts
index 89aa5f2460..334c7e4188 100644
--- a/src/vertex/core/hooks/useLogout.ts
+++ b/src/vertex/core/hooks/useLogout.ts
@@ -10,6 +10,7 @@ import { rememberAccount } from '../utils/rememberedAccounts';
import logger from '@/lib/logger';
import { useAppSelector, useAppDispatch } from '../redux/hooks';
import { persistor } from '../redux/store';
+import { setBackendOAuthSignedOutFlag } from '../auth/oauth-session';
let sharedLogoutPromise: Promise | null = null;
let sharedIsLoggingOut = false;
@@ -67,6 +68,7 @@ export const useLogout = (callbackUrl?: string) => {
queryClient.clear();
await persistor.purge();
+ setBackendOAuthSignedOutFlag();
// Signal other tabs/apps that logout occurred (before signOut clears the cookie)
try {
localStorage.setItem(CROSS_TAB_LOGOUT_KEY, String(Date.now()));
diff --git a/src/vertex/lib/api-routing.ts b/src/vertex/lib/api-routing.ts
index 2d9c80ce35..5555492327 100644
--- a/src/vertex/lib/api-routing.ts
+++ b/src/vertex/lib/api-routing.ts
@@ -1,47 +1,124 @@
-import { getApiBaseUrl } from './envConstants';
+const DEFAULT_API_VERSION_FALLBACK = 'v2';
-const ensureLeadingSlash = (value: string): string => {
- if (!value) return '';
- return value.startsWith('/') ? value : `/${value}`;
+const resolveDefaultApiVersion = (): string => {
+ return DEFAULT_API_VERSION_FALLBACK;
+};
+
+const resolveServiceVersionMap = (): Record => {
+ return {};
+};
+
+const stripApiSuffix = (baseUrl: string): string => {
+ const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, '');
+
+ if (/\/api\/v\d+\/[^/]+$/i.test(trimmedBaseUrl)) {
+ return trimmedBaseUrl.replace(/\/api\/v\d+\/[^/]+$/i, '');
+ }
+
+ if (/\/api\/v\d+$/i.test(trimmedBaseUrl)) {
+ return trimmedBaseUrl.replace(/\/api\/v\d+$/i, '');
+ }
+
+ if (/\/api$/i.test(trimmedBaseUrl)) {
+ return trimmedBaseUrl.replace(/\/api$/i, '');
+ }
+
+ return trimmedBaseUrl;
+};
+
+const splitPathAndQuery = (value: string): { path: string; query: string } => {
+ const queryStartIndex = value.indexOf('?');
+ if (queryStartIndex === -1) {
+ return { path: value, query: '' };
+ }
+
+ return {
+ path: value.slice(0, queryStartIndex),
+ query: value.slice(queryStartIndex),
+ };
};
const isAbsoluteUrl = (value: string): boolean => {
return /^https?:\/\//i.test(value);
};
-/**
- * Builds an absolute API URL for server-side requests.
- * Uses the environment-configured API base URL.
- *
- * @param inputPath - The path to append to the base URL (e.g. '/users/profile')
- * @returns The absolute URL string
- */
-export const buildServerApiUrl = (inputPath: string): string => {
+const ensureLeadingSlash = (value: string): string => {
+ return value.startsWith('/') ? value : `/${value}`;
+};
+
+const isAlreadyVersionedPath = (value: string): boolean => {
+ return /^\/?api\/v\d+\//i.test(value) || /^\/?api\/v\d+$/i.test(value);
+};
+
+export const resolveApiOrigin = (): string => {
+ const configuredBaseUrl =
+ process.env.API_BASE_URL ||
+ process.env.NEXT_PUBLIC_API_BASE_URL ||
+ process.env.NEXT_PUBLIC_API_URL ||
+ process.env.NEXT_PUBLIC_BASE_URL ||
+ '';
+
+ const normalizedOrigin = stripApiSuffix(configuredBaseUrl);
+ if (!normalizedOrigin) {
+ throw new Error(
+ 'API base URL is not defined. Set NEXT_PUBLIC_API_BASE_URL or NEXT_PUBLIC_API_URL (client) and/or API_BASE_URL (server) in environment variables.'
+ );
+ }
+
+ return normalizedOrigin;
+};
+
+export const resolveVersionedApiPath = (inputPath: string): string => {
const trimmedInput = (inputPath || '').trim();
-
+ if (!trimmedInput) {
+ return `/api/${resolveDefaultApiVersion()}`;
+ }
+
if (isAbsoluteUrl(trimmedInput)) {
return trimmedInput;
}
-
- const baseUrl = getApiBaseUrl(); // E.g., https://staging-vertex.airqo.net/api/v2
- return `${baseUrl}${ensureLeadingSlash(trimmedInput)}`;
+
+ const { path, query } = splitPathAndQuery(trimmedInput);
+ const normalizedPath = ensureLeadingSlash(path.trim());
+
+ if (isAlreadyVersionedPath(normalizedPath)) {
+ return `${normalizedPath}${query}`;
+ }
+
+ const noLeadingSlashPath = normalizedPath.replace(/^\/+/, '');
+ if (
+ /^v\d+\//i.test(noLeadingSlashPath) ||
+ /^v\d+$/i.test(noLeadingSlashPath)
+ ) {
+ return `/api/${noLeadingSlashPath}${query}`;
+ }
+
+ const segments = noLeadingSlashPath.split('/').filter(Boolean);
+ if (segments.length === 0) {
+ return `/api/${resolveDefaultApiVersion()}${query}`;
+ }
+
+ const service = segments[0].toLowerCase();
+ const serviceVersionMap = resolveServiceVersionMap();
+ const apiVersion = serviceVersionMap[service] || resolveDefaultApiVersion();
+
+ return `/api/${apiVersion}/${segments.join('/')}${query}`;
+};
+
+export const buildServerApiUrl = (inputPath: string): string => {
+ const versionedPath = resolveVersionedApiPath(inputPath);
+ if (isAbsoluteUrl(versionedPath)) {
+ return versionedPath;
+ }
+
+ return `${resolveApiOrigin()}${versionedPath}`;
};
-/**
- * Builds a relative API URL for browser-side requests.
- * By returning a relative path, the browser natively routes to the current domain,
- * completely eliminating "split-brain" environment variable issues on the client.
- *
- * @param inputPath - The path to format (e.g. '/users/profile')
- * @returns The relative URL string (e.g. '/api/v2/users/profile')
- */
export const buildBrowserApiUrl = (inputPath: string): string => {
- const trimmedInput = (inputPath || '').trim();
-
- if (isAbsoluteUrl(trimmedInput)) {
- return trimmedInput;
+ const versionedPath = resolveVersionedApiPath(inputPath);
+ if (isAbsoluteUrl(versionedPath)) {
+ return versionedPath;
}
-
- // The vertex backend sits under /api/v2 relative to the frontend domain
- return `/api/v2${ensureLeadingSlash(trimmedInput)}`;
+
+ return versionedPath;
};
diff --git a/src/vertex/lib/envConstants.ts b/src/vertex/lib/envConstants.ts
index 8e40402845..3002aeb71e 100644
--- a/src/vertex/lib/envConstants.ts
+++ b/src/vertex/lib/envConstants.ts
@@ -2,7 +2,7 @@
* Environment constants and utilities for API configuration
* Centralizes environment variable access with proper validation
*/
-import { stripTrailingSlash } from './utils';
+
/**
* Gets the current environment from environment variables
@@ -12,17 +12,6 @@ export const getEnvironment = (): string => {
return process.env.NEXT_PUBLIC_ENV || 'development';
};
-/**
- * Gets the API base URL from environment variables
- * @returns {string} The API base URL
- */
-export const getApiBaseUrl = (): string => {
- const apiUrl = process.env.NEXT_PUBLIC_API_URL;
- if (!apiUrl) {
- throw new Error('API base URL is not defined. Set NEXT_PUBLIC_API_URL in environment variables.');
- }
- return stripTrailingSlash(apiUrl);
-};
/**
* Gets the API token from environment variables (server-side only)
diff --git a/src/vertex/vertex.config.ts b/src/vertex/vertex.config.ts
index a8334bfdec..be3b23d401 100644
--- a/src/vertex/vertex.config.ts
+++ b/src/vertex/vertex.config.ts
@@ -3,7 +3,7 @@ import {
validateVertexConfig,
type VertexConfigInput,
} from "./core/config/vertex-config";
-import { getApiBaseUrl } from "./lib/envConstants";
+import { resolveApiOrigin } from "./lib/api-routing";
const config: VertexConfigInput = {
...defaultVertexConfig,
@@ -18,9 +18,8 @@ const config: VertexConfigInput = {
},
api: {
adapter: "airqo",
- baseUrl: getApiBaseUrl(),
- publicMeasurementsBaseUrl:
- process.env.NEXT_PUBLIC_API_BASE_URL || getApiBaseUrl(),
+ baseUrl: resolveApiOrigin(),
+ publicMeasurementsBaseUrl: resolveApiOrigin(),
},
auth: {
provider: "airqo",