Skip to content
Merged
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
121 changes: 119 additions & 2 deletions apps/desktop-main/src/api-gateway.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
ApiInvokeRequest,
ApiOperationId,
Expand Down Expand Up @@ -696,7 +696,7 @@ describe('invokeApiOperation', () => {
'status.github': {
method: 'POST',
url: 'https://api.example.com/write',
retry: { maxAttempts: 3, baseDelayMs: 1 },
retry: { maxAttempts: 2, baseDelayMs: 1 },
},
};
let attempts = 0;
Expand Down Expand Up @@ -751,3 +751,120 @@ describe('getApiOperationDiagnostics', () => {
}
});
});

describe('trust tier policy enforcement', () => {
const okFetch: typeof fetch = async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});

it('blocks operations whose declared policy exceeds the provider trust tier', async () => {
const logEvents: Array<{
level: string;
event: string;
details?: Record<string, unknown>;
}> = [];
const result = await invokeApiOperation(baseRequest('status.github'), {
operations: {
'status.github': {
providerId: 'external-http',
method: 'GET',
url: 'https://api.example.com/health',
auth: { type: 'none' },
timeoutMs: 45_000,
},
},
fetchFn: okFetch,
log: (level, event, details) => logEvents.push({ level, event, details }),
});

const error = expectFailure(result);
expect(error.code).toBe('API/POLICY_VIOLATION');
expect(error.retryable).toBe(false);
expect(error.correlationId).toBe('corr-test');
if (!result.ok) {
const details = result.error.details as {
trustTier?: string;
violations?: Array<{
dimension: string;
declared: number;
ceiling: number;
}>;
};
expect(details.trustTier).toBe('remote-low');
expect(details.violations).toEqual([
{ dimension: 'maxTimeoutMs', declared: 45_000, ceiling: 10_000 },
]);
}
expect(logEvents).toHaveLength(1);
expect(logEvents[0]?.event).toBe('api.trust_policy.violation');
expect(logEvents[0]?.details?.correlationId).toBe('corr-test');
});

it('retries GET operations up to the provider trust tier ceiling', async () => {
let fetchCalls = 0;
const alwaysServerError: typeof fetch = async () => {
fetchCalls += 1;
return new Response(JSON.stringify({ error: 'server boom' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
};

const result = await invokeApiOperation(baseRequest('status.github'), {
operations: {
'status.github': {
providerId: 'bundled-http',
method: 'GET',
url: 'https://api.example.com/health',
auth: { type: 'none' },
retry: { maxAttempts: 3, baseDelayMs: 1 },
},
},
fetchFn: alwaysServerError,
});

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('API/SERVER_ERROR');
}
expect(fetchCalls).toBe(3);
});

it('grants bundled-http operations the local-medium ceilings', async () => {
const result = await invokeApiOperation(baseRequest('status.github'), {
operations: {
'status.github': {
providerId: 'bundled-http',
method: 'GET',
url: 'https://api.example.com/health',
auth: { type: 'none' },
timeoutMs: 14_000,
},
},
fetchFn: okFetch,
});

expect(result.ok).toBe(true);
});

it('blocks operations on providers without an assigned trust tier before any network call', async () => {
const fetchSpy = vi.fn(okFetch);
const result = await invokeApiOperation(baseRequest('status.github'), {
operations: {
'status.github': {
providerId: 'docker-local' as 'bundled-http',
method: 'GET',
url: 'https://api.example.com/health',
auth: { type: 'none' },
},
},
fetchFn: fetchSpy as unknown as typeof fetch,
});

const error = expectFailure(result);
expect(error.code).toBe('API/OPERATION_NOT_ALLOWED');
expect(fetchSpy).not.toHaveBeenCalled();
});
});
111 changes: 89 additions & 22 deletions apps/desktop-main/src/api-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {
type ApiOperationRequestPolicy,
type ApiOperationRegistry,
} from './api-operation-registry';
import {
API_TRUST_TIER_POLICIES,
clampApiOperationToTier,
evaluateApiTrustTier,
resolveApiTrustTier,
} from './api-trust-policy';

