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
21 changes: 12 additions & 9 deletions packages/corsair/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const BaseProviders = [
'abyssale',
'accrediblecertificates',
'activecampaign',
'anchorbrowser',
'activetrail',
'addresszen',
'aeroleads',
Expand All @@ -40,14 +39,15 @@ export const BaseProviders = [
'ambientweather',
'amcards',
'amplitude',
'anchorbrowser',
'anthropicadministrator',
'apaleo',
'api2pdf',
'apibible',
'apipie',
'apify',
'apilabz',
'apininjas',
'apipie',
'apisports',
'asana',
'asindataapi',
Expand Down Expand Up @@ -77,6 +77,7 @@ export const BaseProviders = [
'collegefootballdata',
'confluence',
'contentfulgraphql',
'crowterminal',
'cursor',
'databricks',
'datadog',
Expand All @@ -92,8 +93,8 @@ export const BaseProviders = [
'facebook',
'figma',
'firecrawl',
'formbricks',
'fireflies',
'formbricks',
'gemini',
'github',
'gitlab',
Expand Down Expand Up @@ -185,7 +186,6 @@ export const ProviderDisplayNames = {
abyssale: 'Abyssale',
accrediblecertificates: 'Accredible Certificates',
activecampaign: 'ActiveCampaign',
anchorbrowser: 'Anchor Browser',
activetrail: 'Active Trail',
addresszen: 'Addresszen',
aeroleads: 'Aeroleads',
Expand All @@ -207,14 +207,15 @@ export const ProviderDisplayNames = {
ambientweather: 'Ambient Weather',
amcards: 'AMcards',
amplitude: 'Amplitude',
anchorbrowser: 'Anchor Browser',
anthropicadministrator: 'Anthropic Administrator',
apaleo: 'Apaleo',
api2pdf: 'API2PDF',
apibible: 'API.Bible',
apipie: 'APIpie AI',
apify: 'Apify',
apilabz: 'API Labz',
apininjas: 'API Ninjas',
apipie: 'APIpie AI',
apisports: 'API-Sports',
asana: 'Asana',
asindataapi: 'ASIN Data API',
Expand Down Expand Up @@ -244,6 +245,7 @@ export const ProviderDisplayNames = {
collegefootballdata: 'College Football Data',
confluence: 'Confluence',
contentfulgraphql: 'Contentful GraphQL',
crowterminal: 'CrowTerminal',
cursor: 'Cursor',
databricks: 'Databricks',
datadog: 'Datadog',
Expand All @@ -259,8 +261,8 @@ export const ProviderDisplayNames = {
facebook: 'Facebook',
figma: 'Figma',
firecrawl: 'Firecrawl',
formbricks: 'Formbricks',
fireflies: 'Fireflies',
formbricks: 'Formbricks',
gemini: 'Gemini',
github: 'GitHub',
gitlab: 'GitLab',
Expand Down Expand Up @@ -359,7 +361,6 @@ export type AllProviders =
| 'abyssale'
| 'accrediblecertificates'
| 'activecampaign'
| 'anchorbrowser'
| 'activetrail'
| 'addresszen'
| 'aeroleads'
Expand All @@ -381,14 +382,15 @@ export type AllProviders =
| 'ambientweather'
| 'amcards'
| 'amplitude'
| 'anchorbrowser'
| 'anthropicadministrator'
| 'apaleo'
| 'api2pdf'
| 'apibible'
| 'apipie'
| 'apify'
| 'apilabz'
| 'apininjas'
| 'apipie'
| 'apisports'
| 'asana'
| 'asindataapi'
Expand Down Expand Up @@ -418,6 +420,7 @@ export type AllProviders =
| 'collegefootballdata'
| 'confluence'
| 'contentfulgraphql'
| 'crowterminal'
| 'cursor'
| 'databricks'
| 'datadog'
Expand All @@ -433,8 +436,8 @@ export type AllProviders =
| 'facebook'
| 'figma'
| 'firecrawl'
| 'formbricks'
| 'fireflies'
| 'formbricks'
| 'gemini'
| 'github'
| 'gitlab'
Expand Down
136 changes: 136 additions & 0 deletions packages/crowterminal/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { getTypes } from './endpoints/data';
import { getByokPlatform } from './endpoints/intelligence';
import { getBulk as getBulkMemory, getChangelog } from './endpoints/memory';
import {
getClient as sandboxClient,
engagementAnalysis as sandboxEngagement,
getMemory as sandboxMemory,
validate as sandboxValidate,
} from './endpoints/sandbox';
import {
getComponents,
getHistory,
getIncidents,
get as getStatus,
getUptime,
ping,
} from './endpoints/status';
import { list as listWebhooks } from './endpoints/webhooks';
import type { CrowterminalContext } from './index';

// Hits the real CrowTerminal API. CI skips this file by name
// (--testPathIgnorePatterns="api\.test\.ts"); run it with:
//
// CROWTERMINAL_API_KEY=ct_... pnpm test:live
//
// Only read-only operations run here. Registering an agent, ingesting data and
// creating or deleting webhooks all change real state, so they are left out.

const apiKey = process.env.CROWTERMINAL_API_KEY;
const describeLive = apiKey ? describe : describe.skip;

describeLive('CrowTerminal API', () => {
const ctx = () => ({ key: apiKey }) as unknown as CrowterminalContext;

describe('status', () => {
it('reports service health', async () => {
const result = await getStatus(ctx(), {});
expect(typeof result.status).toBe('string');
}, 30_000);

it('answers a ping', async () => {
await expect(ping(ctx(), {})).resolves.toMatchObject({ pong: true });
}, 30_000);

it.each([
['components', getComponents],
['incidents', getIncidents],
['history', getHistory],
['uptime', getUptime],
])(
'returns %s',
async (_name, endpoint) => {
await expect(
(
endpoint as (c: CrowterminalContext, i: unknown) => Promise<unknown>
)(ctx(), {}),
).resolves.toBeDefined();
},
30_000,
);
});

describe('reference data', () => {
it('lists data types for all three platforms', async () => {
const result = await getTypes(ctx(), {});
expect(Object.keys(result.dataTypes)).toEqual(
expect.arrayContaining(['TIKTOK', 'INSTAGRAM', 'YOUTUBE']),
);
}, 30_000);

it('returns platform intel without LLM inference charges', async () => {
await expect(getByokPlatform(ctx(), {})).resolves.toBeDefined();
}, 30_000);
});

describe('sandbox', () => {
it('returns a mock client and mock memory', async () => {
await expect(sandboxClient(ctx(), {})).resolves.toHaveProperty(
'clientId',
);
await expect(sandboxMemory(ctx(), {})).resolves.toHaveProperty(
'clientId',
);
}, 30_000);

it('runs a mock engagement analysis', async () => {
const result = await sandboxEngagement(ctx(), {
agentMd: { hookPatterns: ['confession'] },
});
expect(result.versionsAnalyzed).toEqual(expect.any(Number));
}, 30_000);

// The documented way to force a blocked result is a change to "tutorial".
it('blocks a change the sandbox knows performed badly', async () => {
const result = await sandboxValidate(ctx(), {
proposedChanges: [
{ field: 'hookPatterns', oldValue: 'story', newValue: 'tutorial' },
],
});
expect(result.validation).toBe('blocked');
expect(result.warnings.length).toBeGreaterThan(0);
}, 30_000);
});

describe('account-scoped reads', () => {
it('lists webhooks for this key', async () => {
await expect(listWebhooks(ctx(), {})).resolves.toHaveProperty('webhooks');
}, 30_000);

it('reads a changelog for an unknown client without failing', async () => {
await expect(
getChangelog(ctx(), { clientId: 'corsair-live-test' }),
).resolves.toHaveProperty('changelog');
}, 30_000);

it('reads several clients in one bulk call', async () => {
const result = await getBulkMemory(ctx(), {
clientIds: ['corsair-live-a', 'corsair-live-b'],
});
expect(result.clients).toHaveLength(2);
}, 30_000);
});

describe('failure modes', () => {
it('rejects an invalid api key', async () => {
const bad = { key: 'ct_invalid' } as unknown as CrowterminalContext;
await expect(listWebhooks(bad, {})).rejects.toThrow();
}, 30_000);
});
});

if (!apiKey) {
it('skips the live suite without CROWTERMINAL_API_KEY', () => {
expect(apiKey).toBeUndefined();
});
}
72 changes: 72 additions & 0 deletions packages/crowterminal/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
import { ApiError, request } from 'corsair/http';

export class CrowterminalAPIError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string,
public readonly retryAfter?: number,
) {
super(message);
this.name = 'CrowterminalAPIError';
}
}

export const CROWTERMINAL_API_BASE = 'https://api.crowterminal.com';

/**
* Escapes a value being spliced into a request path. Without this a clientId of
* `../status` retargets the credentialed request at a different endpoint, and
* one containing `?` appends a query string.
*/
export function pathSegment(value: string): string {
return encodeURIComponent(value);
}

export async function makeCrowterminalRequest<T>(
endpoint: string,
apiKey: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: Record<string, unknown>;
query?: Record<string, string | number | boolean | undefined>;
baseUrl?: string;
} = {},
): Promise<T> {
const {
method = 'GET',
body,
query,
baseUrl = CROWTERMINAL_API_BASE,
} = options;

const config: OpenAPIConfig = {
BASE: baseUrl,
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
// request.ts applies TOKEN after HEADERS, so the bearer goes here rather
// than being set twice.
TOKEN: apiKey,
HEADERS: { 'Content-Type': 'application/json' },
};

const requestOptions: ApiRequestOptions = {
method,
url: endpoint,
body: method === 'GET' || method === 'DELETE' ? undefined : body,
mediaType: 'application/json',
query,
};

try {
return await request<T>(config, requestOptions);
} catch (error) {
// ApiError carries status and retryAfter, which error-handlers.ts needs to
// classify auth and rate-limit failures. Rewrapping would strip both.
if (error instanceof ApiError) throw error;
if (error instanceof Error) throw new CrowterminalAPIError(error.message);
throw new CrowterminalAPIError('Unknown error');
}
}
29 changes: 29 additions & 0 deletions packages/crowterminal/endpoints/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { CrowterminalContext } from '..';
import { callCrowterminal } from './shared';
import type {
CrowterminalEndpointInputs,
CrowterminalEndpointOutputs,
} from './types';
import { RegisterAgentInputSchema, RegisterAgentResponseSchema } from './types';

/**
* Self-registers an agent and returns a new API key. The key is shown once and
* cannot be retrieved later, so the caller has to persist it. Rate limited to
* five calls per hour per IP, and it creates a real agent on every success.
*/
export const register = (
ctx: CrowterminalContext,
input: CrowterminalEndpointInputs['agentRegister'],
): Promise<CrowterminalEndpointOutputs['agentRegister']> =>
callCrowterminal(
ctx,
{
event: 'crowterminal.agent.register',
method: 'POST',
inputSchema: RegisterAgentInputSchema,
outputSchema: RegisterAgentResponseSchema,
path: () => '/api/agent/register',
body: (i) => ({ ...i }),
},
input,
);
Loading
Loading