diff --git a/app/.env.example b/app/.env.example index 306d5ee28..5645c62c2 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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 diff --git a/app/src/components/settings/panels/MessagingPanel.tsx b/app/src/components/settings/panels/MessagingPanel.tsx index 2f74f1a72..4cc857eec 100644 --- a/app/src/components/settings/panels/MessagingPanel.tsx +++ b/app/src/components/settings/panels/MessagingPanel.tsx @@ -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(null); const [busyKeys, setBusyKeys] = useState>({}); const [fieldValues, setFieldValues] = useState>>({}); + const [pendingInstruction, setPendingInstruction] = useState>({}); 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 (
{ {connection?.lastError && (

{connection.lastError}

)} + {instruction && ( +

{instruction}

+ )}
diff --git a/app/src/lib/webhooks/urls.ts b/app/src/lib/webhooks/urls.ts new file mode 100644 index 000000000..43fa61727 --- /dev/null +++ b/app/src/lib/webhooks/urls.ts @@ -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)}`; +} diff --git a/app/src/services/api/__tests__/billingApi.test.ts b/app/src/services/api/__tests__/billingApi.test.ts index c24265831..fdac57589 100644 --- a/app/src/services/api/__tests__/billingApi.test.ts +++ b/app/src/services/api/__tests__/billingApi.test.ts @@ -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' + ); }); }); }); diff --git a/app/src/services/api/__tests__/userApi.test.ts b/app/src/services/api/__tests__/userApi.test.ts index 7f1a4e181..891039c9e 100644 --- a/app/src/services/api/__tests__/userApi.test.ts +++ b/app/src/services/api/__tests__/userApi.test.ts @@ -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(); }); diff --git a/app/src/services/api/authApi.ts b/app/src/services/api/authApi.ts index e182a7b7c..d28cb6d12 100644 --- a/app/src/services/api/authApi.ts +++ b/app/src/services/api/authApi.ts @@ -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 { + const data = await callCoreCommand( + '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 }; +} diff --git a/app/src/services/api/billingApi.ts b/app/src/services/api/billingApi.ts index ea721a917..ee934349f 100644 --- a/app/src/services/api/billingApi.ts +++ b/app/src/services/api/billingApi.ts @@ -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 => { - const response = await apiClient.get>( - '/payments/stripe/currentPlan' - ); - return response.data; + return await callCoreCommand('openhuman.billing_get_current_plan'); }, /** @@ -29,11 +25,7 @@ export const billingApi = { * POST /payments/stripe/purchasePlan */ purchasePlan: async (plan: PlanIdentifier): Promise => { - const response = await apiClient.post>( - '/payments/stripe/purchasePlan', - { plan } - ); - return response.data; + return await callCoreCommand('openhuman.billing_purchase_plan', { plan }); }, /** @@ -41,9 +33,7 @@ export const billingApi = { * POST /payments/stripe/portal */ createPortalSession: async (): Promise => { - const response = - await apiClient.post>('/payments/stripe/portal'); - return response.data; + return await callCoreCommand('openhuman.billing_create_portal_session'); }, /** @@ -54,10 +44,9 @@ export const billingApi = { plan: PlanTier, interval: 'annual' = 'annual' ): Promise => { - const response = await apiClient.post>( - '/payments/coinbase/charge', - { plan, interval } - ); - return response.data; + return await callCoreCommand('openhuman.billing_create_coinbase_charge', { + plan, + interval, + }); }, }; diff --git a/app/src/services/api/creditsApi.ts b/app/src/services/api/creditsApi.ts index 6a7d1e4ed..31e3f72f4 100644 --- a/app/src/services/api/creditsApi.ts +++ b/app/src/services/api/creditsApi.ts @@ -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 => { - const response = await apiClient.get>('/payments/credits/balance'); - return response.data; + return await callCoreCommand('openhuman.billing_get_balance'); }, /** @@ -129,8 +127,7 @@ export const creditsApi = { * GET /teams/me/usage */ getTeamUsage: async (): Promise => { - const response = await apiClient.get>('/teams/me/usage'); - return response.data; + return await callCoreCommand('openhuman.team_get_usage'); }, /** @@ -141,11 +138,7 @@ export const creditsApi = { amountUsd: number, gateway: 'stripe' | 'coinbase' = 'stripe' ): Promise => { - const response = await apiClient.post>('/payments/credits/top-up', { - amountUsd, - gateway, - }); - return response.data; + return await callCoreCommand('openhuman.billing_top_up', { amountUsd, gateway }); }, /** @@ -153,10 +146,10 @@ export const creditsApi = { * GET /credits/transactions */ getTransactions: async (limit = 20, offset = 0): Promise => { - const response = await apiClient.get>( - `/credits/transactions?limit=${limit}&offset=${offset}` - ); - return response.data; + return await callCoreCommand('openhuman.billing_get_transactions', { + limit, + offset, + }); }, // ── Auto-Recharge ────────────────────────────────────────────────────────── @@ -166,10 +159,7 @@ export const creditsApi = { * GET /payments/credits/auto-recharge */ getAutoRecharge: async (): Promise => { - const response = await apiClient.get>( - '/payments/credits/auto-recharge' - ); - return response.data; + return await callCoreCommand('openhuman.billing_get_auto_recharge'); }, /** @@ -177,11 +167,9 @@ export const creditsApi = { * PATCH /payments/credits/auto-recharge */ updateAutoRecharge: async (payload: AutoRechargeUpdatePayload): Promise => { - const response = await apiClient.patch>( - '/payments/credits/auto-recharge', - payload - ); - return response.data; + return await callCoreCommand('openhuman.billing_update_auto_recharge', { + payload, + }); }, /** @@ -189,10 +177,7 @@ export const creditsApi = { * GET /payments/credits/auto-recharge/cards */ getCards: async (): Promise => { - const response = await apiClient.get>( - '/payments/credits/auto-recharge/cards' - ); - return response.data; + return await callCoreCommand('openhuman.billing_get_cards'); }, /** @@ -201,10 +186,7 @@ export const creditsApi = { * POST /payments/credits/auto-recharge/cards/setup-intent */ createSetupIntent: async (): Promise => { - const response = await apiClient.post>( - '/payments/credits/auto-recharge/cards/setup-intent' - ); - return response.data; + return await callCoreCommand('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 => { - const response = await apiClient.patch>( - `/payments/credits/auto-recharge/cards/${encodeURIComponent(paymentMethodId)}`, - payload - ); - return response.data; + return await callCoreCommand('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 => { - const response = await apiClient.delete>( - `/payments/credits/auto-recharge/cards/${encodeURIComponent(paymentMethodId)}` - ); - return response.data; + return await callCoreCommand('openhuman.billing_delete_card', { paymentMethodId }); }, }; diff --git a/app/src/services/api/tunnelsApi.ts b/app/src/services/api/tunnelsApi.ts index 5c54dac5f..19a2cde18 100644 --- a/app/src/services/api/tunnelsApi.ts +++ b/app/src/services/api/tunnelsApi.ts @@ -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 => { - const response = await apiClient.post>(WEBHOOKS_CORE_BASE, body); - return response.data; + return await callCoreCommand('openhuman.webhooks_create_tunnel', body); }, /** GET /webhooks/core — list user's webhook tunnels */ getTunnels: async (): Promise => { - const response = await apiClient.get>(WEBHOOKS_CORE_BASE); - return response.data; + return await callCoreCommand('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 => { - const response = await apiClient.get>( - `${WEBHOOKS_CORE_BASE}/bandwidth` - ); - return response.data; + return await callCoreCommand('openhuman.webhooks_get_bandwidth'); }, /** GET /webhooks/core/:tunnelId — get a specific webhook tunnel by its internal ID. */ getTunnel: async (tunnelId: string): Promise => { - const response = await apiClient.get>(`${WEBHOOKS_CORE_BASE}/${tunnelId}`); - return response.data; + return await callCoreCommand('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 => { - const response = await apiClient.patch>( - `${WEBHOOKS_CORE_BASE}/${tunnelId}`, - body - ); - return response.data; + return await callCoreCommand('openhuman.webhooks_update_tunnel', { + id: tunnelId, + ...body, + }); }, /** DELETE /webhooks/core/:tunnelId — delete a webhook tunnel by its internal ID. */ deleteTunnel: async (tunnelId: string): Promise => { - await apiClient.delete>(`${WEBHOOKS_CORE_BASE}/${tunnelId}`); + await callCoreCommand('openhuman.webhooks_delete_tunnel', { id: tunnelId }); }, ingressUrl: (backendUrl: string, tunnelUuid: string): string => diff --git a/app/src/services/api/userApi.ts b/app/src/services/api/userApi.ts index 56285a635..5ee33b550 100644 --- a/app/src/services/api/userApi.ts +++ b/app/src/services/api/userApi.ts @@ -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 => { - const response = await apiClient.get('/telegram/me'); - if (!response.success) { - throw new Error(response.error || 'Failed to fetch user data'); - } - return response.data; + return await callCoreCommand('openhuman.auth_get_me'); }, /** diff --git a/app/src/services/coreCommandClient.ts b/app/src/services/coreCommandClient.ts new file mode 100644 index 000000000..747bae28e --- /dev/null +++ b/app/src/services/coreCommandClient.ts @@ -0,0 +1,11 @@ +import { callCoreRpc } from './coreRpcClient'; + +export interface CoreCommandResponse { + result: T; + logs: string[]; +} + +export async function callCoreCommand(method: string, params?: unknown): Promise { + const response = await callCoreRpc>({ method, params }); + return response.result; +} diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts index c38083b7e..dc5d75f03 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -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; 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(); } diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index 48a1a226e..4b81ce2b2 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -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, })); diff --git a/app/src/utils/__tests__/authFlow.e2e.test.tsx b/app/src/utils/__tests__/authFlow.e2e.test.tsx index 690d5c80b..ec36b4037 100644 --- a/app/src/utils/__tests__/authFlow.e2e.test.tsx +++ b/app/src/utils/__tests__/authFlow.e2e.test.tsx @@ -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 ); diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index b9da527b9..497d1e69f 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -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) diff --git a/app/test/e2e/specs/auth-access-control.spec.ts b/app/test/e2e/specs/auth-access-control.spec.ts index ba528542a..ea9aa8626 100644 --- a/app/test/e2e/specs/auth-access-control.spec.ts +++ b/app/test/e2e/specs/auth-access-control.spec.ts @@ -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( diff --git a/app/test/e2e/specs/conversations-web-channel-flow.spec.ts b/app/test/e2e/specs/conversations-web-channel-flow.spec.ts index 2768501a9..c748ac126 100644 --- a/app/test/e2e/specs/conversations-web-channel-flow.spec.ts +++ b/app/test/e2e/specs/conversations-web-channel-flow.spec.ts @@ -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'); } diff --git a/app/test/e2e/specs/login-flow.spec.ts b/app/test/e2e/specs/login-flow.spec.ts index d228b5c65..0aa1f5789 100644 --- a/app/test/e2e/specs/login-flow.spec.ts +++ b/app/test/e2e/specs/login-flow.spec.ts @@ -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); diff --git a/app/test/e2e/specs/logout-relogin-onboarding.spec.ts b/app/test/e2e/specs/logout-relogin-onboarding.spec.ts index 49c1494ca..0d720d92d 100644 --- a/app/test/e2e/specs/logout-relogin-onboarding.spec.ts +++ b/app/test/e2e/specs/logout-relogin-onboarding.spec.ts @@ -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(); }); }); diff --git a/app/test/e2e/specs/notion-flow.spec.ts b/app/test/e2e/specs/notion-flow.spec.ts index 66ac7ecad..dd39b1c08 100644 --- a/app/test/e2e/specs/notion-flow.spec.ts +++ b/app/test/e2e/specs/notion-flow.spec.ts @@ -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/') diff --git a/app/test/e2e/specs/telegram-flow.spec.ts b/app/test/e2e/specs/telegram-flow.spec.ts index fc9a94176..015564d41 100644 --- a/app/test/e2e/specs/telegram-flow.spec.ts +++ b/app/test/e2e/specs/telegram-flow.spec.ts @@ -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 diff --git a/docs/TODO.md b/docs/TODO.md index 2771b628d..5d41a4fee 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/scripts/mock-api-core.mjs b/scripts/mock-api-core.mjs index 664316b3e..19f16c8a9 100644 --- a/scripts/mock-api-core.mjs +++ b/scripts/mock-api-core.mjs @@ -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); diff --git a/src/api/mod.rs b/src/api/mod.rs index d0e21e5d7..460627f47 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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; diff --git a/src/api/rest.rs b/src/api/rest.rs index a98310a8f..e4712df90 100644 --- a/src/api/rest.rs +++ b/src/api/rest.rs @@ -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) -> Option { 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 { - 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 { + 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 { }) } +pub fn user_id_from_settings_payload(settings: &Value) -> Option { + 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 { - 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 { + 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 { + 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 { 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 { + 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, + ) -> Result { + 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> { let url = self diff --git a/src/openhuman/billing/ops.rs b/src/openhuman/billing/ops.rs index 52e5979f9..a7441e028 100644 --- a/src/openhuman/billing/ops.rs +++ b/src/openhuman/billing/ops.rs @@ -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::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 { - 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, -) -> Result { - 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 { - 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 { get_session_token(config)? .and_then(|v| { @@ -131,9 +39,12 @@ async fn get_authed_value( body: Option, ) -> Result { 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, String> { @@ -144,6 +55,104 @@ pub async fn get_current_plan(config: &Config) -> Result, Stri )) } +pub async fn get_balance(config: &Config) -> Result, 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, + offset: Option, +) -> Result, 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, 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, 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, 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, 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, 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, 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> { diff --git a/src/openhuman/billing/schemas.rs b/src/openhuman/billing/schemas.rs index 9c1f7ebeb..e738bf0d6 100644 --- a/src/openhuman/billing/schemas.rs +++ b/src/openhuman/billing/schemas.rs @@ -29,13 +29,49 @@ struct CoinbaseChargeParams { interval: Option, } +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct TransactionsParams { + #[serde(default)] + limit: Option, + #[serde(default)] + offset: Option, +} + +#[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 { 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 { 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 { 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) -> ControllerFut }) } +fn handle_billing_get_balance(_params: Map) -> 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) -> 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) -> Controll }) } +fn handle_billing_get_transactions(params: Map) -> 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::(params)? + }; + to_json( + crate::openhuman::billing::get_transactions(&config, payload.limit, payload.offset) + .await?, + ) + }) +} + +fn handle_billing_get_auto_recharge(_params: Map) -> 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) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + to_json(crate::openhuman::billing::update_auto_recharge(&config, payload.payload).await?) + }) +} + +fn handle_billing_get_cards(_params: Map) -> 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) -> 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) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + to_json( + crate::openhuman::billing::update_card( + &config, + payload.payment_method_id.trim(), + payload.payload, + ) + .await?, + ) + }) +} + +fn handle_billing_delete_card(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + to_json( + crate::openhuman::billing::delete_card(&config, payload.payment_method_id.trim()) + .await?, + ) + }) +} + fn to_json(outcome: RpcOutcome) -> Result { 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!["checkoutUrl", "sessionId"] - ); - - let portal = billing_schemas("billing_create_portal_session"); - assert_eq!( - portal - .outputs - .iter() - .map(|field| field.name) - .collect::>(), - vec!["portalUrl"] - ); - - let top_up = billing_schemas("billing_top_up"); - assert_eq!( - top_up - .outputs - .iter() - .map(|field| field.name) - .collect::>(), - vec!["url", "gatewayTransactionId", "amountUsd", "gateway"] - ); - - let coinbase = billing_schemas("billing_create_coinbase_charge"); - assert_eq!( - coinbase - .outputs - .iter() - .map(|field| field.name) - .collect::>(), - vec!["gatewayTransactionId", "hostedUrl", "status", "expiresAt"] - ); - } -} diff --git a/src/openhuman/credentials/mod.rs b/src/openhuman/credentials/mod.rs index 02e52e1cf..5fddd2332 100644 --- a/src/openhuman/credentials/mod.rs +++ b/src/openhuman/credentials/mod.rs @@ -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; diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index c1089f8b7..329a770cf 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -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, 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, 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, diff --git a/src/openhuman/credentials/schemas.rs b/src/openhuman/credentials/schemas.rs index ed56573f0..f17f2341c 100644 --- a/src/openhuman/credentials/schemas.rs +++ b/src/openhuman/credentials/schemas.rs @@ -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 { 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 { 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) -> ControllerFutur }) } +fn handle_auth_get_me(_params: Map) -> 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) -> 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) -> ControllerFutu }) } +fn handle_auth_create_channel_link_token(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(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) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/team/ops.rs b/src/openhuman/team/ops.rs index b5286ded3..8ae661a14 100644 --- a/src/openhuman/team/ops.rs +++ b/src/openhuman/team/ops.rs @@ -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::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 { - 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 { get_session_token(config)? .and_then(|v| { @@ -73,133 +55,6 @@ fn build_api_path(segments: &[&str]) -> Result { 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::>(); - 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::>(); - - format!("/{}", redacted.join("/")) -} - -async fn authed_request( - client: &Client, - base: &Url, - token: &str, - method: Method, - path: &str, - body: Option, -) -> Result { - 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 { - 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, ) -> Result { 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, 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, 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}" - ); - } } diff --git a/src/openhuman/team/schemas.rs b/src/openhuman/team/schemas.rs index 7411c5401..4d01f8e85 100644 --- a/src/openhuman/team/schemas.rs +++ b/src/openhuman/team/schemas.rs @@ -47,6 +47,7 @@ struct RevokeInviteParams { pub fn all_team_controller_schemas() -> Vec { 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 { pub fn all_team_registered_controllers() -> Vec { 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 { 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) -> 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) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/webhooks/ops.rs b/src/openhuman/webhooks/ops.rs index 1da40e6bc..8932cd385 100644 --- a/src/openhuman/webhooks/ops.rs +++ b/src/openhuman/webhooks/ops.rs @@ -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 { + 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, +) -> Result { + 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, 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, 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, +) -> Result, 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, 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, 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, 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, String> { + let data = get_authed_value(config, Method::GET, "/webhooks/core/bandwidth", None).await?; + Ok(RpcOutcome::single_log(data, "webhook bandwidth fetched")) +} diff --git a/src/openhuman/webhooks/schemas.rs b/src/openhuman/webhooks/schemas.rs index c82958614..15c264732 100644 --- a/src/openhuman/webhooks/schemas.rs +++ b/src/openhuman/webhooks/schemas.rs @@ -23,6 +23,26 @@ struct WebhookUnregisterEchoParams { tunnel_uuid: String, } +#[derive(Debug, Deserialize)] +struct WebhookCreateTunnelParams { + name: String, + description: Option, +} + +#[derive(Debug, Deserialize)] +struct WebhookTunnelIdParams { + id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebhookUpdateTunnelParams { + id: String, + name: Option, + description: Option, + is_active: Option, +} + pub fn all_controller_schemas() -> Vec { vec![ schemas("list_registrations"), @@ -30,6 +50,12 @@ pub fn all_controller_schemas() -> Vec { 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 { 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) -> ControllerFuture { }) } +fn handle_list_tunnels(_params: Map) -> 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) -> ControllerFuture { + Box::pin(async move { + let config = crate::openhuman::config::rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + to_json( + crate::openhuman::webhooks::ops::create_tunnel( + &config, + payload.name.trim(), + payload.description, + ) + .await?, + ) + }) +} + +fn handle_delete_tunnel(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = crate::openhuman::config::rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + to_json(crate::openhuman::webhooks::ops::delete_tunnel(&config, payload.id.trim()).await?) + }) +} + +fn handle_get_tunnel(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = crate::openhuman::config::rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + to_json(crate::openhuman::webhooks::ops::get_tunnel(&config, payload.id.trim()).await?) + }) +} + +fn handle_update_tunnel(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = crate::openhuman::config::rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(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) -> 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(params: Map) -> Result { serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) } diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 537dbba5c..f60d7a913 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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, (StatusCode, Json)> { + // Matches authenticated profile fetches used during session validation. + async fn current_user(headers: HeaderMap) -> Result, (StatusCode, Json)> { 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))