mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
* Add JSON-RPC schema definition and HTTP schema endpoint - Introduced a new `schema.json` file containing detailed definitions for various JSON-RPC methods, including their inputs, outputs, and descriptions. - Implemented a new HTTP endpoint `/schema` in the core server to serve the JSON-RPC schema, enhancing API documentation and accessibility. - Updated the core HTTP router to include the new schema route, improving the overall structure and usability of the API. - Enhanced error handling and response formatting in the server to ensure consistent feedback for schema requests. * Update TypeScript configuration and refactor core RPC client - Changed TypeScript target from ES2020 to ESNext and updated library references in `tsconfig.json` for improved compatibility with modern features. - Refactored `coreRpcClient.ts` to enhance JSON-RPC request handling, including the introduction of legacy method aliases and improved error handling. - Updated API service methods in `authApi.ts` and `channelConnectionsApi.ts` to utilize the new core RPC client structure, streamlining authentication and channel connection processes. - Added new utility functions for managing JSON-RPC requests and responses, improving code organization and maintainability. - Enhanced test coverage for the new RPC client methods and refactored existing tests to align with the updated structure. * Enhance Tauri configuration and refactor daemon program arguments - Updated `tauri.conf.json` to include additional macOS infoPlist settings for better application identification and icon management. - Refactored the `daemon_program_args` function in `common.rs` to improve clarity by renaming the parameter to `_exe`, indicating it is unused. This change enhances code readability and maintainability. * Refactor Tauri configuration by removing unused macOS infoPlist settings - Updated `tauri.conf.json` to streamline macOS configuration by removing unnecessary infoPlist entries while retaining essential settings for the application. - This change enhances clarity and maintainability of the Tauri configuration file. * Enhance authentication flow and testing documentation - Introduced a new `isAuthBootstrapComplete` state in the authentication slice to manage the completion of the authentication bootstrap process. - Updated the `UserProvider` to set the `isAuthBootstrapComplete` state based on the authentication status, improving session restoration logic. - Modified route components (`DefaultRedirect`, `ProtectedRoute`, `PublicRoute`) to conditionally render based on the `isAuthBootstrapComplete` state, enhancing user experience during the authentication process. - Added a comprehensive testing guide in `CLAUDE.md`, detailing unit and E2E testing practices, including setup, authoring rules, and a checklist for test coverage. - Updated the `SettingsHome` component to redirect to the home page instead of the login page upon logout, streamlining user navigation. - Enhanced the `LocalModelPanel` to track download progress and manage local AI assets more effectively, improving overall functionality. * Add resolutions for @tauri-apps/api dependency in package.json - Introduced a resolutions field in package.json to enforce the use of @tauri-apps/api version 2.10.1, ensuring compatibility across workspaces. - Updated dependencies in app/package.json to include @tauri-apps/api version 2.10.1, aligning with the new resolution. - Adjusted yarn.lock to reflect the updated version of @tauri-apps/api, enhancing dependency management and consistency. * Refactor Tauri configuration and enhance E2E build process - Updated `tauri.conf.json` to remove unused resource paths, streamlining the configuration for better maintainability. - Modified `wdio.conf.ts` to improve application path resolution for macOS, allowing for multiple bundle base checks to enhance compatibility. - Refactored `e2e-build.sh` to disable updater artifacts for E2E builds and introduced a conditional cargo clean mechanism, improving build efficiency and clarity. * Enhance E2E testing setup and documentation - Updated `CLAUDE.md` to clarify the default behavior of `OPENHUMAN_WORKSPACE` in `e2e-run-spec.sh`, emphasizing automatic creation and cleanup for reproducible E2E runs. - Modified `e2e-run-spec.sh` to implement automatic temporary workspace creation when `OPENHUMAN_WORKSPACE` is not set, improving usability for debugging and testing. - Enhanced cleanup logic in `e2e-run-spec.sh` to ensure proper removal of temporary workspaces after tests, contributing to a cleaner testing environment. * Implement shared mock backend for testing and enhance documentation - Introduced a shared mock backend for unit and integration tests, allowing for deterministic API behavior across app and Rust tests. - Updated `CLAUDE.md` to include detailed instructions on using the shared mock backend, including key admin endpoints and manual run commands. - Modified `package.json` to add scripts for running the mock API server and Rust tests with the mock backend. - Refactored test setup to utilize the new mock backend, improving test reliability and isolation. - Removed obsolete MSW handlers and server setup, streamlining the testing framework. * Enhance authentication state management and testing coverage - Introduced `isAuthBootstrapComplete` state in the authentication slice to track the completion of the authentication process. - Updated `ProtectedRoute` and `PublicRoute` components to utilize the new state, improving user experience during authentication. - Enhanced test cases for `ProtectedRoute` and `PublicRoute` to reflect the updated authentication state structure. - Added a new end-to-end test for the authentication flow, ensuring proper handling of OAuth tokens and session management. - Improved mock setup in tests to better simulate authentication scenarios, enhancing test reliability and coverage. * Enhance README.md with architecture overview and component roles - Added detailed descriptions of the OpenHuman architecture, highlighting the separation of business logic and UI components. - Explained the roles of Rust and the UI in the monorepo, including the use of JSON-RPC, QuickJS, Vite, React, and Tauri. - Documented the structure of controllers and the RPC surface, emphasizing the shared contract for automation and testing. - Provided links to further documentation for architecture, frontend structure, and Tauri commands. * Integrate ServiceBlockingGate component and enhance loading states - Added the `ServiceBlockingGate` component to manage service availability and display appropriate loading screens. - Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to show a loading indicator while authentication bootstrap is in progress. - Implemented timeout handling in `UserProvider` for improved authentication state management. - Introduced tests for `ServiceBlockingGate` to ensure proper rendering and functionality under various service states. * Implement core RPC URL resolution and default hash route handling - Added a function to resolve the core RPC URL based on the environment, improving flexibility for Tauri and non-Tauri contexts. - Introduced a default hash route handler in the main application entry point to ensure proper navigation behavior. - Updated the core RPC command to expose the resolved RPC URL, enhancing the integration with the frontend. - Refactored the core RPC client to utilize the new URL resolution logic, ensuring consistent API calls. * Refactor backend URL usage to API_BASE_URL - Replaced all instances of BACKEND_URL with API_BASE_URL across various components and services to standardize API endpoint references. - Updated OAuth provider configurations, settings panels, hooks, and services to ensure consistent API calls. - Enhanced test setups to reflect the new API_BASE_URL, improving test reliability and alignment with the updated configuration. * Implement RouteLoadingScreen for improved loading states - Introduced a new `RouteLoadingScreen` component to provide a consistent loading experience across various routes. - Updated `DefaultRedirect`, `ProtectedRoute`, and `PublicRoute` components to utilize `RouteLoadingScreen` while waiting for authentication bootstrap completion. - Enhanced `MiniSidebar` to hide on additional public/setup routes, improving user navigation experience. - Refactored `UserProvider` to streamline authentication state management by removing unnecessary references. * Add feature design workflow section to CLAUDE.md - Introduced a comprehensive workflow for feature design, outlining steps from specification to UI implementation and testing. - Emphasized the importance of grounding designs in existing codebases and defined planning rules for E2E scenarios. - Provided detailed instructions for implementing features in Rust, conducting JSON-RPC tests, and building UI components in the Tauri app. * Refactor backend URL handling and improve OAuth flow - Replaced static API_BASE_URL references with dynamic backend URL resolution across various components and services, enhancing flexibility for Tauri and non-Tauri environments. - Updated OAuth provider configurations to utilize the new backend URL logic, ensuring consistent login URL generation. - Refactored API client and socket service to fetch the backend URL dynamically, improving reliability in different deployment contexts. - Introduced a new service for resolving the backend URL, streamlining the configuration and enhancing test setups. * Add debug logging guidelines to CLAUDE.md - Introduced comprehensive guidelines for implementing development-oriented debug logging in both Rust and the app. - Emphasized the importance of logging at appropriate levels (`debug`/`trace`) and following existing patterns for consistency. - Provided instructions on avoiding sensitive information in logs and ensuring terminal output is grep-friendly for easier debugging during development. * Enhance service management with new mock functionality and E2E tests - Added a mock service manager to facilitate deterministic service behavior during end-to-end tests, enabling better simulation of service states. - Introduced new buttons in the `ServiceBlockingGate` component for restarting and uninstalling services, improving user control over service management. - Implemented a comprehensive E2E test suite for the service connectivity flow, covering installation, starting, stopping, restarting, and uninstalling services. - Updated package scripts to include a new E2E test for service connectivity, enhancing testing coverage and reliability. - Refactored service operations to support mock functionality, ensuring consistent behavior across testing and production environments. * Enhance ServiceBlockingGate with improved logging and periodic health polling - Introduced periodic health polling in the `ServiceBlockingGate` component to refresh service status every 3 seconds, enhancing responsiveness to service state changes. - Added detailed logging for various operations, including service status checks and error handling, to improve traceability and debugging. - Updated E2E tests to include logging steps for better visibility during service connectivity flow tests. - Refactored error handling to ensure consistent logging of error messages across service operations. * Refactor E2E testing scripts and enhance CLAUDE.md documentation - Updated paths in CLAUDE.md to reflect the new location of the E2E run script, ensuring accurate instructions for running tests. - Removed outdated E2E test scripts from package.json and migrated relevant functionality to app/package.json for better organization. - Introduced new E2E testing scripts for specific flows (auth, login, payment, etc.) to streamline testing processes and improve modularity. - Added a script to run all E2E flows sequentially, enhancing test coverage and simplifying execution. - Improved documentation for E2E testing procedures in CLAUDE.md, providing clearer guidance for developers. * Refactor imports and enhance code readability - Removed duplicate import of `ServiceBlockingGate` in `App.tsx` for cleaner code. - Improved readability in `MiniSidebar.tsx` by formatting conditional statements. - Reordered imports in `PublicRoute.tsx` for consistency. - Enhanced formatting in `ServiceBlockingGate.tsx` for better clarity in asynchronous operations. - Streamlined import statements in various test files and components for improved organization. - Updated `LocalModelPanel.tsx` to enhance button disable logic readability. - Refactored CORS headers in `jsonrpc.rs` for better formatting. * ci: align build and test workflows with app workspace e2e * ci: align release and typecheck workflows with current workspace * Enhance ServiceBlockingGate functionality with improved refresh options - Introduced a new `RefreshOptions` type to customize the behavior of the `refreshStatus` function, allowing for conditional error clearing and status checking. - Updated the `refreshStatus` function to utilize the new options, enhancing control over service state updates. - Modified the button click handler to pass the new options, improving user experience during service refresh operations. - Adjusted state updates to prevent unnecessary re-renders and maintain consistency in service state management. * Refactor RefreshOptions type for improved clarity in ServiceBlockingGate - Consolidated the definition of the `RefreshOptions` type into a single line for better readability. - Maintained existing functionality while enhancing code clarity in the `ServiceBlockingGate` component.
706 lines
20 KiB
JavaScript
706 lines
20 KiB
JavaScript
import crypto from "node:crypto";
|
|
import http from "node:http";
|
|
|
|
const DEFAULT_PORT = 18473;
|
|
const MOCK_JWT = "e2e-mock-jwt-token";
|
|
|
|
let requestLog = [];
|
|
let mockBehavior = {};
|
|
let server = null;
|
|
const openSockets = new Set();
|
|
|
|
const CORS_HEADERS = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
"Access-Control-Max-Age": "86400",
|
|
};
|
|
|
|
function setCors(res) {
|
|
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
|
res.setHeader(key, value);
|
|
}
|
|
}
|
|
|
|
function json(res, status, body) {
|
|
setCors(res);
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function html(res, status, body) {
|
|
setCors(res);
|
|
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
res.end(body);
|
|
}
|
|
|
|
function requestOrigin(req) {
|
|
const host = req.headers.host || "127.0.0.1:18473";
|
|
return `http://${host}`;
|
|
}
|
|
|
|
function getMockUser() {
|
|
return {
|
|
_id: "user-123",
|
|
telegramId: 12345678,
|
|
hasAccess: true,
|
|
magicWord: "alpha",
|
|
firstName: "Test",
|
|
lastName: "User",
|
|
username: "testuser",
|
|
role: "user",
|
|
activeTeamId: "team-1",
|
|
referral: {},
|
|
subscription: { hasActiveSubscription: false, plan: "FREE" },
|
|
settings: {
|
|
dailySummariesEnabled: false,
|
|
dailySummaryChatIds: [],
|
|
autoCompleteEnabled: false,
|
|
autoCompleteVisibility: "always",
|
|
autoCompleteWhitelistChatIds: [],
|
|
autoCompleteBlacklistChatIds: [],
|
|
},
|
|
usage: {
|
|
cycleBudgetUsd: 10,
|
|
spentThisCycleUsd: 0,
|
|
spentTodayUsd: 0,
|
|
cycleStartDate: new Date().toISOString(),
|
|
},
|
|
autoDeleteTelegramMessagesAfterDays: 30,
|
|
autoDeleteThreadsAfterDays: 30,
|
|
};
|
|
}
|
|
|
|
function getMockTeam() {
|
|
const plan = mockBehavior.plan || "FREE";
|
|
const isActive = mockBehavior.planActive === "true";
|
|
const expiry = mockBehavior.planExpiry || null;
|
|
return {
|
|
team: {
|
|
_id: "team-1",
|
|
name: "Personal",
|
|
slug: "personal",
|
|
createdBy: "test-user-123",
|
|
isPersonal: true,
|
|
maxMembers: 1,
|
|
subscription: { plan, hasActiveSubscription: isActive, planExpiry: expiry },
|
|
usage: { dailyTokenLimit: 1000, remainingTokens: 1000, activeSessionCount: 0 },
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
role: "ADMIN",
|
|
};
|
|
}
|
|
|
|
function getRequestLog() {
|
|
return [...requestLog];
|
|
}
|
|
|
|
function clearRequestLog() {
|
|
requestLog = [];
|
|
}
|
|
|
|
function setMockBehavior(key, value) {
|
|
mockBehavior[key] = String(value);
|
|
}
|
|
|
|
function setMockBehaviors(behavior, mode = "merge") {
|
|
if (mode === "replace") {
|
|
mockBehavior = {};
|
|
}
|
|
for (const [key, value] of Object.entries(behavior || {})) {
|
|
mockBehavior[key] = String(value);
|
|
}
|
|
}
|
|
|
|
function resetMockBehavior() {
|
|
mockBehavior = {};
|
|
}
|
|
|
|
function getMockBehavior() {
|
|
return { ...mockBehavior };
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve) => {
|
|
const chunks = [];
|
|
req.on("data", (c) => chunks.push(c));
|
|
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
});
|
|
}
|
|
|
|
function tryParseJson(raw) {
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function handleRequest(req, res) {
|
|
const method = req.method ?? "GET";
|
|
const url = req.url ?? "/";
|
|
const body = await readBody(req);
|
|
const parsedBody = tryParseJson(body);
|
|
const origin = requestOrigin(req);
|
|
|
|
requestLog.push({ method, url, body, timestamp: Date.now() });
|
|
|
|
if (method === "OPTIONS") {
|
|
setCors(res);
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/__admin\/health\/?$/.test(url)) {
|
|
json(res, 200, { ok: true, port: server?.address()?.port ?? null });
|
|
return;
|
|
}
|
|
if (method === "GET" && /^\/__admin\/requests\/?$/.test(url)) {
|
|
json(res, 200, { success: true, data: getRequestLog() });
|
|
return;
|
|
}
|
|
if (method === "GET" && /^\/__admin\/behavior\/?$/.test(url)) {
|
|
json(res, 200, { success: true, data: getMockBehavior() });
|
|
return;
|
|
}
|
|
if (method === "POST" && /^\/__admin\/reset\/?$/.test(url)) {
|
|
const keepBehavior = parsedBody?.keepBehavior === true;
|
|
const keepRequests = parsedBody?.keepRequests === true;
|
|
if (!keepBehavior) resetMockBehavior();
|
|
if (!keepRequests) clearRequestLog();
|
|
json(res, 200, {
|
|
success: true,
|
|
data: { behavior: getMockBehavior(), requestCount: getRequestLog().length },
|
|
});
|
|
return;
|
|
}
|
|
if (method === "POST" && /^\/__admin\/behavior\/?$/.test(url)) {
|
|
if (parsedBody?.behavior && typeof parsedBody.behavior === "object") {
|
|
setMockBehaviors(parsedBody.behavior, parsedBody.mode);
|
|
} else if (parsedBody?.key) {
|
|
setMockBehavior(parsedBody.key, parsedBody.value ?? "");
|
|
}
|
|
json(res, 200, { success: true, data: getMockBehavior() });
|
|
return;
|
|
}
|
|
|
|
if (url.startsWith("/socket.io/")) {
|
|
const eioOpen = JSON.stringify({
|
|
sid: "mock-sid-" + Date.now(),
|
|
upgrades: ["websocket"],
|
|
pingInterval: 25000,
|
|
pingTimeout: 20000,
|
|
});
|
|
const packet = `${eioOpen.length + 1}:0${eioOpen}`;
|
|
setCors(res);
|
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
res.end(packet);
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/telegram\/login-tokens\/[^/]+\/consume\/?$/.test(url)) {
|
|
if (mockBehavior.token === "expired") {
|
|
json(res, 401, { success: false, error: "Token expired or invalid" });
|
|
return;
|
|
}
|
|
if (mockBehavior.token === "invalid") {
|
|
json(res, 401, { success: false, error: "Invalid token" });
|
|
return;
|
|
}
|
|
const jwt = mockBehavior.jwt ? `${MOCK_JWT}-${mockBehavior.jwt}` : MOCK_JWT;
|
|
json(res, 200, { success: true, data: { jwtToken: jwt } });
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/auth\/desktop-exchange\/?$/.test(url)) {
|
|
json(res, 200, {
|
|
sessionToken: "mock-session-token",
|
|
user: { id: "user-123", firstName: "Test", username: "testuser" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/telegram\/me\/?(\?.*)?$/.test(url)) {
|
|
if (mockBehavior.telegramMeStatus) {
|
|
const status = Number(mockBehavior.telegramMeStatus) || 500;
|
|
json(res, status, {
|
|
success: false,
|
|
error: mockBehavior.telegramMeError || "Mock telegram/me failure",
|
|
});
|
|
return;
|
|
}
|
|
if (mockBehavior.session === "revoked") {
|
|
json(res, 401, { success: false, error: "Unauthorized" });
|
|
return;
|
|
}
|
|
json(res, 200, { success: true, data: getMockUser() });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/settings\/?(\?.*)?$/.test(url)) {
|
|
json(res, 200, { success: true, data: { _id: "e2e-user-1", username: "e2e" } });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/teams\/?(\?.*)?$/.test(url)) {
|
|
json(res, 200, { success: true, data: [getMockTeam()] });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/auth\/integrations\/?(\?.*)?$/.test(url)) {
|
|
json(res, 200, { success: true, data: [] });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/openai\/v1\/models\/?(\?.*)?$/.test(url)) {
|
|
json(res, 200, { data: [{ id: "e2e-mock-model", object: "model" }] });
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/openai\/v1\/chat\/completions\/?$/.test(url)) {
|
|
json(res, 200, {
|
|
choices: [{ message: { role: "assistant", content: "Hello from e2e mock agent" } }],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/auth\/[^/]+\/login\/?(\?.*)?$/.test(url)) {
|
|
const redirectUrl = `${origin}/mock-oauth`;
|
|
if (url.includes("responseType=json")) {
|
|
json(res, 200, { success: true, data: { oauthUrl: redirectUrl } });
|
|
return;
|
|
}
|
|
setCors(res);
|
|
res.writeHead(302, { Location: redirectUrl });
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/auth\/telegram\/connect\/?(\?.*)?$/.test(url)) {
|
|
if (mockBehavior.telegramDuplicate === "true") {
|
|
json(res, 409, { success: false, error: "Telegram account already linked to another user" });
|
|
return;
|
|
}
|
|
json(res, 200, { success: true, data: { oauthUrl: `${origin}/mock-telegram-oauth` } });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/auth\/notion\/connect\/?(\?.*)?$/.test(url)) {
|
|
if (mockBehavior.notionTokenRevoked === "true") {
|
|
json(res, 401, { success: false, error: "OAuth token has been revoked" });
|
|
return;
|
|
}
|
|
const workspace = mockBehavior.notionWorkspace || "Test User's Workspace";
|
|
json(res, 200, {
|
|
success: true,
|
|
data: { oauthUrl: `${origin}/mock-notion-oauth`, workspace },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/auth\/google\/connect\/?(\?.*)?$/.test(url)) {
|
|
if (mockBehavior.gmailTokenRevoked === "true") {
|
|
json(res, 401, { success: false, error: "OAuth token has been revoked" });
|
|
return;
|
|
}
|
|
if (mockBehavior.gmailTokenExpired === "true") {
|
|
json(res, 401, { success: false, error: "OAuth token has expired" });
|
|
return;
|
|
}
|
|
json(res, 200, { success: true, data: { oauthUrl: `${origin}/mock-google-oauth` } });
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/telegram\/command\/?$/.test(url)) {
|
|
if (mockBehavior.telegramUnauthorized === "true") {
|
|
json(res, 403, { success: false, error: "Unauthorized: insufficient permissions" });
|
|
return;
|
|
}
|
|
if (mockBehavior.telegramCommandError === "true") {
|
|
json(res, 400, { success: false, error: "Invalid command format" });
|
|
return;
|
|
}
|
|
json(res, 200, { success: true, data: { result: "Command executed successfully" } });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/telegram\/permissions\/?(\?.*)?$/.test(url)) {
|
|
const level = mockBehavior.telegramPermission || "read";
|
|
json(res, 200, {
|
|
success: true,
|
|
data: { level, canRead: true, canWrite: level !== "read", canInitiate: level === "admin" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/telegram\/webhook\/configure\/?$/.test(url)) {
|
|
json(res, 200, {
|
|
success: true,
|
|
data: { webhookUrl: "https://api.example.com/webhook/telegram", active: true },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/telegram\/disconnect\/?$/.test(url)) {
|
|
json(res, 200, { success: true, data: { disconnected: true } });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/notion\/permissions\/?(\?.*)?$/.test(url)) {
|
|
const level = mockBehavior.notionPermission || "read";
|
|
json(res, 200, {
|
|
success: true,
|
|
data: { level, canRead: true, canWrite: level !== "read", canCreate: level !== "read" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/gmail\/permissions\/?(\?.*)?$/.test(url)) {
|
|
const level = mockBehavior.gmailPermission || "read";
|
|
json(res, 200, {
|
|
success: true,
|
|
data: { level, canRead: true, canWrite: level !== "read", canInitiate: level === "admin" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/gmail\/disconnect\/?$/.test(url)) {
|
|
json(res, 200, { success: true, data: { disconnected: true } });
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/gmail\/emails\/?(\?.*)?$/.test(url)) {
|
|
json(res, 200, {
|
|
success: true,
|
|
data: [
|
|
{
|
|
id: "msg-1",
|
|
subject: "Welcome to OpenHuman",
|
|
from: "team@openhuman.com",
|
|
date: new Date().toISOString(),
|
|
snippet: "Welcome to the platform!",
|
|
hasAttachments: false,
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/skills\/?(\?.*)?$/.test(url)) {
|
|
json(res, 200, {
|
|
success: true,
|
|
data: [
|
|
{
|
|
id: "telegram",
|
|
name: "Telegram",
|
|
status: mockBehavior.telegramSkillStatus || "installed",
|
|
setupComplete: mockBehavior.telegramSetupComplete === "true",
|
|
},
|
|
{
|
|
id: "notion",
|
|
name: "Notion",
|
|
status: mockBehavior.notionSkillStatus || "installed",
|
|
setupComplete: mockBehavior.notionSetupComplete === "true",
|
|
},
|
|
{
|
|
id: "email",
|
|
name: "Email",
|
|
status: mockBehavior.gmailSkillStatus || "installed",
|
|
setupComplete: mockBehavior.gmailSetupComplete === "true",
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/invite\/redeem\/?$/.test(url)) {
|
|
json(res, 200, { success: true, data: { message: "Invite code redeemed successfully" } });
|
|
return;
|
|
}
|
|
if (method === "GET" && /^\/invite\/my-codes\/?(\?.*)?$/.test(url)) {
|
|
json(res, 200, { success: true, data: [] });
|
|
return;
|
|
}
|
|
if (method === "GET" && /^\/invite\/status/.test(url)) {
|
|
json(res, 200, { success: true, data: { valid: true } });
|
|
return;
|
|
}
|
|
if (method === "POST" && /^\/telegram\/settings\/onboarding-complete\/?$/.test(url)) {
|
|
json(res, 200, { success: true, data: {} });
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(method === "GET" && /^\/billing\/current-plan\/?(\?.*)?$/.test(url)) ||
|
|
(method === "GET" && /^\/payments\/stripe\/currentPlan\/?(\?.*)?$/.test(url))
|
|
) {
|
|
const plan = mockBehavior.plan || "FREE";
|
|
const isActive = mockBehavior.planActive === "true";
|
|
const expiry = mockBehavior.planExpiry || null;
|
|
json(res, 200, {
|
|
success: true,
|
|
data: {
|
|
plan,
|
|
hasActiveSubscription: isActive,
|
|
planExpiry: expiry,
|
|
subscription: isActive
|
|
? {
|
|
id: "sub_mock_123",
|
|
status: "active",
|
|
currentPeriodEnd: expiry || new Date(Date.now() + 30 * 86400000).toISOString(),
|
|
}
|
|
: null,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/payments\/stripe\/purchasePlan\/?$/.test(url)) {
|
|
if (mockBehavior.purchaseError === "true") {
|
|
json(res, 500, { success: false, error: "Payment service unavailable" });
|
|
return;
|
|
}
|
|
json(res, 200, {
|
|
success: true,
|
|
data: { checkoutUrl: `${origin}/mock-checkout`, sessionId: `cs_mock_${Date.now()}` },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/payments\/stripe\/portal\/?$/.test(url)) {
|
|
json(res, 200, { success: true, data: { portalUrl: `${origin}/mock-portal` } });
|
|
return;
|
|
}
|
|
|
|
if (method === "POST" && /^\/payments\/coinbase\/charge\/?$/.test(url)) {
|
|
if (mockBehavior.coinbaseError === "true") {
|
|
json(res, 500, { success: false, error: "Coinbase service unavailable" });
|
|
return;
|
|
}
|
|
const chargeId = `coinbase_mock_${Date.now()}`;
|
|
json(res, 200, {
|
|
success: true,
|
|
data: {
|
|
gatewayTransactionId: chargeId,
|
|
hostedUrl: `${origin}/mock-coinbase-checkout`,
|
|
status: "NEW",
|
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/payments\/coinbase\/charge\/[^/]+\/?(\?.*)?$/.test(url)) {
|
|
const status = mockBehavior.cryptoStatus || "NEW";
|
|
json(res, 200, {
|
|
success: true,
|
|
data: {
|
|
status,
|
|
payment: {
|
|
status,
|
|
amountPaid: status === "UNDERPAID" ? "150.00" : status === "OVERPAID" ? "350.00" : "250.00",
|
|
amountExpected: "250.00",
|
|
currency: "USDC",
|
|
underpaidAmount: mockBehavior.cryptoUnderpaidAmount || "0",
|
|
overpaidAmount: mockBehavior.cryptoOverpaidAmount || "0",
|
|
},
|
|
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (method === "GET" && /^\/mock-(telegram|notion|google)-oauth\/?(\?.*)?$/.test(url)) {
|
|
html(res, 200, "<html><body><h1>Mock OAuth</h1></body></html>");
|
|
return;
|
|
}
|
|
if (method === "GET" && /^\/mock-oauth\/?(\?.*)?$/.test(url)) {
|
|
html(res, 200, "<html><body><h1>Mock OAuth Redirect</h1></body></html>");
|
|
return;
|
|
}
|
|
|
|
json(res, 200, { success: true, data: {} });
|
|
}
|
|
|
|
function handleSocketIOMessage(socket, text, sid) {
|
|
if (text === "2") {
|
|
sendWsText(socket, "3");
|
|
return;
|
|
}
|
|
if (text.startsWith("40")) {
|
|
sendWsText(socket, `40{"sid":"${sid}"}`);
|
|
}
|
|
}
|
|
|
|
function sendWsText(socket, text) {
|
|
sendWsFrame(socket, 0x01, Buffer.from(text, "utf-8"));
|
|
}
|
|
|
|
function sendWsFrame(socket, opcode, payload) {
|
|
if (socket.destroyed) return;
|
|
|
|
const len = payload.length;
|
|
let header;
|
|
if (len < 126) {
|
|
header = Buffer.alloc(2);
|
|
header[0] = 0x80 | opcode;
|
|
header[1] = len;
|
|
} else if (len < 65536) {
|
|
header = Buffer.alloc(4);
|
|
header[0] = 0x80 | opcode;
|
|
header[1] = 126;
|
|
header.writeUInt16BE(len, 2);
|
|
} else {
|
|
header = Buffer.alloc(10);
|
|
header[0] = 0x80 | opcode;
|
|
header[1] = 127;
|
|
header.writeBigUInt64BE(BigInt(len), 2);
|
|
}
|
|
try {
|
|
socket.write(header);
|
|
socket.write(payload);
|
|
} catch {
|
|
// noop
|
|
}
|
|
}
|
|
|
|
function handleWebSocketUpgrade(req, socket) {
|
|
if (!req.url?.startsWith("/socket.io/")) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
const key = req.headers["sec-websocket-key"];
|
|
if (!key) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
const acceptKey = crypto
|
|
.createHash("sha1")
|
|
.update(key + "258EAFA5-E914-47DA-95CA-5AB5DC085B11")
|
|
.digest("base64");
|
|
socket.write(
|
|
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Upgrade: websocket\r\n" +
|
|
"Connection: Upgrade\r\n" +
|
|
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
|
"\r\n",
|
|
);
|
|
|
|
const mockSid = "mock-ws-" + Date.now();
|
|
const eioOpen = JSON.stringify({
|
|
sid: mockSid,
|
|
upgrades: [],
|
|
pingInterval: 25000,
|
|
pingTimeout: 60000,
|
|
maxPayload: 1000000,
|
|
});
|
|
sendWsText(socket, `0${eioOpen}`);
|
|
|
|
let buffer = Buffer.alloc(0);
|
|
socket.on("data", (chunk) => {
|
|
buffer = Buffer.concat([buffer, chunk]);
|
|
while (buffer.length >= 2) {
|
|
const firstByte = buffer[0];
|
|
const opcode = firstByte & 0x0f;
|
|
const secondByte = buffer[1];
|
|
const masked = (secondByte & 0x80) !== 0;
|
|
let payloadLen = secondByte & 0x7f;
|
|
let offset = 2;
|
|
|
|
if (payloadLen === 126) {
|
|
if (buffer.length < 4) return;
|
|
payloadLen = buffer.readUInt16BE(2);
|
|
offset = 4;
|
|
} else if (payloadLen === 127) {
|
|
if (buffer.length < 10) return;
|
|
payloadLen = Number(buffer.readBigUInt64BE(2));
|
|
offset = 10;
|
|
}
|
|
|
|
const maskSize = masked ? 4 : 0;
|
|
const totalLen = offset + maskSize + payloadLen;
|
|
if (buffer.length < totalLen) return;
|
|
let payload = buffer.subarray(offset + maskSize, totalLen);
|
|
if (masked) {
|
|
const mask = buffer.subarray(offset, offset + 4);
|
|
payload = Buffer.from(payload);
|
|
for (let i = 0; i < payload.length; i += 1) {
|
|
payload[i] ^= mask[i % 4];
|
|
}
|
|
}
|
|
buffer = buffer.subarray(totalLen);
|
|
if (opcode === 0x08) {
|
|
socket.end();
|
|
return;
|
|
}
|
|
if (opcode === 0x09) {
|
|
sendWsFrame(socket, 0x0a, payload);
|
|
continue;
|
|
}
|
|
if (opcode === 0x01) {
|
|
handleSocketIOMessage(socket, payload.toString("utf-8"), mockSid);
|
|
}
|
|
}
|
|
});
|
|
socket.on("error", () => {});
|
|
socket.on("close", () => {});
|
|
}
|
|
|
|
function startMockServer(port = DEFAULT_PORT) {
|
|
return new Promise((resolve, reject) => {
|
|
if (server) {
|
|
resolve({ port: server.address()?.port ?? port, alreadyRunning: true });
|
|
return;
|
|
}
|
|
server = http.createServer((req, res) => {
|
|
handleRequest(req, res).catch((err) => {
|
|
console.error("[MockServer] Unhandled error:", err);
|
|
json(res, 500, { success: false, error: "Internal mock error" });
|
|
});
|
|
});
|
|
server.on("connection", (socket) => {
|
|
openSockets.add(socket);
|
|
socket.on("close", () => openSockets.delete(socket));
|
|
});
|
|
server.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket));
|
|
server.on("error", reject);
|
|
server.listen(port, "127.0.0.1", () => {
|
|
console.log(`[MockServer] Listening on http://127.0.0.1:${port}`);
|
|
resolve({ port });
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopMockServer() {
|
|
return new Promise((resolve) => {
|
|
if (!server) {
|
|
resolve();
|
|
return;
|
|
}
|
|
for (const socket of openSockets) {
|
|
socket.destroy();
|
|
}
|
|
openSockets.clear();
|
|
server.close(() => {
|
|
console.log("[MockServer] Stopped");
|
|
server = null;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
export {
|
|
DEFAULT_PORT,
|
|
clearRequestLog,
|
|
getMockBehavior,
|
|
getRequestLog,
|
|
resetMockBehavior,
|
|
setMockBehavior,
|
|
setMockBehaviors,
|
|
startMockServer,
|
|
stopMockServer,
|
|
};
|