debug(auth): log session cookie source
This commit is contained in:
@@ -15,6 +15,14 @@ WEB_PORT=3000
|
||||
# ======================
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
# Frontend auth mode:
|
||||
# - real: Normal auth/session flow
|
||||
# - mock: Local-only seeded user for FE development (blocked outside NODE_ENV=development)
|
||||
# Use `mock` locally to continue FE work when auth flow is unstable.
|
||||
# If omitted, web runtime defaults:
|
||||
# - development -> mock
|
||||
# - production -> real
|
||||
NEXT_PUBLIC_AUTH_MODE=real
|
||||
|
||||
# ======================
|
||||
# PostgreSQL Database
|
||||
|
||||
@@ -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,12 +22,16 @@ import { orchestratorConfig } from "./config/orchestrator.config";
|
||||
isGlobal: true,
|
||||
load: [orchestratorConfig],
|
||||
}),
|
||||
BullModule.forRoot({
|
||||
BullModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: process.env.VALKEY_HOST ?? "localhost",
|
||||
port: parseInt(process.env.VALKEY_PORT ?? "6379"),
|
||||
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([
|
||||
{
|
||||
name: "default",
|
||||
|
||||
@@ -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,14 +14,33 @@ const normalizeAiProvider = (): "ollama" | "claude" | "openai" => {
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const orchestratorConfig = registerAs("orchestrator", () => ({
|
||||
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 ?? "localhost",
|
||||
port: parseInt(process.env.VALKEY_PORT ?? "6379", 10),
|
||||
password: process.env.VALKEY_PASSWORD,
|
||||
url: process.env.VALKEY_URL ?? "redis://localhost:6379",
|
||||
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),
|
||||
},
|
||||
@@ -72,4 +92,5 @@ export const orchestratorConfig = registerAs("orchestrator", () => ({
|
||||
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 */
|
||||
|
||||
@@ -251,6 +251,8 @@ services:
|
||||
ORCHESTRATOR_PORT: 3001
|
||||
AI_PROVIDER: ${AI_PROVIDER:-ollama}
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
VALKEY_HOST: valkey
|
||||
VALKEY_PORT: 6379
|
||||
# Claude API (required only when AI_PROVIDER=claude)
|
||||
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
|
||||
DOCKER_SOCKET: /var/run/docker.sock
|
||||
|
||||
@@ -436,6 +436,8 @@ services:
|
||||
AI_PROVIDER: ${AI_PROVIDER:-ollama}
|
||||
# Valkey
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
VALKEY_HOST: valkey
|
||||
VALKEY_PORT: 6379
|
||||
# Claude API (required only when AI_PROVIDER=claude)
|
||||
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
|
||||
# Docker
|
||||
|
||||
@@ -445,6 +445,8 @@ services:
|
||||
AI_PROVIDER: ${AI_PROVIDER:-ollama}
|
||||
# Valkey
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
VALKEY_HOST: valkey
|
||||
VALKEY_PORT: 6379
|
||||
# Claude API (required only when AI_PROVIDER=claude)
|
||||
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
|
||||
# Docker
|
||||
|
||||
657
plans/mosaic-installer-plan.md
Normal file
657
plans/mosaic-installer-plan.md
Normal file
@@ -0,0 +1,657 @@
|
||||
# Mosaic Stack Comprehensive Installer Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines the plan for creating a comprehensive installation script for Mosaic Stack that "just works" across platforms, automatically installs all dependencies, detects the platform, and configures the system. The design is inspired by the OpenClaw installer pattern while addressing Mosaic Stack's unique requirements as a multi-tenant personal assistant platform with optional Docker deployment.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current State Analysis
|
||||
|
||||
### 1.1 Existing Setup Script ([`scripts/setup.sh`](scripts/setup.sh))
|
||||
|
||||
**Strengths:**
|
||||
|
||||
- Interactive configuration wizard
|
||||
- Platform detection (Debian, Arch, Fedora, macOS)
|
||||
- Docker and native deployment modes
|
||||
- Port conflict detection and resolution
|
||||
- .env file management with value preservation
|
||||
- Secret generation
|
||||
- SSO/Authentik configuration
|
||||
- Ollama integration options
|
||||
- Traefik reverse proxy support
|
||||
|
||||
**Gaps:**
|
||||
|
||||
- No curl-able one-liner installation
|
||||
- Missing automatic dependency installation for some platforms
|
||||
- No WSL detection/handling
|
||||
- No dry-run validation of full configuration
|
||||
- Limited post-install validation
|
||||
- No upgrade/migration path handling
|
||||
- Missing verbose/debug mode
|
||||
- No cleanup on failure
|
||||
- PATH management not comprehensive
|
||||
|
||||
### 1.2 OpenClaw Installer Features to Adopt
|
||||
|
||||
| Feature | Description | Priority |
|
||||
| ------------------------ | -------------------------------------------------------- | -------- |
|
||||
| curl-able one-liner | `curl -fsSL URL \| bash` pattern | High |
|
||||
| Color output | Consistent colored output with ACCENT/SUCCESS/WARN/ERROR | High |
|
||||
| Platform detection | macOS, Linux, WSL detection | High |
|
||||
| Auto Homebrew | Automatic Homebrew installation on macOS | Medium |
|
||||
| Node.js management | NodeSource integration for Linux, Homebrew for macOS | High |
|
||||
| npm permissions fix | User-local npm global installs on Linux | Medium |
|
||||
| pnpm via corepack | Corepack-first pnpm installation | High |
|
||||
| Git installation | Automatic Git installation | Medium |
|
||||
| Multiple install methods | npm vs git checkout options | Low |
|
||||
| Dry-run mode | Preview changes without execution | High |
|
||||
| Verbose mode | Debug output with `set -x` | Medium |
|
||||
| Non-interactive mode | CI/CD friendly automation | High |
|
||||
| TTY detection | Graceful handling of piped input | High |
|
||||
| Temp file cleanup | Trap-based cleanup on exit | High |
|
||||
| Downloader detection | curl/wget abstraction | Medium |
|
||||
| PATH warnings | Alert user about PATH issues | Medium |
|
||||
| Post-install doctor | Validation and migration | High |
|
||||
| Fun taglines | User-friendly messaging | Low |
|
||||
|
||||
---
|
||||
|
||||
## 2. Proposed Architecture
|
||||
|
||||
### 2.1 File Structure
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── install.sh # Main entry point (curl-able)
|
||||
├── setup.sh # Interactive configuration wizard (enhanced)
|
||||
├── lib/
|
||||
│ ├── common.sh # Shared utilities (enhanced)
|
||||
│ ├── platform.sh # Platform detection functions
|
||||
│ ├── dependencies.sh # Dependency installation functions
|
||||
│ ├── docker.sh # Docker-specific functions
|
||||
│ ├── native.sh # Native deployment functions
|
||||
│ └── validation.sh # Post-install validation
|
||||
└── commands/
|
||||
├── doctor.sh # Diagnostic and repair tool
|
||||
└── upgrade.sh # Upgrade/migration handler
|
||||
```
|
||||
|
||||
### 2.2 Installer Flow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[curl/install.sh] --> B{Parse Arguments}
|
||||
B --> C{Help?}
|
||||
C -->|Yes| D[Show Usage]
|
||||
C -->|No| E{Dry Run?}
|
||||
|
||||
E --> F[Detect Platform]
|
||||
F --> G[Detect Package Manager]
|
||||
|
||||
G --> H{Install Method}
|
||||
H -->|docker| I[Check Docker]
|
||||
H -->|native| J[Check Node.js/pnpm]
|
||||
|
||||
I --> K{Docker OK?}
|
||||
K -->|No| L[Install Docker]
|
||||
K -->|Yes| M[Check Docker Compose]
|
||||
L --> M
|
||||
|
||||
J --> N{Node.js OK?}
|
||||
N -->|No| O[Install Node.js]
|
||||
N -->|Yes| P{pnpm OK?}
|
||||
O --> P
|
||||
P -->|No| Q[Install pnpm]
|
||||
P -->|Yes| R[Check PostgreSQL]
|
||||
Q --> R
|
||||
|
||||
M --> S[Check Existing Config]
|
||||
R --> S
|
||||
|
||||
S --> T{Has .env?}
|
||||
T -->|Yes| U[Load Existing Values]
|
||||
T -->|No| V[Interactive Config]
|
||||
U --> V
|
||||
|
||||
V --> W[Generate Secrets]
|
||||
W --> X[Resolve Port Conflicts]
|
||||
X --> Y[Write .env]
|
||||
|
||||
Y --> Z{Deployment Mode}
|
||||
Z -->|docker| AA[Docker Compose Up]
|
||||
Z -->|native| AB[Run Migrations]
|
||||
|
||||
AA --> AC[Run Doctor]
|
||||
AB --> AC
|
||||
|
||||
AC --> AD{All Checks Pass?}
|
||||
AD -->|Yes| AE[Show Success]
|
||||
AD -->|No| AF[Show Warnings]
|
||||
|
||||
AE --> AG[Print Next Steps]
|
||||
AF --> AG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Detailed Component Design
|
||||
|
||||
### 3.1 Entry Point: `install.sh`
|
||||
|
||||
The main installer should support both quick-start and advanced usage:
|
||||
|
||||
```bash
|
||||
# Usage patterns:
|
||||
# Quick start (interactive):
|
||||
curl -fsSL https://get.mosaicstack.dev | bash
|
||||
|
||||
# Non-interactive Docker deployment:
|
||||
curl -fsSL https://get.mosaicstack.dev | bash -s -- --non-interactive --mode docker
|
||||
|
||||
# Dry run to preview:
|
||||
curl -fsSL https://get.mosaicstack.dev | bash -s -- --dry-run --mode docker
|
||||
|
||||
# With all options:
|
||||
curl -fsSL https://get.mosaicstack.dev | bash -s -- \
|
||||
--mode docker \
|
||||
--enable-sso \
|
||||
--bundled-authentik \
|
||||
--ollama-mode local \
|
||||
--base-url https://mosaic.example.com
|
||||
```
|
||||
|
||||
**Command-line Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
| -------------------------- | ------------------------------- | --------------------- |
|
||||
| `--mode` | Deployment mode: docker, native | interactive |
|
||||
| `--non-interactive` | Skip all prompts | false |
|
||||
| `--dry-run` | Preview without changes | false |
|
||||
| `--verbose` | Enable debug output | false |
|
||||
| `--enable-sso` | Enable Authentik SSO | false |
|
||||
| `--bundled-authentik` | Use bundled Authentik | false |
|
||||
| `--external-authentik URL` | External Authentik URL | - |
|
||||
| `--ollama-mode` | Ollama: local, remote, disabled | disabled |
|
||||
| `--ollama-url URL` | Remote Ollama URL | - |
|
||||
| `--base-url URL` | Mosaic base URL | http://localhost:3000 |
|
||||
| `--profiles` | Compose profiles to enable | full |
|
||||
| `--no-port-check` | Skip port conflict detection | false |
|
||||
| `--skip-deps` | Skip dependency installation | false |
|
||||
| `--help` | Show usage | - |
|
||||
|
||||
### 3.2 Platform Detection: `lib/platform.sh`
|
||||
|
||||
```bash
|
||||
# Functions to implement:
|
||||
|
||||
detect_os() # Returns: macos, debian, arch, fedora, linux, unknown
|
||||
detect_package_manager() # Returns: brew, apt, pacman, dnf, unknown
|
||||
detect_wsl() # Returns: WSL_DISTRO_NAME or empty
|
||||
detect_init_system() # Returns: systemd, openrc, launchd, unknown
|
||||
get_os_name() # Human-readable OS name
|
||||
get_arch() # Returns: x86_64, aarch64, armv7l
|
||||
is_root() # Check if running as root
|
||||
maybe_sudo() # Run with sudo only if needed
|
||||
```
|
||||
|
||||
**Platform Support Matrix:**
|
||||
|
||||
| Platform | Package Manager | Init System | Node.js Source |
|
||||
| ------------- | ---------------- | ----------- | ---------------- |
|
||||
| macOS | Homebrew | launchd | Homebrew |
|
||||
| Ubuntu/Debian | apt | systemd | NodeSource |
|
||||
| Arch/Manjaro | pacman | systemd | pacman |
|
||||
| Fedora/RHEL | dnf | systemd | NodeSource |
|
||||
| WSL | (host-dependent) | systemd\* | (host-dependent) |
|
||||
|
||||
### 3.3 Dependency Management: `lib/dependencies.sh`
|
||||
|
||||
**Dependencies by Mode:**
|
||||
|
||||
**Docker Mode:**
|
||||
|
||||
- Docker Engine 24+
|
||||
- Docker Compose (plugin or standalone)
|
||||
- Git (for cloning if needed)
|
||||
|
||||
**Native Mode:**
|
||||
|
||||
- Node.js 22+
|
||||
- pnpm 10+
|
||||
- PostgreSQL 17+ (with pgvector)
|
||||
- Valkey/Redis (optional)
|
||||
- Git
|
||||
|
||||
**Optional Dependencies:**
|
||||
|
||||
- Ollama (for local LLM)
|
||||
- OpenSSL (for secret generation)
|
||||
|
||||
**Installation Strategy:**
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
install_homebrew() # Auto-install Homebrew if missing
|
||||
install_docker_macos() # via Homebrew
|
||||
install_node_macos() # via Homebrew
|
||||
install_pnpm() # via corepack or npm
|
||||
|
||||
# Debian/Ubuntu
|
||||
install_docker_debian() # via docker.com apt repo
|
||||
install_node_debian() # via NodeSource
|
||||
install_postgres_debian() # via apt with pgvector
|
||||
|
||||
# Arch Linux
|
||||
install_docker_arch() # via pacman
|
||||
install_node_arch() # via pacman
|
||||
install_postgres_arch() # via pacman with pgvector
|
||||
|
||||
# Fedora/RHEL
|
||||
install_docker_fedora() # via dnf
|
||||
install_node_fedora() # via NodeSource
|
||||
install_postgres_fedora() # via dnf
|
||||
```
|
||||
|
||||
### 3.4 Docker Functions: `lib/docker.sh`
|
||||
|
||||
```bash
|
||||
check_docker() # Verify Docker is installed and accessible
|
||||
check_docker_compose() # Verify Docker Compose is available
|
||||
check_docker_buildx() # Verify buildx is available
|
||||
fix_docker_permissions() # Add user to docker group
|
||||
start_docker() # Start Docker daemon if not running
|
||||
docker_pull_images() # Pre-pull all required images
|
||||
docker_compose_up() # Start services with health checks
|
||||
docker_compose_down() # Stop services
|
||||
docker_logs() # Tail logs for debugging
|
||||
```
|
||||
|
||||
### 3.5 Configuration Generation
|
||||
|
||||
**Environment File Structure:**
|
||||
|
||||
The installer should generate a complete `.env` file based on:
|
||||
|
||||
1. **Existing values** (if `.env` exists)
|
||||
2. **Command-line arguments**
|
||||
3. **Interactive prompts** (if TTY available)
|
||||
4. **Auto-detected defaults**
|
||||
|
||||
**Secret Generation:**
|
||||
|
||||
| Secret | Length | Method |
|
||||
| ---------------------------- | ------ | -------------------- |
|
||||
| POSTGRES_PASSWORD | 32 | openssl rand -base64 |
|
||||
| JWT_SECRET | 32 | openssl rand -base64 |
|
||||
| BETTER_AUTH_SECRET | 32 | openssl rand -base64 |
|
||||
| ENCRYPTION_KEY | 32 | openssl rand -hex 32 |
|
||||
| AUTHENTIK_SECRET_KEY | 50 | openssl rand -base64 |
|
||||
| AUTHENTIK_BOOTSTRAP_PASSWORD | 16 | openssl rand -base64 |
|
||||
| COORDINATOR_API_KEY | 32 | openssl rand -base64 |
|
||||
| ORCHESTRATOR_API_KEY | 32 | openssl rand -base64 |
|
||||
| GITEA_WEBHOOK_SECRET | 32 | openssl rand -hex 32 |
|
||||
|
||||
**URL Derivation:**
|
||||
|
||||
```bash
|
||||
# Localhost mode
|
||||
MOSAIC_BASE_URL=http://localhost:${WEB_PORT}
|
||||
NEXT_PUBLIC_API_URL=http://localhost:${API_PORT}
|
||||
|
||||
# Domain mode (with Traefik)
|
||||
MOSAIC_BASE_URL=https://${MOSAIC_WEB_DOMAIN}
|
||||
NEXT_PUBLIC_API_URL=https://${MOSAIC_API_DOMAIN}
|
||||
|
||||
# IP mode (local network)
|
||||
MOSAIC_BASE_URL=http://${LOCAL_IP}:${WEB_PORT}
|
||||
NEXT_PUBLIC_API_URL=http://${LOCAL_IP}:${API_PORT}
|
||||
```
|
||||
|
||||
### 3.6 Port Conflict Resolution
|
||||
|
||||
```bash
|
||||
# Default ports
|
||||
declare -A DEFAULT_PORTS=(
|
||||
[WEB_PORT]=3000
|
||||
[API_PORT]=3001
|
||||
[POSTGRES_PORT]=5432
|
||||
[VALKEY_PORT]=6379
|
||||
[AUTHENTIK_PORT_HTTP]=9000
|
||||
[AUTHENTIK_PORT_HTTPS]=9443
|
||||
[OLLAMA_PORT]=11434
|
||||
[TRAEFIK_HTTP_PORT]=80
|
||||
[TRAEFIK_HTTPS_PORT]=443
|
||||
[TRAEFIK_DASHBOARD_PORT]=8080
|
||||
)
|
||||
|
||||
# Check if port is in use
|
||||
check_port_in_use() {
|
||||
local port=$1
|
||||
# Try ss first, fall back to netstat, then lsof
|
||||
}
|
||||
|
||||
# Suggest alternative port
|
||||
suggest_alternative_port() {
|
||||
local base_port=$1
|
||||
# Try base_port+1, base_port+100, etc.
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 Post-Install Validation: `lib/validation.sh`
|
||||
|
||||
**Doctor Checks:**
|
||||
|
||||
```bash
|
||||
# Docker mode checks
|
||||
doctor_docker_running() # Docker daemon is running
|
||||
doctor_containers_healthy() # All containers are healthy
|
||||
doctor_database_connected() # PostgreSQL is accessible
|
||||
doctor_cache_connected() # Valkey is accessible
|
||||
doctor_api_responding() # API health check passes
|
||||
doctor_web_responding() # Web frontend is accessible
|
||||
|
||||
# Native mode checks
|
||||
doctor_node_version() # Node.js version is sufficient
|
||||
doctor_pnpm_version() # pnpm is installed
|
||||
doctor_postgres_running() # PostgreSQL is running
|
||||
doctor_migrations_applied() # Database migrations are up to date
|
||||
doctor_dependencies_installed() # node_modules is complete
|
||||
|
||||
# General checks
|
||||
doctor_env_complete() # All required env vars are set
|
||||
doctor_secrets_valid() # Secrets are not placeholders
|
||||
doctor_ports_available() # Configured ports are available
|
||||
doctor_disk_space() # Sufficient disk space
|
||||
```
|
||||
|
||||
### 3.8 Doctor Command: `scripts/commands/doctor.sh`
|
||||
|
||||
A standalone diagnostic tool that can be run after installation:
|
||||
|
||||
```bash
|
||||
# Usage
|
||||
./scripts/commands/doctor.sh # Run all checks
|
||||
./scripts/commands/doctor.sh --fix # Attempt automatic fixes
|
||||
./scripts/commands/doctor.sh --verbose # Detailed output
|
||||
./scripts/commands/doctor.sh --json # JSON output for CI
|
||||
|
||||
# Exit codes
|
||||
# 0: All checks passed
|
||||
# 1: Some checks failed
|
||||
# 2: Critical failure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. User Experience Design
|
||||
|
||||
### 4.1 Banner and Branding
|
||||
|
||||
```
|
||||
__ __ _ ____ _ _
|
||||
| \/ | ___ ___ __ _(_) ___ / ___| |_ __ _ ___| | __
|
||||
| |\/| |/ _ \/ __|/ _` | |/ __|\___ | __/ _` |/ __| |/ /
|
||||
| | | | (_) \__ \ (_| | | (__ ___/ | || (_| | (__| <
|
||||
|_| |_|\___/|___/\__,_|_|\___|____/ \__\__,_|\___|_|\_\
|
||||
|
||||
Multi-Tenant Personal Assistant Platform
|
||||
|
||||
Claws out, configs in — let's ship a calm, clean stack.
|
||||
```
|
||||
|
||||
### 4.2 Progress Indicators
|
||||
|
||||
```
|
||||
✓ Detected: macOS (brew)
|
||||
✓ Docker: OK (version 27.0.0)
|
||||
✓ Docker Compose: OK (version 2.28.0)
|
||||
✓ Node.js: OK (v22.5.0)
|
||||
⚠ Port 3000 is in use, using 3001
|
||||
→ Generating secrets...
|
||||
→ Writing .env file...
|
||||
→ Starting services...
|
||||
✓ All services healthy
|
||||
```
|
||||
|
||||
### 4.3 Error Messages
|
||||
|
||||
```
|
||||
✗ Docker is not running
|
||||
Start with: open -a Docker (macOS)
|
||||
Start with: sudo systemctl start docker (Linux)
|
||||
|
||||
✗ Port conflicts detected:
|
||||
- Web UI (3000): In use by process 12345 (node)
|
||||
- API (3001): In use by process 12346 (python)
|
||||
Run with --auto-ports to use alternative ports
|
||||
```
|
||||
|
||||
### 4.4 Success Message
|
||||
|
||||
```
|
||||
════════════════════════════════════════════════════════════
|
||||
Mosaic Stack is ready!
|
||||
════════════════════════════════════════════════════════════
|
||||
|
||||
Web UI: http://localhost:3000
|
||||
API: http://localhost:3001
|
||||
Database: localhost:5432
|
||||
|
||||
Next steps:
|
||||
1. Open http://localhost:3000 in your browser
|
||||
2. Create your first workspace
|
||||
3. Configure AI providers in Settings
|
||||
|
||||
To stop: docker compose down
|
||||
To restart: docker compose restart
|
||||
To view logs: docker compose logs -f
|
||||
|
||||
Documentation: https://docs.mosaicstack.dev
|
||||
Support: https://github.com/mosaicstack/stack/issues
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Phases
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
|
||||
- [ ] Create `install.sh` entry point with argument parsing
|
||||
- [ ] Implement platform detection in `lib/platform.sh`
|
||||
- [ ] Create dependency checking functions
|
||||
- [ ] Add temp file cleanup and error handling
|
||||
- [ ] Implement colored output system
|
||||
|
||||
### Phase 2: Dependency Installation
|
||||
|
||||
- [ ] Implement Homebrew auto-installation for macOS
|
||||
- [ ] Implement Docker installation for all platforms
|
||||
- [ ] Implement Node.js installation via NodeSource (Debian/Fedora)
|
||||
- [ ] Implement pnpm installation via corepack
|
||||
- [ ] Implement PostgreSQL installation with pgvector
|
||||
|
||||
### Phase 3: Configuration
|
||||
|
||||
- [ ] Enhance .env file generation
|
||||
- [ ] Implement secret generation with proper entropy
|
||||
- [ ] Add URL derivation logic for all access modes
|
||||
- [ ] Implement port conflict detection and resolution
|
||||
- [ ] Add configuration validation
|
||||
|
||||
### Phase 4: Service Management
|
||||
|
||||
- [ ] Implement Docker Compose service orchestration
|
||||
- [ ] Add health check polling
|
||||
- [ ] Implement database migration running
|
||||
- [ ] Add service status reporting
|
||||
|
||||
### Phase 5: Validation and Doctor
|
||||
|
||||
- [ ] Create `scripts/commands/doctor.sh`
|
||||
- [ ] Implement all diagnostic checks
|
||||
- [ ] Add automatic fix capabilities
|
||||
- [ ] Create JSON output mode for CI
|
||||
|
||||
### Phase 6: Polish and Testing
|
||||
|
||||
- [ ] Add comprehensive logging
|
||||
- [ ] Test on all supported platforms
|
||||
- [ ] Test upgrade scenarios
|
||||
- [ ] Write documentation
|
||||
- [ ] Create CI/CD integration examples
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing Strategy
|
||||
|
||||
### 6.1 Platform Testing Matrix
|
||||
|
||||
| Platform | Version | Docker | Native | CI |
|
||||
| -------- | ------------- | ------ | ------ | -------------- |
|
||||
| macOS | 14 (Sonoma) | ✓ | ✓ | Manual |
|
||||
| macOS | 13 (Ventura) | ✓ | ✓ | Manual |
|
||||
| Ubuntu | 24.04 LTS | ✓ | ✓ | GitHub Actions |
|
||||
| Ubuntu | 22.04 LTS | ✓ | ✓ | GitHub Actions |
|
||||
| Debian | 12 (Bookworm) | ✓ | ✓ | GitHub Actions |
|
||||
| Arch | Rolling | ✓ | ✓ | Manual |
|
||||
| Fedora | 40 | ✓ | ✓ | Manual |
|
||||
| WSL2 | Ubuntu | ✓ | ✓ | Manual |
|
||||
|
||||
### 6.2 Test Scenarios
|
||||
|
||||
1. **Fresh Installation**
|
||||
- No dependencies installed
|
||||
- Clean system
|
||||
|
||||
2. **Partial Dependencies**
|
||||
- Some dependencies already installed
|
||||
- Different versions
|
||||
|
||||
3. **Upgrade Scenario**
|
||||
- Existing .env file
|
||||
- Running containers
|
||||
|
||||
4. **Port Conflicts**
|
||||
- Common ports in use
|
||||
- All ports in use
|
||||
|
||||
5. **Error Recovery**
|
||||
- Network failures
|
||||
- Permission issues
|
||||
- Disk space issues
|
||||
|
||||
---
|
||||
|
||||
## 7. Security Considerations
|
||||
|
||||
### 7.1 Secret Handling
|
||||
|
||||
- Never log secrets to console or file
|
||||
- Generate secrets with cryptographically secure random
|
||||
- Validate secrets are not placeholders before starting
|
||||
- Warn if default/weak secrets are detected
|
||||
|
||||
### 7.2 Network Security
|
||||
|
||||
- Download scripts over HTTPS only
|
||||
- Verify TLS certificates
|
||||
- Use specific version tags for Docker images
|
||||
- Pin NodeSource repository version
|
||||
|
||||
### 7.3 File Permissions
|
||||
|
||||
- Set restrictive permissions on .env (600)
|
||||
- Ensure secrets are not world-readable
|
||||
- Validate file ownership
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation Requirements
|
||||
|
||||
### 8.1 README Updates
|
||||
|
||||
- Add installation section with curl command
|
||||
- Document all command-line options
|
||||
- Add troubleshooting section
|
||||
- Include platform-specific notes
|
||||
|
||||
### 8.2 Inline Documentation
|
||||
|
||||
- Comment all functions with usage examples
|
||||
- Document expected return values
|
||||
- Include error codes
|
||||
|
||||
### 8.3 User Guide
|
||||
|
||||
- Step-by-step installation guide
|
||||
- Configuration options explained
|
||||
- Common issues and solutions
|
||||
- Upgrade procedures
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
1. **Should we support Windows native installation?**
|
||||
- Current recommendation: WSL2 only
|
||||
- PowerShell version could be a future addition
|
||||
|
||||
2. **Should we support ARM platforms (Raspberry Pi)?**
|
||||
- Docker images would need ARM builds
|
||||
- Could be community-supported
|
||||
|
||||
3. **What is the upgrade strategy?**
|
||||
- In-place upgrade vs. migrate data
|
||||
- Database migration handling
|
||||
- Configuration file changes
|
||||
|
||||
4. **Should we provide an uninstaller?**
|
||||
- Clean up Docker volumes
|
||||
- Remove configuration files
|
||||
- Uninstall dependencies (optional)
|
||||
|
||||
---
|
||||
|
||||
## 10. Success Criteria
|
||||
|
||||
The installer is considered successful when:
|
||||
|
||||
1. ✅ A user can run `curl -fsSL https://get.mosaicstack.dev | bash` and have a working installation
|
||||
2. ✅ All dependencies are automatically installed on supported platforms
|
||||
3. ✅ Port conflicts are automatically detected and resolved
|
||||
4. ✅ Secrets are automatically generated with proper entropy
|
||||
5. ✅ The installation completes in under 5 minutes on a fresh system
|
||||
6. ✅ Post-install validation confirms all services are healthy
|
||||
7. ✅ Clear next steps are provided to the user
|
||||
8. ✅ Non-interactive mode works for CI/CD pipelines
|
||||
9. ✅ The doctor command can diagnose and fix common issues
|
||||
10. ✅ Upgrade from previous versions preserves data and configuration
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Environment Variables Reference
|
||||
|
||||
See [`.env.example`](.env.example) for the complete list of configuration options.
|
||||
|
||||
## Appendix B: Docker Compose Profiles
|
||||
|
||||
| Profile | Services | Use Case |
|
||||
| ----------------- | ------------ | ------------------- |
|
||||
| `full` | All services | Development/Trial |
|
||||
| `database` | PostgreSQL | External cache/auth |
|
||||
| `cache` | Valkey | External database |
|
||||
| `authentik` | Authentik | SSO enabled |
|
||||
| `ollama` | Ollama | Local LLM |
|
||||
| `traefik-bundled` | Traefik | Reverse proxy |
|
||||
| `openbao` | OpenBao | Secrets management |
|
||||
|
||||
## Appendix C: Related Files
|
||||
|
||||
- Current setup script: [`scripts/setup.sh`](scripts/setup.sh)
|
||||
- Common utilities: [`scripts/lib/common.sh`](scripts/lib/common.sh)
|
||||
- Docker Compose: [`docker-compose.yml`](docker-compose.yml)
|
||||
- Environment example: [`.env.example`](.env.example)
|
||||
- OpenClaw installer reference: [`examples/openclaw/install.sh`](examples/openclaw/install.sh)
|
||||
238
plans/prisma-middleware-migration-plan.md
Normal file
238
plans/prisma-middleware-migration-plan.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Prisma Middleware Migration Plan: $use to $extends
|
||||
|
||||
## Problem Summary
|
||||
|
||||
The application fails to start with the following error:
|
||||
|
||||
```
|
||||
TypeError: prisma.$use is not a function
|
||||
at registerAccountEncryptionMiddleware (/app/apps/api/dist/prisma/account-encryption.middleware.js:45:12)
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
|
||||
The project uses **Prisma 6.19.2**, which removed the deprecated `$use()` middleware API. The `$use()` method was deprecated in Prisma4.16.0 and removed in Prisma5.0.0. The replacement is **Prisma Client Extensions** using the `$extends()` API.
|
||||
|
||||
### Affected Files
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| [`account-encryption.middleware.ts`](apps/api/src/prisma/account-encryption.middleware.ts) | Encrypts/decrypts OAuth tokens in Account table |
|
||||
| [`llm-encryption.middleware.ts`](apps/api/src/prisma/llm-encryption.middleware.ts) | Encrypts/decrypts API keys in LlmProviderInstance.config |
|
||||
| [`prisma.service.ts`](apps/api/src/prisma/prisma.service.ts) | Registers both middleware functions |
|
||||
| [`account-encryption.middleware.spec.ts`](apps/api/src/prisma/account-encryption.middleware.spec.ts) | Unit tests for account encryption |
|
||||
| [`llm-encryption.middleware.spec.ts`](apps/api/src/prisma/llm-encryption.middleware.spec.ts) | Unit tests for LLM encryption |
|
||||
| [`prisma.service.spec.ts`](apps/api/src/prisma/prisma.service.spec.ts) | Unit tests for PrismaService |
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Current Architecture (Broken)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[PrismaService.onModuleInit] --> B[$connect to database]
|
||||
B --> C[registerAccountEncryptionMiddleware]
|
||||
B --> D[registerLlmEncryptionMiddleware]
|
||||
C --> E[prisma.$use - REMOVED IN PRISMA5]
|
||||
D --> F[prisma.$use - REMOVED IN PRISMA5]
|
||||
E --> G[ERROR: $use is not a function]
|
||||
F --> G
|
||||
```
|
||||
|
||||
### Target Architecture (Client Extensions)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[PrismaService] --> B[Create Extended Client]
|
||||
B --> C[prisma.$extends with Account query override]
|
||||
B --> D[prisma.$extends with LlmProviderInstance query override]
|
||||
C --> E[Extended Client with transparent encryption]
|
||||
D --> E
|
||||
E --> F[All queries use extended client automatically]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Phase 1: Create Extension Functions
|
||||
|
||||
#### Task 1.1: Create Account Encryption Extension
|
||||
|
||||
Replace [`account-encryption.middleware.ts`](apps/api/src/prisma/account-encryption.middleware.ts) with a Client Extension:
|
||||
|
||||
**Key changes:**
|
||||
|
||||
- Remove `registerAccountEncryptionMiddleware` function
|
||||
- Create `createAccountEncryptionExtension` function that returns a Prisma extension
|
||||
- Use `prisma.$extends({ query: { account: { ... } } })` pattern
|
||||
- Override `$allOperations` or specific operations: `create`, `update`, `upsert`, `findUnique`, `findFirst`, `findMany`
|
||||
|
||||
**Extension structure:**
|
||||
|
||||
```typescript
|
||||
export function createAccountEncryptionExtension(prisma: PrismaClient, vaultService: VaultService) {
|
||||
return prisma.$extends({
|
||||
query: {
|
||||
account: {
|
||||
async $allOperations({ model, operation, args, query }) {
|
||||
// Pre-operation: encrypt on writes
|
||||
// Execute: call original query
|
||||
// Post-operation: decrypt on reads
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Task 1.2: Create LLM Encryption Extension
|
||||
|
||||
Replace [`llm-encryption.middleware.ts`](apps/api/src/prisma/llm-encryption.middleware.ts) with similar Client Extension pattern for `LlmProviderInstance` model.
|
||||
|
||||
### Phase 2: Update PrismaService
|
||||
|
||||
#### Task 2.1: Modify PrismaService to Use Extensions
|
||||
|
||||
Update [`prisma.service.ts`](apps/api/src/prisma/prisma.service.ts:28-45):
|
||||
|
||||
**Current code:**
|
||||
|
||||
```typescript
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
registerAccountEncryptionMiddleware(this, this.vaultService);
|
||||
registerLlmEncryptionMiddleware(this, this.vaultService);
|
||||
}
|
||||
```
|
||||
|
||||
**New approach:**
|
||||
|
||||
- Create extended client in constructor or onModuleInit
|
||||
- Store extended client as property
|
||||
- Export extended client for use throughout application
|
||||
|
||||
**Challenge:** PrismaService extends PrismaClient. We need to decide:
|
||||
|
||||
- Option A: Return extended client from a getter method
|
||||
- Option B: Create a wrapper that delegates to extended client
|
||||
- Option C: Use composition instead of inheritance
|
||||
|
||||
**Recommended: Option A with factory pattern**
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class PrismaService {
|
||||
private readonly baseClient: PrismaClient;
|
||||
private readonly extendedClient: ExtendedPrismaClient;
|
||||
|
||||
constructor(vaultService: VaultService) {
|
||||
this.baseClient = new PrismaClient({...});
|
||||
this.extendedClient = createExtendedClient(this.baseClient, vaultService);
|
||||
}
|
||||
|
||||
// Delegate all PrismaClient methods to extended client
|
||||
get $queryRaw() { return this.extendedClient.$queryRaw; }
|
||||
// ... other delegates
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Update Unit Tests
|
||||
|
||||
#### Task 3.1: Update account-encryption.middleware.spec.ts
|
||||
|
||||
- Change mock from `$use` to `$extends`
|
||||
- Update test structure to work with extension pattern
|
||||
- Test that extension correctly intercepts queries
|
||||
|
||||
#### Task 3.2: Update llm-encryption.middleware.spec.ts
|
||||
|
||||
Same updates as Task 3.1 for LLM encryption.
|
||||
|
||||
#### Task 3.3: Update prisma.service.spec.ts
|
||||
|
||||
- Remove `$use` mock
|
||||
- Update to test extension registration
|
||||
|
||||
### Phase 4: Integration Testing
|
||||
|
||||
#### Task 4.1: Verify Encryption/Decryption Works
|
||||
|
||||
- Run existing integration tests
|
||||
- Verify OAuth tokens are encrypted at rest
|
||||
- Verify LLM API keys are encrypted at rest
|
||||
- Verify transparent decryption on read
|
||||
|
||||
#### Task 4.2: Test Backward Compatibility
|
||||
|
||||
- Verify existing encrypted data can be decrypted
|
||||
- Verify plaintext data is encrypted on next write
|
||||
|
||||
---
|
||||
|
||||
## Detailed Implementation Checklist
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- [ ] `apps/api/src/prisma/account-encryption.middleware.ts`
|
||||
- Rename to `account-encryption.extension.ts` or keep name
|
||||
- Replace `$use` with `$extends` pattern
|
||||
- [ ] `apps/api/src/prisma/llm-encryption.middleware.ts`
|
||||
- Rename to `llm-encryption.extension.ts` or keep name
|
||||
- Replace `$use` with `$extends` pattern
|
||||
|
||||
- [ ] `apps/api/src/prisma/prisma.service.ts`
|
||||
- Refactor to use extended client
|
||||
- Maintain backward compatibility for existing code
|
||||
|
||||
- [ ] `apps/api/src/prisma/account-encryption.middleware.spec.ts`
|
||||
- Update mocks and test structure
|
||||
|
||||
- [ ] `apps/api/src/prisma/llm-encryption.middleware.spec.ts`
|
||||
- Update mocks and test structure
|
||||
|
||||
- [ ] `apps/api/src/prisma/prisma.service.spec.ts`
|
||||
- Update mocks and test structure
|
||||
|
||||
- [ ] `apps/api/src/prisma/index.ts` (if exists)
|
||||
- Update exports if file names change
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
| ------------------------- | -------- | ----------------------------------------- |
|
||||
| Breaking existing queries | High | Comprehensive test coverage before/after |
|
||||
| Type safety issues | Medium | Use Prisma generated types with extension |
|
||||
| Performance regression | Low | Extension overhead is minimal |
|
||||
| Data corruption | Critical | Test with real encryption/decryption |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Prisma Client Extensions Documentation](https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions)
|
||||
- [Migration Guide: Middleware to Extensions](https://www.prisma.io/docs/concepts/components/prisma-client/middleware)
|
||||
- Original TODO comments in code pointing to this migration
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Code Mode**: Implement extension functions (Phase 1)
|
||||
2. **Code Mode**: Update PrismaService (Phase 2)
|
||||
3. **Code Mode**: Update unit tests (Phase 3)
|
||||
4. **Debug Mode**: Integration testing and verification (Phase 4)
|
||||
|
||||
---
|
||||
|
||||
## User Decisions
|
||||
|
||||
- **File naming**: Rename middleware files to `.extension.ts` for clarity
|
||||
- `account-encryption.middleware.ts` → `account-encryption.extension.ts`
|
||||
- `llm-encryption.middleware.ts` → `llm-encryption.extension.ts`
|
||||
- `account-encryption.middleware.spec.ts` → `account-encryption.extension.spec.ts`
|
||||
- `llm-encryption.middleware.spec.ts` → `llm-encryption.extension.spec.ts`
|
||||
Reference in New Issue
Block a user