Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"google-auth-library": "^10.3.0",
"jose": "^6.0.11",
"multer": "^2.0.2",
"neverthrow": "^8.2.0",
"openai": "^6.2.0",
"prisma": "6.16.0",
"typescript": "^5.3.3",
Expand Down
16 changes: 13 additions & 3 deletions packages/app/server/src/__tests__/endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ReadableStream } from 'stream/web';
import type express from 'express';
import request from 'supertest';
import { vi } from 'vitest';
import { ok } from 'neverthrow';

import { EchoControlService } from '../services/EchoControlService';

Expand Down Expand Up @@ -208,12 +209,21 @@ const setupMockEchoControlService = (balance: number = 1000) => {
echoApp: MOCK_ECHO_APP,
});

(mockInstance.getBalance as any).mockResolvedValue(balance);
(mockInstance.getBalance as any).mockResolvedValue(ok(balance));
(mockInstance.getUserId as any).mockReturnValue(TEST_USER_ID);
(mockInstance.getEchoAppId as any).mockReturnValue(TEST_ECHO_APP_ID);
(mockInstance.getUser as any).mockReturnValue(MOCK_USER);
(mockInstance.getEchoApp as any).mockReturnValue(MOCK_ECHO_APP);
(mockInstance.createTransaction as any).mockResolvedValue(undefined);
(mockInstance.createTransaction as any).mockResolvedValue(ok(undefined));
(mockInstance.computeTransactionCosts as any).mockResolvedValue(ok({
rawTransactionCost: 0.01,
totalTransactionCost: 0.01,
totalAppProfit: 0,
referralProfit: 0,
markUpProfit: 0,
echoProfit: 0,
}));
(mockInstance.getOrNoneFreeTierSpendPool as any).mockResolvedValue(ok(null));

MockedEchoControlService.mockImplementation(() => mockInstance);

