debug(auth): log session cookie source
This commit is contained in:
55
apps/web/src/lib/api/client.mock-mode.test.ts
Normal file
55
apps/web/src/lib/api/client.mock-mode.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("API Client (mock auth mode)", (): void => {
|
||||
beforeEach((): void => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
NODE_ENV: "development",
|
||||
NEXT_PUBLIC_AUTH_MODE: "mock",
|
||||
};
|
||||
vi.resetModules();
|
||||
mockFetch.mockReset();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return local mock data for active projects widget without network calls", async (): Promise<void> => {
|
||||
const { apiPost } = await import("./client");
|
||||
interface ProjectResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const response = await apiPost<ProjectResponse[]>("/api/widgets/data/active-projects");
|
||||
|
||||
expect(response.length).toBeGreaterThan(0);
|
||||
const firstProject = response[0];
|
||||
expect(firstProject).toBeDefined();
|
||||
if (firstProject) {
|
||||
expect(typeof firstProject.id).toBe("string");
|
||||
expect(typeof firstProject.status).toBe("string");
|
||||
}
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return local mock data for agent chains widget without network calls", async (): Promise<void> => {
|
||||
const { apiPost } = await import("./client");
|
||||
interface AgentChainResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const response = await apiPost<AgentChainResponse[]>("/api/widgets/data/agent-chains");
|
||||
|
||||
expect(response.length).toBeGreaterThan(0);
|
||||
expect(response.some((session) => session.status === "active")).toBe(true);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
import { API_BASE_URL } from "../config";
|
||||
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "../config";
|
||||
|
||||
/**
|
||||
* In-memory CSRF token storage
|
||||
@@ -41,6 +41,74 @@ export interface ApiRequestOptions extends RequestInit {
|
||||
_isRetry?: boolean; // Internal flag to prevent infinite retry loops
|
||||
}
|
||||
|
||||
const MOCK_ACTIVE_PROJECTS_RESPONSE = [
|
||||
{
|
||||
id: "project-dev-1",
|
||||
name: "Mosaic Stack FE Go-Live",
|
||||
status: "active",
|
||||
lastActivity: new Date().toISOString(),
|
||||
taskCount: 7,
|
||||
eventCount: 2,
|
||||
color: "#3B82F6",
|
||||
},
|
||||
{
|
||||
id: "project-dev-2",
|
||||
name: "Auth Flow Remediation",
|
||||
status: "in-progress",
|
||||
lastActivity: new Date(Date.now() - 12 * 60_000).toISOString(),
|
||||
taskCount: 4,
|
||||
eventCount: 0,
|
||||
color: "#F59E0B",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const MOCK_AGENT_CHAINS_RESPONSE = [
|
||||
{
|
||||
id: "agent-session-dev-1",
|
||||
sessionKey: "dev-session-1",
|
||||
label: "UI Validator Agent",
|
||||
channel: "codex",
|
||||
agentName: "jarvis-agent",
|
||||
agentStatus: "WORKING",
|
||||
status: "active",
|
||||
startedAt: new Date(Date.now() - 42 * 60_000).toISOString(),
|
||||
lastMessageAt: new Date(Date.now() - 20_000).toISOString(),
|
||||
runtimeMs: 42 * 60_000,
|
||||
messageCount: 27,
|
||||
contextSummary: "Validating dashboard, tasks, and auth-bypass UX for local development flow.",
|
||||
},
|
||||
{
|
||||
id: "agent-session-dev-2",
|
||||
sessionKey: "dev-session-2",
|
||||
label: "Telemetry Stub Agent",
|
||||
channel: "codex",
|
||||
agentName: "jarvis-agent",
|
||||
agentStatus: "TERMINATED",
|
||||
status: "ended",
|
||||
startedAt: new Date(Date.now() - 3 * 60 * 60_000).toISOString(),
|
||||
lastMessageAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(),
|
||||
runtimeMs: 63 * 60_000,
|
||||
messageCount: 41,
|
||||
contextSummary: "Generated telemetry mock payloads for usage and widget rendering.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function getMockApiResponse(endpoint: string, method: string): unknown {
|
||||
if (!IS_MOCK_AUTH_MODE || process.env.NODE_ENV !== "development") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (method === "POST" && endpoint === "/api/widgets/data/active-projects") {
|
||||
return [...MOCK_ACTIVE_PROJECTS_RESPONSE];
|
||||
}
|
||||
|
||||
if (method === "POST" && endpoint === "/api/widgets/data/agent-chains") {
|
||||
return [...MOCK_AGENT_CHAINS_RESPONSE];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CSRF token from the API
|
||||
* Token is stored in an httpOnly cookie and returned in response body
|
||||
@@ -100,6 +168,12 @@ async function ensureCsrfToken(): Promise<string> {
|
||||
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const { workspaceId, timeoutMs, _isRetry, ...fetchOptions } = options;
|
||||
const method = (fetchOptions.method ?? "GET").toUpperCase();
|
||||
|
||||
const mockResponse = getMockApiResponse(endpoint, method);
|
||||
if (mockResponse !== undefined) {
|
||||
return mockResponse as T;
|
||||
}
|
||||
|
||||
// Set up abort controller for timeout
|
||||
const timeout = timeoutMs ?? DEFAULT_API_TIMEOUT_MS;
|
||||
@@ -134,7 +208,6 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)
|
||||
const method = (fetchOptions.method ?? "GET").toUpperCase();
|
||||
const isStateChanging = ["POST", "PUT", "PATCH", "DELETE"].includes(method);
|
||||
|
||||
if (isStateChanging) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "react";
|
||||
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
||||
import { apiGet, apiPost } from "../api/client";
|
||||
import { IS_MOCK_AUTH_MODE } from "../config";
|
||||
import { parseAuthError } from "./auth-errors";
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,11 @@ const SESSION_EXPIRY_WARNING_MINUTES = 5;
|
||||
|
||||
/** Interval in milliseconds to check session expiry */
|
||||
const SESSION_CHECK_INTERVAL_MS = 60_000;
|
||||
const MOCK_AUTH_USER: AuthUser = {
|
||||
id: "dev-user-local",
|
||||
email: "dev@localhost",
|
||||
name: "Local Dev User",
|
||||
};
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
@@ -70,6 +76,14 @@ function logAuthError(message: string, error: unknown): void {
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
if (IS_MOCK_AUTH_MODE) {
|
||||
return <MockAuthProvider>{children}</MockAuthProvider>;
|
||||
}
|
||||
|
||||
return <RealAuthProvider>{children}</RealAuthProvider>;
|
||||
}
|
||||
|
||||
function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [authError, setAuthError] = useState<AuthErrorType>(null);
|
||||
@@ -176,6 +190,33 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
function MockAuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
const [user, setUser] = useState<AuthUser | null>(MOCK_AUTH_USER);
|
||||
|
||||
const signOut = useCallback((): Promise<void> => {
|
||||
setUser(null);
|
||||
return Promise.resolve();
|
||||
}, []);
|
||||
|
||||
const refreshSession = useCallback((): Promise<void> => {
|
||||
setUser(MOCK_AUTH_USER);
|
||||
return Promise.resolve();
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
user,
|
||||
isLoading: false,
|
||||
isAuthenticated: user !== null,
|
||||
authError: null,
|
||||
sessionExpiring: false,
|
||||
sessionMinutesRemaining: 0,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
|
||||
@@ -22,11 +22,16 @@ describe("API Configuration", () => {
|
||||
it("should use default API URL when NEXT_PUBLIC_API_URL is not set", async () => {
|
||||
delete process.env.NEXT_PUBLIC_API_URL;
|
||||
delete process.env.NEXT_PUBLIC_ORCHESTRATOR_URL;
|
||||
delete process.env.NEXT_PUBLIC_AUTH_MODE;
|
||||
process.env = { ...process.env, NODE_ENV: "development" };
|
||||
|
||||
const { API_BASE_URL, ORCHESTRATOR_URL } = await import("./config");
|
||||
const { API_BASE_URL, ORCHESTRATOR_URL, AUTH_MODE, IS_MOCK_AUTH_MODE } =
|
||||
await import("./config");
|
||||
|
||||
expect(API_BASE_URL).toBe("http://localhost:3001");
|
||||
expect(ORCHESTRATOR_URL).toBe("http://localhost:3001");
|
||||
expect(AUTH_MODE).toBe("mock");
|
||||
expect(IS_MOCK_AUTH_MODE).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,17 +39,22 @@ describe("API Configuration", () => {
|
||||
it("should use NEXT_PUBLIC_API_URL when set", async () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||
delete process.env.NEXT_PUBLIC_ORCHESTRATOR_URL;
|
||||
delete process.env.NEXT_PUBLIC_AUTH_MODE;
|
||||
process.env = { ...process.env, NODE_ENV: "development" };
|
||||
|
||||
const { API_BASE_URL, ORCHESTRATOR_URL } = await import("./config");
|
||||
const { API_BASE_URL, ORCHESTRATOR_URL, AUTH_MODE } = await import("./config");
|
||||
|
||||
expect(API_BASE_URL).toBe("https://api.example.com");
|
||||
// ORCHESTRATOR_URL should fall back to API_BASE_URL
|
||||
expect(ORCHESTRATOR_URL).toBe("https://api.example.com");
|
||||
expect(AUTH_MODE).toBe("mock");
|
||||
});
|
||||
|
||||
it("should use separate NEXT_PUBLIC_ORCHESTRATOR_URL when set", async () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL = "https://orchestrator.example.com";
|
||||
process.env = { ...process.env, NODE_ENV: "development" };
|
||||
delete process.env.NEXT_PUBLIC_AUTH_MODE;
|
||||
|
||||
const { API_BASE_URL, ORCHESTRATOR_URL } = await import("./config");
|
||||
|
||||
@@ -57,6 +67,8 @@ describe("API Configuration", () => {
|
||||
it("should build API URLs correctly", async () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||
delete process.env.NEXT_PUBLIC_ORCHESTRATOR_URL;
|
||||
process.env = { ...process.env, NODE_ENV: "development" };
|
||||
delete process.env.NEXT_PUBLIC_AUTH_MODE;
|
||||
|
||||
const { buildApiUrl } = await import("./config");
|
||||
|
||||
@@ -67,6 +79,8 @@ describe("API Configuration", () => {
|
||||
it("should build orchestrator URLs correctly", async () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL = "https://orch.example.com";
|
||||
process.env = { ...process.env, NODE_ENV: "development" };
|
||||
delete process.env.NEXT_PUBLIC_AUTH_MODE;
|
||||
|
||||
const { buildOrchestratorUrl } = await import("./config");
|
||||
|
||||
@@ -79,13 +93,44 @@ describe("API Configuration", () => {
|
||||
it("should expose all configuration through apiConfig", async () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL = "https://orch.example.com";
|
||||
process.env = { ...process.env, NODE_ENV: "development" };
|
||||
process.env.NEXT_PUBLIC_AUTH_MODE = "real";
|
||||
|
||||
const { apiConfig } = await import("./config");
|
||||
|
||||
expect(apiConfig.baseUrl).toBe("https://api.example.com");
|
||||
expect(apiConfig.orchestratorUrl).toBe("https://orch.example.com");
|
||||
expect(apiConfig.authMode).toBe("real");
|
||||
expect(apiConfig.buildUrl("/test")).toBe("https://api.example.com/test");
|
||||
expect(apiConfig.buildOrchestratorUrl("/test")).toBe("https://orch.example.com/test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth mode", () => {
|
||||
it("should enable mock mode only in development", async () => {
|
||||
process.env = { ...process.env, NODE_ENV: "development" };
|
||||
process.env.NEXT_PUBLIC_AUTH_MODE = "mock";
|
||||
|
||||
const { AUTH_MODE, IS_MOCK_AUTH_MODE } = await import("./config");
|
||||
|
||||
expect(AUTH_MODE).toBe("mock");
|
||||
expect(IS_MOCK_AUTH_MODE).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw on invalid auth mode", async () => {
|
||||
process.env = { ...process.env, NODE_ENV: "development" };
|
||||
process.env.NEXT_PUBLIC_AUTH_MODE = "invalid";
|
||||
|
||||
await expect(import("./config")).rejects.toThrow("Invalid NEXT_PUBLIC_AUTH_MODE");
|
||||
});
|
||||
|
||||
it("should throw when mock mode is set outside development", async () => {
|
||||
process.env = { ...process.env, NODE_ENV: "production" };
|
||||
process.env.NEXT_PUBLIC_AUTH_MODE = "mock";
|
||||
|
||||
await expect(import("./config")).rejects.toThrow(
|
||||
"NEXT_PUBLIC_AUTH_MODE=mock is only allowed when NODE_ENV=development."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,19 @@
|
||||
* Environment Variables:
|
||||
* - NEXT_PUBLIC_API_URL: The main API server URL (default: http://localhost:3001)
|
||||
* - NEXT_PUBLIC_ORCHESTRATOR_URL: The orchestrator service URL (default: same as API URL)
|
||||
* - NEXT_PUBLIC_AUTH_MODE: Auth mode for web app (`real` or `mock`)
|
||||
* - If unset: development defaults to `mock`, production defaults to `real`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default API server URL for local development
|
||||
*/
|
||||
const DEFAULT_API_URL = "http://localhost:3001";
|
||||
const DEFAULT_AUTH_MODE = process.env.NODE_ENV === "development" ? "mock" : "real";
|
||||
|
||||
const VALID_AUTH_MODES = ["real", "mock"] as const;
|
||||
|
||||
export type AuthMode = (typeof VALID_AUTH_MODES)[number];
|
||||
|
||||
/**
|
||||
* Main API server URL
|
||||
@@ -20,6 +27,34 @@ const DEFAULT_API_URL = "http://localhost:3001";
|
||||
*/
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? DEFAULT_API_URL;
|
||||
|
||||
function resolveAuthMode(): AuthMode {
|
||||
const rawMode = (process.env.NEXT_PUBLIC_AUTH_MODE ?? DEFAULT_AUTH_MODE).toLowerCase();
|
||||
|
||||
if (!VALID_AUTH_MODES.includes(rawMode as AuthMode)) {
|
||||
throw new Error(
|
||||
`Invalid NEXT_PUBLIC_AUTH_MODE "${rawMode}". Expected one of: ${VALID_AUTH_MODES.join(", ")}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (rawMode === "mock" && process.env.NODE_ENV !== "development") {
|
||||
throw new Error("NEXT_PUBLIC_AUTH_MODE=mock is only allowed when NODE_ENV=development.");
|
||||
}
|
||||
|
||||
return rawMode as AuthMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication mode for frontend runtime.
|
||||
* - real: uses normal BetterAuth/Backend session flow
|
||||
* - mock: local-only seeded mock user for FE development
|
||||
*/
|
||||
export const AUTH_MODE: AuthMode = resolveAuthMode();
|
||||
|
||||
/**
|
||||
* Whether local mock auth mode is enabled.
|
||||
*/
|
||||
export const IS_MOCK_AUTH_MODE = AUTH_MODE === "mock";
|
||||
|
||||
/**
|
||||
* Orchestrator service URL
|
||||
* Used for agent management, task progress, and orchestration features
|
||||
@@ -53,6 +88,8 @@ export const apiConfig = {
|
||||
baseUrl: API_BASE_URL,
|
||||
/** Orchestrator service URL */
|
||||
orchestratorUrl: ORCHESTRATOR_URL,
|
||||
/** Authentication mode (`real` or `mock`) */
|
||||
authMode: AUTH_MODE,
|
||||
/** Build full API URL for an endpoint */
|
||||
buildUrl: buildApiUrl,
|
||||
/** Build full orchestrator URL for an endpoint */
|
||||
|
||||
Reference in New Issue
Block a user