const API_DEFAULT_TIMEOUT_MS = 8_000;
const API_DEFAULT_MAX_RESPONSE_BYTES = 1_000_000;
Expand All @@ -34,6 +40,11 @@ export const refreshDefaultApiOperationsFromEnv = () => {
type InvokeApiDeps = {
fetchFn?: typeof fetch;
operations?: ApiOperationRegistry;
log?: (
level: 'debug' | 'info' | 'warn' | 'error',
event: string,
details?: Record<string, unknown>,
) => void;
};

type GetApiOperationDiagnosticsDeps = {
Expand Down Expand Up @@ -759,6 +770,7 @@ export const invokeApiOperation = async (
): Promise<DesktopResult<ApiSuccess>> => {
const operations = deps.operations ?? defaultApiOperations;
const fetchFn = deps.fetchFn ?? fetch;
const log = deps.log;
const correlationId = request.correlationId;

const operation = operations[request.payload.operationId];
Expand Down Expand Up @@ -788,7 +800,70 @@ export const invokeApiOperation = async (
);
}

const requestPolicyResult = validateRequestPolicy(request, operation);
const providerId = operation.providerId ?? 'external-http';
const provider = executionProviders[providerId];
if (!provider) {
return asFailure(
'API/OPERATION_NOT_ALLOWED',
'Requested API operation is not allowed.',
{
operationId: request.payload.operationId,
providerId,
},
false,
correlationId,
);
}

const trustTier = resolveApiTrustTier(providerId);
if (!trustTier) {
log?.('warn', 'api.trust_policy.violation', {
operationId: request.payload.operationId,
correlationId,
providerId,
reason: 'provider_has_no_trust_tier',
});
return asFailure(
'API/POLICY_VIOLATION',
'API operation provider has no assigned trust tier.',
{
operationId: request.payload.operationId,
providerId,
},
false,
correlationId,
);
}

const tierEvaluation = evaluateApiTrustTier(operation, trustTier);
if (!tierEvaluation.ok) {
log?.('warn', 'api.trust_policy.violation', {
operationId: request.payload.operationId,
correlationId,
providerId,
trustTier,
violations: tierEvaluation.violations,
});
return asFailure(
'API/POLICY_VIOLATION',
'API operation configuration exceeds its provider trust tier.',
{
operationId: request.payload.operationId,
providerId,
trustTier,
violations: tierEvaluation.violations,
},
false,
correlationId,
);
}

const effectiveOperation = clampApiOperationToTier(operation, trustTier);

const requestPolicyResult = validateRequestPolicy(
request,
effectiveOperation,
);
if (!requestPolicyResult.ok) {
return requestPolicyResult;
}
Expand Down Expand Up @@ -826,34 +901,26 @@ export const invokeApiOperation = async (
state.lastStartedAt = Date.now();

try {
const providerId = operation.providerId ?? 'external-http';
const provider = executionProviders[providerId];
if (!provider) {
return asFailure(
'API/OPERATION_NOT_ALLOWED',
'Requested API operation is not allowed.',
{
operationId: request.payload.operationId,
providerId,
},
false,
correlationId,
);
}

const tierPolicy = API_TRUST_TIER_POLICIES[trustTier];
const declaredRetryAttempts =
effectiveOperation.retry?.maxAttempts ?? API_DEFAULT_RETRY_ATTEMPTS;
const retryAttempts =
operation.method === 'GET'
? Math.max(
1,
operation.retry?.maxAttempts ?? API_DEFAULT_RETRY_ATTEMPTS,
effectiveOperation.method === 'GET'
? Math.min(
Math.max(1, declaredRetryAttempts),
tierPolicy.maxRetryAttempts,
)
: 1;

const retryBaseDelayMs =
operation.retry?.baseDelayMs ?? API_DEFAULT_RETRY_BASE_DELAY_MS;
effectiveOperation.retry?.baseDelayMs ?? API_DEFAULT_RETRY_BASE_DELAY_MS;

for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
const result = await provider.invoke(request, operation, fetchFn);
const result = await provider.invoke(
request,
effectiveOperation,
fetchFn,
);
if (!result.ok) {
const failure = result as Extract<typeof result, { ok: false }>;
const errorCode = failure.error.code;
Expand Down
Loading
Loading