Expand Down Expand Up @@ -735,7 +745,7 @@ describe('Endpoint Tests', () => {
describe('Account Balance Tests', () => {
it('should reject requests when account has insufficient balance', async () => {
// Setup EchoControlService with zero balance
(mockEchoControlService.getBalance as any).mockResolvedValue(0);
(mockEchoControlService.getBalance as any).mockResolvedValue(ok(0));

const response = await request(app)
.post('/chat/completions')
Expand Down
14 changes: 12 additions & 2 deletions packages/app/server/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ReadableStream } from 'stream/web';
import type express from 'express';
import request from 'supertest';
import { vi } from 'vitest';
import { ok } from 'neverthrow';

import { EchoControlService } from '../services/EchoControlService';

Expand Down Expand Up @@ -40,12 +41,21 @@ const setupMockEchoControlService = (balance: number = 100) => {
echoApp: MOCK_ECHO_APP,
});

(mockInstance.getBalance as any).mockResolvedValue(balance);
(mockInstance.getBalance as any).mockResolvedValue(ok(balance));
(mockInstance.getUserId as any).mockReturnValue(TEST_USER_ID);
(mockInstance.getEchoAppId as any).mockReturnValue(TEST_ECHO_APP_ID);
(mockInstance.getUser as any).mockReturnValue(MOCK_USER);
(mockInstance.getEchoApp as any).mockReturnValue(MOCK_ECHO_APP);
(mockInstance.createTransaction as any).mockResolvedValue(undefined);
(mockInstance.createTransaction as any).mockResolvedValue(ok(undefined));
(mockInstance.computeTransactionCosts as any).mockResolvedValue(ok({
rawTransactionCost: 0.01,
totalTransactionCost: 0.01,
totalAppProfit: 0,
referralProfit: 0,
markUpProfit: 0,
echoProfit: 0,
}));
(mockInstance.getOrNoneFreeTierSpendPool as any).mockResolvedValue(ok(null));

MockedEchoControlService.mockImplementation(() => mockInstance);

Expand Down
14 changes: 12 additions & 2 deletions packages/app/server/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dotenv from 'dotenv';
import { vi } from 'vitest';
import { ok } from 'neverthrow';

// Load environment variables from .env.test if it exists, otherwise from .env
dotenv.config({ path: '.env.test' });
Expand All @@ -9,8 +10,17 @@ vi.mock('../services/EchoControlService', () => {
return {
EchoControlService: vi.fn().mockImplementation(() => ({
verifyApiKey: vi.fn(),
getBalance: vi.fn(),
createTransaction: vi.fn().mockResolvedValue(undefined),
getBalance: vi.fn().mockResolvedValue(ok(100)),
createTransaction: vi.fn().mockResolvedValue(ok(undefined)),
computeTransactionCosts: vi.fn().mockResolvedValue(ok({
rawTransactionCost: 0.01,
totalTransactionCost: 0.01,
totalAppProfit: 0,
referralProfit: 0,
markUpProfit: 0,
echoProfit: 0,
})),
getOrNoneFreeTierSpendPool: vi.fn().mockResolvedValue(ok(null)),
getUserId: vi.fn(),
getEchoAppId: vi.fn(),
getUser: vi.fn(),
Expand Down
74 changes: 51 additions & 23 deletions packages/app/server/src/auth/headers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { context, trace } from '@opentelemetry/api';
import { UnauthorizedError } from '../errors/http';
import { ResultAsync } from 'neverthrow';
import {
AuthenticationError,
MissingHeaderError,
InvalidApiKeyError
} from '../errors';
import { AppResultAsync } from '../errors/result-helpers';
import type { PrismaClient } from '../generated/prisma';
import logger from '../logger';
import { EchoControlService } from '../services/EchoControlService';

export const verifyUserHeaderCheck = async (
/**
* Processes authentication headers and returns processed headers with EchoControlService
*
* @param headers - Request headers
* @param prisma - Prisma client instance
* @returns ResultAsync containing tuple of processed headers and EchoControlService
*/
export const verifyUserHeaderCheck = (
headers: Record<string, string>,
prisma: PrismaClient
): Promise<[Record<string, string>, EchoControlService]> => {
): AppResultAsync<[Record<string, string>, EchoControlService], AuthenticationError | MissingHeaderError | InvalidApiKeyError> => {
/**
* Process authentication for the user (authenticated with Echo Api Key)
*
Expand Down Expand Up @@ -39,32 +52,47 @@ export const verifyUserHeaderCheck = async (

if (!(authorization || xApiKey || xGoogleApiKey)) {
logger.error(`Missing authentication headers: ${JSON.stringify(headers)}`);
throw new UnauthorizedError('Please include auth headers.');
return ResultAsync.fromPromise(
Promise.reject(new MissingHeaderError('authentication', 'Please include auth headers.')),
(e) => e as MissingHeaderError
);
}

const apiKey = authorization ?? xApiKey ?? xGoogleApiKey;
const cleanApiKey = apiKey?.replace('Bearer ', '') ?? '';

const echoControlService = new EchoControlService(prisma, cleanApiKey);
const authResult = await echoControlService.verifyApiKey();

return ResultAsync.fromPromise(
echoControlService.verifyApiKey(),
(e) => new InvalidApiKeyError({ apiKey: cleanApiKey.substring(0, 8) + '...' })
)
.andThen((authResult) => {
if (!authResult) {
logger.error('API key validation returned null');
return ResultAsync.fromPromise(
Promise.reject(new InvalidApiKeyError({ apiKey: cleanApiKey.substring(0, 8) + '...' })),
(e) => e as InvalidApiKeyError
);
}

if (!authResult) {
throw new UnauthorizedError('Authentication failed.');
}
const span = trace.getSpan(context.active());
if (span) {
span.setAttribute('echo.app.id', authResult.echoApp.id);
span.setAttribute('echo.app.name', authResult.echoApp.name);
span.setAttribute('echo.user.id', authResult.user.id);
span.setAttribute('echo.user.email', authResult.user.email);
span.setAttribute('echo.user.name', authResult.user.name ?? '');
}

const span = trace.getSpan(context.active());
if (span) {
span.setAttribute('echo.app.id', authResult.echoApp.id);
span.setAttribute('echo.app.name', authResult.echoApp.name);
span.setAttribute('echo.user.id', authResult.user.id);
span.setAttribute('echo.user.email', authResult.user.email);
span.setAttribute('echo.user.name', authResult.user.name ?? '');
}
const processedHeaders = {
...restHeaders,
'accept-encoding': 'gzip, deflate',
};

return [
{
...restHeaders,
'accept-encoding': 'gzip, deflate',
},
echoControlService,
];
return ResultAsync.fromPromise(
Promise.resolve([processedHeaders, echoControlService] as [Record<string, string>, EchoControlService]),
(e) => new AuthenticationError('Failed to create auth result')
);
});
};
32 changes: 16 additions & 16 deletions packages/app/server/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { isX402Request } from 'utils';
import type { PrismaClient } from '../generated/prisma';
import { EchoControlService } from '../services/EchoControlService';
import { verifyUserHeaderCheck } from './headers';
import {
AppResultAsync,
AuthenticationError,
MissingHeaderError,
InvalidApiKeyError
} from '../errors';

/**
* Handles complete authentication flow including path extraction, header verification, and app ID validation.
Expand All @@ -11,26 +17,20 @@ import { verifyUserHeaderCheck } from './headers';
* 2. Verifies user authentication headers
* 3. Validates that the authenticated user has permission to use the specified app
*
* @param path - The request path
* @param headers - The request headers
* @returns Object containing processedHeaders, echoControlService, and forwardingPath
* @throws UnauthorizedError if authentication fails or app ID validation fails
* @param prisma - Prisma client instance
* @returns ResultAsync containing object with processedHeaders and echoControlService
*/
export async function authenticateRequest(
export function authenticateRequest(
headers: Record<string, string>,
prisma: PrismaClient
): Promise<{
): AppResultAsync<{
processedHeaders: Record<string, string>;
echoControlService: EchoControlService;
}> {
// Process headers and instantiate provider
const [processedHeaders, echoControlService] = await verifyUserHeaderCheck(
headers,
prisma
);

return {
processedHeaders,
echoControlService,
};
}, AuthenticationError | MissingHeaderError | InvalidApiKeyError> {
return verifyUserHeaderCheck(headers, prisma)
.map(([processedHeaders, echoControlService]) => ({
processedHeaders,
echoControlService,
}));
}
6 changes: 0 additions & 6 deletions packages/app/server/src/errors/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ export class UnauthorizedError extends HttpError {
}
}

export class PaymentRequiredError extends HttpError {
constructor(message: string = 'Payment Required') {
super(402, message);
}
}

export class UnknownModelError extends HttpError {
constructor(message: string = 'Unknown Model argument passed in') {
super(400, message);
Expand Down
3 changes: 3 additions & 0 deletions packages/app/server/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './http';
export * from './types';
export * from './result-helpers';
Loading