diff --git a/__tests__/e2e/runtime/isolate-limits.test.ts b/__tests__/e2e/runtime/isolate-limits.test.ts new file mode 100644 index 0000000..cd346c0 --- /dev/null +++ b/__tests__/e2e/runtime/isolate-limits.test.ts @@ -0,0 +1,415 @@ +/** + * E2E tests for isolate VM limits (memory and timeout) + * Ensures that the isolate properly enforces limits and handles exceeding them gracefully + */ + +import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import { AgentToolProtocolServer } from '@mondaydotcomorg/atp-server'; +import { AgentToolProtocolClient } from '@mondaydotcomorg/atp-client'; +import { randomUUID } from 'node:crypto'; + +const TEST_PORT = 3355; + +describe('Isolate VM Limits E2E', () => { + let server: AgentToolProtocolServer; + + beforeAll(async () => { + process.env.ATP_JWT_SECRET = 'test-secret-isolate-limits'; + + server = new AgentToolProtocolServer({ + execution: { + timeout: 30000, + memory: 128 * 1024 * 1024, // 128MB + llmCalls: 10, + }, + }); + + await server.listen(TEST_PORT); + }); + + afterAll(async () => { + if (server) { + await server.stop(); + } + delete process.env.ATP_JWT_SECRET; + }); + + describe('Memory Limit Tests', () => { + test('should enforce memory limit and fail gracefully when exceeded', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + // Code that tries to allocate more memory than the 128MB limit + const memoryExceedingCode = ` + // Try to allocate ~150MB of memory (exceeding 128MB limit) + const arrays = []; + try { + for (let i = 0; i < 35; i++) { + // Each array is ~4MB (1M * 4 bytes per number) + const largeArray = new Array(1000000).fill(i); + arrays.push(largeArray); + + // Log progress to help debug + if (i % 5 === 0) { + console.log(\`Allocated array \${i}, total arrays: \${arrays.length}\`); + } + } + return 'Memory allocation succeeded unexpectedly'; + } catch (error) { + return \`Memory limit enforced: \${error.message}\`; + } + `; + + const result = await client.execute(memoryExceedingCode); + + console.log('----------------- ISOLATED HERE') + // Should fail due to memory limit + expect(result.status).toMatch(/^(failed|memory_exceeded)$/); + expect(result.error).toBeDefined(); + expect(result.error?.message).toMatch(/memory|limit|exceeded|disposed/i); + + // Should have some memory usage recorded + expect(result.stats?.memoryUsed).toBeDefined(); + expect(typeof result.stats?.memoryUsed).toBe('number'); + }, 15000); + + test('should handle memory limit with different allocation patterns', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + // Try rapid small allocations that accumulate beyond limit + const rapidAllocationCode = ` + const objects = []; + try { + // Create many objects that accumulate to exceed memory limit + for (let i = 0; i < 200000; i++) { + objects.push({ + id: i, + data: new Array(500).fill(\`item-\${i}\`), + timestamp: Date.now(), + random: Math.random() + }); + + if (i % 50000 === 0) { + console.log(\`Created \${i} objects\`); + } + } + return \`Created \${objects.length} objects\`; + } catch (error) { + return \`Caught error after \${objects.length} objects: \${error.message}\`; + } + `; + + const result = await client.execute(rapidAllocationCode); + + // Should either complete with fewer objects or fail with memory error + expect(result.status).toMatch(/^(completed|failed|memory_exceeded)$/); + + if (result.status === 'failed' || result.status === 'memory_exceeded') { + expect(result.error?.message).toMatch(/memory|limit|exceeded|disposed/i); + } else { + // If completed, should have created fewer than expected objects + expect(result.result).toMatch(/Created \d+ objects/); + const match = (result.result as string).match(/Created (\d+) objects/); + if (match) { + const objectCount = parseInt(match[1]); + expect(objectCount).toBeLessThan(200000); + } + } + }, 15000); + + test('should allow execution within memory limits', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + // Code that stays well within the 128MB limit + const withinLimitCode = ` + // Allocate ~50MB (well within 128MB limit) + const arrays = []; + for (let i = 0; i < 12; i++) { + // Each array is ~4MB + const array = new Array(1000000).fill(i); + arrays.push(array); + } + + return { + arraysCreated: arrays.length, + totalElements: arrays.reduce((sum, arr) => sum + arr.length, 0), + sampleValue: arrays[0][0] + }; + `; + + const result = await client.execute(withinLimitCode); + + expect(result.status).toBe('completed'); + expect(result.result).toEqual({ + arraysCreated: 12, + totalElements: 12000000, + sampleValue: 0 + }); + + // Should have reasonable memory usage recorded + expect(result.stats?.memoryUsed).toBeDefined(); + expect(result.stats?.memoryUsed).toBeGreaterThan(0); + }); + }); + + describe('Timeout Limit Tests', () => { + test('should enforce timeout and terminate execution', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + // Code that runs longer than timeout (using 5 second timeout) + const longRunningCode = ` + const startTime = Date.now(); + let iterations = 0; + + // Busy loop that runs for ~10 seconds (longer than 5s timeout) + while (Date.now() - startTime < 10000) { + iterations++; + // Add some work to prevent optimization + Math.sqrt(iterations); + } + + return \`Completed \${iterations} iterations\`; + `; + + const startTime = Date.now(); + const result = await client.execute(longRunningCode, { + timeout: 5000, // 5 second timeout + }); + const duration = Date.now() - startTime; + + // Should timeout + expect(result.status).toBe('timeout'); + expect(result.error).toBeDefined(); + expect(result.error?.message).toMatch(/timeout|time.*out|exceeded/i); + + // Should have terminated around the timeout duration + expect(duration).toBeGreaterThanOrEqual(4500); // Allow some variance + expect(duration).toBeLessThan(8000); // Should not run to completion + + // Should have duration stats + expect(result.stats?.duration).toBeDefined(); + expect(result.stats?.duration).toBeGreaterThanOrEqual(5000); + }, 10000); + + test('should allow execution within timeout limits', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + // Code that completes well within timeout + const withinTimeoutCode = ` + let sum = 0; + const startTime = Date.now(); + + // Run for ~1 second (well within 5s timeout) + while (Date.now() - startTime < 1000) { + sum += Math.random(); + } + + return { + sum: Math.floor(sum), + duration: Date.now() - startTime, + completed: true + }; + `; + + const result = await client.execute(withinTimeoutCode, { + timeout: 5000, + }); + + expect(result.status).toBe('completed'); + expect(result.result).toMatchObject({ + completed: true + }); + expect((result.result as any).duration).toBeGreaterThan(900); + expect((result.result as any).duration).toBeLessThan(1500); + + // Should have reasonable duration stats + expect(result.stats?.duration).toBeGreaterThan(900); + expect(result.stats?.duration).toBeLessThan(2000); + }); + }); + + describe('Combined Limit Tests', () => { + test('should handle scripts that could exceed both limits', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + // Code that tries to exceed both memory and time limits + const combinedLimitCode = ` + const arrays = []; + const startTime = Date.now(); + let iterations = 0; + + try { + // Try to allocate memory while also running for a long time + while (Date.now() - startTime < 8000) { // 8 seconds + iterations++; + + // Allocate memory every 100 iterations + if (iterations % 100 === 0) { + arrays.push(new Array(100000).fill(iterations)); + } + + // Some computation + Math.sqrt(iterations); + } + + return \`Completed \${iterations} iterations with \${arrays.length} arrays\`; + } catch (error) { + return \`Failed after \${iterations} iterations: \${error.message}\`; + } + `; + + const result = await client.execute(combinedLimitCode, { + timeout: 3000, // 3 second timeout + }); + + // Should fail due to either timeout or memory limit + expect(result.status).toMatch(/^(timeout|failed|memory_exceeded)$/); + expect(result.error).toBeDefined(); + + if (result.status === 'timeout') { + expect(result.error?.message).toMatch(/timeout|time.*out/i); + } else { + expect(result.error?.message).toMatch(/memory|limit|exceeded|disposed/i); + } + }, 8000); + + test('should properly clean up resources after limit exceeded', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + // First execution that exceeds memory limit + const memoryCode = ` + const arrays = []; + for (let i = 0; i < 35; i++) { + arrays.push(new Array(1000000).fill(i)); + } + return 'Should not complete'; + `; + + const result1 = await client.execute(memoryCode); + expect(result1.status).toMatch(/^(failed|memory_exceeded)$/); + + // Second execution should work normally (resources cleaned up) + const normalCode = ` + const small = [1, 2, 3, 4, 5]; + return small.reduce((a, b) => a + b, 0); + `; + + const result2 = await client.execute(normalCode); + expect(result2.status).toBe('completed'); + expect(result2.result).toBe(15); + }, 15000); + }); + + describe('Error Handling and Recovery', () => { + test('should provide meaningful error messages for memory limits', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + const code = ` + // Try to allocate ~150MB of memory (exceeding 128MB limit) + const arrays = []; + try { + for (let i = 0; i < 35; i++) { + // Each array is ~4MB (1M * 4 bytes per number) + const largeArray = new Array(1000000).fill(i); + arrays.push(largeArray); + + // Log progress to help debug + if (i % 5 === 0) { + console.log(\`Allocated array \${i}, total arrays: \${arrays.length}\`); + } + } + return 'Memory allocation succeeded unexpectedly'; + } catch (error) { + return \`Memory limit enforced: \${error.message}\`; + } + `; + + const result = await client.execute(code); + + expect(result.status).toMatch(/^(failed|memory_exceeded)$/); + expect(result.error).toBeDefined(); + expect(result.error?.message).toBeTruthy(); + expect(typeof result.error?.message).toBe('string'); + + // Should have execution metadata + expect(result.executionId).toBeDefined(); + expect(result.stats).toBeDefined(); + }); + + test('should provide meaningful error messages for timeout limits', async () => { + const client = new AgentToolProtocolClient({ + baseUrl: `http://localhost:${TEST_PORT}`, + headers: { Authorization: `Bearer test-${randomUUID()}` }, + }); + + await client.init(); + await client.connect(); + + const code = ` + // Infinite loop + while (true) { + Math.random(); + } + `; + + const result = await client.execute(code, { timeout: 2000 }); + + expect(result.status).toBe('timeout'); + expect(result.error).toBeDefined(); + expect(result.error?.message).toBeTruthy(); + expect(typeof result.error?.message).toBe('string'); + + // Should have execution metadata + expect(result.executionId).toBeDefined(); + expect(result.stats).toBeDefined(); + expect(result.stats?.duration).toBeGreaterThanOrEqual(2000); + }, 5000); + }); +}); diff --git a/examples/test-server/server.ts b/examples/test-server/server.ts index c0f5ec7..9d09dae 100644 --- a/examples/test-server/server.ts +++ b/examples/test-server/server.ts @@ -24,10 +24,10 @@ async function main() { // In production, use longer TTLs (e.g., 1 hour default) const server = new AgentToolProtocolServer({ execution: { timeout: 30000 }, -/* clientInit: { + clientInit: { tokenTTL: 10000, // 10 seconds for testing tokenRotation: 5000, - }*/ + } }); // Register tools diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 2f4cc17..e6759ad 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -43,7 +43,6 @@ interface InProcessServer { handleExplore(ctx: unknown): Promise; handleExecute(ctx: unknown): Promise; handleResume(ctx: unknown, executionId: string): Promise; - handleTokenRefresh(ctx: unknown): Promise; } /** diff --git a/packages/client/src/core/base-session.ts b/packages/client/src/core/base-session.ts index 750a46f..5260718 100644 --- a/packages/client/src/core/base-session.ts +++ b/packages/client/src/core/base-session.ts @@ -29,6 +29,15 @@ export interface ISession { setTokenRefreshConfig(config: Partial): void; } +/** + * Stored init parameters for re-initialization during token refresh. + */ +export interface StoredInitParams { + clientInfo?: { name?: string; version?: string; [key: string]: unknown }; + tools?: ClientToolDefinition[]; + services?: { hasLLM: boolean; hasApproval: boolean; hasEmbedding: boolean; hasTools: boolean }; +} + /** * Base session class with shared token management logic. * Subclasses implement the transport-specific operations (HTTP vs in-process). @@ -40,12 +49,13 @@ export abstract class BaseSession implements ISession { protected tokenRotateAt?: number; protected initPromise?: Promise; protected refreshPromise?: Promise; + protected storedInitParams?: StoredInitParams; protected tokenRefreshConfig: TokenRefreshConfig = { enabled: true, bufferMs: 1000, }; - constructor(tokenRefreshConfig?: Partial) { + protected constructor(tokenRefreshConfig?: Partial) { if (tokenRefreshConfig) { this.tokenRefreshConfig = { ...this.tokenRefreshConfig, ...tokenRefreshConfig }; } @@ -81,9 +91,25 @@ export abstract class BaseSession implements ISession { abstract prepareHeaders(method: string, url: string, body?: unknown): Promise>; /** - * Perform the actual token refresh - must be implemented by subclass + * Perform token refresh by re-initializing the session. + * Resets the init guard and calls init() again with the stored params, + * effectively creating a fresh session without depending on the old + * session still existing in the server's cache. */ - protected abstract doRefreshToken(): Promise; + protected async doRefreshToken(): Promise { + if (!this.storedInitParams) { + throw new Error('Cannot refresh token: init params not stored. Was init() called?'); + } + + // Reset the init guard so init() runs a fresh handshake + this.initPromise = undefined; + + await this.init( + this.storedInitParams.clientInfo, + this.storedInitParams.tools, + this.storedInitParams.services, + ); + } /** * Gets the unique client ID. @@ -118,8 +144,8 @@ export abstract class BaseSession implements ISession { * This is called automatically before requests when autoRefresh is enabled. * Uses a shared promise to prevent concurrent refresh requests. * - * Note: Even expired tokens can be refreshed as long as the server session - * still exists. The server accepts expired JWTs for the refresh endpoint. + * Refresh works by re-initializing the session (calling init() again), + * so it does not depend on the old session still existing in the server's cache. */ async refreshTokenIfNeeded(): Promise { // Skip if auto-refresh is disabled @@ -163,9 +189,10 @@ export abstract class BaseSession implements ISession { } /** - * Check if URL should skip token refresh (to avoid infinite recursion) + * Check if URL should skip token refresh (to avoid infinite recursion). + * Since refresh now calls init(), we only need to guard the init path. */ protected shouldSkipRefreshForUrl(url: string): boolean { - return url.includes('/api/token/refresh') || url.includes('/api/init'); + return url.includes('/api/init'); } } diff --git a/packages/client/src/core/in-process-session.ts b/packages/client/src/core/in-process-session.ts index 9e4abf0..71079a0 100644 --- a/packages/client/src/core/in-process-session.ts +++ b/packages/client/src/core/in-process-session.ts @@ -15,7 +15,6 @@ export interface InProcessServer { handleExplore(ctx: InProcessRequestContext): Promise; handleExecute(ctx: InProcessRequestContext): Promise; handleResume(ctx: InProcessRequestContext, executionId: string): Promise; - handleTokenRefresh(ctx: InProcessRequestContext): Promise; } /** @@ -65,6 +64,9 @@ export class InProcessSession extends BaseSession { tools?: ClientToolDefinition[], services?: { hasLLM: boolean; hasApproval: boolean; hasEmbedding: boolean; hasTools: boolean } ): Promise { + // Store init params so doRefreshToken() can re-init with the same data + this.storedInitParams = { clientInfo, tools, services }; + if (this.initPromise) { await this.initPromise; return { @@ -133,20 +135,6 @@ export class InProcessSession extends BaseSession { return ''; } - /** - * Perform the actual token refresh via in-process server call - */ - protected async doRefreshToken(): Promise { - const ctx = await this.createContext({ - method: 'POST', - path: '/api/token/refresh', - body: { clientId: this.clientId }, - }); - - const result = (await this.server.handleTokenRefresh(ctx)) as TokenCredentials; - this.updateTokenState(result); - } - /** * Prepares headers for a request, refreshing token if needed */ diff --git a/packages/client/src/core/session.ts b/packages/client/src/core/session.ts index d67f01d..b16aef5 100644 --- a/packages/client/src/core/session.ts +++ b/packages/client/src/core/session.ts @@ -32,6 +32,9 @@ export class ClientSession extends BaseSession { tools?: ClientToolDefinition[], services?: { hasLLM: boolean; hasApproval: boolean; hasEmbedding: boolean; hasTools: boolean } ): Promise { + // Store init params so doRefreshToken() can re-init with the same data + this.storedInitParams = { clientInfo, tools, services }; + if (this.initPromise) { await this.initPromise; return { @@ -107,36 +110,6 @@ export class ClientSession extends BaseSession { return this.baseUrl; } - /** - * Perform the actual token refresh via HTTP - */ - protected async doRefreshToken(): Promise { - const url = `${this.baseUrl}/api/token/refresh`; - const body = JSON.stringify({ clientId: this.clientId }); - - // Use current token for auth, but don't recursively try to refresh - const headers: Record = { - 'Content-Type': 'application/json', - ...this.customHeaders, - 'X-Client-ID': this.clientId!, - Authorization: `Bearer ${this.clientToken}`, - }; - - const response = await fetch(url, { - method: 'POST', - headers, - body, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`); - } - - const data = (await response.json()) as TokenCredentials; - this.updateTokenState(data); - } - /** * Prepares headers for a request, refreshing token if needed and calling preRequest hook if configured */ diff --git a/packages/server/src/client-sessions.ts b/packages/server/src/client-sessions.ts index 08c3079..9da30a8 100644 --- a/packages/server/src/client-sessions.ts +++ b/packages/server/src/client-sessions.ts @@ -74,10 +74,9 @@ export class ClientSessionManager { } } - private ensureClientJWT(token: string, clientId: string, ignoreExpiration = false) { + private ensureClientJWT(token: string, clientId: string) { const decoded = jwt.verify(token, this.jwtSecret, { algorithms: ['HS256'], - ignoreExpiration, }) as { clientId: string; type: string }; if (decoded.clientId !== clientId || decoded.type !== 'client') { @@ -136,27 +135,6 @@ export class ClientSessionManager { } } - /** - * Verify client token for refresh purposes - allows expired JWT tokens. - * This is used during token refresh when the JWT may have expired but - * the session still exists in cache. - */ - async verifyClientForRefresh(clientId: string, token: string): Promise { - try { - // Verify token structure but ignore expiration - token refresh should work - // even if the JWT has expired, as long as the session still exists in cache - if (!this.ensureClientJWT(token, clientId, true)) { - return false; - } - - // Check if session exists in cache - don't check session.expiresAt - const session = await this.cache.get(`session:${clientId}`); - return session !== null; - } catch { - return false; - } - } - /** * Get client session */ @@ -207,47 +185,6 @@ export class ClientSessionManager { ); } - /** - * Refresh token for an existing client session. - * Returns new token credentials if session exists in cache. - * This works even if the session's expiresAt has passed - the refresh - * will update expiresAt to extend the session. - */ - async refreshToken(clientId: string): Promise { - // Get session directly from cache without expiry check - const session = await this.cache.get(`session:${clientId}`); - if (!session) { - // Throw happens in handler - return null; - } - - // Remove old client session entry. - await this.cache.delete(`session:${clientId}`);; - - const newClientId = this.generateClientId(); - const now = Date.now(); - const newExpiresAt = now + this.tokenTTL; - const newTokenRotateAt = now + this.tokenRotation; - - // Update session with both new clientId and new expiresAt - const updatedSession: ClientSession = { - ...session, - clientId, - expiresAt: newExpiresAt, - }; - - await this.cache.set(`session:${newClientId}`, updatedSession); - - const newToken = this.generateToken(newClientId); - - return { - clientId: newClientId, - token: newToken, - expiresAt: newExpiresAt, - tokenRotateAt: newTokenRotateAt, - }; - } - /** * Get token TTL and rotation settings (useful for clients) */ diff --git a/packages/server/src/create-server.ts b/packages/server/src/create-server.ts index 2d6cd15..6dd103b 100644 --- a/packages/server/src/create-server.ts +++ b/packages/server/src/create-server.ts @@ -39,7 +39,6 @@ import { handleSearch, handleSearchQuery } from './handlers/search.handler.js'; import { handleExplore } from './handlers/explorer.handler.js'; import { handleExecute } from './handlers/execute.handler.js'; import { handleResume } from './handlers/resume.handler.js'; -import { handleTokenRefresh } from './handlers/token.handler.js'; import { getDefinitions } from './handlers/definitions.handler.js'; import { shutdownAudit } from './middleware/audit.js'; import { @@ -159,7 +158,7 @@ export class AgentToolProtocolServer { } if (!this.cacheProvider) { - this.cacheProvider = new MemoryCache({ maxKeys: 1000, defaultTTL: 3600 }); + this.cacheProvider = new MemoryCache({ maxKeys: 1000, defaultTTL: 24 * 3600 }); log.info('Cache provider configured (default)', { provider: 'memory' }); } @@ -612,11 +611,6 @@ export class AgentToolProtocolServer { ); } - async handleTokenRefresh(ctx: RequestContext): Promise { - if (!this.sessionManager) ctx.throw(503, 'Session manager not initialized'); - return await handleTokenRefresh(ctx, this.sessionManager); - } - /** * Update server components with new API groups (internal method) * @private diff --git a/packages/server/src/graphql-loader.ts b/packages/server/src/graphql-loader.ts index 43e635a..0ad2028 100644 --- a/packages/server/src/graphql-loader.ts +++ b/packages/server/src/graphql-loader.ts @@ -204,6 +204,28 @@ export interface LoadGraphQLOptions { auth?: AuthConfig; depthLimit?: number; queryDepthLimit?: number; + + /** + * Custom fetch function for full control over the HTTP transport layer. + * Use this when the target API requires custom TLS certificates (mTLS, custom CA), + * a proxy, or any other transport-level configuration that headers alone cannot provide. + * + * Applied to both schema loading and runtime GraphQL requests. + * + * @example + * ```typescript + * import { Agent } from 'undici'; + * + * const mtlsAgent = new Agent({ + * connect: { ca: fs.readFileSync('/certs/ca.pem') }, + * }); + * + * await server.loadGraphQL('https://api.example.com/graphql', { + * fetcher: (url, init) => fetch(url, { ...init, dispatcher: mtlsAgent }), + * }); + * ``` + */ + fetcher?: (url: string, init: RequestInit) => Promise; } /** @@ -244,10 +266,11 @@ export async function loadGraphQL( async function loadSchema(source: string, options: LoadGraphQLOptions): Promise { // Check if source is a URL if (source.startsWith('http://') || source.startsWith('https://')) { + const fetchFn = options.fetcher || fetch; // Resolve headers for schema loading const headers = await resolveHeaders(options); - const response = await fetch(source, { + const response = await fetchFn(source, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -258,7 +281,8 @@ async function loadSchema(source: string, options: LoadGraphQLOptions): Promise< if (!response.ok) { // If POST with introspection fails, try GET assuming it might return SDL or JSON - const getResponse = await fetch(source, { + const getResponse = await fetchFn(source, { + method: 'GET', headers, }); if (getResponse.ok) { @@ -352,7 +376,8 @@ function convertFieldToFunction( // Pass execution context (including requestContext) to resolveHeaders const headers = await resolveHeaders(options, paramsObj, context); - const response = await fetch(url, { + const fetchFn = options.fetcher || fetch; + const response = await fetchFn(url, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/server/src/handlers/token.handler.ts b/packages/server/src/handlers/token.handler.ts deleted file mode 100644 index f03fc19..0000000 --- a/packages/server/src/handlers/token.handler.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { RequestContext } from '../core/config.js'; -import type { ClientSessionManager } from '../client-sessions.js'; -import { log } from '@mondaydotcomorg/atp-runtime'; - -export interface TokenRefreshRequest { - clientId: string; -} - -export interface TokenRefreshResponse { - clientId: string; - token: string; - expiresAt: number; - tokenRotateAt: number; -} - -/** - * Handle token refresh requests. - * Allows clients to refresh their token, even if the JWT has expired. - * The session must still exist in the cache for refresh to succeed. - */ -export async function handleTokenRefresh( - ctx: RequestContext, - sessionManager: ClientSessionManager -): Promise { - // Get clientId from header or body - const clientId = ctx.clientId || (ctx.body as TokenRefreshRequest)?.clientId; - - if (!clientId) { - ctx.throw(400, 'Client ID is required for token refresh'); - } - - // Verify the current token (from Authorization header) - const authHeader = ctx.headers['authorization']; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - ctx.throw(401, 'Bearer token required for refresh'); - } - - const currentToken = authHeader.substring(7); - - // Verify the token belongs to this client - allows expired JWT tokens - const isValid = await sessionManager.verifyClientForRefresh(clientId, currentToken); - if (!isValid) { - ctx.throw(401, 'Invalid token or session expired'); - } - - // Refresh the token - const refreshResult = await sessionManager.refreshToken(clientId); - if (!refreshResult) { - ctx.throw(401, 'Session not found or expired'); - } - - log.debug('Token refreshed', { - clientId, - newExpiresAt: refreshResult.expiresAt, - newRotateAt: refreshResult.tokenRotateAt, - }); - - return refreshResult; -} diff --git a/packages/server/src/http/router.ts b/packages/server/src/http/router.ts index 13fc029..c722ff5 100644 --- a/packages/server/src/http/router.ts +++ b/packages/server/src/http/router.ts @@ -24,8 +24,6 @@ export async function handleRoute( } else if (ctx.path.startsWith('/api/resume/') && ctx.method === 'POST') { const executionId = ctx.path.substring('/api/resume/'.length); ctx.responseBody = await server.handleResume(ctx, executionId); - } else if (ctx.path === '/api/token/refresh' && ctx.method === 'POST') { - ctx.responseBody = await server.handleTokenRefresh(ctx); } else { ctx.status = 404; ctx.responseBody = { error: 'Not found' }; diff --git a/packages/server/src/openapi-loader.ts b/packages/server/src/openapi-loader.ts index 9a473f9..24c5383 100644 --- a/packages/server/src/openapi-loader.ts +++ b/packages/server/src/openapi-loader.ts @@ -210,6 +210,35 @@ export interface LoadOpenAPIOptions { headers?: Record; body?: string | undefined; }; + + /** + * Custom fetch function for full control over the HTTP transport layer. + * Use this when the target API requires custom TLS certificates (mTLS, custom CA), + * a proxy, or any other transport-level configuration that headers alone cannot provide. + * + * The function receives the URL and standard RequestInit on every request, + * making it inherently dynamic — you can route to different agents based on the URL. + * + * Applied to both spec loading and runtime API calls. + * + * @example + * ```typescript + * import { Agent } from 'undici'; + * + * const mtlsAgent = new Agent({ + * connect: { + * ca: fs.readFileSync('/certs/ca.pem'), + * cert: fs.readFileSync('/certs/client.pem'), + * key: fs.readFileSync('/certs/client-key.pem'), + * }, + * }); + * + * await server.loadOpenAPI('https://partner-api.example.com/spec.json', { + * fetcher: (url, init) => fetch(url, { ...init, dispatcher: mtlsAgent }), + * }); + * ``` + */ + fetcher?: (url: string, init: RequestInit) => Promise; } /** @@ -233,7 +262,7 @@ export async function loadOpenAPI( source: string, options: LoadOpenAPIOptions = {} ): Promise { - const spec = await loadSpec(source); + const spec = await loadSpec(source, options.fetcher); const name = options.name || spec.info.title.toLowerCase().replace(/\s+/g, '-'); @@ -297,12 +326,16 @@ export async function loadOpenAPI( /** * Load OpenAPI spec from file or URL */ -async function loadSpec(source: string): Promise { +async function loadSpec( + source: string, + fetcher?: (url: string, init: RequestInit) => Promise +): Promise { let content: string; let isYaml = false; if (source.startsWith('http://') || source.startsWith('https://')) { - const response = await fetch(source); + const fetchFn = fetcher || fetch; + const response = await fetchFn(source, { method: 'GET' }); if (!response.ok) { throw new Error(`Failed to load OpenAPI spec from ${source}: ${response.statusText}`); } @@ -625,7 +658,8 @@ function convertOperation( if (transformed.body !== undefined) finalBody = transformed.body; } - const response = await fetch(finalUrl, { + const fetchFn = options.fetcher || fetch; + const response = await fetchFn(finalUrl, { method: finalMethod, headers: finalHeaders, body: finalBody,