Fix/update api (#319)

* feat(webhooks): refactor webhook URL handling and enhance API endpoints

- Introduced a new utility function `buildWebhookIngressUrl` to standardize the construction of webhook URLs across components.
- Updated `WebhooksDebugPanel` and `TunnelList` components to utilize the new URL builder for improved consistency and maintainability.
- Refactored API endpoints in `tunnelsApi` to reflect changes in webhook routing, ensuring all references to tunnel management are aligned with the new structure.
- Adjusted user API calls to replace `/telegram/me` with `/auth/me` for better clarity and consistency in authentication flows.
- Enhanced socket service to normalize channel connection update payloads, improving error handling and data integrity.

These changes streamline webhook management and enhance the overall developer experience when working with webhooks.

* feat(messaging): enhance Telegram and Discord channel linking functionality

- Added support for managed DM linking with Telegram and OAuth flow for Discord.
- Introduced `createChannelLinkToken` API to generate short-lived link tokens for messaging channels.
- Updated `MessagingPanel` to handle managed linking flows, including building launch URLs and user instructions.
- Enhanced configuration to include a Telegram bot username for fallback linking.
- Updated tests to mock new configuration values for consistent testing.

These changes improve the user experience for linking messaging channels and streamline the authentication process.

* feat(api): introduce core command client and refactor API calls

- Added a new `coreCommandClient` to facilitate RPC calls to core services.
- Refactored existing API endpoints in `authApi`, `billingApi`, `creditsApi`, `tunnelsApi`, and `userApi` to utilize the new command client for improved consistency and maintainability.
- Updated the billing API to include new endpoints for fetching balance, transactions, and managing auto-recharge settings.
- Enhanced the user API to streamline user data retrieval.
- Improved error handling and logging across the refactored API calls.

These changes enhance the overall architecture of the API services, making them more modular and easier to maintain.

* refactor(api): streamline API calls and enhance error handling

- Refactored `creditsApi` and `tunnelsApi` to ensure consistent use of the new core command client.
- Updated API methods to improve error handling and response parsing.
- Introduced a new `authed_json` method in `BackendOAuthClient` for standardized authenticated requests.
- Cleaned up unused imports and optimized code structure across various modules.

These changes enhance the maintainability and reliability of the API services.

* refactor(api): streamline API calls and improve code readability

- Reorganized API calls in `authApi`, `billingApi`, `creditsApi`, and `tunnelsApi` to enhance consistency and maintainability.
- Updated function formatting for better readability, ensuring parameters are clearly defined.
- Cleaned up import statements and removed unnecessary line breaks to improve code clarity.

These changes contribute to a more maintainable codebase and enhance the overall developer experience.

* refactor(MessagingPanel): clean up imports and remove unused constants

- Reorganized import statements for clarity and consistency.
- Removed unused constants related to channel definitions and status styles to streamline the component.
- Simplified the `updateField` function call in the rendering logic for better readability.

These changes enhance the maintainability of the MessagingPanel component.

* fix: restore helper functions removed during merge cleanup

buildManagedChannelLaunchUrl and buildManagedChannelInstruction were
stripped by the linter after merge resolution, breaking the build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(messaging): enhance MessagingPanel with pending instruction handling

- Introduced a new state for pending instructions to improve user feedback during channel connection processes.
- Updated error handling to ensure users receive clear instructions, including URLs for manual access if automatic opening fails.
- Refactored connection status updates to streamline the dispatch process and improve code readability.
- Enhanced the rendering logic to display pending instructions in the UI, providing better context for users during authentication flows.

* fix(api): encode channel and ID parameters in webhook and OAuth URLs

- Updated the URL construction in the BackendOAuthClient to encode the channel parameter, ensuring proper formatting for requests.
- Modified the get_tunnel, update_tunnel, and delete_tunnel functions to encode the ID parameter in webhook URLs, enhancing security and compatibility with special characters.

* fix(auth): validate channel input for link token creation

- Added validation to ensure the channel is not empty and is one of the supported types (telegram, discord).
- Converted the channel string to lowercase for consistent matching.
- Updated the call to create_channel_link_token to use a reference to the channel variable.

* chore(todos): update TODO list with completed tasks and new feature requests

- Marked several tasks as completed, including integration of the custom memory engine and skills registry into the core.
- Added new items to the TODO list, including improvements for voiceover functionalities, screen intelligence, and Gmail skill enhancements.
- Included tasks for debugging skills from the UI and improving prompts to reduce hallucinations.

* refactor(webhooks): improve formatting of get_tunnel function for readability

- Reformatted the get_tunnel function to enhance code clarity by adjusting the indentation and line breaks in the call to get_authed_value.
- This change improves the maintainability of the code and aligns with the overall code style.

* test(userApi): enhance userApi tests with improved mocking and error handling

- Added a mock function for core command calls to streamline test setup.
- Introduced a helper function to generate mock user data for consistent test cases.
- Updated tests to use the mock function for simulating API responses, improving clarity and maintainability.
- Enhanced error handling in tests to cover various API error scenarios, ensuring robustness in userApi.getMe functionality.

* refactor(billingApi): update tests to use core command mocking and improve error handling

- Replaced apiClient mocks with core command mocks for better alignment with the new API structure.
- Updated test cases to reflect changes in API call expectations, enhancing clarity and maintainability.
- Improved error handling in tests to ensure proper propagation of errors from core command calls.

* refactor(billingApi): simplify test cases by consolidating mock function calls

- Streamlined mock function calls in billingApi tests for improved readability and consistency.
- Removed unnecessary line breaks in mock responses, enhancing clarity in test definitions.
- Ensured that error handling and API call expectations are clearly represented in the tests.

* refactor(api): rename settings endpoint to current_user for clarity

- Updated the endpoint handling authenticated profile fetches to improve clarity in the codebase.
- Adjusted the routing to reflect the new function name, enhancing maintainability and understanding of the API's purpose.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-04-04 02:59:31 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent acfa5497ef
commit 8381283c52
35 changed files with 1363 additions and 591 deletions
+3
View File
@@ -10,6 +10,9 @@ VITE_OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc
# [required] Backend API URL (web fallback when core RPC is unavailable)
VITE_BACKEND_URL=https://staging-api.alphahuman.xyz
# [optional] Telegram bot username used for managed DM linking fallback (default: openhuman_bot)
VITE_TELEGRAM_BOT_USERNAME=openhuman_bot
# [optional] Skills GitHub repository slug (default: tinyhumansai/openhuman-skills)
VITE_SKILLS_GITHUB_REPO=tinyhumansai/openhuman-skills
@@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useChannelDefinitions } from '../../../hooks/useChannelDefinitions';
import { AUTH_MODE_LABELS } from '../../../lib/channels/definitions';
import { resolvePreferredAuthModeForChannel } from '../../../lib/channels/routing';
import { createChannelLinkToken } from '../../../services/api/authApi';
import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi';
import { callCoreRpc } from '../../../services/coreRpcClient';
import {
@@ -18,12 +19,55 @@ import type {
ChannelConnectionStatus,
ChannelType,
} from '../../../types/channels';
import { BACKEND_URL, TELEGRAM_BOT_USERNAME } from '../../../utils/config';
import { openUrl } from '../../../utils/openUrl';
import ChannelFieldInput from '../../channels/ChannelFieldInput';
import ChannelStatusBadge from '../../channels/ChannelStatusBadge';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
function normalizeBaseUrl(baseUrl?: string): string {
return (baseUrl || 'https://api.tinyhumans.ai').trim().replace(/\/+$/, '');
}
function buildManagedChannelLaunchUrl(
channel: ChannelType,
token: string,
launchUrl?: string
): string | undefined {
if (launchUrl) return launchUrl;
if (channel === 'telegram') {
return `https://t.me/${encodeURIComponent(TELEGRAM_BOT_USERNAME)}?start=${encodeURIComponent(token)}`;
}
if (channel === 'discord') {
return `${normalizeBaseUrl(BACKEND_URL)}/auth/discord/connect?linkToken=${encodeURIComponent(token)}`;
}
return undefined;
}
function buildManagedChannelInstruction(
channel: ChannelType,
token: string,
launchUrl?: string
): string {
if (channel === 'telegram') {
return launchUrl
? 'Continue in Telegram to finish linking your account.'
: `Open Telegram and message @${TELEGRAM_BOT_USERNAME} with this link token: ${token}`;
}
if (channel === 'discord') {
return launchUrl
? 'Continue in Discord to finish linking your account.'
: `Use this Discord link token to continue linking your account: ${token}`;
}
return `Use this link token to continue: ${token}`;
}
const MessagingPanel = () => {
const { navigateBack } = useSettingsNavigation();
const dispatch = useAppDispatch();
@@ -33,6 +77,7 @@ const MessagingPanel = () => {
const [error, setError] = useState<string | null>(null);
const [busyKeys, setBusyKeys] = useState<Record<string, boolean>>({});
const [fieldValues, setFieldValues] = useState<Record<string, Record<string, string>>>({});
const [pendingInstruction, setPendingInstruction] = useState<Record<string, string>>({});
const recommendedRoute = useMemo(() => {
const channel = channelConnections.defaultMessagingChannel;
@@ -96,23 +141,70 @@ const MessagingPanel = () => {
if (val) credentials[field.key] = val;
}
const isManagedLinkFlow =
(channel === 'telegram' && spec.mode === 'managed_dm') ||
(channel === 'discord' && spec.mode === 'oauth');
if (isManagedLinkFlow) {
try {
const link = await createChannelLinkToken(channel);
const launchUrl = buildManagedChannelLaunchUrl(channel, link.token, link.launchUrl);
const instruction = buildManagedChannelInstruction(channel, link.token, launchUrl);
dispatch(
upsertChannelConnection({
channel,
authMode: spec.mode,
patch: { status: 'connecting' },
})
);
setPendingInstruction(prev => ({ ...prev, [key]: instruction }));
if (launchUrl) {
try {
await openUrl(launchUrl);
} catch {
// Opening the URL failed — include the URL so the user can copy it manually.
setPendingInstruction(prev => ({
...prev,
[key]: `${instruction} (URL: ${launchUrl})`,
}));
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
dispatch(
setChannelConnectionStatus({
channel,
authMode: spec.mode,
status: 'error',
lastError: msg,
})
);
throw e;
}
return;
}
const result = await channelConnectionsApi.connectChannel(channel, {
authMode: spec.mode,
credentials: Object.keys(credentials).length > 0 ? credentials : undefined,
});
if (result.status === 'pending_auth' && result.auth_action) {
const instruction = result.message ?? `Initiate ${result.auth_action} flow`;
dispatch(
upsertChannelConnection({
channel,
authMode: spec.mode,
patch: {
status: 'connecting',
lastError: result.message ?? `Initiate ${result.auth_action} flow`,
},
patch: { status: 'connecting' },
})
);
setPendingInstruction(prev => ({ ...prev, [key]: instruction }));
if (result.auth_action.includes('oauth')) {
try {
const oauthResponse = await callCoreRpc<{ result: { oauthUrl?: string } }>({
@@ -129,6 +221,12 @@ const MessagingPanel = () => {
return;
}
setPendingInstruction(prev => {
const next = { ...prev };
delete next[key];
return next;
});
dispatch(
upsertChannelConnection({
channel,
@@ -231,6 +329,8 @@ const MessagingPanel = () => {
const connection = channelConnections.connections[channelId]?.[spec.mode];
const status: ChannelConnectionStatus = connection?.status ?? 'disconnected';
const instruction = pendingInstruction[compositeKey];
return (
<div
key={spec.mode}
@@ -244,6 +344,9 @@ const MessagingPanel = () => {
{connection?.lastError && (
<p className="text-xs text-coral-300 mt-1">{connection.lastError}</p>
)}
{instruction && (
<p className="text-xs text-primary-500 mt-1">{instruction}</p>
)}
</div>
<ChannelStatusBadge status={status} />
</div>
+12
View File
@@ -0,0 +1,12 @@
import { BACKEND_URL } from '../../utils/config';
const DEFAULT_BACKEND_URL = 'https://api.tinyhumans.ai';
function normalizedBackendUrl(baseUrl?: string): string {
const value = (baseUrl || BACKEND_URL || DEFAULT_BACKEND_URL).trim();
return value.replace(/\/+$/, '');
}
export function buildWebhookIngressUrl(tunnelUuid: string, baseUrl?: string): string {
return `${normalizedBackendUrl(baseUrl)}/webhooks/ingress/${encodeURIComponent(tunnelUuid)}`;
}
@@ -1,26 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { billingApi } from '../billingApi';
const mockCallCoreCommand = vi.fn();
// Mock the apiClient module
const mockGet = vi.fn();
const mockPost = vi.fn();
vi.mock('../../apiClient', () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
},
vi.mock('../../coreCommandClient', () => ({
callCoreCommand: (...args: unknown[]) => mockCallCoreCommand(...args),
}));
const { billingApi } = await import('../billingApi');
describe('billingApi', () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
mockCallCoreCommand.mockReset();
});
describe('getCurrentPlan', () => {
it('should call GET /payments/stripe/currentPlan', async () => {
it('should call openhuman.billing_get_current_plan', async () => {
const planData = {
plan: 'BASIC',
hasActiveSubscription: true,
@@ -31,11 +25,11 @@ describe('billingApi', () => {
currentPeriodEnd: '2026-12-31T00:00:00.000Z',
},
};
mockGet.mockResolvedValue({ success: true, data: planData });
mockCallCoreCommand.mockResolvedValue(planData);
const result = await billingApi.getCurrentPlan();
expect(mockGet).toHaveBeenCalledWith('/payments/stripe/currentPlan');
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_current_plan');
expect(result).toEqual(planData);
});
@@ -46,7 +40,7 @@ describe('billingApi', () => {
planExpiry: null,
subscription: null,
};
mockGet.mockResolvedValue({ success: true, data: planData });
mockCallCoreCommand.mockResolvedValue(planData);
const result = await billingApi.getCurrentPlan();
@@ -55,50 +49,44 @@ describe('billingApi', () => {
expect(result.subscription).toBeNull();
});
it('should propagate errors from apiClient', async () => {
mockGet.mockRejectedValue({ success: false, error: 'Unauthorized' });
it('should propagate errors', async () => {
mockCallCoreCommand.mockRejectedValue(new Error('Unauthorized'));
await expect(billingApi.getCurrentPlan()).rejects.toEqual({
success: false,
error: 'Unauthorized',
});
await expect(billingApi.getCurrentPlan()).rejects.toThrow('Unauthorized');
});
});
describe('purchasePlan', () => {
it('should call POST /payments/stripe/purchasePlan with plan ID', async () => {
it('should call openhuman.billing_purchase_plan with plan ID', async () => {
const checkoutData = {
checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_123',
sessionId: 'cs_test_123',
};
mockPost.mockResolvedValue({ success: true, data: checkoutData });
mockCallCoreCommand.mockResolvedValue(checkoutData);
const result = await billingApi.purchasePlan('BASIC_MONTHLY');
expect(mockPost).toHaveBeenCalledWith('/payments/stripe/purchasePlan', {
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_purchase_plan', {
plan: 'BASIC_MONTHLY',
});
expect(result).toEqual(checkoutData);
});
it('should pass yearly plan IDs correctly', async () => {
mockPost.mockResolvedValue({
success: true,
data: { checkoutUrl: 'https://stripe.com/...', sessionId: 'cs_456' },
mockCallCoreCommand.mockResolvedValue({
checkoutUrl: 'https://stripe.com/...',
sessionId: 'cs_456',
});
await billingApi.purchasePlan('PRO_YEARLY');
expect(mockPost).toHaveBeenCalledWith('/payments/stripe/purchasePlan', {
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_purchase_plan', {
plan: 'PRO_YEARLY',
});
});
it('should return null checkoutUrl when session creation has no URL', async () => {
mockPost.mockResolvedValue({
success: true,
data: { checkoutUrl: null, sessionId: 'cs_789' },
});
mockCallCoreCommand.mockResolvedValue({ checkoutUrl: null, sessionId: 'cs_789' });
const result = await billingApi.purchasePlan('BASIC_MONTHLY');
@@ -106,31 +94,27 @@ describe('billingApi', () => {
expect(result.sessionId).toBe('cs_789');
});
it('should propagate errors from apiClient', async () => {
mockPost.mockRejectedValue({ success: false, error: 'Invalid plan' });
it('should propagate errors', async () => {
mockCallCoreCommand.mockRejectedValue(new Error('Invalid plan'));
await expect(billingApi.purchasePlan('BASIC_MONTHLY')).rejects.toEqual({
success: false,
error: 'Invalid plan',
});
await expect(billingApi.purchasePlan('BASIC_MONTHLY')).rejects.toThrow('Invalid plan');
});
});
describe('createPortalSession', () => {
it('should call POST /payments/stripe/portal with no body', async () => {
it('should call openhuman.billing_create_portal_session', async () => {
const portalData = { portalUrl: 'https://billing.stripe.com/p/session/test_123' };
mockPost.mockResolvedValue({ success: true, data: portalData });
mockCallCoreCommand.mockResolvedValue(portalData);
const result = await billingApi.createPortalSession();
expect(mockPost).toHaveBeenCalledWith('/payments/stripe/portal');
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_create_portal_session');
expect(result).toEqual(portalData);
});
it('should return the portal URL string', async () => {
mockPost.mockResolvedValue({
success: true,
data: { portalUrl: 'https://billing.stripe.com/session/abc' },
mockCallCoreCommand.mockResolvedValue({
portalUrl: 'https://billing.stripe.com/session/abc',
});
const result = await billingApi.createPortalSession();
@@ -138,29 +122,28 @@ describe('billingApi', () => {
expect(result.portalUrl).toBe('https://billing.stripe.com/session/abc');
});
it('should propagate errors from apiClient', async () => {
mockPost.mockRejectedValue({ success: false, error: 'Unable to resolve Stripe customer' });
it('should propagate errors', async () => {
mockCallCoreCommand.mockRejectedValue(new Error('Unable to resolve Stripe customer'));
await expect(billingApi.createPortalSession()).rejects.toEqual({
success: false,
error: 'Unable to resolve Stripe customer',
});
await expect(billingApi.createPortalSession()).rejects.toThrow(
'Unable to resolve Stripe customer'
);
});
});
describe('createCoinbaseCharge', () => {
it('should call POST /payments/coinbase/charge with plan and interval', async () => {
it('should call openhuman.billing_create_coinbase_charge with plan and interval', async () => {
const chargeData = {
gatewayTransactionId: 'charge_abc',
hostedUrl: 'https://commerce.coinbase.com/charges/abc',
status: 'created',
expiresAt: '2026-01-31T12:15:00.000Z',
};
mockPost.mockResolvedValue({ success: true, data: chargeData });
mockCallCoreCommand.mockResolvedValue(chargeData);
const result = await billingApi.createCoinbaseCharge('BASIC', 'annual');
expect(mockPost).toHaveBeenCalledWith('/payments/coinbase/charge', {
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_create_coinbase_charge', {
plan: 'BASIC',
interval: 'annual',
});
@@ -168,19 +151,16 @@ describe('billingApi', () => {
});
it('should default interval to annual', async () => {
mockPost.mockResolvedValue({
success: true,
data: {
gatewayTransactionId: 'charge_xyz',
hostedUrl: 'https://commerce.coinbase.com/charges/xyz',
status: 'created',
expiresAt: '2026-01-31T12:15:00.000Z',
},
mockCallCoreCommand.mockResolvedValue({
gatewayTransactionId: 'charge_xyz',
hostedUrl: 'https://commerce.coinbase.com/charges/xyz',
status: 'created',
expiresAt: '2026-01-31T12:15:00.000Z',
});
await billingApi.createCoinbaseCharge('PRO');
expect(mockPost).toHaveBeenCalledWith('/payments/coinbase/charge', {
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_create_coinbase_charge', {
plan: 'PRO',
interval: 'annual',
});
@@ -188,14 +168,11 @@ describe('billingApi', () => {
it('should return hosted URL for payment redirect', async () => {
const expectedUrl = 'https://commerce.coinbase.com/charges/test';
mockPost.mockResolvedValue({
success: true,
data: {
gatewayTransactionId: 'charge_t',
hostedUrl: expectedUrl,
status: 'created',
expiresAt: '2026-01-31T12:15:00.000Z',
},
mockCallCoreCommand.mockResolvedValue({
gatewayTransactionId: 'charge_t',
hostedUrl: expectedUrl,
status: 'created',
expiresAt: '2026-01-31T12:15:00.000Z',
});
const result = await billingApi.createCoinbaseCharge('BASIC', 'annual');
@@ -203,16 +180,14 @@ describe('billingApi', () => {
expect(result.hostedUrl).toBe(expectedUrl);
});
it('should propagate errors from apiClient', async () => {
mockPost.mockRejectedValue({
success: false,
error: 'Crypto payments are only available for annual plans',
});
it('should propagate errors', async () => {
mockCallCoreCommand.mockRejectedValue(
new Error('Crypto payments are only available for annual plans')
);
await expect(billingApi.createCoinbaseCharge('BASIC', 'annual')).rejects.toEqual({
success: false,
error: 'Crypto payments are only available for annual plans',
});
await expect(billingApi.createCoinbaseCharge('BASIC', 'annual')).rejects.toThrow(
'Crypto payments are only available for annual plans'
);
});
});
});
+48 -18
View File
@@ -1,24 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// @ts-ignore - test-only JS module outside app/src
import { setMockBehavior } from '../../../../../scripts/mock-api-core.mjs';
const mockCallCoreCommand = vi.fn();
// Mock the store import that apiClient depends on
vi.mock('../../../store', () => ({
store: { getState: () => ({ auth: { token: 'test-jwt-token' } }) },
vi.mock('../../coreCommandClient', () => ({
callCoreCommand: (...args: unknown[]) => mockCallCoreCommand(...args),
}));
vi.mock('../../../services/backendUrl', () => ({
getBackendUrl: vi.fn().mockResolvedValue('http://localhost:5005'),
}));
// Import after mocks
const { userApi } = await import('../userApi');
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,
remainingUsd: 10,
spentThisCycleUsd: 0,
spentTodayUsd: 0,
cycleStartDate: new Date().toISOString(),
},
autoDeleteTelegramMessagesAfterDays: 30,
autoDeleteThreadsAfterDays: 30,
};
}
describe('userApi.getMe', () => {
beforeEach(() => {
mockCallCoreCommand.mockReset();
});
it('returns user data on success', async () => {
// Default handler from handlers.ts already handles this
mockCallCoreCommand.mockResolvedValue(getMockUser());
const user = await userApi.getMe();
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.auth_get_me');
expect(user._id).toBe('user-123');
expect(user.firstName).toBe('Test');
expect(user.username).toBe('testuser');
@@ -26,22 +59,19 @@ describe('userApi.getMe', () => {
});
it('throws when API returns error response', async () => {
setMockBehavior('telegramMeStatus', '401');
setMockBehavior('telegramMeError', 'Unauthorized');
mockCallCoreCommand.mockRejectedValue(new Error('Unauthorized'));
await expect(userApi.getMe()).rejects.toThrow();
});
it('throws when API returns success=false', async () => {
setMockBehavior('telegramMeStatus', '200');
setMockBehavior('telegramMeError', 'Invalid token');
mockCallCoreCommand.mockRejectedValue(new Error('Invalid token'));
await expect(userApi.getMe()).rejects.toThrow('Invalid token');
});
it('throws on network error', async () => {
setMockBehavior('telegramMeStatus', '503');
setMockBehavior('telegramMeError', 'Service unavailable');
mockCallCoreCommand.mockRejectedValue(new Error('Service unavailable'));
await expect(userApi.getMe()).rejects.toBeDefined();
});
+60
View File
@@ -1,4 +1,5 @@
import { base64ToBytes, encryptIntegrationTokens } from '../../utils/integrationTokensCrypto';
import { callCoreCommand } from '../coreCommandClient';
import { callCoreRpc } from '../coreRpcClient';
interface IntegrationTokensResponse {
@@ -12,6 +13,27 @@ interface IntegrationTokensPayload {
expiresAt: string;
}
type LinkableChannel = 'telegram' | 'discord';
interface RawChannelLinkTokenData {
token?: string;
linkToken?: string;
jwtToken?: string;
url?: string;
linkUrl?: string;
authUrl?: string;
deepLinkUrl?: string;
expiresAt?: string;
expires_at?: string;
[key: string]: unknown;
}
export interface ChannelLinkTokenResult {
token: string;
launchUrl?: string;
expiresAt?: string;
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map(byte => byte.toString(16).padStart(2, '0'))
@@ -71,3 +93,41 @@ export async function fetchIntegrationTokens(
);
return { success: true, data: { encrypted } };
}
/**
* Create a short-lived link token that can be handed to a messaging channel login flow.
* POST /auth/channels/:channel/link-token (auth required)
*/
export async function createChannelLinkToken(
channel: LinkableChannel
): Promise<ChannelLinkTokenResult> {
const data = await callCoreCommand<RawChannelLinkTokenData>(
'openhuman.auth_create_channel_link_token',
{ channel }
);
const token =
typeof data?.token === 'string'
? data.token
: typeof data?.linkToken === 'string'
? data.linkToken
: typeof data?.jwtToken === 'string'
? data.jwtToken
: '';
if (!token) {
throw new Error('Channel link token response missing token');
}
const launchUrlCandidates = [data?.url, data?.linkUrl, data?.authUrl, data?.deepLinkUrl];
const launchUrl = launchUrlCandidates.find(
(value): value is string => typeof value === 'string' && value.trim().length > 0
);
const expiresAt =
typeof data?.expiresAt === 'string'
? data.expiresAt
: typeof data?.expires_at === 'string'
? data.expires_at
: undefined;
return { token, launchUrl, expiresAt };
}
+8 -19
View File
@@ -1,5 +1,4 @@
import type {
ApiResponse,
CoinbaseChargeData,
CurrentPlanData,
PlanIdentifier,
@@ -7,7 +6,7 @@ import type {
PortalSessionData,
PurchasePlanData,
} from '../../types/api';
import { apiClient } from '../apiClient';
import { callCoreCommand } from '../coreCommandClient';
/**
* Billing API endpoints
@@ -18,10 +17,7 @@ export const billingApi = {
* GET /payments/stripe/currentPlan
*/
getCurrentPlan: async (): Promise<CurrentPlanData> => {
const response = await apiClient.get<ApiResponse<CurrentPlanData>>(
'/payments/stripe/currentPlan'
);
return response.data;
return await callCoreCommand<CurrentPlanData>('openhuman.billing_get_current_plan');
},
/**
@@ -29,11 +25,7 @@ export const billingApi = {
* POST /payments/stripe/purchasePlan
*/
purchasePlan: async (plan: PlanIdentifier): Promise<PurchasePlanData> => {
const response = await apiClient.post<ApiResponse<PurchasePlanData>>(
'/payments/stripe/purchasePlan',
{ plan }
);
return response.data;
return await callCoreCommand<PurchasePlanData>('openhuman.billing_purchase_plan', { plan });
},
/**
@@ -41,9 +33,7 @@ export const billingApi = {
* POST /payments/stripe/portal
*/
createPortalSession: async (): Promise<PortalSessionData> => {
const response =
await apiClient.post<ApiResponse<PortalSessionData>>('/payments/stripe/portal');
return response.data;
return await callCoreCommand<PortalSessionData>('openhuman.billing_create_portal_session');
},
/**
@@ -54,10 +44,9 @@ export const billingApi = {
plan: PlanTier,
interval: 'annual' = 'annual'
): Promise<CoinbaseChargeData> => {
const response = await apiClient.post<ApiResponse<CoinbaseChargeData>>(
'/payments/coinbase/charge',
{ plan, interval }
);
return response.data;
return await callCoreCommand<CoinbaseChargeData>('openhuman.billing_create_coinbase_charge', {
plan,
interval,
});
},
};
+19 -41
View File
@@ -1,5 +1,4 @@
import type { ApiResponse } from '../../types/api';
import { apiClient } from '../apiClient';
import { callCoreCommand } from '../coreCommandClient';
export interface CreditBalance {
balanceUsd: number;
@@ -120,8 +119,7 @@ export const creditsApi = {
* GET /credits/balance
*/
getBalance: async (): Promise<CreditBalance> => {
const response = await apiClient.get<ApiResponse<CreditBalance>>('/payments/credits/balance');
return response.data;
return await callCoreCommand<CreditBalance>('openhuman.billing_get_balance');
},
/**
@@ -129,8 +127,7 @@ export const creditsApi = {
* GET /teams/me/usage
*/
getTeamUsage: async (): Promise<TeamUsage> => {
const response = await apiClient.get<ApiResponse<TeamUsage>>('/teams/me/usage');
return response.data;
return await callCoreCommand<TeamUsage>('openhuman.team_get_usage');
},
/**
@@ -141,11 +138,7 @@ export const creditsApi = {
amountUsd: number,
gateway: 'stripe' | 'coinbase' = 'stripe'
): Promise<TopUpResult> => {
const response = await apiClient.post<ApiResponse<TopUpResult>>('/payments/credits/top-up', {
amountUsd,
gateway,
});
return response.data;
return await callCoreCommand<TopUpResult>('openhuman.billing_top_up', { amountUsd, gateway });
},
/**
@@ -153,10 +146,10 @@ export const creditsApi = {
* GET /credits/transactions
*/
getTransactions: async (limit = 20, offset = 0): Promise<PaginatedTransactions> => {
const response = await apiClient.get<ApiResponse<PaginatedTransactions>>(
`/credits/transactions?limit=${limit}&offset=${offset}`
);
return response.data;
return await callCoreCommand<PaginatedTransactions>('openhuman.billing_get_transactions', {
limit,
offset,
});
},
// ── Auto-Recharge ──────────────────────────────────────────────────────────
@@ -166,10 +159,7 @@ export const creditsApi = {
* GET /payments/credits/auto-recharge
*/
getAutoRecharge: async (): Promise<AutoRechargeSettings> => {
const response = await apiClient.get<ApiResponse<AutoRechargeSettings>>(
'/payments/credits/auto-recharge'
);
return response.data;
return await callCoreCommand<AutoRechargeSettings>('openhuman.billing_get_auto_recharge');
},
/**
@@ -177,11 +167,9 @@ export const creditsApi = {
* PATCH /payments/credits/auto-recharge
*/
updateAutoRecharge: async (payload: AutoRechargeUpdatePayload): Promise<AutoRechargeSettings> => {
const response = await apiClient.patch<ApiResponse<AutoRechargeSettings>>(
'/payments/credits/auto-recharge',
payload
);
return response.data;
return await callCoreCommand<AutoRechargeSettings>('openhuman.billing_update_auto_recharge', {
payload,
});
},
/**
@@ -189,10 +177,7 @@ export const creditsApi = {
* GET /payments/credits/auto-recharge/cards
*/
getCards: async (): Promise<CardsData> => {
const response = await apiClient.get<ApiResponse<CardsData>>(
'/payments/credits/auto-recharge/cards'
);
return response.data;
return await callCoreCommand<CardsData>('openhuman.billing_get_cards');
},
/**
@@ -201,10 +186,7 @@ export const creditsApi = {
* POST /payments/credits/auto-recharge/cards/setup-intent
*/
createSetupIntent: async (): Promise<SetupIntentData> => {
const response = await apiClient.post<ApiResponse<SetupIntentData>>(
'/payments/credits/auto-recharge/cards/setup-intent'
);
return response.data;
return await callCoreCommand<SetupIntentData>('openhuman.billing_create_setup_intent');
},
/**
@@ -212,11 +194,10 @@ export const creditsApi = {
* PATCH /payments/credits/auto-recharge/cards/:paymentMethodId
*/
updateCard: async (paymentMethodId: string, payload: UpdateCardPayload): Promise<CardsData> => {
const response = await apiClient.patch<ApiResponse<CardsData>>(
`/payments/credits/auto-recharge/cards/${encodeURIComponent(paymentMethodId)}`,
payload
);
return response.data;
return await callCoreCommand<CardsData>('openhuman.billing_update_card', {
paymentMethodId,
payload,
});
},
/**
@@ -224,9 +205,6 @@ export const creditsApi = {
* DELETE /payments/credits/auto-recharge/cards/:paymentMethodId
*/
deleteCard: async (paymentMethodId: string): Promise<CardsData> => {
const response = await apiClient.delete<ApiResponse<CardsData>>(
`/payments/credits/auto-recharge/cards/${encodeURIComponent(paymentMethodId)}`
);
return response.data;
return await callCoreCommand<CardsData>('openhuman.billing_delete_card', { paymentMethodId });
},
};
+13 -21
View File
@@ -1,7 +1,6 @@
import type { ApiResponse } from '../../types/api';
import { apiClient } from '../apiClient';
import { callCoreCommand } from '../coreCommandClient';
const WEBHOOKS_CORE_BASE = '/webhooks/core';
// const WEBHOOKS_CORE_BASE = '/webhooks/core';
const WEBHOOKS_INGRESS_BASE = '/webhooks/ingress';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -9,7 +8,7 @@ const WEBHOOKS_INGRESS_BASE = '/webhooks/ingress';
export interface Tunnel {
/** Internal backend ID (used for CRUD endpoints: GET/PATCH/DELETE /webhooks/core/:id). */
id: string;
/** External UUID used for webhook routing (appears in webhook URLs and local registrations). */
/** External UUID used for ingress routing (appears in webhook URLs and local registrations). */
uuid: string;
name: string;
description?: string;
@@ -38,42 +37,35 @@ export interface UpdateTunnelRequest {
export const tunnelsApi = {
/** POST /webhooks/core — create a new webhook tunnel */
createTunnel: async (body: CreateTunnelRequest): Promise<Tunnel> => {
const response = await apiClient.post<ApiResponse<Tunnel>>(WEBHOOKS_CORE_BASE, body);
return response.data;
return await callCoreCommand<Tunnel>('openhuman.webhooks_create_tunnel', body);
},
/** GET /webhooks/core — list user's webhook tunnels */
getTunnels: async (): Promise<Tunnel[]> => {
const response = await apiClient.get<ApiResponse<Tunnel[]>>(WEBHOOKS_CORE_BASE);
return response.data;
return await callCoreCommand<Tunnel[]>('openhuman.webhooks_list_tunnels');
},
/** GET /webhooks/core/bandwidth — get remaining webhook budget for the current cycle */
/** GET /webhooks/core/bandwidth — get remaining webhook bandwidth budget */
getBandwidthUsage: async (): Promise<TunnelBandwidthUsage> => {
const response = await apiClient.get<ApiResponse<TunnelBandwidthUsage>>(
`${WEBHOOKS_CORE_BASE}/bandwidth`
);
return response.data;
return await callCoreCommand<TunnelBandwidthUsage>('openhuman.webhooks_get_bandwidth');
},
/** GET /webhooks/core/:tunnelId — get a specific webhook tunnel by its internal ID. */
getTunnel: async (tunnelId: string): Promise<Tunnel> => {
const response = await apiClient.get<ApiResponse<Tunnel>>(`${WEBHOOKS_CORE_BASE}/${tunnelId}`);
return response.data;
return await callCoreCommand<Tunnel>('openhuman.webhooks_get_tunnel', { id: tunnelId });
},
/** PATCH /webhooks/core/:tunnelId — update a webhook tunnel by its internal ID. */
updateTunnel: async (tunnelId: string, body: UpdateTunnelRequest): Promise<Tunnel> => {
const response = await apiClient.patch<ApiResponse<Tunnel>>(
`${WEBHOOKS_CORE_BASE}/${tunnelId}`,
body
);
return response.data;
return await callCoreCommand<Tunnel>('openhuman.webhooks_update_tunnel', {
id: tunnelId,
...body,
});
},
/** DELETE /webhooks/core/:tunnelId — delete a webhook tunnel by its internal ID. */
deleteTunnel: async (tunnelId: string): Promise<void> => {
await apiClient.delete<ApiResponse<unknown>>(`${WEBHOOKS_CORE_BASE}/${tunnelId}`);
await callCoreCommand<unknown>('openhuman.webhooks_delete_tunnel', { id: tunnelId });
},
ingressUrl: (backendUrl: string, tunnelUuid: string): string =>
+4 -7
View File
@@ -1,5 +1,6 @@
import type { GetMeResponse, User } from '../../types/api';
import type { User } from '../../types/api';
import { apiClient } from '../apiClient';
import { callCoreCommand } from '../coreCommandClient';
/**
* User API endpoints
@@ -7,14 +8,10 @@ import { apiClient } from '../apiClient';
export const userApi = {
/**
* Get current authenticated user information
* GET /telegram/me
* Core RPC -> GET /auth/me
*/
getMe: async (): Promise<User> => {
const response = await apiClient.get<GetMeResponse>('/telegram/me');
if (!response.success) {
throw new Error(response.error || 'Failed to fetch user data');
}
return response.data;
return await callCoreCommand<User>('openhuman.auth_get_me');
},
/**
+11
View File
@@ -0,0 +1,11 @@
import { callCoreRpc } from './coreRpcClient';
export interface CoreCommandResponse<T> {
result: T;
logs: string[];
}
export async function callCoreCommand<T>(method: string, params?: unknown): Promise<T> {
const response = await callCoreRpc<CoreCommandResponse<T>>({ method, params });
return response.result;
}
+47 -22
View File
@@ -54,23 +54,43 @@ interface ChannelConnectionUpdatedEvent {
capabilities?: string[];
}
function isChannelConnectionUpdatePayload(value: unknown): value is ChannelConnectionUpdatedEvent {
if (!value || typeof value !== 'object') return false;
function normalizeChannelConnectionUpdatePayload(
value: unknown
): ChannelConnectionUpdatedEvent | null {
if (!value || typeof value !== 'object') return null;
const obj = value as Record<string, unknown>;
const channel = obj.channel;
const authMode = obj.authMode;
const authMode = obj.authMode ?? obj.auth_mode;
const status = obj.status;
return (
(channel === 'telegram' || channel === 'discord') &&
(authMode === 'managed_dm' ||
authMode === 'oauth' ||
authMode === 'bot_token' ||
authMode === 'api_key') &&
(status === 'connected' ||
status === 'connecting' ||
status === 'disconnected' ||
status === 'error')
);
const lastError = obj.lastError ?? obj.last_error;
const capabilities = obj.capabilities;
const isKnownChannel = channel === 'telegram' || channel === 'discord' || channel === 'web';
const isKnownAuthMode =
authMode === 'managed_dm' ||
authMode === 'oauth' ||
authMode === 'bot_token' ||
authMode === 'api_key';
const isKnownStatus =
status === 'connected' ||
status === 'connecting' ||
status === 'disconnected' ||
status === 'error';
if (!isKnownChannel || !isKnownAuthMode || !isKnownStatus) {
return null;
}
return {
channel,
authMode,
status,
lastError: typeof lastError === 'string' ? lastError : undefined,
capabilities: Array.isArray(capabilities)
? capabilities.filter((item): item is string => typeof item === 'string')
: undefined,
};
}
function getSocketUserId(): string {
@@ -195,20 +215,25 @@ class SocketService {
store.dispatch(setStatusForUser({ userId: uid, status: 'disconnected' }));
});
this.socket.on('channel:connection-updated', data => {
if (!isChannelConnectionUpdatePayload(data)) return;
const handleChannelConnectionUpdated = (data: unknown) => {
const payload = normalizeChannelConnectionUpdatePayload(data);
if (!payload) return;
store.dispatch(
upsertChannelConnection({
channel: data.channel,
authMode: data.authMode,
channel: payload.channel,
authMode: payload.authMode,
patch: {
status: data.status,
lastError: data.lastError,
capabilities: data.capabilities ?? [],
status: payload.status,
lastError: payload.lastError,
...(payload.capabilities !== undefined && { capabilities: payload.capabilities }),
},
})
);
});
};
this.socket.on('channel:connection-updated', handleChannelConnectionUpdated);
this.socket.on('channel_connection_updated', handleChannelConnectionUpdated);
this.socket.connect();
}
+1
View File
@@ -63,6 +63,7 @@ vi.mock('../utils/config', () => ({
SKILLS_GITHUB_REPO: 'test/skills',
SENTRY_DSN: undefined,
BACKEND_URL: 'http://localhost:5005',
TELEGRAM_BOT_USERNAME: 'openhuman_bot',
DEV_JWT_TOKEN: undefined,
}));
@@ -84,6 +84,9 @@ describe('Auth flow e2e (binary + OAuth callback)', () => {
await waitFor(() => expect(mockStoreSession).toHaveBeenCalledWith('jwt-from-core', { id: '' }));
const requests = getRequestLog() as Array<{ method: string; url: string }>;
expect(requests.some(req => req.method === 'GET' && req.url.startsWith('/auth/me'))).toBe(
false
);
expect(requests.some(req => req.method === 'GET' && req.url.startsWith('/telegram/me'))).toBe(
false
);
+4
View File
@@ -32,6 +32,10 @@ export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined;
/** Backend API URL (web fallback when core RPC is unavailable). */
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL as string | undefined;
/** Telegram bot username used for managed DM linking when backend does not return a launch URL. */
export const TELEGRAM_BOT_USERNAME =
(import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined) || 'openhuman_bot';
/** Dev only: auto-inject JWT token to skip login flow. */
export const DEV_JWT_TOKEN = import.meta.env.DEV
? (import.meta.env.VITE_DEV_JWT_TOKEN as string | undefined)
@@ -96,9 +96,9 @@ async function performFullLogin(token = 'e2e-test-token') {
);
throw new Error('Auth consume call missing in performFullLogin');
}
// The app may call /telegram/me or /settings for user profile
// The app may call /auth/me or /settings for user profile
const meCall =
(await waitForRequest('GET', '/telegram/me', 10_000)) ||
(await waitForRequest('GET', '/auth/me', 10_000)) ||
(await waitForRequest('GET', '/settings', 10_000));
if (!meCall) {
console.log(
@@ -68,7 +68,7 @@ suiteRunner('Conversations web channel flow', () => {
// triggerAuthDeepLinkBypass uses key=auth which sets the token directly
// (no /telegram/login-tokens/ consume call). Wait for user profile instead.
stepLog('wait for user profile request');
const profileCall = await waitForRequest('GET', '/telegram/me', 15_000);
const profileCall = await waitForRequest('GET', '/auth/me', 15_000);
if (!profileCall) {
stepLog('user profile call not found — bypass token may have been set without API call');
}
+2 -2
View File
@@ -7,7 +7,7 @@
* 1. `openhuman://auth?token=...` deep link is triggered via __simulateDeepLink
* 2. App calls POST /telegram/login-tokens/:token/consume (mock server)
* 3. App receives JWT, dispatches to Redux authSlice
* 4. UserProvider calls GET /telegram/me (mock server)
* 4. UserProvider calls GET /auth/me (mock server)
*
* Phase 2 — Onboarding steps (6 steps in Onboarding.tsx):
* Step 0: WelcomeStep — "Continue"
@@ -168,7 +168,7 @@ describe('Login flow — complete with mock data (Linux)', () => {
while (Date.now() < deadline) {
const log = getRequestLog();
call = log.find(
r => r.method === 'GET' && (r.url.includes('/telegram/me') || r.url.includes('/settings'))
r => r.method === 'GET' && (r.url.includes('/auth/me') || r.url.includes('/settings'))
);
if (call) break;
await browser.pause(500);
@@ -198,7 +198,7 @@ describe('Logout -> re-login onboarding overlay', () => {
expect(await textExists('Welcome')).toBe(true);
expect(await textExists('Skip')).toBe(true);
const meCall = await waitForRequest(getRequestLog, 'GET', '/telegram/me', 10_000);
const meCall = await waitForRequest(getRequestLog, 'GET', '/auth/me', 10_000);
expect(meCall).toBeDefined();
});
});
+2 -2
View File
@@ -801,12 +801,12 @@ describe('Notion Integration Flows', () => {
console.log(`${LOG_PREFIX} 8.4.4: App stable after permission downgrade: "${homeMarker}"`);
// Verify auth calls were made during each re-auth.
// The app may call /telegram/me, /teams, /settings, or consume tokens
// The app may call /auth/me, /teams, /settings, or consume tokens
// via /telegram/login-tokens — any of these confirm auth activity.
const allRequests = getRequestLog();
const authCall = allRequests.find(
r =>
r.url.includes('/telegram/me') ||
r.url.includes('/auth/me') ||
r.url.includes('/teams') ||
r.url.includes('/settings') ||
r.url.includes('/telegram/login-tokens/')
+4 -4
View File
@@ -750,9 +750,9 @@ describe.skip('Telegram Integration Flows', () => {
console.log(`${LOG_PREFIX} 7.4.1: App stable after webhook setup. Home: "${homeMarker}"`);
// Verify mock server received at least the authentication-related calls
// (login token consumption and /telegram/me are always called on re-auth)
// (login token consumption and /auth/me are always called on re-auth)
const authCall = allRequests.find(r => r.url.includes('/telegram/login-tokens'));
const meCall = allRequests.find(r => r.url.includes('/telegram/me'));
const meCall = allRequests.find(r => r.url.includes('/auth/me'));
expect(authCall || meCall).toBeTruthy();
console.log(`${LOG_PREFIX} 7.4.1: Auth calls confirmed in request log`);
@@ -972,11 +972,11 @@ describe.skip('Telegram Integration Flows', () => {
// Verify the app made auth calls (which trigger permission sync)
const allRequests = getRequestLog();
const meCall = allRequests.find(r => r.url.includes('/telegram/me'));
const meCall = allRequests.find(r => r.url.includes('/auth/me'));
const teamsCall = allRequests.find(r => r.url.includes('/teams'));
console.log(
`${LOG_PREFIX} 7.5.4: Post re-auth calls — /telegram/me: ${!!meCall}, /teams: ${!!teamsCall}`
`${LOG_PREFIX} 7.5.4: Post re-auth calls — /auth/me: ${!!meCall}, /teams: ${!!teamsCall}`
);
// At least one of the auth/sync calls should have been made
+33 -5
View File
@@ -14,8 +14,8 @@ todo
[] clean up the core so that we can run it as a binary on a server or as docker
[x] Separate the binary from the tauri codebase
[] Integrate our custom memory engine into core - sanil
[] Integrate our skills registry into core - steve
[x] Integrate our custom memory engine into core - sanil
[x] Integrate our skills registry into core - steve
[x] Integrate accessibility service installation
[] Add as a step and setting in the UI - cyrus
[x] Remove mentions of zeroclaw from the codebaes
@@ -28,16 +28,44 @@ todo
[x] fix all the rust and cargo issues
[] Add icon and app name to the various permission settings - mithil
[] add self update based on github release. create a update action on the cli - aniketh
[] for each skill show information on how much data has been synced locally and information on how much syncs have happened so far etc.. - mithil/elvin
[x] for each skill show information on how much data has been synced locally and information on how much syncs have happened so far etc.. - mithil/elvin
[x] redo the docs once everything is done.
[x] remove unwanted feature flags from the rust binary
[] fix the config properly - mithil
[] Allow for Migrating from OpenClaw - steve done - to be tested
[] allow users to choose which version of LLM model they'd like to choose based on their CPU. better ram and gpu means higher parameter model can be used. - mithil
[x] in the client side app, make console.log follow a logger style logging where there's a namespace for every logger (like python) - steve
[ ] - currently we bundle tauri in the openhumany rust core but that shouldn't really have to be there. it can be completely removed.
[] allow skills to be debuggged from the UI (we shuold try to call various tools or see state from the UI itself)
[x] - currently we bundle tauri in the openhumany rust core but that shouldn't really have to be there. it can be completely removed.
[x] allow skills to be debuggged from the UI (we shuold try to call various tools or see state from the UI itself)
[] improve the prompts so that it avoid Hallucination. So that we can start to focus more on useful things.I asked a question on Notion. Instead of identifying that it is not connected and should install Notion, it gave me suggestions on fake Notion pages.
- voiceover functionalities
[] fix the overlay
[] get it to listen to meetings
[] get it to actually use the local whisper model
- screen intelligence
- ollama
[] fix bug where downloads get iterrupted and it keeps restarting over and over again
[] fix bug where download progress each download part instead of the whole model (as download happens in parts)
[] once a model has been downloaded we can hide the model window from the IU
- gmail skill
[] allow skills to have their oauth setup locally or credentials enterred manually. in which case we will need to ask for oauth creds and setup the webhook urls ourselves.
[] allows skills to have an index so that we can setup functionality to have multiple instances of skill for a user (mulitple gmail accounts etc etc...).
[] we need to massively improve the skills development and testing environment so that we can get it as close to production really. so todo that we need to somehow be able to run just the skills runtime from the core rust code within the skills repo so that testing becomes super straightforward (might be heavy, but it'll work)
[] use encryption to encrypt data back and forth; especially when working with our version of skills
[] massively simplify the skills flow and codebase (less is better)
- webhook functionality test
[] create a debug screen to view and test the available webhooks and also monitor their events
- memory skill
[] should index properly all the things (sanil)
--- e2e tests to write up
- [ ] connecting a channel like telegram/discord works properly
- [] add cmake and tauri driver into the build containers so that we can skip
+4 -1
View File
@@ -266,7 +266,10 @@ async function handleRequest(req, res) {
return;
}
if (method === "GET" && /^\/telegram\/me\/?(\?.*)?$/.test(url)) {
if (
method === "GET" &&
(/^\/telegram\/me\/?(\?.*)?$/.test(url) || /^\/auth\/me\/?(\?.*)?$/.test(url))
) {
const delayMs = getDelayMs("telegramMeDelayMs");
if (delayMs > 0) {
await sleep(delayMs);
+2 -2
View File
@@ -17,7 +17,7 @@ pub use config::{
};
pub use jwt::{bearer_authorization_value, get_session_token};
pub use rest::{
decrypt_handoff_blob, user_id_from_settings_payload, BackendOAuthClient, ConnectResponse,
IntegrationSummary, IntegrationTokensHandoff,
decrypt_handoff_blob, user_id_from_profile_payload, user_id_from_settings_payload,
BackendOAuthClient, ConnectResponse, IntegrationSummary, IntegrationTokensHandoff,
};
pub use socket::websocket_url;
+104 -11
View File
@@ -3,7 +3,7 @@
use anyhow::{Context, Result};
use base64::Engine;
use reqwest::header::AUTHORIZATION;
use reqwest::{Client, Url};
use reqwest::{Client, Method, Url};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
@@ -65,9 +65,20 @@ fn user_id_from_object(obj: &serde_json::Map<String, Value>) -> Option<String> {
None
}
/// Best-effort user id from a `GET /settings` body (unwraps `data`, checks root then nested `user`).
pub fn user_id_from_settings_payload(settings: &Value) -> Option<String> {
let obj = settings.as_object()?;
/// Best-effort user id from an authenticated profile payload.
///
/// Accepts a raw user object or an envelope that nests the user under `data`
/// or `user`.
pub fn user_id_from_profile_payload(payload: &Value) -> Option<String> {
let obj = payload.as_object()?;
if let Some(data) = obj.get("data").and_then(|v| v.as_object()) {
return user_id_from_object(data).or_else(|| {
data.get("user")
.and_then(|u| u.as_object())
.and_then(user_id_from_object)
});
}
user_id_from_object(obj).or_else(|| {
obj.get("user")
.and_then(|u| u.as_object())
@@ -75,6 +86,10 @@ pub fn user_id_from_settings_payload(settings: &Value) -> Option<String> {
})
}
pub fn user_id_from_settings_payload(settings: &Value) -> Option<String> {
user_id_from_profile_payload(settings)
}
/// JSON body returned by the backend after OAuth connect starts.
#[derive(Debug, Clone, Deserialize)]
pub struct ConnectResponse {
@@ -218,25 +233,30 @@ impl BackendOAuthClient {
Ok(ConnectResponse { oauth_url, state })
}
/// `GET /settings` — current user settings for the Bearer session JWT (used after login).
pub async fn fetch_settings(&self, bearer_jwt: &str) -> Result<Value> {
let url = self.base.join("settings").context("build /settings URL")?;
/// `GET /auth/me` — current authenticated user profile for the Bearer session JWT.
pub async fn fetch_current_user(&self, bearer_jwt: &str) -> Result<Value> {
let url = self.base.join("auth/me").context("build /auth/me URL")?;
let resp = self
.client
.get(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.send()
.await
.context("GET /settings")?;
.context("GET /auth/me")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("GET /settings failed ({status}): {text}");
anyhow::bail!("GET /auth/me failed ({status}): {text}");
}
parse_settings_response_json(&text)
}
/// Backward-compatible alias retained while older call sites are migrated.
pub async fn fetch_settings(&self, bearer_jwt: &str) -> Result<Value> {
self.fetch_current_user(bearer_jwt).await
}
/// `POST /telegram/login-tokens/:token/consume` — exchange a one-time login token for a JWT.
pub async fn consume_login_token(&self, login_token: &str) -> Result<String> {
let token = login_token.trim();
@@ -277,12 +297,85 @@ impl BackendOAuthClient {
Ok(jwt)
}
/// Confirms the JWT is accepted by the API using `GET /settings`.
/// Confirms the JWT is accepted by the API using `GET /auth/me`.
pub async fn validate_session_token(&self, bearer_jwt: &str) -> Result<()> {
let _ = self.fetch_settings(bearer_jwt).await?;
let _ = self.fetch_current_user(bearer_jwt).await?;
Ok(())
}
/// `POST /auth/channels/:channel/link-token` — create a short-lived channel link token.
pub async fn create_channel_link_token(
&self,
channel: &str,
bearer_jwt: &str,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
let encoded_channel = urlencoding::encode(channel);
let url = self
.base
.join(&format!("auth/channels/{encoded_channel}/link-token"))
.context("build channel link-token URL")?;
let resp = self
.client
.post(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.send()
.await
.context("create channel link token")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("create channel link token failed ({status}): {text}");
}
parse_settings_response_json(&text)
}
/// Generic authenticated JSON request helper for backend API routes that
/// follow the standard `{ success, data, message }` envelope.
pub async fn authed_json(
&self,
bearer_jwt: &str,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value> {
let url = self
.base
.join(path.trim_start_matches('/'))
.with_context(|| format!("build URL for {path}"))?;
let mut request = self
.client
.request(method.clone(), url.clone())
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt));
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.with_context(|| format!("backend request {} {}", method.as_str(), url.path()))?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!(
"{} {} failed ({status}): {text}",
method.as_str(),
url.path()
);
}
parse_settings_response_json(&text)
}
/// `GET /auth/integrations`
pub async fn list_integrations(&self, bearer_jwt: &str) -> Result<Vec<IntegrationSummary>> {
let url = self
+107 -98
View File
@@ -9,108 +9,16 @@
//! backend 401/403 error surfaced verbatim as an RPC error string.
//! API keys / JWTs are never written to logs (only redacted status codes + paths).
use log::debug;
use reqwest::{header::AUTHORIZATION, Client, Method, Url};
use reqwest::Method;
use serde::Serialize;
use serde_json::{json, Value};
use std::time::Duration;
use crate::api::config::effective_api_url;
use crate::api::jwt::{bearer_authorization_value, get_session_token};
use crate::api::jwt::get_session_token;
use crate::api::BackendOAuthClient;
use crate::openhuman::config::Config;
use crate::rpc::RpcOutcome;
const LOG_PREFIX: &str = "[billing]";
fn build_client() -> Result<Client, String> {
Client::builder()
.use_rustls_tls()
.http1_only()
.timeout(Duration::from_secs(120))
.connect_timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))
}
fn resolve_base(config: &Config) -> Result<Url, String> {
let base = effective_api_url(&config.api_url);
Url::parse(base.trim()).map_err(|e| format!("invalid api_url '{}': {e}", base))
}
async fn authed_request(
client: &Client,
base: &Url,
token: &str,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value, String> {
let url = base
.join(path.trim_start_matches('/'))
.map_err(|e| format!("build URL failed: {e}"))?;
let mut req = client
.request(method.clone(), url.clone())
.header(AUTHORIZATION, bearer_authorization_value(token));
if let Some(b) = body {
req = req.json(&b);
}
let resp = req
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| format!("failed to read response body: {e}"))?;
debug!("{LOG_PREFIX} {} {} -> {}", method, url, status);
let raw: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone()));
if !status.is_success() {
let msg = raw
.as_object()
.and_then(|o| {
o.get("message")
.or_else(|| o.get("error"))
.or_else(|| o.get("detail"))
.and_then(|v| v.as_str())
})
.unwrap_or(&text);
return Err(format!(
"backend responded with {} for {}: {}",
status.as_u16(),
url.path(),
msg
));
}
unwrap_api_envelope(raw)
}
fn unwrap_api_envelope(raw: Value) -> Result<Value, String> {
if let Some(obj) = raw.as_object() {
if let Some(success) = obj.get("success").and_then(|v| v.as_bool()) {
if !success {
let msg = obj
.get("message")
.or_else(|| obj.get("error"))
.and_then(|v| v.as_str())
.unwrap_or("request unsuccessful");
return Err(msg.to_string());
}
}
if let Some(data) = obj.get("data") {
return Ok(data.clone());
}
}
Ok(raw)
}
fn require_token(config: &Config) -> Result<String, String> {
get_session_token(config)?
.and_then(|v| {
@@ -131,9 +39,12 @@ async fn get_authed_value(
body: Option<Value>,
) -> Result<Value, String> {
let token = require_token(config)?;
let client = build_client()?;
let base = resolve_base(config)?;
authed_request(&client, &base, &token, method, path, body).await
let api_url = effective_api_url(&config.api_url);
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
client
.authed_json(&token, method, path, body)
.await
.map_err(|e| e.to_string())
}
pub async fn get_current_plan(config: &Config) -> Result<RpcOutcome<Value>, String> {
@@ -144,6 +55,104 @@ pub async fn get_current_plan(config: &Config) -> Result<RpcOutcome<Value>, Stri
))
}
pub async fn get_balance(config: &Config) -> Result<RpcOutcome<Value>, String> {
let data = get_authed_value(config, Method::GET, "/payments/credits/balance", None).await?;
Ok(RpcOutcome::single_log(data, "credit balance fetched"))
}
pub async fn get_transactions(
config: &Config,
limit: Option<u64>,
offset: Option<u64>,
) -> Result<RpcOutcome<Value>, String> {
let limit = limit.unwrap_or(20);
let offset = offset.unwrap_or(0);
let path = format!("/payments/credits/transactions?limit={limit}&offset={offset}");
let data = get_authed_value(config, Method::GET, &path, None).await?;
Ok(RpcOutcome::single_log(data, "credit transactions fetched"))
}
pub async fn get_auto_recharge(config: &Config) -> Result<RpcOutcome<Value>, String> {
let data =
get_authed_value(config, Method::GET, "/payments/credits/auto-recharge", None).await?;
Ok(RpcOutcome::single_log(
data,
"auto recharge settings fetched",
))
}
pub async fn update_auto_recharge(
config: &Config,
payload: Value,
) -> Result<RpcOutcome<Value>, String> {
let data = get_authed_value(
config,
Method::PATCH,
"/payments/credits/auto-recharge",
Some(payload),
)
.await?;
Ok(RpcOutcome::single_log(
data,
"auto recharge settings updated",
))
}
pub async fn get_cards(config: &Config) -> Result<RpcOutcome<Value>, String> {
let data = get_authed_value(
config,
Method::GET,
"/payments/credits/auto-recharge/cards",
None,
)
.await?;
Ok(RpcOutcome::single_log(data, "saved cards fetched"))
}
pub async fn create_setup_intent(config: &Config) -> Result<RpcOutcome<Value>, String> {
let data = get_authed_value(
config,
Method::POST,
"/payments/credits/auto-recharge/cards/setup-intent",
None,
)
.await?;
Ok(RpcOutcome::single_log(data, "setup intent created"))
}
pub async fn update_card(
config: &Config,
payment_method_id: &str,
payload: Value,
) -> Result<RpcOutcome<Value>, String> {
let payment_method_id = payment_method_id.trim();
if payment_method_id.is_empty() {
return Err("paymentMethodId is required".to_string());
}
let path = format!(
"/payments/credits/auto-recharge/cards/{}",
urlencoding::encode(payment_method_id)
);
let data = get_authed_value(config, Method::PATCH, &path, Some(payload)).await?;
Ok(RpcOutcome::single_log(data, "saved card updated"))
}
pub async fn delete_card(
config: &Config,
payment_method_id: &str,
) -> Result<RpcOutcome<Value>, String> {
let payment_method_id = payment_method_id.trim();
if payment_method_id.is_empty() {
return Err("paymentMethodId is required".to_string());
}
let path = format!(
"/payments/credits/auto-recharge/cards/{}",
urlencoding::encode(payment_method_id)
);
let data = get_authed_value(config, Method::DELETE, &path, None).await?;
Ok(RpcOutcome::single_log(data, "saved card deleted"))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct PurchasePlanBody<'a> {
+238 -63
View File
@@ -29,13 +29,49 @@ struct CoinbaseChargeParams {
interval: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct TransactionsParams {
#[serde(default)]
limit: Option<u64>,
#[serde(default)]
offset: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct JsonValueParams {
payload: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CardParams {
payment_method_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateCardParams {
payment_method_id: String,
payload: Value,
}
pub fn all_billing_controller_schemas() -> Vec<ControllerSchema> {
vec![
billing_schemas("billing_get_current_plan"),
billing_schemas("billing_get_balance"),
billing_schemas("billing_purchase_plan"),
billing_schemas("billing_create_portal_session"),
billing_schemas("billing_top_up"),
billing_schemas("billing_create_coinbase_charge"),
billing_schemas("billing_get_transactions"),
billing_schemas("billing_get_auto_recharge"),
billing_schemas("billing_update_auto_recharge"),
billing_schemas("billing_get_cards"),
billing_schemas("billing_create_setup_intent"),
billing_schemas("billing_update_card"),
billing_schemas("billing_delete_card"),
]
}
@@ -45,6 +81,10 @@ pub fn all_billing_registered_controllers() -> Vec<RegisteredController> {
schema: billing_schemas("billing_get_current_plan"),
handler: handle_billing_get_current_plan,
},
RegisteredController {
schema: billing_schemas("billing_get_balance"),
handler: handle_billing_get_balance,
},
RegisteredController {
schema: billing_schemas("billing_purchase_plan"),
handler: handle_billing_purchase_plan,
@@ -61,6 +101,34 @@ pub fn all_billing_registered_controllers() -> Vec<RegisteredController> {
schema: billing_schemas("billing_create_coinbase_charge"),
handler: handle_billing_create_coinbase_charge,
},
RegisteredController {
schema: billing_schemas("billing_get_transactions"),
handler: handle_billing_get_transactions,
},
RegisteredController {
schema: billing_schemas("billing_get_auto_recharge"),
handler: handle_billing_get_auto_recharge,
},
RegisteredController {
schema: billing_schemas("billing_update_auto_recharge"),
handler: handle_billing_update_auto_recharge,
},
RegisteredController {
schema: billing_schemas("billing_get_cards"),
handler: handle_billing_get_cards,
},
RegisteredController {
schema: billing_schemas("billing_create_setup_intent"),
handler: handle_billing_create_setup_intent,
},
RegisteredController {
schema: billing_schemas("billing_update_card"),
handler: handle_billing_update_card,
},
RegisteredController {
schema: billing_schemas("billing_delete_card"),
handler: handle_billing_delete_card,
},
]
}
@@ -76,6 +144,16 @@ pub fn billing_schemas(function: &str) -> ControllerSchema {
"Current plan payload from backend /payments/stripe/currentPlan.",
)],
},
"billing_get_balance" => ControllerSchema {
namespace: "billing",
function: "get_balance",
description: "Fetch the current user's credit balance.",
inputs: vec![],
outputs: vec![json_output(
"balance",
"Credit balance payload from backend /payments/credits/balance.",
)],
},
"billing_purchase_plan" => ControllerSchema {
namespace: "billing",
function: "purchase_plan",
@@ -175,6 +253,80 @@ pub fn billing_schemas(function: &str) -> ControllerSchema {
),
],
},
"billing_get_transactions" => ControllerSchema {
namespace: "billing",
function: "get_transactions",
description: "Fetch paginated credit transaction history.",
inputs: vec![
optional_u64("limit", "Optional page size."),
optional_u64("offset", "Optional pagination offset."),
],
outputs: vec![json_output(
"transactions",
"Credit transaction page payload.",
)],
},
"billing_get_auto_recharge" => ControllerSchema {
namespace: "billing",
function: "get_auto_recharge",
description: "Fetch Stripe auto-recharge settings.",
inputs: vec![],
outputs: vec![json_output("settings", "Auto-recharge settings payload.")],
},
"billing_update_auto_recharge" => ControllerSchema {
namespace: "billing",
function: "update_auto_recharge",
description: "Update Stripe auto-recharge settings.",
inputs: vec![FieldSchema {
name: "payload",
ty: TypeSchema::Json,
comment: "PATCH payload for /payments/credits/auto-recharge.",
required: true,
}],
outputs: vec![json_output(
"settings",
"Updated auto-recharge settings payload.",
)],
},
"billing_get_cards" => ControllerSchema {
namespace: "billing",
function: "get_cards",
description: "List saved Stripe cards for auto-recharge.",
inputs: vec![],
outputs: vec![json_output("cards", "Saved cards payload.")],
},
"billing_create_setup_intent" => ControllerSchema {
namespace: "billing",
function: "create_setup_intent",
description: "Create a Stripe SetupIntent for adding a card.",
inputs: vec![],
outputs: vec![json_output("result", "Stripe SetupIntent payload.")],
},
"billing_update_card" => ControllerSchema {
namespace: "billing",
function: "update_card",
description: "Update a saved card for auto-recharge.",
inputs: vec![
required_string("paymentMethodId", "Stripe payment method id."),
FieldSchema {
name: "payload",
ty: TypeSchema::Json,
comment: "PATCH payload for card update.",
required: true,
},
],
outputs: vec![json_output("cards", "Updated saved cards payload.")],
},
"billing_delete_card" => ControllerSchema {
namespace: "billing",
function: "delete_card",
description: "Delete a saved card for auto-recharge.",
inputs: vec![required_string(
"paymentMethodId",
"Stripe payment method id.",
)],
outputs: vec![json_output("cards", "Updated saved cards payload.")],
},
_ => ControllerSchema {
namespace: "billing",
function: "unknown",
@@ -197,6 +349,13 @@ fn handle_billing_get_current_plan(_params: Map<String, Value>) -> ControllerFut
})
}
fn handle_billing_get_balance(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::billing::get_balance(&config).await?)
})
}
fn handle_billing_purchase_plan(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
@@ -238,6 +397,76 @@ fn handle_billing_create_coinbase_charge(params: Map<String, Value>) -> Controll
})
}
fn handle_billing_get_transactions(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = if params.is_empty() {
TransactionsParams::default()
} else {
deserialize_params::<TransactionsParams>(params)?
};
to_json(
crate::openhuman::billing::get_transactions(&config, payload.limit, payload.offset)
.await?,
)
})
}
fn handle_billing_get_auto_recharge(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::billing::get_auto_recharge(&config).await?)
})
}
fn handle_billing_update_auto_recharge(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<JsonValueParams>(params)?;
to_json(crate::openhuman::billing::update_auto_recharge(&config, payload.payload).await?)
})
}
fn handle_billing_get_cards(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::billing::get_cards(&config).await?)
})
}
fn handle_billing_create_setup_intent(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::billing::create_setup_intent(&config).await?)
})
}
fn handle_billing_update_card(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<UpdateCardParams>(params)?;
to_json(
crate::openhuman::billing::update_card(
&config,
payload.payment_method_id.trim(),
payload.payload,
)
.await?,
)
})
}
fn handle_billing_delete_card(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<CardParams>(params)?;
to_json(
crate::openhuman::billing::delete_card(&config, payload.payment_method_id.trim())
.await?,
)
})
}
fn to_json(outcome: RpcOutcome<Value>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
@@ -264,6 +493,15 @@ fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
}
}
fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment,
required: false,
}
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
@@ -281,66 +519,3 @@ fn output_field(name: &'static str, ty: TypeSchema, comment: &'static str) -> Fi
required: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_names_are_stable() {
let s = billing_schemas("billing_top_up");
assert_eq!(s.namespace, "billing");
assert_eq!(s.function, "top_up");
}
#[test]
fn controller_lists_match_lengths() {
assert_eq!(
all_billing_controller_schemas().len(),
all_billing_registered_controllers().len()
);
}
#[test]
fn schemas_match_unwrapped_backend_payload_keys() {
let purchase = billing_schemas("billing_purchase_plan");
assert_eq!(
purchase
.outputs
.iter()
.map(|field| field.name)
.collect::<Vec<_>>(),
vec!["checkoutUrl", "sessionId"]
);
let portal = billing_schemas("billing_create_portal_session");
assert_eq!(
portal
.outputs
.iter()
.map(|field| field.name)
.collect::<Vec<_>>(),
vec!["portalUrl"]
);
let top_up = billing_schemas("billing_top_up");
assert_eq!(
top_up
.outputs
.iter()
.map(|field| field.name)
.collect::<Vec<_>>(),
vec!["url", "gatewayTransactionId", "amountUsd", "gateway"]
);
let coinbase = billing_schemas("billing_create_coinbase_charge");
assert_eq!(
coinbase
.outputs
.iter()
.map(|field| field.name)
.collect::<Vec<_>>(),
vec!["gatewayTransactionId", "hostedUrl", "status", "expiresAt"]
);
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ mod schemas;
pub mod session_support;
pub use crate::api::rest::{
decrypt_handoff_blob, user_id_from_settings_payload, BackendOAuthClient, ConnectResponse,
IntegrationSummary, IntegrationTokensHandoff,
decrypt_handoff_blob, user_id_from_profile_payload, user_id_from_settings_payload,
BackendOAuthClient, ConnectResponse, IntegrationSummary, IntegrationTokensHandoff,
};
pub use core::*;
pub use ops as rpc;
+44 -5
View File
@@ -4,7 +4,7 @@ use serde_json::json;
use crate::api::config::effective_api_url;
use crate::api::jwt::get_session_token;
use crate::api::rest::{user_id_from_settings_payload, BackendOAuthClient};
use crate::api::rest::{user_id_from_profile_payload, BackendOAuthClient};
use crate::openhuman::config::Config;
use crate::openhuman::credentials::session_support::{
build_session_state, parse_fields_value, profile_name_or_default, summarize_auth_profile,
@@ -55,9 +55,9 @@ pub async fn store_session(
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
let settings = client
.fetch_settings(trimmed_token)
.fetch_current_user(trimmed_token)
.await
.map_err(|e| format!("Session validation failed (GET /settings): {e:#}"))?;
.map_err(|e| format!("Session validation failed (GET /auth/me): {e:#}"))?;
let mut metadata = std::collections::HashMap::new();
if let Some(uid) = user_id
@@ -65,7 +65,7 @@ pub async fn store_session(
let t = v.trim().to_string();
(!t.is_empty()).then_some(t)
})
.or_else(|| user_id_from_settings_payload(&settings))
.or_else(|| user_id_from_profile_payload(&settings))
{
metadata.insert("user_id".to_string(), uid);
}
@@ -87,7 +87,7 @@ pub async fn store_session(
summarize_auth_profile(&profile),
vec![
format!(
"session JWT verified via GET /settings on {}",
"session JWT verified via GET /auth/me on {}",
api_url.trim_end_matches('/')
),
"session stored".to_string(),
@@ -123,6 +123,18 @@ pub async fn auth_get_session_token_json(
))
}
pub async fn auth_get_me(config: &Config) -> Result<RpcOutcome<serde_json::Value>, String> {
let api_url = effective_api_url(&config.api_url);
let token = get_session_token(config)?.ok_or_else(|| "session JWT required".to_string())?;
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
let user = client
.fetch_current_user(&token)
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::single_log(user, "current user fetched"))
}
pub async fn consume_login_token(
config: &Config,
login_token: &str,
@@ -151,6 +163,33 @@ pub async fn consume_login_token(
))
}
pub async fn auth_create_channel_link_token(
config: &Config,
channel: &str,
) -> Result<RpcOutcome<serde_json::Value>, String> {
let channel = channel.trim();
if channel.is_empty() {
return Err("channel is required".to_string());
}
let channel = channel.to_lowercase();
if !matches!(channel.as_str(), "telegram" | "discord") {
return Err(format!("unsupported channel: {channel}"));
}
let api_url = effective_api_url(&config.api_url);
let token = get_session_token(config)?.ok_or_else(|| "session JWT required".to_string())?;
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
let payload = client
.create_channel_link_token(&channel, &token)
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::single_log(
payload,
"channel link token created",
))
}
pub async fn store_provider_credentials(
config: &Config,
provider: &str,
+51
View File
@@ -23,6 +23,12 @@ struct AuthConsumeLoginTokenParams {
login_token: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthCreateChannelLinkTokenParams {
channel: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthStoreProviderCredentialsParams {
@@ -81,7 +87,9 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("auth_clear_session"),
schemas("auth_get_state"),
schemas("auth_get_session_token"),
schemas("auth_get_me"),
schemas("auth_consume_login_token"),
schemas("auth_create_channel_link_token"),
schemas("auth_store_provider_credentials"),
schemas("auth_remove_provider_credentials"),
schemas("auth_list_provider_credentials"),
@@ -110,10 +118,18 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("auth_get_session_token"),
handler: handle_auth_get_session_token,
},
RegisteredController {
schema: schemas("auth_get_me"),
handler: handle_auth_get_me,
},
RegisteredController {
schema: schemas("auth_consume_login_token"),
handler: handle_auth_consume_login_token,
},
RegisteredController {
schema: schemas("auth_create_channel_link_token"),
handler: handle_auth_create_channel_link_token,
},
RegisteredController {
schema: schemas("auth_store_provider_credentials"),
handler: handle_auth_store_provider_credentials,
@@ -179,6 +195,13 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![],
outputs: vec![json_output("token", "Session token payload.")],
},
"auth_get_me" => ControllerSchema {
namespace: "auth",
function: "get_me",
description: "Fetch the current authenticated backend user profile.",
inputs: vec![],
outputs: vec![json_output("user", "Current authenticated user payload.")],
},
"auth_consume_login_token" => ControllerSchema {
namespace: "auth",
function: "consume_login_token",
@@ -186,6 +209,13 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![required_string("loginToken", "One-time login token.")],
outputs: vec![json_output("result", "Consumed login token result.")],
},
"auth_create_channel_link_token" => ControllerSchema {
namespace: "auth",
function: "create_channel_link_token",
description: "Create a short-lived channel link token for Telegram or Discord.",
inputs: vec![required_string("channel", "Channel id (telegram|discord).")],
outputs: vec![json_output("result", "Created channel link token payload.")],
},
"auth_store_provider_credentials" => ControllerSchema {
namespace: "auth",
function: "store_provider_credentials",
@@ -303,6 +333,13 @@ fn handle_auth_get_session_token(_params: Map<String, Value>) -> ControllerFutur
})
}
fn handle_auth_get_me(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::credentials::rpc::auth_get_me(&config).await?)
})
}
fn handle_auth_consume_login_token(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
@@ -317,6 +354,20 @@ fn handle_auth_consume_login_token(params: Map<String, Value>) -> ControllerFutu
})
}
fn handle_auth_create_channel_link_token(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<AuthCreateChannelLinkTokenParams>(params)?;
to_json(
crate::openhuman::credentials::rpc::auth_create_channel_link_token(
&config,
payload.channel.trim(),
)
.await?,
)
})
}
fn handle_auth_store_provider_credentials(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
+17 -177
View File
@@ -9,34 +9,16 @@
//! receive a backend 401/403 surfaced verbatim as an RPC error string.
//! API keys / JWTs are never written to logs.
use log::debug;
use reqwest::{header::AUTHORIZATION, Client, Method, Url};
use reqwest::{Method, Url};
use serde::Serialize;
use serde_json::{json, Value};
use std::time::Duration;
use crate::api::config::effective_api_url;
use crate::api::jwt::{bearer_authorization_value, get_session_token};
use crate::api::jwt::get_session_token;
use crate::api::BackendOAuthClient;
use crate::openhuman::config::Config;
use crate::rpc::RpcOutcome;
const LOG_PREFIX: &str = "[team]";
fn build_client() -> Result<Client, String> {
Client::builder()
.use_rustls_tls()
.http1_only()
.timeout(Duration::from_secs(120))
.connect_timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))
}
fn resolve_base(config: &Config) -> Result<Url, String> {
let base = effective_api_url(&config.api_url);
Url::parse(base.trim()).map_err(|e| format!("invalid api_url '{}': {e}", base))
}
fn require_token(config: &Config) -> Result<String, String> {
get_session_token(config)?
.and_then(|v| {
@@ -73,133 +55,6 @@ fn build_api_path(segments: &[&str]) -> Result<String, String> {
Ok(url.path().to_string())
}
fn is_identifier_segment(segment: &str) -> bool {
let trimmed = segment.trim();
if trimmed.is_empty() {
return false;
}
let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '%' | '.');
let has_digit = trimmed.chars().any(|c| c.is_ascii_digit());
let is_uuid_like = trimmed.len() >= 8
&& trimmed.chars().all(allowed)
&& trimmed.contains('-')
&& trimmed.chars().any(|c| c.is_ascii_hexdigit());
(has_digit && trimmed.chars().all(allowed)) || is_uuid_like
}
fn redact_route_template(url: &Url) -> String {
let Some(segments) = url.path_segments() else {
return url.path().to_string();
};
let segments = segments.collect::<Vec<_>>();
let redacted = segments
.iter()
.enumerate()
.map(|(idx, segment)| {
match (
idx,
segments.first().copied(),
segments.get(2).copied(),
*segment,
) {
(1, Some("teams"), _, _) => "{team_id}".to_string(),
(3, Some("teams"), Some("members"), _) => "{user_id}".to_string(),
(3, Some("teams"), Some("invites"), _) => "{invite_id}".to_string(),
(_, _, _, value)
if is_identifier_segment(value)
&& !matches!(value, "teams" | "members" | "invites" | "role") =>
{
"{id}".to_string()
}
(_, _, _, value) => value.to_string(),
}
})
.collect::<Vec<_>>();
format!("/{}", redacted.join("/"))
}
async fn authed_request(
client: &Client,
base: &Url,
token: &str,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value, String> {
let url = base
.join(path.trim_start_matches('/'))
.map_err(|e| format!("build URL failed: {e}"))?;
let mut req = client
.request(method.clone(), url.clone())
.header(AUTHORIZATION, bearer_authorization_value(token));
if let Some(b) = body {
req = req.json(&b);
}
let resp = req
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| format!("failed to read backend response body: {e}"))?;
debug!(
"{LOG_PREFIX} {} {} -> {}",
method,
redact_route_template(&url),
status
);
let raw: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone()));
if !status.is_success() {
let msg = raw
.as_object()
.and_then(|o| {
o.get("message")
.or_else(|| o.get("error"))
.or_else(|| o.get("detail"))
.and_then(|v| v.as_str())
})
.unwrap_or(&text);
return Err(format!(
"backend responded with {} for {}: {}",
status.as_u16(),
url.path(),
msg
));
}
unwrap_api_envelope(raw)
}
fn unwrap_api_envelope(raw: Value) -> Result<Value, String> {
if let Some(obj) = raw.as_object() {
if let Some(success) = obj.get("success").and_then(|v| v.as_bool()) {
if !success {
let msg = obj
.get("message")
.or_else(|| obj.get("error"))
.and_then(|v| v.as_str())
.unwrap_or("request unsuccessful");
return Err(msg.to_string());
}
}
if let Some(data) = obj.get("data") {
return Ok(data.clone());
}
}
Ok(raw)
}
async fn get_authed_value(
config: &Config,
method: Method,
@@ -207,9 +62,20 @@ async fn get_authed_value(
body: Option<Value>,
) -> Result<Value, String> {
let token = require_token(config)?;
let client = build_client()?;
let base = resolve_base(config)?;
authed_request(&client, &base, &token, method, path, body).await
let api_url = effective_api_url(&config.api_url);
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
client
.authed_json(&token, method, path, body)
.await
.map_err(|e| e.to_string())
}
pub async fn get_usage(config: &Config) -> Result<RpcOutcome<Value>, String> {
let data = get_authed_value(config, Method::GET, "/teams/me/usage", None).await?;
Ok(RpcOutcome::single_log(
data,
"team usage fetched from backend",
))
}
pub async fn list_members(config: &Config, team_id: &str) -> Result<RpcOutcome<Value>, String> {
@@ -323,30 +189,4 @@ mod tests {
assert_eq!(path, "/teams/team%2Fwith%3Freserved/members/user%23frag");
}
#[test]
fn redact_route_template_hides_team_member_and_invite_ids() {
let members_url =
Url::parse("https://api.example.test/teams/team-1/members").expect("members url");
assert_eq!(
redact_route_template(&members_url),
"/teams/{team_id}/members"
);
let member_role_url = Url::parse(
"https://api.example.test/teams/69ca3f94bc6e00bbdc551900/members/user-2/role",
)
.expect("member role url");
assert_eq!(
redact_route_template(&member_role_url),
"/teams/{team_id}/members/{user_id}/role"
);
let invite_url =
Url::parse("https://api.example.test/teams/team-1/invites/inv-1").expect("invite url");
assert_eq!(
redact_route_template(&invite_url),
"/teams/{team_id}/invites/{invite_id}"
);
}
}
+22
View File
@@ -47,6 +47,7 @@ struct RevokeInviteParams {
pub fn all_team_controller_schemas() -> Vec<ControllerSchema> {
vec![
team_schemas("team_get_usage"),
team_schemas("team_list_members"),
team_schemas("team_create_invite"),
team_schemas("team_list_invites"),
@@ -58,6 +59,10 @@ pub fn all_team_controller_schemas() -> Vec<ControllerSchema> {
pub fn all_team_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: team_schemas("team_get_usage"),
handler: handle_team_get_usage,
},
RegisteredController {
schema: team_schemas("team_list_members"),
handler: handle_team_list_members,
@@ -87,6 +92,16 @@ pub fn all_team_registered_controllers() -> Vec<RegisteredController> {
pub fn team_schemas(function: &str) -> ControllerSchema {
match function {
"team_get_usage" => ControllerSchema {
namespace: "team",
function: "get_usage",
description: "Fetch the current authenticated user's active team usage.",
inputs: vec![],
outputs: vec![json_output(
"result",
"Raw usage payload returned by /teams/me/usage.",
)],
},
"team_list_members" => ControllerSchema {
namespace: "team",
function: "list_members",
@@ -180,6 +195,13 @@ pub fn team_schemas(function: &str) -> ControllerSchema {
}
}
fn handle_team_get_usage(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::team::get_usage(&config).await?)
})
}
fn handle_team_list_members(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
+121
View File
@@ -1,3 +1,7 @@
use crate::api::config::effective_api_url;
use crate::api::jwt::get_session_token;
use crate::api::BackendOAuthClient;
use crate::openhuman::config::Config;
use crate::openhuman::skills::global_engine;
use crate::openhuman::webhooks::{
WebhookDebugLogListResult, WebhookDebugLogsClearedResult, WebhookDebugRegistrationsResult,
@@ -5,8 +9,38 @@ use crate::openhuman::webhooks::{
};
use crate::rpc::RpcOutcome;
use base64::Engine;
use reqwest::Method;
use serde_json::Value;
use std::collections::HashMap;
fn require_token(config: &Config) -> Result<String, String> {
get_session_token(config)?
.and_then(|v| {
let t = v.trim().to_string();
if t.is_empty() {
None
} else {
Some(t)
}
})
.ok_or_else(|| "no backend session token; run auth_store_session first".to_string())
}
async fn get_authed_value(
config: &Config,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value, String> {
let token = require_token(config)?;
let api_url = effective_api_url(&config.api_url);
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
client
.authed_json(&token, method, path, body)
.await
.map_err(|e| e.to_string())
}
pub async fn list_registrations() -> Result<RpcOutcome<WebhookDebugRegistrationsResult>, String> {
let engine = global_engine().ok_or_else(|| "skill runtime not initialized".to_string())?;
let registrations = engine.webhook_router().list_all();
@@ -98,3 +132,90 @@ pub fn build_echo_response(request: &WebhookRequest) -> WebhookResponseData {
body: base64::engine::general_purpose::STANDARD.encode(response_body.to_string()),
}
}
pub async fn list_tunnels(config: &Config) -> Result<RpcOutcome<Value>, String> {
let data = get_authed_value(config, Method::GET, "/webhooks/core", None).await?;
Ok(RpcOutcome::single_log(data, "webhook tunnels fetched"))
}
pub async fn create_tunnel(
config: &Config,
name: &str,
description: Option<String>,
) -> Result<RpcOutcome<Value>, String> {
let name = name.trim();
if name.is_empty() {
return Err("name is required".to_string());
}
let mut body_map = serde_json::Map::new();
body_map.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
if let Some(desc) = description {
let desc = desc.trim().to_string();
if !desc.is_empty() {
body_map.insert("description".to_string(), serde_json::Value::String(desc));
}
}
let body = serde_json::Value::Object(body_map);
let data = get_authed_value(config, Method::POST, "/webhooks/core", Some(body)).await?;
Ok(RpcOutcome::single_log(data, "webhook tunnel created"))
}
pub async fn get_tunnel(config: &Config, id: &str) -> Result<RpcOutcome<Value>, String> {
let id = id.trim();
if id.is_empty() {
return Err("id is required".to_string());
}
let encoded_id = urlencoding::encode(id);
let data = get_authed_value(
config,
Method::GET,
&format!("/webhooks/core/{encoded_id}"),
None,
)
.await?;
Ok(RpcOutcome::single_log(data, "webhook tunnel fetched"))
}
pub async fn update_tunnel(
config: &Config,
id: &str,
payload: Value,
) -> Result<RpcOutcome<Value>, String> {
let id = id.trim();
if id.is_empty() {
return Err("id is required".to_string());
}
let encoded_id = urlencoding::encode(id);
let data = get_authed_value(
config,
Method::PATCH,
&format!("/webhooks/core/{encoded_id}"),
Some(payload),
)
.await?;
Ok(RpcOutcome::single_log(data, "webhook tunnel updated"))
}
pub async fn delete_tunnel(config: &Config, id: &str) -> Result<RpcOutcome<Value>, String> {
let id = id.trim();
if id.is_empty() {
return Err("id is required".to_string());
}
let encoded_id = urlencoding::encode(id);
let data = get_authed_value(
config,
Method::DELETE,
&format!("/webhooks/core/{encoded_id}"),
None,
)
.await?;
Ok(RpcOutcome::single_log(data, "webhook tunnel deleted"))
}
pub async fn get_bandwidth(config: &Config) -> Result<RpcOutcome<Value>, String> {
let data = get_authed_value(config, Method::GET, "/webhooks/core/bandwidth", None).await?;
Ok(RpcOutcome::single_log(data, "webhook bandwidth fetched"))
}
+207
View File
@@ -23,6 +23,26 @@ struct WebhookUnregisterEchoParams {
tunnel_uuid: String,
}
#[derive(Debug, Deserialize)]
struct WebhookCreateTunnelParams {
name: String,
description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct WebhookTunnelIdParams {
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebhookUpdateTunnelParams {
id: String,
name: Option<String>,
description: Option<String>,
is_active: Option<bool>,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("list_registrations"),
@@ -30,6 +50,12 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("clear_logs"),
schemas("register_echo"),
schemas("unregister_echo"),
schemas("list_tunnels"),
schemas("create_tunnel"),
schemas("get_tunnel"),
schemas("update_tunnel"),
schemas("delete_tunnel"),
schemas("get_bandwidth"),
]
}
@@ -55,6 +81,30 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("unregister_echo"),
handler: handle_unregister_echo,
},
RegisteredController {
schema: schemas("list_tunnels"),
handler: handle_list_tunnels,
},
RegisteredController {
schema: schemas("create_tunnel"),
handler: handle_create_tunnel,
},
RegisteredController {
schema: schemas("get_tunnel"),
handler: handle_get_tunnel,
},
RegisteredController {
schema: schemas("update_tunnel"),
handler: handle_update_tunnel,
},
RegisteredController {
schema: schemas("delete_tunnel"),
handler: handle_delete_tunnel,
},
RegisteredController {
schema: schemas("get_bandwidth"),
handler: handle_get_bandwidth,
},
]
}
@@ -125,6 +175,96 @@ pub fn schemas(function: &str) -> ControllerSchema {
}],
outputs: vec![json_output("result", "Updated webhook registrations.")],
},
"list_tunnels" => ControllerSchema {
namespace: "webhooks",
function: "list_tunnels",
description: "List backend-managed webhook tunnels for the current user.",
inputs: vec![],
outputs: vec![json_output("result", "Webhook tunnel list.")],
},
"create_tunnel" => ControllerSchema {
namespace: "webhooks",
function: "create_tunnel",
description: "Create a backend-managed webhook tunnel.",
inputs: vec![
FieldSchema {
name: "name",
ty: TypeSchema::String,
comment: "Tunnel name.",
required: true,
},
FieldSchema {
name: "description",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional tunnel description.",
required: false,
},
],
outputs: vec![json_output("result", "Created webhook tunnel.")],
},
"delete_tunnel" => ControllerSchema {
namespace: "webhooks",
function: "delete_tunnel",
description: "Delete a backend-managed webhook tunnel.",
inputs: vec![FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "Backend tunnel id.",
required: true,
}],
outputs: vec![json_output("result", "Delete webhook tunnel result.")],
},
"get_tunnel" => ControllerSchema {
namespace: "webhooks",
function: "get_tunnel",
description: "Fetch a backend-managed webhook tunnel by id.",
inputs: vec![FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "Backend tunnel id.",
required: true,
}],
outputs: vec![json_output("result", "Webhook tunnel payload.")],
},
"update_tunnel" => ControllerSchema {
namespace: "webhooks",
function: "update_tunnel",
description: "Update a backend-managed webhook tunnel.",
inputs: vec![
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "Backend tunnel id.",
required: true,
},
FieldSchema {
name: "name",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional tunnel name.",
required: false,
},
FieldSchema {
name: "description",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional tunnel description.",
required: false,
},
FieldSchema {
name: "isActive",
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment: "Optional active flag.",
required: false,
},
],
outputs: vec![json_output("result", "Updated webhook tunnel payload.")],
},
"get_bandwidth" => ControllerSchema {
namespace: "webhooks",
function: "get_bandwidth",
description: "Fetch the remaining webhook bandwidth budget.",
inputs: vec![],
outputs: vec![json_output("result", "Webhook bandwidth payload.")],
},
_ => ControllerSchema {
namespace: "webhooks",
function: "unknown",
@@ -176,6 +316,73 @@ fn handle_unregister_echo(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_list_tunnels(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = crate::openhuman::config::rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::webhooks::ops::list_tunnels(&config).await?)
})
}
fn handle_create_tunnel(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = crate::openhuman::config::rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<WebhookCreateTunnelParams>(params)?;
to_json(
crate::openhuman::webhooks::ops::create_tunnel(
&config,
payload.name.trim(),
payload.description,
)
.await?,
)
})
}
fn handle_delete_tunnel(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = crate::openhuman::config::rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<WebhookTunnelIdParams>(params)?;
to_json(crate::openhuman::webhooks::ops::delete_tunnel(&config, payload.id.trim()).await?)
})
}
fn handle_get_tunnel(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = crate::openhuman::config::rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<WebhookTunnelIdParams>(params)?;
to_json(crate::openhuman::webhooks::ops::get_tunnel(&config, payload.id.trim()).await?)
})
}
fn handle_update_tunnel(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = crate::openhuman::config::rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<WebhookUpdateTunnelParams>(params)?;
let mut body = serde_json::Map::new();
if let Some(name) = payload.name {
body.insert("name".to_string(), Value::String(name));
}
if let Some(desc) = payload.description {
body.insert("description".to_string(), Value::String(desc));
}
if let Some(active) = payload.is_active {
body.insert("isActive".to_string(), Value::Bool(active));
}
let body = Value::Object(body);
to_json(
crate::openhuman::webhooks::ops::update_tunnel(&config, payload.id.trim(), body)
.await?,
)
})
}
fn handle_get_bandwidth(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = crate::openhuman::config::rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::webhooks::ops::get_bandwidth(&config).await?)
})
}
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
}
+4 -3
View File
@@ -141,8 +141,8 @@ fn mock_upstream_router() -> Router {
})
}
// Matches `GET /settings` in `BackendOAuthClient::fetch_settings` (session store validation).
async fn settings(headers: HeaderMap) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Matches authenticated profile fetches used during session validation.
async fn current_user(headers: HeaderMap) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_any_bearer(&headers, &[GENERAL_TOKEN, BILLING_TOKEN, TEAM_TOKEN])?;
Ok(Json(json!({
"success": true,
@@ -370,7 +370,8 @@ fn mock_upstream_router() -> Router {
}
Router::new()
.route("/settings", get(settings))
.route("/settings", get(current_user))
.route("/auth/me", get(current_user))
.route("/openai/v1/chat/completions", post(chat_completions))
// billing
.route("/payments/stripe/currentPlan", get(stripe_current_plan))