debug(auth): log session cookie source
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { AuthService } from "../auth.service";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AuthGuard.name);
|
||||
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -59,7 +67,8 @@ export class AuthGuard implements CanActivate {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from cookie (BetterAuth stores session token in better-auth.session_token cookie)
|
||||
* Extract token from cookie.
|
||||
* BetterAuth may prefix the cookie name with "__Secure-" when running on HTTPS.
|
||||
*/
|
||||
private extractTokenFromCookie(request: MaybeAuthenticatedRequest): string | undefined {
|
||||
// Express types `cookies` as `any`; cast to a known shape for type safety.
|
||||
@@ -68,8 +77,23 @@ export class AuthGuard implements CanActivate {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// BetterAuth uses 'better-auth.session_token' as the cookie name by default
|
||||
return cookies["better-auth.session_token"];
|
||||
// BetterAuth default cookie name is "better-auth.session_token"
|
||||
// When Secure cookies are enabled, BetterAuth prefixes with "__Secure-".
|
||||
const candidates = [
|
||||
"__Secure-better-auth.session_token",
|
||||
"better-auth.session_token",
|
||||
"__Host-better-auth.session_token",
|
||||
] as const;
|
||||
|
||||
for (const name of candidates) {
|
||||
const token = cookies[name];
|
||||
if (token) {
|
||||
this.logger.debug(`Session cookie found: ${name}`);
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { BullModule } from "@nestjs/bullmq";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { HealthModule } from "./api/health/health.module";
|
||||
@@ -22,11 +22,15 @@ import { orchestratorConfig } from "./config/orchestrator.config";
|
||||
isGlobal: true,
|
||||
load: [orchestratorConfig],
|
||||
}),
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
host: process.env.VALKEY_HOST ?? "localhost",
|
||||
port: parseInt(process.env.VALKEY_PORT ?? "6379"),
|
||||
},
|
||||
BullModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: configService.get<string>("orchestrator.valkey.host", "localhost"),
|
||||
port: configService.get<number>("orchestrator.valkey.port", 6379),
|
||||
password: configService.get<string>("orchestrator.valkey.password"),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
|
||||
@@ -120,6 +120,42 @@ describe("orchestratorConfig", () => {
|
||||
expect(config.valkey.port).toBe(6379);
|
||||
expect(config.valkey.url).toBe("redis://localhost:6379");
|
||||
});
|
||||
|
||||
it("should derive valkey host and port from VALKEY_URL when VALKEY_HOST/VALKEY_PORT are not set", () => {
|
||||
delete process.env.VALKEY_HOST;
|
||||
delete process.env.VALKEY_PORT;
|
||||
process.env.VALKEY_URL = "redis://valkey:6380";
|
||||
|
||||
const config = orchestratorConfig();
|
||||
|
||||
expect(config.valkey.host).toBe("valkey");
|
||||
expect(config.valkey.port).toBe(6380);
|
||||
expect(config.valkey.url).toBe("redis://valkey:6380");
|
||||
});
|
||||
|
||||
it("should derive valkey password from VALKEY_URL when VALKEY_PASSWORD is not set", () => {
|
||||
delete process.env.VALKEY_PASSWORD;
|
||||
delete process.env.VALKEY_HOST;
|
||||
delete process.env.VALKEY_PORT;
|
||||
process.env.VALKEY_URL = "redis://:url-secret@valkey:6379";
|
||||
|
||||
const config = orchestratorConfig();
|
||||
|
||||
expect(config.valkey.password).toBe("url-secret");
|
||||
});
|
||||
|
||||
it("should prefer explicit valkey env vars over VALKEY_URL values", () => {
|
||||
process.env.VALKEY_HOST = "explicit-host";
|
||||
process.env.VALKEY_PORT = "6390";
|
||||
process.env.VALKEY_PASSWORD = "explicit-password";
|
||||
process.env.VALKEY_URL = "redis://:url-secret@valkey:6380";
|
||||
|
||||
const config = orchestratorConfig();
|
||||
|
||||
expect(config.valkey.host).toBe("explicit-host");
|
||||
expect(config.valkey.port).toBe(6390);
|
||||
expect(config.valkey.password).toBe("explicit-password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("valkey timeout config (SEC-ORCH-28)", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
const normalizeAiProvider = (): "ollama" | "claude" | "openai" => {
|
||||
const provider = process.env.AI_PROVIDER?.trim().toLowerCase();
|
||||
|
||||
@@ -13,63 +14,83 @@ const normalizeAiProvider = (): "ollama" | "claude" | "openai" => {
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const orchestratorConfig = registerAs("orchestrator", () => ({
|
||||
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
|
||||
port: parseInt(process.env.ORCHESTRATOR_PORT ?? "3001", 10),
|
||||
valkey: {
|
||||
host: process.env.VALKEY_HOST ?? "localhost",
|
||||
port: parseInt(process.env.VALKEY_PORT ?? "6379", 10),
|
||||
password: process.env.VALKEY_PASSWORD,
|
||||
url: process.env.VALKEY_URL ?? "redis://localhost:6379",
|
||||
connectTimeout: parseInt(process.env.VALKEY_CONNECT_TIMEOUT_MS ?? "5000", 10),
|
||||
commandTimeout: parseInt(process.env.VALKEY_COMMAND_TIMEOUT_MS ?? "3000", 10),
|
||||
},
|
||||
claude: {
|
||||
apiKey: process.env.CLAUDE_API_KEY,
|
||||
},
|
||||
aiProvider: normalizeAiProvider(),
|
||||
docker: {
|
||||
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
|
||||
},
|
||||
git: {
|
||||
userName: process.env.GIT_USER_NAME ?? "Mosaic Orchestrator",
|
||||
userEmail: process.env.GIT_USER_EMAIL ?? "orchestrator@mosaicstack.dev",
|
||||
},
|
||||
killswitch: {
|
||||
enabled: process.env.KILLSWITCH_ENABLED === "true",
|
||||
},
|
||||
sandbox: {
|
||||
enabled: process.env.SANDBOX_ENABLED !== "false",
|
||||
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
|
||||
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "256", 10),
|
||||
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
|
||||
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "none",
|
||||
},
|
||||
coordinator: {
|
||||
url: process.env.COORDINATOR_URL ?? "http://localhost:8000",
|
||||
timeout: parseInt(process.env.COORDINATOR_TIMEOUT_MS ?? "30000", 10),
|
||||
retries: parseInt(process.env.COORDINATOR_RETRIES ?? "3", 10),
|
||||
apiKey: process.env.COORDINATOR_API_KEY,
|
||||
},
|
||||
yolo: {
|
||||
enabled: process.env.YOLO_MODE === "true",
|
||||
},
|
||||
spawner: {
|
||||
maxConcurrentAgents: parseInt(process.env.MAX_CONCURRENT_AGENTS ?? "2", 10),
|
||||
sessionCleanupDelayMs: parseInt(process.env.SESSION_CLEANUP_DELAY_MS ?? "30000", 10),
|
||||
},
|
||||
queue: {
|
||||
name: process.env.ORCHESTRATOR_QUEUE_NAME ?? "orchestrator-tasks",
|
||||
maxRetries: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_RETRIES ?? "3", 10),
|
||||
baseDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_BASE_DELAY_MS ?? "1000", 10),
|
||||
maxDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_DELAY_MS ?? "60000", 10),
|
||||
concurrency: parseInt(process.env.ORCHESTRATOR_QUEUE_CONCURRENCY ?? "1", 10),
|
||||
completedRetentionCount: parseInt(process.env.QUEUE_COMPLETED_RETENTION_COUNT ?? "100", 10),
|
||||
completedRetentionAgeSeconds: parseInt(
|
||||
process.env.QUEUE_COMPLETED_RETENTION_AGE_S ?? "3600",
|
||||
10
|
||||
),
|
||||
failedRetentionCount: parseInt(process.env.QUEUE_FAILED_RETENTION_COUNT ?? "1000", 10),
|
||||
failedRetentionAgeSeconds: parseInt(process.env.QUEUE_FAILED_RETENTION_AGE_S ?? "86400", 10),
|
||||
},
|
||||
}));
|
||||
const parseValkeyUrl = (url: string): { host?: string; port?: number; password?: string } => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const port = parsed.port ? parseInt(parsed.port, 10) : undefined;
|
||||
|
||||
return {
|
||||
host: parsed.hostname || undefined,
|
||||
port: Number.isNaN(port) ? undefined : port,
|
||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const orchestratorConfig = registerAs("orchestrator", () => {
|
||||
const valkeyUrl = process.env.VALKEY_URL ?? "redis://localhost:6379";
|
||||
const parsedValkeyUrl = parseValkeyUrl(valkeyUrl);
|
||||
|
||||
return {
|
||||
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
|
||||
port: parseInt(process.env.ORCHESTRATOR_PORT ?? "3001", 10),
|
||||
valkey: {
|
||||
host: process.env.VALKEY_HOST ?? parsedValkeyUrl.host ?? "localhost",
|
||||
port: parseInt(process.env.VALKEY_PORT ?? String(parsedValkeyUrl.port ?? 6379), 10),
|
||||
password: process.env.VALKEY_PASSWORD ?? parsedValkeyUrl.password,
|
||||
url: valkeyUrl,
|
||||
connectTimeout: parseInt(process.env.VALKEY_CONNECT_TIMEOUT_MS ?? "5000", 10),
|
||||
commandTimeout: parseInt(process.env.VALKEY_COMMAND_TIMEOUT_MS ?? "3000", 10),
|
||||
},
|
||||
claude: {
|
||||
apiKey: process.env.CLAUDE_API_KEY,
|
||||
},
|
||||
aiProvider: normalizeAiProvider(),
|
||||
docker: {
|
||||
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
|
||||
},
|
||||
git: {
|
||||
userName: process.env.GIT_USER_NAME ?? "Mosaic Orchestrator",
|
||||
userEmail: process.env.GIT_USER_EMAIL ?? "orchestrator@mosaicstack.dev",
|
||||
},
|
||||
killswitch: {
|
||||
enabled: process.env.KILLSWITCH_ENABLED === "true",
|
||||
},
|
||||
sandbox: {
|
||||
enabled: process.env.SANDBOX_ENABLED !== "false",
|
||||
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
|
||||
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "256", 10),
|
||||
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
|
||||
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "none",
|
||||
},
|
||||
coordinator: {
|
||||
url: process.env.COORDINATOR_URL ?? "http://localhost:8000",
|
||||
timeout: parseInt(process.env.COORDINATOR_TIMEOUT_MS ?? "30000", 10),
|
||||
retries: parseInt(process.env.COORDINATOR_RETRIES ?? "3", 10),
|
||||
apiKey: process.env.COORDINATOR_API_KEY,
|
||||
},
|
||||
yolo: {
|
||||
enabled: process.env.YOLO_MODE === "true",
|
||||
},
|
||||
spawner: {
|
||||
maxConcurrentAgents: parseInt(process.env.MAX_CONCURRENT_AGENTS ?? "2", 10),
|
||||
sessionCleanupDelayMs: parseInt(process.env.SESSION_CLEANUP_DELAY_MS ?? "30000", 10),
|
||||
},
|
||||
queue: {
|
||||
name: process.env.ORCHESTRATOR_QUEUE_NAME ?? "orchestrator-tasks",
|
||||
maxRetries: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_RETRIES ?? "3", 10),
|
||||
baseDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_BASE_DELAY_MS ?? "1000", 10),
|
||||
maxDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_DELAY_MS ?? "60000", 10),
|
||||
concurrency: parseInt(process.env.ORCHESTRATOR_QUEUE_CONCURRENCY ?? "1", 10),
|
||||
completedRetentionCount: parseInt(process.env.QUEUE_COMPLETED_RETENTION_COUNT ?? "100", 10),
|
||||
completedRetentionAgeSeconds: parseInt(
|
||||
process.env.QUEUE_COMPLETED_RETENTION_AGE_S ?? "3600",
|
||||
10
|
||||
),
|
||||
failedRetentionCount: parseInt(process.env.QUEUE_FAILED_RETENTION_COUNT ?? "1000", 10),
|
||||
failedRetentionAgeSeconds: parseInt(process.env.QUEUE_FAILED_RETENTION_AGE_S ?? "86400", 10),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const defaultAuthMode = process.env.NODE_ENV === "development" ? "mock" : "real";
|
||||
const authMode = (process.env.NEXT_PUBLIC_AUTH_MODE ?? defaultAuthMode).toLowerCase();
|
||||
|
||||
if (!["real", "mock"].includes(authMode)) {
|
||||
throw new Error(`Invalid NEXT_PUBLIC_AUTH_MODE "${authMode}". Expected one of: real, mock.`);
|
||||
}
|
||||
|
||||
if (authMode === "mock" && process.env.NODE_ENV !== "development") {
|
||||
throw new Error("NEXT_PUBLIC_AUTH_MODE=mock is only allowed for local development.");
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ["@mosaic/ui", "@mosaic/shared"],
|
||||
};
|
||||
|
||||
87
apps/web/src/app/(auth)/login/page.mock-mode.test.tsx
Normal file
87
apps/web/src/app/(auth)/login/page.mock-mode.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LoginPage from "./page";
|
||||
|
||||
const { mockPush, mockReplace, mockSearchParams, authState } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockReplace: vi.fn(),
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
authState: {
|
||||
isAuthenticated: false,
|
||||
refreshSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { mockFetchWithRetry } = vi.hoisted(() => ({
|
||||
mockFetchWithRetry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: (): { push: Mock; replace: Mock } => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
useSearchParams: (): URLSearchParams => mockSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/config", () => ({
|
||||
API_BASE_URL: "http://localhost:3001",
|
||||
IS_MOCK_AUTH_MODE: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-client", () => ({
|
||||
signIn: {
|
||||
oauth2: vi.fn(),
|
||||
email: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: (): { isAuthenticated: boolean; refreshSession: Mock } => ({
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
refreshSession: authState.refreshSession,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/fetch-with-retry", () => ({
|
||||
fetchWithRetry: mockFetchWithRetry,
|
||||
}));
|
||||
|
||||
describe("LoginPage (mock auth mode)", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.delete("error");
|
||||
authState.isAuthenticated = false;
|
||||
authState.refreshSession.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should render mock auth controls", (): void => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByText(/local mock auth mode is active/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-auth-login")).toBeInTheDocument();
|
||||
expect(mockFetchWithRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should continue with mock session and navigate to tasks", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await user.click(screen.getByTestId("mock-auth-login"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authState.refreshSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockPush).toHaveBeenCalledWith("/tasks");
|
||||
});
|
||||
});
|
||||
|
||||
it("should auto-redirect authenticated mock users to tasks", async (): Promise<void> => {
|
||||
authState.isAuthenticated = true;
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith("/tasks");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@ const { mockOAuth2, mockSignInEmail, mockPush, mockReplace, mockSearchParams } =
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const { mockRefreshSession, mockIsAuthenticated } = vi.hoisted(() => ({
|
||||
mockRefreshSession: vi.fn(),
|
||||
mockIsAuthenticated: false,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: (): { push: Mock; replace: Mock } => ({
|
||||
push: mockPush,
|
||||
@@ -33,6 +38,14 @@ vi.mock("@/lib/auth-client", () => ({
|
||||
|
||||
vi.mock("@/lib/config", () => ({
|
||||
API_BASE_URL: "http://localhost:3001",
|
||||
IS_MOCK_AUTH_MODE: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: (): { isAuthenticated: boolean; refreshSession: Mock } => ({
|
||||
isAuthenticated: mockIsAuthenticated,
|
||||
refreshSession: mockRefreshSession,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fetchWithRetry to behave like fetch for test purposes
|
||||
@@ -91,6 +104,7 @@ describe("LoginPage", (): void => {
|
||||
mockSearchParams.delete("error");
|
||||
// Default: OAuth2 returns a resolved promise (fire-and-forget redirect)
|
||||
mockOAuth2.mockResolvedValue(undefined);
|
||||
mockRefreshSession.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("renders loading state initially", (): void => {
|
||||
|
||||
@@ -5,10 +5,11 @@ import type { ReactElement } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
|
||||
import { parseAuthError } from "@/lib/auth/auth-errors";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { OAuthButton } from "@/components/auth/OAuthButton";
|
||||
import { LoginForm } from "@/components/auth/LoginForm";
|
||||
import { AuthDivider } from "@/components/auth/AuthDivider";
|
||||
@@ -45,6 +46,7 @@ export default function LoginPage(): ReactElement {
|
||||
function LoginPageContent(): ReactElement {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated, refreshSession } = useAuth();
|
||||
const [config, setConfig] = useState<AuthConfigResponse | null | undefined>(undefined);
|
||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
@@ -68,6 +70,18 @@ function LoginPageContent(): ReactElement {
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_MOCK_AUTH_MODE && isAuthenticated) {
|
||||
router.replace("/tasks");
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_MOCK_AUTH_MODE) {
|
||||
setConfig({ providers: [] });
|
||||
setLoadingConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchConfig(): Promise<void> {
|
||||
@@ -158,6 +172,48 @@ function LoginPageContent(): ReactElement {
|
||||
setRetryCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const handleMockLogin = useCallback(async (): Promise<void> => {
|
||||
setError(null);
|
||||
try {
|
||||
await refreshSession();
|
||||
router.push("/tasks");
|
||||
} catch (err: unknown) {
|
||||
const parsed = parseAuthError(err);
|
||||
setError(parsed.message);
|
||||
}
|
||||
}, [refreshSession, router]);
|
||||
|
||||
if (IS_MOCK_AUTH_MODE) {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
||||
<p className="text-base sm:text-lg text-gray-600">
|
||||
Local mock auth mode is active. Real sign-in is bypassed for frontend development.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md space-y-4">
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
Mock auth mode is local-only and blocked outside development.
|
||||
</div>
|
||||
{error && <AuthErrorBanner message={error} />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleMockLogin();
|
||||
}}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
||||
data-testid="mock-auth-login"
|
||||
>
|
||||
Continue with Mock Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { ChatOverlay } from "@/components/chat";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -36,8 +37,18 @@ export default function AuthenticatedLayout({
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
<div className="pt-16">{children}</div>
|
||||
<ChatOverlay />
|
||||
<div className="pt-16">
|
||||
{IS_MOCK_AUTH_MODE && (
|
||||
<div
|
||||
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900"
|
||||
data-testid="mock-auth-banner"
|
||||
>
|
||||
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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