From 0f174e8272485e2ca169a9deaf09e6dfc0dec8d0 Mon Sep 17 00:00:00 2001 From: Vinit Date: Sat, 22 Aug 2026 17:53:00 +0530 Subject: [PATCH 1/7] feat: add CrowTerminal plugin --- packages/corsair/core/constants.ts | 21 +- packages/crowterminal/client.ts | 64 +++ packages/crowterminal/endpoints/data.ts | 24 ++ packages/crowterminal/endpoints/index.ts | 33 ++ packages/crowterminal/endpoints/memory.ts | 36 ++ packages/crowterminal/endpoints/status.ts | 18 + packages/crowterminal/endpoints/types.test.ts | 149 +++++++ packages/crowterminal/endpoints/types.ts | 206 ++++++++++ packages/crowterminal/endpoints/webhooks.ts | 101 +++++ packages/crowterminal/error-handlers.ts | 36 ++ packages/crowterminal/index.ts | 373 ++++++++++++++++++ packages/crowterminal/jest.config.cjs | 55 +++ packages/crowterminal/package.json | 44 +++ packages/crowterminal/schema.test.ts | 20 + packages/crowterminal/schema/database.ts | 1 + packages/crowterminal/schema/index.ts | 4 + packages/crowterminal/tsconfig.json | 20 + packages/crowterminal/tsup.config.ts | 15 + packages/crowterminal/webhooks/events.ts | 63 +++ packages/crowterminal/webhooks/index.ts | 19 + packages/crowterminal/webhooks/types.test.ts | 91 +++++ packages/crowterminal/webhooks/types.ts | 154 ++++++++ pnpm-lock.yaml | 24 ++ 23 files changed, 1562 insertions(+), 9 deletions(-) create mode 100644 packages/crowterminal/client.ts create mode 100644 packages/crowterminal/endpoints/data.ts create mode 100644 packages/crowterminal/endpoints/index.ts create mode 100644 packages/crowterminal/endpoints/memory.ts create mode 100644 packages/crowterminal/endpoints/status.ts create mode 100644 packages/crowterminal/endpoints/types.test.ts create mode 100644 packages/crowterminal/endpoints/types.ts create mode 100644 packages/crowterminal/endpoints/webhooks.ts create mode 100644 packages/crowterminal/error-handlers.ts create mode 100644 packages/crowterminal/index.ts create mode 100644 packages/crowterminal/jest.config.cjs create mode 100644 packages/crowterminal/package.json create mode 100644 packages/crowterminal/schema.test.ts create mode 100644 packages/crowterminal/schema/database.ts create mode 100644 packages/crowterminal/schema/index.ts create mode 100644 packages/crowterminal/tsconfig.json create mode 100644 packages/crowterminal/tsup.config.ts create mode 100644 packages/crowterminal/webhooks/events.ts create mode 100644 packages/crowterminal/webhooks/index.ts create mode 100644 packages/crowterminal/webhooks/types.test.ts create mode 100644 packages/crowterminal/webhooks/types.ts diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 93ce1126d..5f37d2447 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -18,7 +18,6 @@ export const BaseProviders = [ 'abyssale', 'accrediblecertificates', 'activecampaign', - 'anchorbrowser', 'activetrail', 'addresszen', 'aeroleads', @@ -40,14 +39,15 @@ export const BaseProviders = [ 'ambientweather', 'amcards', 'amplitude', + 'anchorbrowser', 'anthropicadministrator', 'apaleo', 'api2pdf', 'apibible', - 'apipie', 'apify', 'apilabz', 'apininjas', + 'apipie', 'apisports', 'asana', 'asindataapi', @@ -77,6 +77,7 @@ export const BaseProviders = [ 'collegefootballdata', 'confluence', 'contentfulgraphql', + 'crowterminal', 'cursor', 'databricks', 'datadog', @@ -92,8 +93,8 @@ export const BaseProviders = [ 'facebook', 'figma', 'firecrawl', - 'formbricks', 'fireflies', + 'formbricks', 'gemini', 'github', 'gitlab', @@ -185,7 +186,6 @@ export const ProviderDisplayNames = { abyssale: 'Abyssale', accrediblecertificates: 'Accredible Certificates', activecampaign: 'ActiveCampaign', - anchorbrowser: 'Anchor Browser', activetrail: 'Active Trail', addresszen: 'Addresszen', aeroleads: 'Aeroleads', @@ -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', @@ -244,6 +245,7 @@ export const ProviderDisplayNames = { collegefootballdata: 'College Football Data', confluence: 'Confluence', contentfulgraphql: 'Contentful GraphQL', + crowterminal: 'Crowterminal', cursor: 'Cursor', databricks: 'Databricks', datadog: 'Datadog', @@ -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', @@ -359,7 +361,6 @@ export type AllProviders = | 'abyssale' | 'accrediblecertificates' | 'activecampaign' - | 'anchorbrowser' | 'activetrail' | 'addresszen' | 'aeroleads' @@ -381,14 +382,15 @@ export type AllProviders = | 'ambientweather' | 'amcards' | 'amplitude' + | 'anchorbrowser' | 'anthropicadministrator' | 'apaleo' | 'api2pdf' | 'apibible' - | 'apipie' | 'apify' | 'apilabz' | 'apininjas' + | 'apipie' | 'apisports' | 'asana' | 'asindataapi' @@ -418,6 +420,7 @@ export type AllProviders = | 'collegefootballdata' | 'confluence' | 'contentfulgraphql' + | 'crowterminal' | 'cursor' | 'databricks' | 'datadog' @@ -433,8 +436,8 @@ export type AllProviders = | 'facebook' | 'figma' | 'firecrawl' - | 'formbricks' | 'fireflies' + | 'formbricks' | 'gemini' | 'github' | 'gitlab' diff --git a/packages/crowterminal/client.ts b/packages/crowterminal/client.ts new file mode 100644 index 000000000..0fc4af0b4 --- /dev/null +++ b/packages/crowterminal/client.ts @@ -0,0 +1,64 @@ +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'; + } +} + +const CROWTERMINAL_API_BASE = 'https://api.crowterminal.com'; + +export async function makeCrowterminalRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + } = {}, +): Promise { + const { method = 'GET', body, query } = options; + + const config: OpenAPIConfig = { + BASE: CROWTERMINAL_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + HEADERS: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query: method === 'GET' ? query : undefined, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + // Preserve HTTP status and Retry-After metadata so Corsair's error + // handlers can classify authentication and rate-limit failures. + if (error instanceof ApiError) { + throw error; + } + if (error instanceof Error) { + throw new CrowterminalAPIError(error.message); + } + throw new CrowterminalAPIError('Unknown error'); + } +} diff --git a/packages/crowterminal/endpoints/data.ts b/packages/crowterminal/endpoints/data.ts new file mode 100644 index 000000000..e1bdd338d --- /dev/null +++ b/packages/crowterminal/endpoints/data.ts @@ -0,0 +1,24 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeCrowterminalRequest } from '../client'; +import type { CrowterminalEndpoints } from '../index'; +import type { CrowterminalEndpointOutputs } from './types'; + +export const ingest: CrowterminalEndpoints['dataIngest'] = async ( + ctx, + input, +) => { + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['dataIngest'] + >('/api/agent/data/ingest', ctx.key, { + method: 'POST', + body: input, + }); + + await logEventFromContext( + ctx, + 'crowterminal.data.ingest', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/crowterminal/endpoints/index.ts b/packages/crowterminal/endpoints/index.ts new file mode 100644 index 000000000..47bcde1d2 --- /dev/null +++ b/packages/crowterminal/endpoints/index.ts @@ -0,0 +1,33 @@ +import { ingest } from './data'; +import { engagementAnalysis, get as getMemory } from './memory'; +import { get as getStatus } from './status'; +import { + create as createWebhook, + deleteWebhook, + list as listWebhooks, + test as testWebhook, + update as updateWebhook, +} from './webhooks'; + +export const Memory = { + get: getMemory, + engagementAnalysis, +}; + +export const Data = { + ingest, +}; + +export const Status = { + get: getStatus, +}; + +export const Webhooks = { + create: createWebhook, + list: listWebhooks, + update: updateWebhook, + delete: deleteWebhook, + test: testWebhook, +}; + +export * from './types'; diff --git a/packages/crowterminal/endpoints/memory.ts b/packages/crowterminal/endpoints/memory.ts new file mode 100644 index 000000000..435a7556b --- /dev/null +++ b/packages/crowterminal/endpoints/memory.ts @@ -0,0 +1,36 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeCrowterminalRequest } from '../client'; +import type { CrowterminalEndpoints } from '../index'; +import type { CrowterminalEndpointOutputs } from './types'; + +export const get: CrowterminalEndpoints['memoryGet'] = async (ctx, input) => { + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['memoryGet'] + >(`/api/agent/memory/${input.clientId}`, ctx.key); + + await logEventFromContext( + ctx, + 'crowterminal.memory.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const engagementAnalysis: CrowterminalEndpoints['memoryEngagementAnalysis'] = + async (ctx, input) => { + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['memoryEngagementAnalysis'] + >(`/api/agent/memory/${input.clientId}/engagement-analysis`, ctx.key, { + method: 'POST', + body: { agentMd: input.agentMd }, + }); + + await logEventFromContext( + ctx, + 'crowterminal.memory.engagement_analysis', + { ...input }, + 'completed', + ); + return response; + }; diff --git a/packages/crowterminal/endpoints/status.ts b/packages/crowterminal/endpoints/status.ts new file mode 100644 index 000000000..e4f84c1d7 --- /dev/null +++ b/packages/crowterminal/endpoints/status.ts @@ -0,0 +1,18 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeCrowterminalRequest } from '../client'; +import type { CrowterminalEndpoints } from '../index'; +import type { CrowterminalEndpointOutputs } from './types'; + +export const get: CrowterminalEndpoints['statusGet'] = async (ctx, input) => { + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['statusGet'] + >('/api/agent/status', ctx.key); + + await logEventFromContext( + ctx, + 'crowterminal.status.get', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/crowterminal/endpoints/types.test.ts b/packages/crowterminal/endpoints/types.test.ts new file mode 100644 index 000000000..dd7caae7b --- /dev/null +++ b/packages/crowterminal/endpoints/types.test.ts @@ -0,0 +1,149 @@ +import { + CrowterminalEndpointInputSchemas, + CrowterminalEndpointOutputSchemas, +} from './types'; + +describe('CrowTerminal endpoint schemas', () => { + it('validates documented memory read input and output', () => { + expect( + CrowterminalEndpointInputSchemas.memoryGet.parse({ + clientId: 'client_123', + }), + ).toEqual({ clientId: 'client_123' }); + expect( + CrowterminalEndpointOutputSchemas.memoryGet.parse({ + success: true, + version: 47, + skill: { + primaryNiche: 'fitness', + hookPatterns: ['confession'], + avgEngagement: 4.2, + bestPostingTimes: [{ day: 2, hour: 7, score: 0.89 }], + }, + }), + ).toMatchObject({ success: true, version: 47 }); + }); + + it('validates documented engagement-analysis input and output', () => { + expect( + CrowterminalEndpointInputSchemas.memoryEngagementAnalysis.parse({ + clientId: 'client_123', + agentMd: { hookPatterns: ['confession'], contentStyle: 'casual' }, + }), + ).toMatchObject({ clientId: 'client_123' }); + expect( + CrowterminalEndpointOutputSchemas.memoryEngagementAnalysis.parse({ + success: true, + versionsAnalyzed: 47, + overallStats: { + peakEngagement: 6.2, + peakVersion: 28, + yourSimilarityToTop: '65%', + yourSimilarityToBottom: '20%', + }, + fieldAnalysis: [ + { + field: 'hookPatterns', + yourValue: ['confession'], + bestValue: ['POV', 'confession'], + bestEngagement: 6.2, + yourPredictedEngagement: 4.1, + improvement: '+51% potential improvement', + confidence: 'high', + }, + ], + recommendations: ['Change hookPatterns'], + }), + ).toMatchObject({ success: true, versionsAnalyzed: 47 }); + }); + + it('validates documented data ingestion input and output', () => { + expect( + CrowterminalEndpointInputSchemas.dataIngest.parse({ + clientId: 'client_123', + platform: 'TIKTOK', + dataType: 'retention', + videoId: 'video_456', + data: { retentionCurve: [100, 95], completionRate: 0.3 }, + confidence: 0.9, + }), + ).toMatchObject({ platform: 'TIKTOK', dataType: 'retention' }); + expect( + CrowterminalEndpointOutputSchemas.dataIngest.parse({ + success: true, + message: 'Data ingested successfully', + id: 'abc123', + clientId: 'client_123', + platform: 'TIKTOK', + dataType: 'retention', + _tip: 'Use engagement_analysis endpoint', + }), + ).toMatchObject({ success: true, id: 'abc123' }); + }); + + it('validates the no-argument status operation', () => { + expect(CrowterminalEndpointInputSchemas.statusGet.parse({})).toEqual({}); + expect( + CrowterminalEndpointOutputSchemas.statusGet.parse({ + currentStatus: 'operational', + }), + ).toEqual({ currentStatus: 'operational' }); + }); + + it('validates webhook creation input and its documented response', () => { + expect( + CrowterminalEndpointInputSchemas.webhooksCreate.parse({ + url: 'https://example.com/crowterminal', + events: ['skill.updated', 'data.ingested'], + secret: 'webhook-secret', + }), + ).toMatchObject({ events: ['skill.updated', 'data.ingested'] }); + expect( + CrowterminalEndpointOutputSchemas.webhooksCreate.parse({ + id: 'wh_123', + secret: 'webhook-secret', + }), + ).toEqual({ id: 'wh_123', secret: 'webhook-secret' }); + }); + + it('validates webhook listing input', () => { + expect(CrowterminalEndpointInputSchemas.webhooksList.parse({})).toEqual({}); + expect( + CrowterminalEndpointOutputSchemas.webhooksList.parse({ data: [] }), + ).toEqual({ data: [] }); + }); + + it('validates webhook update input', () => { + expect( + CrowterminalEndpointInputSchemas.webhooksUpdate.parse({ + webhookId: 'wh_123', + isActive: false, + }), + ).toEqual({ webhookId: 'wh_123', isActive: false }); + expect( + CrowterminalEndpointOutputSchemas.webhooksUpdate.parse({ id: 'wh_123' }), + ).toEqual({ id: 'wh_123' }); + }); + + it('validates webhook deletion input', () => { + expect( + CrowterminalEndpointInputSchemas.webhooksDelete.parse({ + webhookId: 'wh_123', + }), + ).toEqual({ webhookId: 'wh_123' }); + expect( + CrowterminalEndpointOutputSchemas.webhooksDelete.parse({ success: true }), + ).toEqual({ success: true }); + }); + + it('validates webhook test input', () => { + expect( + CrowterminalEndpointInputSchemas.webhooksTest.parse({ + url: 'https://example.com/crowterminal', + }), + ).toEqual({ url: 'https://example.com/crowterminal' }); + expect( + CrowterminalEndpointOutputSchemas.webhooksTest.parse({ delivered: true }), + ).toEqual({ delivered: true }); + }); +}); diff --git a/packages/crowterminal/endpoints/types.ts b/packages/crowterminal/endpoints/types.ts new file mode 100644 index 000000000..975cda7ff --- /dev/null +++ b/packages/crowterminal/endpoints/types.ts @@ -0,0 +1,206 @@ +import { z } from 'zod'; + +const ClientIdSchema = z.string().min(1); + +const CrowterminalPlatformSchema = z.enum(['TIKTOK', 'INSTAGRAM', 'YOUTUBE']); + +const CrowterminalDataTypeSchema = z.enum([ + 'retention', + 'demographics', + 'traffic_sources', + 'watch_time', + 'audience_activity', + 'follower_growth', + 'video_performance', + 'sound_performance', + 'hashtag_performance', + 'reach_sources', + 'content_interactions', + 'story_metrics', + 'reel_metrics', + 'subscriber_growth', + 'click_through_rate', + 'impression_sources', + 'end_screen_performance', +]); + +const CrowterminalWebhookEventNameSchema = z.enum([ + 'skill.updated', + 'skill.version_created', + 'data.ingested', + 'validation.blocked', + 'posting.completed', + 'posting.failed', +]); + +const GetMemoryInputSchema = z.object({ + clientId: ClientIdSchema, +}); + +const EngagementAnalysisInputSchema = z.object({ + clientId: ClientIdSchema, + agentMd: z.record(z.string(), z.unknown()), +}); + +const IngestDataInputSchema = z.object({ + clientId: ClientIdSchema, + platform: CrowterminalPlatformSchema, + dataType: CrowterminalDataTypeSchema, + videoId: z.string().min(1).optional(), + data: z.record(z.string(), z.unknown()), + confidence: z.number().min(0).max(1).optional(), +}); + +const GetStatusInputSchema = z.object({}); + +const CreateWebhookInputSchema = z.object({ + url: z.string().url(), + events: z.array(CrowterminalWebhookEventNameSchema), + secret: z.string().min(1).optional(), +}); + +const ListWebhooksInputSchema = z.object({}); + +const UpdateWebhookInputSchema = z.object({ + webhookId: z.string().min(1), + url: z.string().url().optional(), + events: z.array(CrowterminalWebhookEventNameSchema).optional(), + isActive: z.boolean().optional(), +}); + +const DeleteWebhookInputSchema = z.object({ + webhookId: z.string().min(1), +}); + +const TestWebhookInputSchema = z.object({ + url: z.string().url(), + secret: z.string().min(1).optional(), +}); + +const SkillSchema = z + .object({ + primaryNiche: z.string().optional(), + hookPatterns: z.array(z.string()).optional(), + avgEngagement: z.number().optional(), + bestPostingTimes: z + .array( + z.object({ + day: z.number().int(), + hour: z.number().int(), + score: z.number(), + }), + ) + .optional(), + }) + .loose(); + +const GetMemoryResponseSchema = z.object({ + success: z.literal(true), + version: z.number().int(), + skill: SkillSchema, +}); + +const EngagementAnalysisResponseSchema = z.object({ + success: z.literal(true), + versionsAnalyzed: z.number().int().nonnegative(), + overallStats: z.object({ + peakEngagement: z.number(), + peakVersion: z.number().int(), + yourSimilarityToTop: z.string(), + yourSimilarityToBottom: z.string(), + }), + fieldAnalysis: z.array( + z.object({ + field: z.string(), + yourValue: z.unknown(), + bestValue: z.unknown(), + bestEngagement: z.number(), + yourPredictedEngagement: z.number(), + improvement: z.string(), + confidence: z.string(), + }), + ), + recommendations: z.array(z.string()), +}); + +const IngestDataResponseSchema = z.object({ + success: z.literal(true), + message: z.string(), + id: z.string(), + clientId: z.string(), + platform: CrowterminalPlatformSchema, + dataType: CrowterminalDataTypeSchema, + _tip: z.string().optional(), +}); + +// The docs describe status and webhook-management responses as JSON objects but +// do not publish their field shapes. Keep them object-validated without +// fabricating fields that CrowTerminal has not documented. +const DocumentedObjectResponseSchema = z.object({}).loose(); + +const CreateWebhookResponseSchema = z + .object({ + id: z.string(), + secret: z.string(), + }) + .loose(); + +export const CrowterminalEndpointInputSchemas = { + memoryGet: GetMemoryInputSchema, + memoryEngagementAnalysis: EngagementAnalysisInputSchema, + dataIngest: IngestDataInputSchema, + statusGet: GetStatusInputSchema, + webhooksCreate: CreateWebhookInputSchema, + webhooksList: ListWebhooksInputSchema, + webhooksUpdate: UpdateWebhookInputSchema, + webhooksDelete: DeleteWebhookInputSchema, + webhooksTest: TestWebhookInputSchema, +} as const; + +export type CrowterminalEndpointInputs = { + [K in keyof typeof CrowterminalEndpointInputSchemas]: z.infer< + (typeof CrowterminalEndpointInputSchemas)[K] + >; +}; + +export const CrowterminalEndpointOutputSchemas = { + memoryGet: GetMemoryResponseSchema, + memoryEngagementAnalysis: EngagementAnalysisResponseSchema, + dataIngest: IngestDataResponseSchema, + statusGet: DocumentedObjectResponseSchema, + webhooksCreate: CreateWebhookResponseSchema, + webhooksList: DocumentedObjectResponseSchema, + webhooksUpdate: DocumentedObjectResponseSchema, + webhooksDelete: DocumentedObjectResponseSchema, + webhooksTest: DocumentedObjectResponseSchema, +} as const; + +export type CrowterminalEndpointOutputs = { + [K in keyof typeof CrowterminalEndpointOutputSchemas]: z.infer< + (typeof CrowterminalEndpointOutputSchemas)[K] + >; +}; + +export type GetMemoryInput = CrowterminalEndpointInputs['memoryGet']; +export type GetMemoryResponse = CrowterminalEndpointOutputs['memoryGet']; +export type EngagementAnalysisInput = + CrowterminalEndpointInputs['memoryEngagementAnalysis']; +export type EngagementAnalysisResponse = + CrowterminalEndpointOutputs['memoryEngagementAnalysis']; +export type IngestDataInput = CrowterminalEndpointInputs['dataIngest']; +export type IngestDataResponse = CrowterminalEndpointOutputs['dataIngest']; +export type GetStatusInput = CrowterminalEndpointInputs['statusGet']; +export type GetStatusResponse = CrowterminalEndpointOutputs['statusGet']; +export type CreateWebhookInput = CrowterminalEndpointInputs['webhooksCreate']; +export type CreateWebhookResponse = + CrowterminalEndpointOutputs['webhooksCreate']; +export type ListWebhooksInput = CrowterminalEndpointInputs['webhooksList']; +export type ListWebhooksResponse = CrowterminalEndpointOutputs['webhooksList']; +export type UpdateWebhookInput = CrowterminalEndpointInputs['webhooksUpdate']; +export type UpdateWebhookResponse = + CrowterminalEndpointOutputs['webhooksUpdate']; +export type DeleteWebhookInput = CrowterminalEndpointInputs['webhooksDelete']; +export type DeleteWebhookResponse = + CrowterminalEndpointOutputs['webhooksDelete']; +export type TestWebhookInput = CrowterminalEndpointInputs['webhooksTest']; +export type TestWebhookResponse = CrowterminalEndpointOutputs['webhooksTest']; diff --git a/packages/crowterminal/endpoints/webhooks.ts b/packages/crowterminal/endpoints/webhooks.ts new file mode 100644 index 000000000..816544683 --- /dev/null +++ b/packages/crowterminal/endpoints/webhooks.ts @@ -0,0 +1,101 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeCrowterminalRequest } from '../client'; +import type { CrowterminalEndpoints } from '../index'; +import type { CrowterminalEndpointOutputs } from './types'; + +export const create: CrowterminalEndpoints['webhooksCreate'] = async ( + ctx, + input, +) => { + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['webhooksCreate'] + >('/api/agent/webhooks', ctx.key, { + method: 'POST', + body: input, + }); + + await logEventFromContext( + ctx, + 'crowterminal.webhooks.create', + { ...input }, + 'completed', + ); + return response; +}; + +export const list: CrowterminalEndpoints['webhooksList'] = async ( + ctx, + input, +) => { + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['webhooksList'] + >('/api/agent/webhooks', ctx.key); + + await logEventFromContext( + ctx, + 'crowterminal.webhooks.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const update: CrowterminalEndpoints['webhooksUpdate'] = async ( + ctx, + input, +) => { + const { webhookId, ...body } = input; + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['webhooksUpdate'] + >(`/api/agent/webhooks/${webhookId}`, ctx.key, { + method: 'PATCH', + body, + }); + + await logEventFromContext( + ctx, + 'crowterminal.webhooks.update', + { ...input }, + 'completed', + ); + return response; +}; + +export const deleteWebhook: CrowterminalEndpoints['webhooksDelete'] = async ( + ctx, + input, +) => { + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['webhooksDelete'] + >(`/api/agent/webhooks/${input.webhookId}`, ctx.key, { + method: 'DELETE', + }); + + await logEventFromContext( + ctx, + 'crowterminal.webhooks.delete', + { ...input }, + 'completed', + ); + return response; +}; + +export const test: CrowterminalEndpoints['webhooksTest'] = async ( + ctx, + input, +) => { + const response = await makeCrowterminalRequest< + CrowterminalEndpointOutputs['webhooksTest'] + >('/api/agent/webhooks/test', ctx.key, { + method: 'POST', + body: input, + }); + + await logEventFromContext( + ctx, + 'crowterminal.webhooks.test', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/crowterminal/error-handlers.ts b/packages/crowterminal/error-handlers.ts new file mode 100644 index 000000000..1cbffa8ea --- /dev/null +++ b/packages/crowterminal/error-handlers.ts @@ -0,0 +1,36 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return ( + msg.includes('rate_limited') || + msg.includes('rate limit') || + msg.includes('rate_001') || + msg.includes('rate_002') + ); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/crowterminal/index.ts b/packages/crowterminal/index.ts new file mode 100644 index 000000000..591ce8cff --- /dev/null +++ b/packages/crowterminal/index.ts @@ -0,0 +1,373 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { Data, Memory, Status, Webhooks } from './endpoints'; +import type { + CrowterminalEndpointInputs, + CrowterminalEndpointOutputs, +} from './endpoints/types'; +import { + CrowterminalEndpointInputSchemas, + CrowterminalEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { CrowterminalSchema } from './schema'; +import { CrowterminalWebhooks } from './webhooks'; +import type { CrowterminalWebhookOutputs } from './webhooks/types'; +import { + DataIngestedEventSchema, + hasCrowterminalWebhookSignature, + PostingCompletedEventSchema, + PostingFailedEventSchema, + SkillUpdatedEventSchema, + SkillVersionCreatedEventSchema, + ValidationBlockedEventSchema, +} from './webhooks/types'; + +export type CrowterminalPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + webhookSecret?: string; + hooks?: InternalCrowterminalPlugin['hooks']; + webhookHooks?: InternalCrowterminalPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type CrowterminalContext = CorsairPluginContext< + typeof CrowterminalSchema, + CrowterminalPluginOptions +>; + +export type CrowterminalKeyBuilderContext = + KeyBuilderContext; + +export type CrowterminalBoundEndpoints = BindEndpoints< + typeof crowterminalEndpointsNested +>; + +type CrowterminalEndpoint = + CorsairEndpoint< + CrowterminalContext, + CrowterminalEndpointInputs[K], + CrowterminalEndpointOutputs[K] + >; + +export type CrowterminalEndpoints = { + memoryGet: CrowterminalEndpoint<'memoryGet'>; + memoryEngagementAnalysis: CrowterminalEndpoint<'memoryEngagementAnalysis'>; + dataIngest: CrowterminalEndpoint<'dataIngest'>; + statusGet: CrowterminalEndpoint<'statusGet'>; + webhooksCreate: CrowterminalEndpoint<'webhooksCreate'>; + webhooksList: CrowterminalEndpoint<'webhooksList'>; + webhooksUpdate: CrowterminalEndpoint<'webhooksUpdate'>; + webhooksDelete: CrowterminalEndpoint<'webhooksDelete'>; + webhooksTest: CrowterminalEndpoint<'webhooksTest'>; +}; + +type CrowterminalWebhook< + K extends keyof CrowterminalWebhookOutputs, + TEvent, +> = CorsairWebhook; + +export type CrowterminalWebhooks = { + skillUpdated: CrowterminalWebhook< + 'skillUpdated', + CrowterminalWebhookOutputs['skillUpdated'] + >; + skillVersionCreated: CrowterminalWebhook< + 'skillVersionCreated', + CrowterminalWebhookOutputs['skillVersionCreated'] + >; + dataIngested: CrowterminalWebhook< + 'dataIngested', + CrowterminalWebhookOutputs['dataIngested'] + >; + validationBlocked: CrowterminalWebhook< + 'validationBlocked', + CrowterminalWebhookOutputs['validationBlocked'] + >; + postingCompleted: CrowterminalWebhook< + 'postingCompleted', + CrowterminalWebhookOutputs['postingCompleted'] + >; + postingFailed: CrowterminalWebhook< + 'postingFailed', + CrowterminalWebhookOutputs['postingFailed'] + >; +}; + +export type CrowterminalBoundWebhooks = BindWebhooks; + +const crowterminalEndpointsNested = { + memory: { + get: Memory.get, + engagementAnalysis: Memory.engagementAnalysis, + }, + data: { + ingest: Data.ingest, + }, + status: { + get: Status.get, + }, + webhooks: { + create: Webhooks.create, + list: Webhooks.list, + update: Webhooks.update, + delete: Webhooks.delete, + test: Webhooks.test, + }, +} as const; + +const crowterminalWebhooksNested = { + skill: { + updated: CrowterminalWebhooks.skillUpdated, + versionCreated: CrowterminalWebhooks.skillVersionCreated, + }, + data: { + ingested: CrowterminalWebhooks.dataIngested, + }, + validation: { + blocked: CrowterminalWebhooks.validationBlocked, + }, + posting: { + completed: CrowterminalWebhooks.postingCompleted, + failed: CrowterminalWebhooks.postingFailed, + }, +} as const; + +export const crowterminalEndpointSchemas = { + 'memory.get': { + input: CrowterminalEndpointInputSchemas.memoryGet, + output: CrowterminalEndpointOutputSchemas.memoryGet, + }, + 'memory.engagementAnalysis': { + input: CrowterminalEndpointInputSchemas.memoryEngagementAnalysis, + output: CrowterminalEndpointOutputSchemas.memoryEngagementAnalysis, + }, + 'data.ingest': { + input: CrowterminalEndpointInputSchemas.dataIngest, + output: CrowterminalEndpointOutputSchemas.dataIngest, + }, + 'status.get': { + input: CrowterminalEndpointInputSchemas.statusGet, + output: CrowterminalEndpointOutputSchemas.statusGet, + }, + 'webhooks.create': { + input: CrowterminalEndpointInputSchemas.webhooksCreate, + output: CrowterminalEndpointOutputSchemas.webhooksCreate, + }, + 'webhooks.list': { + input: CrowterminalEndpointInputSchemas.webhooksList, + output: CrowterminalEndpointOutputSchemas.webhooksList, + }, + 'webhooks.update': { + input: CrowterminalEndpointInputSchemas.webhooksUpdate, + output: CrowterminalEndpointOutputSchemas.webhooksUpdate, + }, + 'webhooks.delete': { + input: CrowterminalEndpointInputSchemas.webhooksDelete, + output: CrowterminalEndpointOutputSchemas.webhooksDelete, + }, + 'webhooks.test': { + input: CrowterminalEndpointInputSchemas.webhooksTest, + output: CrowterminalEndpointOutputSchemas.webhooksTest, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof crowterminalEndpointsNested +>; + +const crowterminalWebhookSchemas = { + 'skill.updated': { + description: 'A client skill was updated', + payload: SkillUpdatedEventSchema, + response: SkillUpdatedEventSchema, + }, + 'skill.versionCreated': { + description: 'A new skill version was created', + payload: SkillVersionCreatedEventSchema, + response: SkillVersionCreatedEventSchema, + }, + 'data.ingested': { + description: 'Agent data was ingested', + payload: DataIngestedEventSchema, + response: DataIngestedEventSchema, + }, + 'validation.blocked': { + description: 'A proposed memory change was blocked', + payload: ValidationBlockedEventSchema, + response: ValidationBlockedEventSchema, + }, + 'posting.completed': { + description: 'Content posting completed successfully', + payload: PostingCompletedEventSchema, + response: PostingCompletedEventSchema, + }, + 'posting.failed': { + description: 'Content posting failed', + payload: PostingFailedEventSchema, + response: PostingFailedEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas< + typeof crowterminalWebhooksNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const crowterminalEndpointMeta = { + 'memory.get': { + riskLevel: 'read', + description: 'Get the latest version of a client skill', + }, + 'memory.engagementAnalysis': { + riskLevel: 'read', + description: 'Analyze a client skill against historical engagement', + }, + 'data.ingest': { + riskLevel: 'write', + description: 'Ingest a platform data point for a client', + }, + 'status.get': { + riskLevel: 'read', + description: 'Get CrowTerminal service health', + }, + 'webhooks.create': { + riskLevel: 'write', + description: 'Register a CrowTerminal webhook', + }, + 'webhooks.list': { + riskLevel: 'read', + description: 'List registered CrowTerminal webhooks', + }, + 'webhooks.update': { + riskLevel: 'write', + description: 'Update a CrowTerminal webhook', + }, + 'webhooks.delete': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a CrowTerminal webhook', + }, + 'webhooks.test': { + riskLevel: 'write', + description: 'Send a test delivery to a webhook URL', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof crowterminalEndpointsNested +>; + +export const crowterminalAuthConfig = { + api_key: {}, +} as const satisfies PluginAuthConfig; + +export type BaseCrowterminalPlugin = + CorsairPlugin< + 'crowterminal', + typeof CrowterminalSchema, + typeof crowterminalEndpointsNested, + typeof crowterminalWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalCrowterminalPlugin = + BaseCrowterminalPlugin; + +export type ExternalCrowterminalPlugin = + BaseCrowterminalPlugin; + +export function crowterminal( + incomingOptions: CrowterminalPluginOptions & + T = {} as CrowterminalPluginOptions & T, +): ExternalCrowterminalPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'crowterminal', + authConfig: crowterminalAuthConfig, + schema: CrowterminalSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: crowterminalEndpointsNested, + webhooks: crowterminalWebhooksNested, + endpointMeta: crowterminalEndpointMeta, + endpointSchemas: crowterminalEndpointSchemas, + webhookSchemas: crowterminalWebhookSchemas, + pluginWebhookMatcher: hasCrowterminalWebhookSignature, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: CrowterminalKeyBuilderContext, source) => { + if (source === 'webhook' && options.webhookSecret) { + return options.webhookSecret; + } + + if (source === 'webhook') { + const res = await ctx.keys.get_webhook_signature(); + return res ?? ''; + } + + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalCrowterminalPlugin; +} + +export type { + CreateWebhookInput, + CreateWebhookResponse, + CrowterminalEndpointInputs, + CrowterminalEndpointOutputs, + DeleteWebhookInput, + DeleteWebhookResponse, + EngagementAnalysisInput, + EngagementAnalysisResponse, + GetMemoryInput, + GetMemoryResponse, + GetStatusInput, + GetStatusResponse, + IngestDataInput, + IngestDataResponse, + ListWebhooksInput, + ListWebhooksResponse, + TestWebhookInput, + TestWebhookResponse, + UpdateWebhookInput, + UpdateWebhookResponse, +} from './endpoints/types'; +export type { + CrowterminalWebhookOutputs, + CrowterminalWebhookPayload, + DataIngestedEvent, + PostingCompletedEvent, + PostingFailedEvent, + SkillUpdatedEvent, + SkillVersionCreatedEvent, + ValidationBlockedEvent, +} from './webhooks/types'; diff --git a/packages/crowterminal/jest.config.cjs b/packages/crowterminal/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/crowterminal/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/crowterminal/package.json b/packages/crowterminal/package.json new file mode 100644 index 000000000..52414a3cc --- /dev/null +++ b/packages/crowterminal/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/crowterminal", + "version": "0.1.0", + "description": "Crowterminal plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "crowterminal", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/crowterminal/schema.test.ts b/packages/crowterminal/schema.test.ts new file mode 100644 index 000000000..174fdba19 --- /dev/null +++ b/packages/crowterminal/schema.test.ts @@ -0,0 +1,20 @@ +import { CrowterminalSchema } from './schema'; + +describe('Crowterminal schema', () => { + it('declares a semver version', () => { + expect(CrowterminalSchema.version).toBeDefined(); + expect(CrowterminalSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof CrowterminalSchema.entities).toBe('object'); + expect(CrowterminalSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(CrowterminalSchema.entities))).toBe(true); + for (const entity of Object.values(CrowterminalSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/crowterminal/schema/database.ts b/packages/crowterminal/schema/database.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/crowterminal/schema/database.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/crowterminal/schema/index.ts b/packages/crowterminal/schema/index.ts new file mode 100644 index 000000000..bc4cd7076 --- /dev/null +++ b/packages/crowterminal/schema/index.ts @@ -0,0 +1,4 @@ +export const CrowterminalSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/crowterminal/tsconfig.json b/packages/crowterminal/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/crowterminal/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/crowterminal/tsup.config.ts b/packages/crowterminal/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/crowterminal/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/crowterminal/webhooks/events.ts b/packages/crowterminal/webhooks/events.ts new file mode 100644 index 000000000..412fc95c7 --- /dev/null +++ b/packages/crowterminal/webhooks/events.ts @@ -0,0 +1,63 @@ +import type { CorsairWebhook, WebhookRequest } from 'corsair/core'; +import { logEventFromContext } from 'corsair/core'; +import type { CrowterminalContext } from '../index'; +import type { + CrowterminalWebhookPayload, + DataIngestedEvent, + PostingCompletedEvent, + PostingFailedEvent, + SkillUpdatedEvent, + SkillVersionCreatedEvent, + ValidationBlockedEvent, +} from './types'; +import { + createCrowterminalMatch, + verifyCrowterminalWebhookSignature, +} from './types'; + +function createCrowterminalWebhook( + eventType: TEvent['event'], +): CorsairWebhook { + return { + match: createCrowterminalMatch(eventType), + handler: async ( + ctx: CrowterminalContext, + request: WebhookRequest, + ) => { + const verification = verifyCrowterminalWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error ?? 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.event !== eventType) { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + `crowterminal.webhook.${event.event}`, + { ...event }, + 'completed', + ); + return { success: true, data: event }; + }, + }; +} + +export const skillUpdated = + createCrowterminalWebhook('skill.updated'); +export const skillVersionCreated = + createCrowterminalWebhook('skill.version_created'); +export const dataIngested = + createCrowterminalWebhook('data.ingested'); +export const validationBlocked = + createCrowterminalWebhook('validation.blocked'); +export const postingCompleted = + createCrowterminalWebhook('posting.completed'); +export const postingFailed = + createCrowterminalWebhook('posting.failed'); diff --git a/packages/crowterminal/webhooks/index.ts b/packages/crowterminal/webhooks/index.ts new file mode 100644 index 000000000..7f199c4da --- /dev/null +++ b/packages/crowterminal/webhooks/index.ts @@ -0,0 +1,19 @@ +import { + dataIngested, + postingCompleted, + postingFailed, + skillUpdated, + skillVersionCreated, + validationBlocked, +} from './events'; + +export const CrowterminalWebhooks = { + skillUpdated, + skillVersionCreated, + dataIngested, + validationBlocked, + postingCompleted, + postingFailed, +}; + +export * from './types'; diff --git a/packages/crowterminal/webhooks/types.test.ts b/packages/crowterminal/webhooks/types.test.ts new file mode 100644 index 000000000..24dfa2d2e --- /dev/null +++ b/packages/crowterminal/webhooks/types.test.ts @@ -0,0 +1,91 @@ +import type { WebhookRequest } from 'corsair/core'; +import crypto from 'crypto'; +import type { SkillUpdatedEvent } from './types'; +import { + createCrowterminalMatch, + hasCrowterminalWebhookSignature, + verifyCrowterminalWebhookSignature, +} from './types'; + +describe('CrowTerminal webhook verification', () => { + const secret = 'crowterminal-webhook-secret'; + const payload: SkillUpdatedEvent = { + event: 'skill.updated', + timestamp: '2026-02-18T12:00:00Z', + webhookId: 'wh_123', + agentId: 'agent_123', + data: { clientId: 'client_123' }, + }; + const rawBody = JSON.stringify(payload); + const signature = `sha256=${crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('hex')}`; + + function requestWith( + headers: Record, + body: string | null = rawBody, + ): WebhookRequest { + return { payload, headers, rawBody: body ?? undefined }; + } + + it('matches a documented event from the payload event field', () => { + const match = createCrowterminalMatch('skill.updated'); + expect(match({ headers: {}, body: rawBody })).toBe(true); + expect(match({ headers: {}, body: { event: 'data.ingested' } })).toBe( + false, + ); + }); + + it('detects the documented signature header case-insensitively', () => { + expect( + hasCrowterminalWebhookSignature({ + headers: { 'X-CrowTerminal-Signature': signature }, + body: rawBody, + }), + ).toBe(true); + }); + + it('accepts a correctly signed raw payload', () => { + expect( + verifyCrowterminalWebhookSignature( + requestWith({ 'x-crowterminal-signature': signature }), + secret, + ), + ).toEqual({ valid: true }); + }); + + it('rejects a missing secret, body, or signature', () => { + expect( + verifyCrowterminalWebhookSignature( + requestWith({ 'x-crowterminal-signature': signature }), + ), + ).toEqual({ valid: false, error: 'Missing webhook secret' }); + expect( + verifyCrowterminalWebhookSignature( + requestWith({ 'x-crowterminal-signature': signature }, null), + secret, + ), + ).toEqual({ + valid: false, + error: 'Missing raw body for signature verification', + }); + expect(verifyCrowterminalWebhookSignature(requestWith({}), secret)).toEqual( + { + valid: false, + error: 'Missing X-CrowTerminal-Signature header', + }, + ); + }); + + it('rejects a signature with the wrong HMAC', () => { + expect( + verifyCrowterminalWebhookSignature( + requestWith({ + 'x-crowterminal-signature': 'sha256=not-the-expected-hmac', + }), + secret, + ), + ).toEqual({ valid: false, error: 'Invalid signature' }); + }); +}); diff --git a/packages/crowterminal/webhooks/types.ts b/packages/crowterminal/webhooks/types.ts new file mode 100644 index 000000000..2ebabafbc --- /dev/null +++ b/packages/crowterminal/webhooks/types.ts @@ -0,0 +1,154 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { verifyHmacSignatureWithPrefix } from 'corsair/http'; +import { z } from 'zod'; + +export const CrowterminalWebhookEventNameSchema = z.enum([ + 'skill.updated', + 'skill.version_created', + 'data.ingested', + 'validation.blocked', + 'posting.completed', + 'posting.failed', +]); + +export const CrowterminalWebhookPayloadSchema = z.object({ + event: CrowterminalWebhookEventNameSchema, + timestamp: z.string(), + webhookId: z.string(), + agentId: z.string(), + data: z.record(z.string(), z.unknown()), +}); + +export type CrowterminalWebhookPayload = z.infer< + typeof CrowterminalWebhookPayloadSchema +>; + +export const SkillUpdatedEventSchema = CrowterminalWebhookPayloadSchema.extend({ + event: z.literal('skill.updated'), +}); +export const SkillVersionCreatedEventSchema = + CrowterminalWebhookPayloadSchema.extend({ + event: z.literal('skill.version_created'), + }); +export const DataIngestedEventSchema = CrowterminalWebhookPayloadSchema.extend({ + event: z.literal('data.ingested'), +}); +export const ValidationBlockedEventSchema = + CrowterminalWebhookPayloadSchema.extend({ + event: z.literal('validation.blocked'), + }); +export const PostingCompletedEventSchema = + CrowterminalWebhookPayloadSchema.extend({ + event: z.literal('posting.completed'), + }); +export const PostingFailedEventSchema = CrowterminalWebhookPayloadSchema.extend( + { + event: z.literal('posting.failed'), + }, +); + +export type SkillUpdatedEvent = z.infer; +export type SkillVersionCreatedEvent = z.infer< + typeof SkillVersionCreatedEventSchema +>; +export type DataIngestedEvent = z.infer; +export type ValidationBlockedEvent = z.infer< + typeof ValidationBlockedEventSchema +>; +export type PostingCompletedEvent = z.infer; +export type PostingFailedEvent = z.infer; + +export type CrowterminalWebhookOutputs = { + skillUpdated: SkillUpdatedEvent; + skillVersionCreated: SkillVersionCreatedEvent; + dataIngested: DataIngestedEvent; + validationBlocked: ValidationBlockedEvent; + postingCompleted: PostingCompletedEvent; + postingFailed: PostingFailedEvent; +}; + +function parseBody(body: unknown): Record | null { + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body); + return parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + return body !== null && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : null; +} + +export function createCrowterminalMatch( + eventType: string, +): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + return parsedBody !== null && parsedBody.event === eventType; + }; +} + +function signatureHeader( + headers: Record, +): string | undefined { + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() !== 'x-crowterminal-signature') continue; + return Array.isArray(value) ? value[0] : value; + } + return undefined; +} + +export function hasCrowterminalWebhookSignature( + request: RawWebhookRequest, +): boolean { + return signatureHeader(request.headers) !== undefined; +} + +export function verifyCrowterminalWebhookSignature( + request: WebhookRequest, + secret?: string, +): { valid: boolean; error?: string } { + if (request.hubVerified === true) { + return { valid: true }; + } + if (!secret) { + return { valid: false, error: 'Missing webhook secret' }; + } + if (!request.rawBody) { + return { + valid: false, + error: 'Missing raw body for signature verification', + }; + } + + const signature = signatureHeader(request.headers); + if (!signature) { + return { + valid: false, + error: 'Missing X-CrowTerminal-Signature header', + }; + } + + if ( + !verifyHmacSignatureWithPrefix( + request.rawBody, + secret, + signature, + 'sha256=', + ) + ) { + return { valid: false, error: 'Invalid signature' }; + } + + return { valid: true }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0779d3702..ac71ec3d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2059,6 +2059,30 @@ importers: specifier: workspace:* version: link:../frpc-win32-x64 + packages/crowterminal: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/cursor: devDependencies: '@types/jest': From 51c12cae24ea9b8e450f4808a963a37fc25a9f89 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 24 Aug 2026 01:36:38 +0530 Subject: [PATCH 2/7] fix(crowterminal): encode path ids, parse schemas, redact secrets --- packages/crowterminal/client.ts | 50 +++--- packages/crowterminal/endpoints/data.ts | 81 ++++++--- packages/crowterminal/endpoints/memory.ts | 169 +++++++++++++++---- packages/crowterminal/endpoints/shared.ts | 96 +++++++++++ packages/crowterminal/endpoints/webhooks.ts | 175 ++++++++++---------- packages/crowterminal/error-handlers.ts | 38 +++-- 6 files changed, 439 insertions(+), 170 deletions(-) create mode 100644 packages/crowterminal/endpoints/shared.ts diff --git a/packages/crowterminal/client.ts b/packages/crowterminal/client.ts index 0fc4af0b4..8307fa166 100644 --- a/packages/crowterminal/client.ts +++ b/packages/crowterminal/client.ts @@ -13,7 +13,16 @@ export class CrowterminalAPIError extends Error { } } -const CROWTERMINAL_API_BASE = 'https://api.crowterminal.com'; +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( endpoint: string, @@ -22,43 +31,42 @@ export async function makeCrowterminalRequest( method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; body?: Record; query?: Record; + baseUrl?: string; } = {}, ): Promise { - const { method = 'GET', body, query } = options; + const { + method = 'GET', + body, + query, + baseUrl = CROWTERMINAL_API_BASE, + } = options; const config: OpenAPIConfig = { - BASE: CROWTERMINAL_API_BASE, + BASE: baseUrl, VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'omit', - HEADERS: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, + // 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 === 'POST' || method === 'PUT' || method === 'PATCH' - ? body - : undefined, - mediaType: 'application/json; charset=utf-8', - query: method === 'GET' ? query : undefined, + body: method === 'GET' || method === 'DELETE' ? undefined : body, + mediaType: 'application/json', + query, }; try { return await request(config, requestOptions); } catch (error) { - // Preserve HTTP status and Retry-After metadata so Corsair's error - // handlers can classify authentication and rate-limit failures. - if (error instanceof ApiError) { - throw error; - } - if (error instanceof Error) { - throw new CrowterminalAPIError(error.message); - } + // 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'); } } diff --git a/packages/crowterminal/endpoints/data.ts b/packages/crowterminal/endpoints/data.ts index e1bdd338d..5c708cb5b 100644 --- a/packages/crowterminal/endpoints/data.ts +++ b/packages/crowterminal/endpoints/data.ts @@ -1,24 +1,65 @@ -import { logEventFromContext } from 'corsair/core'; -import { makeCrowterminalRequest } from '../client'; -import type { CrowterminalEndpoints } from '../index'; -import type { CrowterminalEndpointOutputs } from './types'; +import type { CrowterminalContext } from '..'; +import { callCrowterminal } from './shared'; +import type { + CrowterminalEndpointInputs, + CrowterminalEndpointOutputs, +} from './types'; +import { + BulkIngestInputSchema, + BulkIngestResponseSchema, + GetDataTypesInputSchema, + GetDataTypesResponseSchema, + IngestDataInputSchema, + IngestDataResponseSchema, +} from './types'; -export const ingest: CrowterminalEndpoints['dataIngest'] = async ( - ctx, - input, -) => { - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['dataIngest'] - >('/api/agent/data/ingest', ctx.key, { - method: 'POST', - body: input, - }); +export const ingest = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['dataIngest'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.data.ingest', + method: 'POST', + inputSchema: IngestDataInputSchema, + outputSchema: IngestDataResponseSchema, + path: () => '/api/agent/data/ingest', + body: (i) => ({ ...i }), + }, + input, + ); + +/** Up to 50 points per call. The API names the array `items`. */ +export const ingestBulk = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['dataIngestBulk'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.data.ingest_bulk', + method: 'POST', + inputSchema: BulkIngestInputSchema, + outputSchema: BulkIngestResponseSchema, + path: () => '/api/agent/data/ingest/bulk', + body: (i) => ({ items: i.items }), + }, + input, + ); - await logEventFromContext( +/** Data types accepted per platform; the valid set differs by platform. */ +export const getTypes = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['dataGetTypes'], +): Promise => + callCrowterminal( ctx, - 'crowterminal.data.ingest', - { ...input }, - 'completed', + { + event: 'crowterminal.data.get_types', + inputSchema: GetDataTypesInputSchema, + outputSchema: GetDataTypesResponseSchema, + path: () => '/api/agent/data/types', + }, + input, ); - return response; -}; diff --git a/packages/crowterminal/endpoints/memory.ts b/packages/crowterminal/endpoints/memory.ts index 435a7556b..d14245c0d 100644 --- a/packages/crowterminal/endpoints/memory.ts +++ b/packages/crowterminal/endpoints/memory.ts @@ -1,36 +1,143 @@ -import { logEventFromContext } from 'corsair/core'; -import { makeCrowterminalRequest } from '../client'; -import type { CrowterminalEndpoints } from '../index'; -import type { CrowterminalEndpointOutputs } from './types'; +import type { CrowterminalContext } from '..'; +import { pathSegment } from '../client'; +import { callCrowterminal } from './shared'; +import type { + CrowterminalEndpointInputs, + CrowterminalEndpointOutputs, +} from './types'; +import { + BulkMemoryInputSchema, + BulkMemoryResponseSchema, + CompareMdInputSchema, + CompareMdResponseSchema, + EngagementAnalysisInputSchema, + EngagementAnalysisResponseSchema, + GetChangelogInputSchema, + GetChangelogResponseSchema, + GetMemoryInputSchema, + GetMemoryResponseSchema, + GetPatternInputSchema, + GetPatternResponseSchema, + ValidateChangesInputSchema, + ValidateChangesResponseSchema, +} from './types'; -export const get: CrowterminalEndpoints['memoryGet'] = async (ctx, input) => { - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['memoryGet'] - >(`/api/agent/memory/${input.clientId}`, ctx.key); +const memoryPath = (clientId: string, suffix = '') => + `/api/agent/memory/${pathSegment(clientId)}${suffix}`; - await logEventFromContext( +export const get = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['memoryGet'], +): Promise => + callCrowterminal( ctx, - 'crowterminal.memory.get', - { ...input }, - 'completed', + { + event: 'crowterminal.memory.get', + inputSchema: GetMemoryInputSchema, + outputSchema: GetMemoryResponseSchema, + path: (i) => memoryPath(i.clientId), + }, + input, ); - return response; -}; - -export const engagementAnalysis: CrowterminalEndpoints['memoryEngagementAnalysis'] = - async (ctx, input) => { - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['memoryEngagementAnalysis'] - >(`/api/agent/memory/${input.clientId}/engagement-analysis`, ctx.key, { + +/** Reads up to 50 clients in one call. */ +export const getBulk = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['memoryGetBulk'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.memory.get_bulk', method: 'POST', - body: { agentMd: input.agentMd }, - }); - - await logEventFromContext( - ctx, - 'crowterminal.memory.engagement_analysis', - { ...input }, - 'completed', - ); - return response; - }; + inputSchema: BulkMemoryInputSchema, + outputSchema: BulkMemoryResponseSchema, + path: () => '/api/agent/memory/bulk', + body: (i) => ({ clientIds: i.clientIds }), + }, + input, + ); + +export const getChangelog = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['memoryGetChangelog'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.memory.get_changelog', + inputSchema: GetChangelogInputSchema, + outputSchema: GetChangelogResponseSchema, + path: (i) => memoryPath(i.clientId, '/changelog'), + }, + input, + ); + +/** Trends one skill field across versions; the API requires `field`. */ +export const getPattern = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['memoryGetPattern'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.memory.get_pattern', + inputSchema: GetPatternInputSchema, + outputSchema: GetPatternResponseSchema, + path: (i) => memoryPath(i.clientId, '/pattern'), + query: (i) => ({ field: i.field }), + }, + input, + ); + +export const engagementAnalysis = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['memoryEngagementAnalysis'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.memory.engagement_analysis', + method: 'POST', + inputSchema: EngagementAnalysisInputSchema, + outputSchema: EngagementAnalysisResponseSchema, + path: (i) => memoryPath(i.clientId, '/engagement-analysis'), + body: (i) => ({ agentMd: i.agentMd }), + }, + input, + ); + +export const compareMd = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['memoryCompareMd'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.memory.compare_md', + method: 'POST', + inputSchema: CompareMdInputSchema, + outputSchema: CompareMdResponseSchema, + path: (i) => memoryPath(i.clientId, '/compare-md'), + body: (i) => ({ agentMd: i.agentMd }), + }, + input, + ); + +/** Checks proposed edits against stored history before they are applied. */ +export const validateChanges = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['memoryValidateChanges'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.memory.validate_changes', + method: 'POST', + inputSchema: ValidateChangesInputSchema, + outputSchema: ValidateChangesResponseSchema, + path: (i) => memoryPath(i.clientId, '/validate'), + body: (i) => ({ proposedChanges: i.proposedChanges }), + }, + input, + ); diff --git a/packages/crowterminal/endpoints/shared.ts b/packages/crowterminal/endpoints/shared.ts new file mode 100644 index 000000000..6b72c3b9b --- /dev/null +++ b/packages/crowterminal/endpoints/shared.ts @@ -0,0 +1,96 @@ +import { logEventFromContext } from 'corsair/core'; +import type { z } from 'zod'; +import type { CrowterminalContext } from '..'; +import { makeCrowterminalRequest } from '../client'; + +/** + * Fields never written to the event log. `secret` is a webhook signing + * credential; `data`, `agentMd` and `proposedChanges` are caller payloads that + * are unbounded and may carry creator analytics. + */ +const REDACTED = new Set(['secret', 'data', 'agentMd', 'proposedChanges']); + +/** + * Summarises a call for the event log: scalar arguments are kept as-is, + * payloads are reduced to their size so a regression is still traceable + * without copying the body into a second store. + */ +export function describeInput(input: unknown): Record { + if (typeof input !== 'object' || input === null) return {}; + const out: Record = {}; + + for (const [key, value] of Object.entries(input as Record)) { + if (value === undefined) continue; + if (REDACTED.has(key)) { + if (Array.isArray(value)) out[`${key}Count`] = value.length; + else if (value !== null && typeof value === 'object') { + out[`${key}Keys`] = Object.keys(value).length; + } else out[`${key}Present`] = true; + continue; + } + if (Array.isArray(value)) { + out[`${key}Count`] = value.length; + continue; + } + if (value !== null && typeof value === 'object') continue; + out[key] = value; + } + + return out; +} + +/** + * Runs one CrowTerminal call end to end: validate the caller's input, send it, + * validate the response, then log. bind.ts does not parse endpoint schemas, so + * parsing here is what actually enforces them. + */ +export async function callCrowterminal< + TInput extends z.ZodTypeAny, + TOutput extends z.ZodTypeAny, +>( + ctx: CrowterminalContext, + { + event, + method = 'GET', + inputSchema, + outputSchema, + path, + body, + query, + }: { + event: string; + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + inputSchema: TInput; + outputSchema: TOutput; + /** Built from the parsed input; path segments must already be encoded. */ + path: (input: z.infer) => string; + body?: (input: z.infer) => Record | undefined; + query?: ( + input: z.infer, + ) => Record | undefined; + }, + input: z.input, +): Promise> { + const parsedInput = inputSchema.parse(input); + + const raw = await makeCrowterminalRequest( + path(parsedInput), + ctx.key, + { + method, + body: body?.(parsedInput), + query: query?.(parsedInput), + }, + ); + + const parsed = outputSchema.parse(raw); + + await logEventFromContext( + ctx, + event, + describeInput(parsedInput), + 'completed', + ); + + return parsed; +} diff --git a/packages/crowterminal/endpoints/webhooks.ts b/packages/crowterminal/endpoints/webhooks.ts index 816544683..d1dd43bd5 100644 --- a/packages/crowterminal/endpoints/webhooks.ts +++ b/packages/crowterminal/endpoints/webhooks.ts @@ -1,101 +1,106 @@ -import { logEventFromContext } from 'corsair/core'; -import { makeCrowterminalRequest } from '../client'; -import type { CrowterminalEndpoints } from '../index'; -import type { CrowterminalEndpointOutputs } from './types'; +import type { CrowterminalContext } from '..'; +import { pathSegment } from '../client'; +import { callCrowterminal } from './shared'; +import type { + CrowterminalEndpointInputs, + CrowterminalEndpointOutputs, +} from './types'; +import { + CreateWebhookInputSchema, + CreateWebhookResponseSchema, + DeleteWebhookInputSchema, + DeleteWebhookResponseSchema, + ListWebhooksInputSchema, + ListWebhooksResponseSchema, + TestWebhookInputSchema, + TestWebhookResponseSchema, + UpdateWebhookInputSchema, + UpdateWebhookResponseSchema, +} from './types'; -export const create: CrowterminalEndpoints['webhooksCreate'] = async ( - ctx, - input, -) => { - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['webhooksCreate'] - >('/api/agent/webhooks', ctx.key, { - method: 'POST', - body: input, - }); +const webhookPath = (webhookId: string) => + `/api/agent/webhooks/${pathSegment(webhookId)}`; - await logEventFromContext( +/** The response carries the signing secret, generated if none was supplied. */ +export const create = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['webhooksCreate'], +): Promise => + callCrowterminal( ctx, - 'crowterminal.webhooks.create', - { ...input }, - 'completed', + { + event: 'crowterminal.webhooks.create', + method: 'POST', + inputSchema: CreateWebhookInputSchema, + outputSchema: CreateWebhookResponseSchema, + path: () => '/api/agent/webhooks', + body: (i) => ({ ...i }), + }, + input, ); - return response; -}; -export const list: CrowterminalEndpoints['webhooksList'] = async ( - ctx, - input, -) => { - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['webhooksList'] - >('/api/agent/webhooks', ctx.key); - - await logEventFromContext( +export const list = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['webhooksList'], +): Promise => + callCrowterminal( ctx, - 'crowterminal.webhooks.list', - { ...input }, - 'completed', + { + event: 'crowterminal.webhooks.list', + inputSchema: ListWebhooksInputSchema, + outputSchema: ListWebhooksResponseSchema, + path: () => '/api/agent/webhooks', + }, + input, ); - return response; -}; - -export const update: CrowterminalEndpoints['webhooksUpdate'] = async ( - ctx, - input, -) => { - const { webhookId, ...body } = input; - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['webhooksUpdate'] - >(`/api/agent/webhooks/${webhookId}`, ctx.key, { - method: 'PATCH', - body, - }); - await logEventFromContext( +export const update = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['webhooksUpdate'], +): Promise => + callCrowterminal( ctx, - 'crowterminal.webhooks.update', - { ...input }, - 'completed', + { + event: 'crowterminal.webhooks.update', + method: 'PATCH', + inputSchema: UpdateWebhookInputSchema, + outputSchema: UpdateWebhookResponseSchema, + path: (i) => webhookPath(i.webhookId), + body: ({ webhookId: _webhookId, ...rest }) => ({ ...rest }), + }, + input, ); - return response; -}; -export const deleteWebhook: CrowterminalEndpoints['webhooksDelete'] = async ( - ctx, - input, -) => { - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['webhooksDelete'] - >(`/api/agent/webhooks/${input.webhookId}`, ctx.key, { - method: 'DELETE', - }); - - await logEventFromContext( +export const deleteWebhook = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['webhooksDelete'], +): Promise => + callCrowterminal( ctx, - 'crowterminal.webhooks.delete', - { ...input }, - 'completed', + { + event: 'crowterminal.webhooks.delete', + method: 'DELETE', + inputSchema: DeleteWebhookInputSchema, + outputSchema: DeleteWebhookResponseSchema, + path: (i) => webhookPath(i.webhookId), + }, + input, ); - return response; -}; - -export const test: CrowterminalEndpoints['webhooksTest'] = async ( - ctx, - input, -) => { - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['webhooksTest'] - >('/api/agent/webhooks/test', ctx.key, { - method: 'POST', - body: input, - }); - await logEventFromContext( +/** Sends a test payload to a URL that need not be registered yet. */ +export const test = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['webhooksTest'], +): Promise => + callCrowterminal( ctx, - 'crowterminal.webhooks.test', - { ...input }, - 'completed', + { + event: 'crowterminal.webhooks.test', + method: 'POST', + inputSchema: TestWebhookInputSchema, + outputSchema: TestWebhookResponseSchema, + path: () => '/api/agent/webhooks/test', + body: (i) => ({ ...i }), + }, + input, ); - return response; -}; diff --git a/packages/crowterminal/error-handlers.ts b/packages/crowterminal/error-handlers.ts index 1cbffa8ea..7c46abd9e 100644 --- a/packages/crowterminal/error-handlers.ts +++ b/packages/crowterminal/error-handlers.ts @@ -1,31 +1,43 @@ import type { CorsairErrorHandler } from 'corsair/core'; import { ApiError } from 'corsair/http'; +// CrowTerminal returns { error, code } with documented code families: +// AUTH_001-006 (401/403), VAL_001-004 (400), RES_001-004 (404/409/410), +// RATE_001-002 (429), EXT_001/004 (502/503), BIZ_001-003 (402/403). +const AUTH_CODE = /\bauth_00[1-6]\b/; +const RATE_CODE = /\brate_00[12]\b/; + export const errorHandlers = { RATE_LIMIT_ERROR: { match: (error: Error) => { if (error instanceof ApiError && error.status === 429) return true; const msg = error.message.toLowerCase(); - return ( - msg.includes('rate_limited') || - msg.includes('rate limit') || - msg.includes('rate_001') || - msg.includes('rate_002') - ); + return RATE_CODE.test(msg) || msg.includes('too many requests'); }, handler: async (error: Error) => { - let retryAfterMs: number | undefined; - if (error instanceof ApiError && error.retryAfter !== undefined) { - retryAfterMs = error.retryAfter; - } - return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + // maxRetries stays 0. The transport already retries 429 and honours + // Retry-After, and a retry here would replay writes such as data + // ingestion and webhook mutations, which have no idempotency key. + // Retry-After is still forwarded so callers can back off themselves. + const retryAfterMs = + error instanceof ApiError ? error.retryAfter : undefined; + return { maxRetries: 0, headersRetryAfterMs: retryAfterMs }; }, }, AUTH_ERROR: { match: (error: Error) => { - if (error instanceof ApiError && error.status === 401) return true; + if ( + error instanceof ApiError && + (error.status === 401 || error.status === 403) + ) { + return true; + } const msg = error.message.toLowerCase(); - return msg.includes('unauthorized') || msg.includes('invalid_auth'); + return ( + AUTH_CODE.test(msg) || + msg.includes('api key required') || + msg.includes('invalid or revoked api key') + ); }, handler: async () => ({ maxRetries: 0 }), }, From 7e6aabc7f0bf4b637b67af5acf70a28eca9de1fd Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 24 Aug 2026 01:36:51 +0530 Subject: [PATCH 3/7] feat(crowterminal): add all 27 documented API operations --- packages/corsair/core/constants.ts | 2 +- packages/crowterminal/api.test.ts | 147 ++++++ packages/crowterminal/endpoints/agent.ts | 29 + .../crowterminal/endpoints/endpoints.test.ts | 385 ++++++++++++++ packages/crowterminal/endpoints/index.ts | 57 +- .../crowterminal/endpoints/intelligence.ts | 46 ++ packages/crowterminal/endpoints/sandbox.ts | 84 +++ packages/crowterminal/endpoints/status.ts | 124 ++++- packages/crowterminal/endpoints/types.test.ts | 149 ------ packages/crowterminal/endpoints/types.ts | 495 +++++++++++++----- packages/crowterminal/index.ts | 247 ++++++++- packages/crowterminal/jest.config.cjs | 3 + packages/crowterminal/package.json | 5 +- packages/crowterminal/schema.test.ts | 20 - packages/crowterminal/schema/database.ts | 65 ++- packages/crowterminal/schema/index.ts | 16 +- 16 files changed, 1534 insertions(+), 340 deletions(-) create mode 100644 packages/crowterminal/api.test.ts create mode 100644 packages/crowterminal/endpoints/agent.ts create mode 100644 packages/crowterminal/endpoints/endpoints.test.ts create mode 100644 packages/crowterminal/endpoints/intelligence.ts create mode 100644 packages/crowterminal/endpoints/sandbox.ts delete mode 100644 packages/crowterminal/endpoints/types.test.ts delete mode 100644 packages/crowterminal/schema.test.ts diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 5f37d2447..006a22715 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -245,7 +245,7 @@ export const ProviderDisplayNames = { collegefootballdata: 'College Football Data', confluence: 'Confluence', contentfulgraphql: 'Contentful GraphQL', - crowterminal: 'Crowterminal', + crowterminal: 'CrowTerminal', cursor: 'Cursor', databricks: 'Databricks', datadog: 'Datadog', diff --git a/packages/crowterminal/api.test.ts b/packages/crowterminal/api.test.ts new file mode 100644 index 000000000..3127784f2 --- /dev/null +++ b/packages/crowterminal/api.test.ts @@ -0,0 +1,147 @@ +import { getTypes } from './endpoints/data'; +import { getByokPlatform, getPlatform } 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 + )(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.each([ + ['platform intel', getPlatform], + ['byok platform intel', getByokPlatform], + ])( + 'returns %s', + async (_name, endpoint) => { + await expect( + ( + endpoint as (c: CrowterminalContext, i: unknown) => Promise + )(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(); + }); +} diff --git a/packages/crowterminal/endpoints/agent.ts b/packages/crowterminal/endpoints/agent.ts new file mode 100644 index 000000000..43847792e --- /dev/null +++ b/packages/crowterminal/endpoints/agent.ts @@ -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 => + callCrowterminal( + ctx, + { + event: 'crowterminal.agent.register', + method: 'POST', + inputSchema: RegisterAgentInputSchema, + outputSchema: RegisterAgentResponseSchema, + path: () => '/api/agent/register', + body: (i) => ({ ...i }), + }, + input, + ); diff --git a/packages/crowterminal/endpoints/endpoints.test.ts b/packages/crowterminal/endpoints/endpoints.test.ts new file mode 100644 index 000000000..08a9f6aa0 --- /dev/null +++ b/packages/crowterminal/endpoints/endpoints.test.ts @@ -0,0 +1,385 @@ +import type { CrowterminalContext } from '..'; +import * as client from '../client'; +import { register } from './agent'; +import { getTypes, ingest, ingestBulk } from './data'; +import { getByokPlatform, getPlatform } from './intelligence'; +import { + compareMd, + engagementAnalysis, + getBulk as getBulkMemory, + getChangelog, + get as getMemory, + getPattern, + validateChanges, +} from './memory'; +import { + getClient as sandboxClient, + engagementAnalysis as sandboxEngagement, + getMemory as sandboxMemory, + validate as sandboxValidate, +} from './sandbox'; +import { describeInput } from './shared'; +import { + getComponents, + getHistory, + getIncidents, + get as getStatus, + getUptime, + ping, +} from './status'; +import { + create as createWebhook, + deleteWebhook, + list as listWebhooks, + test as testWebhook, + update as updateWebhook, +} from './webhooks'; + +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn().mockResolvedValue(undefined), +})); + +const { logEventFromContext } = jest.requireMock('corsair/core') as { + logEventFromContext: jest.Mock; +}; + +const requestSpy = jest.spyOn(client, 'makeCrowterminalRequest'); + +const ctx = () => ({ key: 'ct_test_key' }) as unknown as CrowterminalContext; + +beforeEach(() => { + requestSpy.mockReset().mockResolvedValue({ success: true }); + logEventFromContext.mockClear(); +}); + +const lastCall = () => requestSpy.mock.calls[0]; + +describe('request routing', () => { + // Paths below were confirmed against live api.crowterminal.com. + it.each([ + ['statusGet', '/api/agent/status', getStatus], + ['statusPing', '/api/agent/status/ping', ping], + ['statusGetComponents', '/api/agent/status/components', getComponents], + ['statusGetIncidents', '/api/agent/status/incidents', getIncidents], + ['statusGetHistory', '/api/agent/status/history', getHistory], + ['statusGetUptime', '/api/agent/status/uptime', getUptime], + ['dataGetTypes', '/api/agent/data/types', getTypes], + ['intelGetPlatform', '/api/agent/platform-intel', getPlatform], + ['intelGetByok', '/api/agent/byok/platform-intel', getByokPlatform], + ['sandboxGetClient', '/api/agent/sandbox/client', sandboxClient], + ['sandboxGetMemory', '/api/agent/sandbox/memory', sandboxMemory], + ['webhooksList', '/api/agent/webhooks', listWebhooks], + ])('routes %s to %s', async (_name, path, endpoint) => { + requestSpy.mockResolvedValue({ + success: true, + status: 'ok', + pong: true, + webhooks: [], + components: [], + incidents: [], + dataPoints: [], + uptime: {}, + dataTypes: {}, + }); + + await ( + endpoint as (c: CrowterminalContext, i: unknown) => Promise + )(ctx(), {}); + + expect(lastCall()?.[0]).toBe(path); + expect(lastCall()?.[1]).toBe('ct_test_key'); + }); + + it('routes the client-scoped memory reads', async () => { + requestSpy.mockResolvedValue({ success: true, changelog: [] }); + await getChangelog(ctx(), { clientId: 'c1' }); + expect(lastCall()?.[0]).toBe('/api/agent/memory/c1/changelog'); + }); + + it('sends the pattern field as a query parameter', async () => { + requestSpy.mockResolvedValue({ success: true, dataPoints: [] }); + + await getPattern(ctx(), { clientId: 'c1', field: 'primaryNiche' }); + + expect(lastCall()?.[0]).toBe('/api/agent/memory/c1/pattern'); + expect(lastCall()?.[2]?.query).toEqual({ field: 'primaryNiche' }); + }); + + it('posts compare-md to the documented hyphenated path', async () => { + await compareMd(ctx(), { clientId: 'c1', agentMd: { a: 1 } }); + + expect(lastCall()?.[0]).toBe('/api/agent/memory/c1/compare-md'); + expect(lastCall()?.[2]).toMatchObject({ + method: 'POST', + body: { agentMd: { a: 1 } }, + }); + }); + + it('names the bulk ingest array items, as the API requires', async () => { + await ingestBulk(ctx(), { + items: [ + { + clientId: 'c1', + platform: 'TIKTOK', + dataType: 'retention', + data: { avgWatchTime: 12 }, + }, + ], + }); + + expect(lastCall()?.[0]).toBe('/api/agent/data/ingest/bulk'); + expect(Object.keys(lastCall()?.[2]?.body ?? {})).toEqual(['items']); + }); + + it('drops webhookId from the update body and puts it in the path', async () => { + await updateWebhook(ctx(), { webhookId: 'wh_1', isActive: false }); + + expect(lastCall()?.[0]).toBe('/api/agent/webhooks/wh_1'); + expect(lastCall()?.[2]?.method).toBe('PATCH'); + expect(lastCall()?.[2]?.body).toEqual({ isActive: false }); + }); + + it('sends no body when deleting a webhook', async () => { + await deleteWebhook(ctx(), { webhookId: 'wh_1' }); + + expect(lastCall()?.[0]).toBe('/api/agent/webhooks/wh_1'); + expect(lastCall()?.[2]?.method).toBe('DELETE'); + expect(lastCall()?.[2]?.body).toBeUndefined(); + }); + + it('registers an agent against the documented path', async () => { + await register(ctx(), { agentName: 'MyBot' }); + + expect(lastCall()?.[0]).toBe('/api/agent/register'); + expect(lastCall()?.[2]?.method).toBe('POST'); + }); +}); + +describe('path segment encoding', () => { + // A raw clientId of ../status retargets the credentialed request at a + // different endpoint; the live API answers it with real status data. + it('rejects an id that would escape its route', async () => { + await expect(getMemory(ctx(), { clientId: '../status' })).rejects.toThrow(); + await expect( + getChangelog(ctx(), { clientId: 'x/changelog' }), + ).rejects.toThrow(); + await expect( + deleteWebhook(ctx(), { webhookId: '../../agent/webhooks' }), + ).rejects.toThrow(); + + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('percent-encodes characters that are legal but reserved', async () => { + requestSpy.mockResolvedValue({ success: true, changelog: [] }); + + await getChangelog(ctx(), { clientId: 'a b&c' }); + + expect(lastCall()?.[0]).toBe('/api/agent/memory/a%20b%26c/changelog'); + }); +}); + +describe('input validation', () => { + it('rejects a bulk read over the documented 50-client limit', async () => { + await expect( + getBulkMemory(ctx(), { + clientIds: Array.from({ length: 51 }, (_v, i) => `c${i}`), + }), + ).rejects.toThrow(); + + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('rejects an unknown platform', async () => { + await expect( + ingest(ctx(), { + clientId: 'c1', + platform: 'MYSPACE', + dataType: 'retention', + data: {}, + } as never), + ).rejects.toThrow(); + }); + + it('rejects a confidence outside 0-1', async () => { + await expect( + ingest(ctx(), { + clientId: 'c1', + platform: 'TIKTOK', + dataType: 'retention', + data: {}, + confidence: 1.5, + }), + ).rejects.toThrow(); + }); + + it('rejects a webhook url that is not a url', async () => { + await expect( + createWebhook(ctx(), { url: 'not-a-url', events: ['skill.updated'] }), + ).rejects.toThrow(); + }); + + it('rejects an unknown webhook event name', async () => { + await expect( + createWebhook(ctx(), { + url: 'https://example.com/hook', + events: ['skill.exploded'], + } as never), + ).rejects.toThrow(); + }); + + it('requires at least one proposed change', async () => { + await expect( + validateChanges(ctx(), { clientId: 'c1', proposedChanges: [] }), + ).rejects.toThrow(); + }); +}); + +describe('output validation', () => { + it('accepts the live status shape', async () => { + requestSpy.mockResolvedValue({ + status: 'outage', + timestamp: '2026-08-23T15:00:00.935Z', + version: '2.0.0', + services: { redis: { status: 'outage', description: 'fallback' } }, + metrics: { uptime: '181d' }, + }); + + const result = await getStatus(ctx(), {}); + + expect(result.status).toBe('outage'); + expect(result.services?.redis?.status).toBe('outage'); + }); + + it('accepts the live webhook listing shape', async () => { + requestSpy.mockResolvedValue({ + success: true, + webhooks: [], + _tip: 'Use POST /api/agent/webhooks to register a new webhook', + }); + + await expect(listWebhooks(ctx(), {})).resolves.toMatchObject({ + webhooks: [], + }); + }); + + it('accepts an insufficient-data engagement analysis', async () => { + requestSpy.mockResolvedValue({ + success: true, + clientId: 'c1', + analysis: 'insufficient_data', + message: 'Only 0 versions stored', + }); + + await expect( + engagementAnalysis(ctx(), { clientId: 'c1', agentMd: {} }), + ).resolves.toMatchObject({ analysis: 'insufficient_data' }); + }); + + it('accepts a blocked sandbox validation', async () => { + requestSpy.mockResolvedValue({ + success: true, + _sandbox: true, + validation: 'blocked', + warnings: [{ field: 'hookPatterns', severity: 'critical' }], + recommendations: [], + }); + + const result = await sandboxValidate(ctx(), { + proposedChanges: [{ field: 'hookPatterns', newValue: 'tutorial' }], + }); + + expect(result.validation).toBe('blocked'); + expect(result.warnings).toHaveLength(1); + }); + + it('keeps advisory fields the service adds', async () => { + requestSpy.mockResolvedValue({ + success: true, + dataTypes: { TIKTOK: ['retention'] }, + _docs: 'https://crowterminal.com/llms.txt', + }); + + await expect(getTypes(ctx(), {})).resolves.toMatchObject({ + _docs: 'https://crowterminal.com/llms.txt', + }); + }); + + it('rejects a status response missing its required field', async () => { + requestSpy.mockResolvedValue({ currentStatus: 'operational' }); + + await expect(getStatus(ctx(), {})).rejects.toThrow(); + }); +}); + +describe('event logging', () => { + it('never logs a webhook signing secret', async () => { + await createWebhook(ctx(), { + url: 'https://example.com/hook', + events: ['skill.updated'], + secret: 'super-secret-signing-key', + }); + + const [, event, payload] = logEventFromContext.mock.calls[0]; + expect(event).toBe('crowterminal.webhooks.create'); + expect(JSON.stringify(payload)).not.toContain('super-secret-signing-key'); + expect(payload).toMatchObject({ + url: 'https://example.com/hook', + secretPresent: true, + }); + }); + + it('never logs the secret from a webhook test', async () => { + await testWebhook(ctx(), { + url: 'https://example.com/hook', + secret: 'another-secret', + }); + + const [, , payload] = logEventFromContext.mock.calls[0]; + expect(JSON.stringify(payload)).not.toContain('another-secret'); + }); + + it('records ingest metadata without the payload body', async () => { + await ingest(ctx(), { + clientId: 'c1', + platform: 'TIKTOK', + dataType: 'retention', + videoId: 'v9', + data: { avgWatchTime: 12.5, viewerIds: ['u1', 'u2'] }, + }); + + const [, , payload] = logEventFromContext.mock.calls[0]; + expect(payload).toMatchObject({ + clientId: 'c1', + platform: 'TIKTOK', + dataType: 'retention', + videoId: 'v9', + dataKeys: 2, + }); + expect(JSON.stringify(payload)).not.toContain('avgWatchTime'); + expect(JSON.stringify(payload)).not.toContain('u1'); + }); + + it('does not log a call that failed validation', async () => { + await expect(getMemory(ctx(), { clientId: '../status' })).rejects.toThrow(); + + expect(logEventFromContext).not.toHaveBeenCalled(); + }); + + it('summarises payload fields by size only', () => { + expect( + describeInput({ + clientId: 'c1', + agentMd: { a: 1, b: 2 }, + proposedChanges: [{ field: 'x' }], + secret: 's3cret', + }), + ).toEqual({ + clientId: 'c1', + agentMdKeys: 2, + proposedChangesCount: 1, + secretPresent: true, + }); + }); +}); diff --git a/packages/crowterminal/endpoints/index.ts b/packages/crowterminal/endpoints/index.ts index 47bcde1d2..1008910c1 100644 --- a/packages/crowterminal/endpoints/index.ts +++ b/packages/crowterminal/endpoints/index.ts @@ -1,6 +1,29 @@ -import { ingest } from './data'; -import { engagementAnalysis, get as getMemory } from './memory'; -import { get as getStatus } from './status'; +import { register } from './agent'; +import { getTypes, ingest, ingestBulk } from './data'; +import { getByokPlatform, getPlatform } from './intelligence'; +import { + compareMd, + engagementAnalysis, + getBulk as getBulkMemory, + getChangelog, + get as getMemory, + getPattern, + validateChanges, +} from './memory'; +import { + engagementAnalysis as sandboxEngagementAnalysis, + getClient as sandboxGetClient, + getMemory as sandboxGetMemory, + validate as sandboxValidate, +} from './sandbox'; +import { + getComponents, + getHistory, + getIncidents, + get as getStatus, + getUptime, + ping, +} from './status'; import { create as createWebhook, deleteWebhook, @@ -11,15 +34,43 @@ import { export const Memory = { get: getMemory, + getBulk: getBulkMemory, + getChangelog, + getPattern, engagementAnalysis, + compareMd, + validateChanges, }; export const Data = { ingest, + ingestBulk, + getTypes, +}; + +export const Intelligence = { + getPlatform, + getByokPlatform, }; export const Status = { get: getStatus, + ping, + getComponents, + getIncidents, + getHistory, + getUptime, +}; + +export const Sandbox = { + getClient: sandboxGetClient, + getMemory: sandboxGetMemory, + engagementAnalysis: sandboxEngagementAnalysis, + validate: sandboxValidate, +}; + +export const Agent = { + register, }; export const Webhooks = { diff --git a/packages/crowterminal/endpoints/intelligence.ts b/packages/crowterminal/endpoints/intelligence.ts new file mode 100644 index 000000000..95d32ef2b --- /dev/null +++ b/packages/crowterminal/endpoints/intelligence.ts @@ -0,0 +1,46 @@ +import type { CrowterminalContext } from '..'; +import { callCrowterminal } from './shared'; +import type { + CrowterminalEndpointInputs, + CrowterminalEndpointOutputs, +} from './types'; +import { + GetByokPlatformIntelInputSchema, + GetByokPlatformIntelResponseSchema, + GetPlatformIntelInputSchema, + GetPlatformIntelResponseSchema, +} from './types'; + +export const getPlatform = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['intelGetPlatform'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.intel.get_platform', + inputSchema: GetPlatformIntelInputSchema, + outputSchema: GetPlatformIntelResponseSchema, + path: () => '/api/agent/platform-intel', + }, + input, + ); + +/** + * The BYOK variant returns the same algorithm context without client scoping, + * so it does not trigger LLM inference charges. + */ +export const getByokPlatform = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['intelGetByokPlatform'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.intel.get_byok_platform', + inputSchema: GetByokPlatformIntelInputSchema, + outputSchema: GetByokPlatformIntelResponseSchema, + path: () => '/api/agent/byok/platform-intel', + }, + input, + ); diff --git a/packages/crowterminal/endpoints/sandbox.ts b/packages/crowterminal/endpoints/sandbox.ts new file mode 100644 index 000000000..25eb3ee18 --- /dev/null +++ b/packages/crowterminal/endpoints/sandbox.ts @@ -0,0 +1,84 @@ +import type { CrowterminalContext } from '..'; +import { callCrowterminal } from './shared'; +import type { + CrowterminalEndpointInputs, + CrowterminalEndpointOutputs, +} from './types'; +import { + SandboxClientInputSchema, + SandboxClientResponseSchema, + SandboxEngagementInputSchema, + SandboxEngagementResponseSchema, + SandboxMemoryInputSchema, + SandboxMemoryResponseSchema, + SandboxValidateInputSchema, + SandboxValidateResponseSchema, +} from './types'; + +// Sandbox responses are fixtures and are marked `_sandbox: true`. They touch no +// real data and need no auth, so they are the safe way to exercise a workflow. + +export const getClient = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['sandboxGetClient'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.sandbox.get_client', + inputSchema: SandboxClientInputSchema, + outputSchema: SandboxClientResponseSchema, + path: () => '/api/agent/sandbox/client', + }, + input, + ); + +export const getMemory = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['sandboxGetMemory'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.sandbox.get_memory', + inputSchema: SandboxMemoryInputSchema, + outputSchema: SandboxMemoryResponseSchema, + path: () => '/api/agent/sandbox/memory', + }, + input, + ); + +export const engagementAnalysis = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['sandboxEngagementAnalysis'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.sandbox.engagement_analysis', + method: 'POST', + inputSchema: SandboxEngagementInputSchema, + outputSchema: SandboxEngagementResponseSchema, + path: () => '/api/agent/sandbox/engagement-analysis', + body: (i) => (i.agentMd ? { agentMd: i.agentMd } : {}), + }, + input, + ); + +/** Sending a change whose newValue is "tutorial" returns a blocked result. */ +export const validate = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['sandboxValidate'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.sandbox.validate', + method: 'POST', + inputSchema: SandboxValidateInputSchema, + outputSchema: SandboxValidateResponseSchema, + path: () => '/api/agent/sandbox/validate', + body: (i) => ({ proposedChanges: i.proposedChanges }), + }, + input, + ); diff --git a/packages/crowterminal/endpoints/status.ts b/packages/crowterminal/endpoints/status.ts index e4f84c1d7..ece9b2f55 100644 --- a/packages/crowterminal/endpoints/status.ts +++ b/packages/crowterminal/endpoints/status.ts @@ -1,18 +1,114 @@ -import { logEventFromContext } from 'corsair/core'; -import { makeCrowterminalRequest } from '../client'; -import type { CrowterminalEndpoints } from '../index'; -import type { CrowterminalEndpointOutputs } from './types'; +import type { CrowterminalContext } from '..'; +import { callCrowterminal } from './shared'; +import type { + CrowterminalEndpointInputs, + CrowterminalEndpointOutputs, +} from './types'; +import { + GetComponentsInputSchema, + GetComponentsResponseSchema, + GetIncidentsInputSchema, + GetIncidentsResponseSchema, + GetStatusHistoryInputSchema, + GetStatusHistoryResponseSchema, + GetStatusInputSchema, + GetStatusResponseSchema, + GetUptimeInputSchema, + GetUptimeResponseSchema, + PingInputSchema, + PingResponseSchema, +} from './types'; -export const get: CrowterminalEndpoints['statusGet'] = async (ctx, input) => { - const response = await makeCrowterminalRequest< - CrowterminalEndpointOutputs['statusGet'] - >('/api/agent/status', ctx.key); +// The status endpoints are public. They still go through the authenticated +// client so that one code path handles retries and error classification. - await logEventFromContext( +export const get = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['statusGet'], +): Promise => + callCrowterminal( ctx, - 'crowterminal.status.get', - { ...input }, - 'completed', + { + event: 'crowterminal.status.get', + inputSchema: GetStatusInputSchema, + outputSchema: GetStatusResponseSchema, + path: () => '/api/agent/status', + }, + input, + ); + +export const ping = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['statusPing'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.status.ping', + inputSchema: PingInputSchema, + outputSchema: PingResponseSchema, + path: () => '/api/agent/status/ping', + }, + input, + ); + +export const getComponents = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['statusGetComponents'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.status.get_components', + inputSchema: GetComponentsInputSchema, + outputSchema: GetComponentsResponseSchema, + path: () => '/api/agent/status/components', + }, + input, + ); + +export const getIncidents = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['statusGetIncidents'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.status.get_incidents', + inputSchema: GetIncidentsInputSchema, + outputSchema: GetIncidentsResponseSchema, + path: () => '/api/agent/status/incidents', + }, + input, + ); + +/** Seven days of daily uptime points, shaped for charting. */ +export const getHistory = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['statusGetHistory'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.status.get_history', + inputSchema: GetStatusHistoryInputSchema, + outputSchema: GetStatusHistoryResponseSchema, + path: () => '/api/agent/status/history', + }, + input, + ); + +export const getUptime = ( + ctx: CrowterminalContext, + input: CrowterminalEndpointInputs['statusGetUptime'], +): Promise => + callCrowterminal( + ctx, + { + event: 'crowterminal.status.get_uptime', + inputSchema: GetUptimeInputSchema, + outputSchema: GetUptimeResponseSchema, + path: () => '/api/agent/status/uptime', + }, + input, ); - return response; -}; diff --git a/packages/crowterminal/endpoints/types.test.ts b/packages/crowterminal/endpoints/types.test.ts deleted file mode 100644 index dd7caae7b..000000000 --- a/packages/crowterminal/endpoints/types.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { - CrowterminalEndpointInputSchemas, - CrowterminalEndpointOutputSchemas, -} from './types'; - -describe('CrowTerminal endpoint schemas', () => { - it('validates documented memory read input and output', () => { - expect( - CrowterminalEndpointInputSchemas.memoryGet.parse({ - clientId: 'client_123', - }), - ).toEqual({ clientId: 'client_123' }); - expect( - CrowterminalEndpointOutputSchemas.memoryGet.parse({ - success: true, - version: 47, - skill: { - primaryNiche: 'fitness', - hookPatterns: ['confession'], - avgEngagement: 4.2, - bestPostingTimes: [{ day: 2, hour: 7, score: 0.89 }], - }, - }), - ).toMatchObject({ success: true, version: 47 }); - }); - - it('validates documented engagement-analysis input and output', () => { - expect( - CrowterminalEndpointInputSchemas.memoryEngagementAnalysis.parse({ - clientId: 'client_123', - agentMd: { hookPatterns: ['confession'], contentStyle: 'casual' }, - }), - ).toMatchObject({ clientId: 'client_123' }); - expect( - CrowterminalEndpointOutputSchemas.memoryEngagementAnalysis.parse({ - success: true, - versionsAnalyzed: 47, - overallStats: { - peakEngagement: 6.2, - peakVersion: 28, - yourSimilarityToTop: '65%', - yourSimilarityToBottom: '20%', - }, - fieldAnalysis: [ - { - field: 'hookPatterns', - yourValue: ['confession'], - bestValue: ['POV', 'confession'], - bestEngagement: 6.2, - yourPredictedEngagement: 4.1, - improvement: '+51% potential improvement', - confidence: 'high', - }, - ], - recommendations: ['Change hookPatterns'], - }), - ).toMatchObject({ success: true, versionsAnalyzed: 47 }); - }); - - it('validates documented data ingestion input and output', () => { - expect( - CrowterminalEndpointInputSchemas.dataIngest.parse({ - clientId: 'client_123', - platform: 'TIKTOK', - dataType: 'retention', - videoId: 'video_456', - data: { retentionCurve: [100, 95], completionRate: 0.3 }, - confidence: 0.9, - }), - ).toMatchObject({ platform: 'TIKTOK', dataType: 'retention' }); - expect( - CrowterminalEndpointOutputSchemas.dataIngest.parse({ - success: true, - message: 'Data ingested successfully', - id: 'abc123', - clientId: 'client_123', - platform: 'TIKTOK', - dataType: 'retention', - _tip: 'Use engagement_analysis endpoint', - }), - ).toMatchObject({ success: true, id: 'abc123' }); - }); - - it('validates the no-argument status operation', () => { - expect(CrowterminalEndpointInputSchemas.statusGet.parse({})).toEqual({}); - expect( - CrowterminalEndpointOutputSchemas.statusGet.parse({ - currentStatus: 'operational', - }), - ).toEqual({ currentStatus: 'operational' }); - }); - - it('validates webhook creation input and its documented response', () => { - expect( - CrowterminalEndpointInputSchemas.webhooksCreate.parse({ - url: 'https://example.com/crowterminal', - events: ['skill.updated', 'data.ingested'], - secret: 'webhook-secret', - }), - ).toMatchObject({ events: ['skill.updated', 'data.ingested'] }); - expect( - CrowterminalEndpointOutputSchemas.webhooksCreate.parse({ - id: 'wh_123', - secret: 'webhook-secret', - }), - ).toEqual({ id: 'wh_123', secret: 'webhook-secret' }); - }); - - it('validates webhook listing input', () => { - expect(CrowterminalEndpointInputSchemas.webhooksList.parse({})).toEqual({}); - expect( - CrowterminalEndpointOutputSchemas.webhooksList.parse({ data: [] }), - ).toEqual({ data: [] }); - }); - - it('validates webhook update input', () => { - expect( - CrowterminalEndpointInputSchemas.webhooksUpdate.parse({ - webhookId: 'wh_123', - isActive: false, - }), - ).toEqual({ webhookId: 'wh_123', isActive: false }); - expect( - CrowterminalEndpointOutputSchemas.webhooksUpdate.parse({ id: 'wh_123' }), - ).toEqual({ id: 'wh_123' }); - }); - - it('validates webhook deletion input', () => { - expect( - CrowterminalEndpointInputSchemas.webhooksDelete.parse({ - webhookId: 'wh_123', - }), - ).toEqual({ webhookId: 'wh_123' }); - expect( - CrowterminalEndpointOutputSchemas.webhooksDelete.parse({ success: true }), - ).toEqual({ success: true }); - }); - - it('validates webhook test input', () => { - expect( - CrowterminalEndpointInputSchemas.webhooksTest.parse({ - url: 'https://example.com/crowterminal', - }), - ).toEqual({ url: 'https://example.com/crowterminal' }); - expect( - CrowterminalEndpointOutputSchemas.webhooksTest.parse({ delivered: true }), - ).toEqual({ delivered: true }); - }); -}); diff --git a/packages/crowterminal/endpoints/types.ts b/packages/crowterminal/endpoints/types.ts index 975cda7ff..6573bc9a4 100644 --- a/packages/crowterminal/endpoints/types.ts +++ b/packages/crowterminal/endpoints/types.ts @@ -1,10 +1,30 @@ import { z } from 'zod'; -const ClientIdSchema = z.string().min(1); +// Request shapes follow https://crowterminal.com/llms.txt. Response shapes were +// captured from live api.crowterminal.com responses and are loose, because the +// service adds advisory fields (_tip, _note, _docs, _sandbox) and returns only +// what a plan is entitled to. -const CrowterminalPlatformSchema = z.enum(['TIKTOK', 'INSTAGRAM', 'YOUTUBE']); +/** + * Ids are spliced into request paths. `/`, `?` and `#` would retarget the + * request at a different endpoint, so they are rejected rather than escaped + * only at the call site. + */ +const IdSchema = z + .string() + .min(1) + .refine((v) => !/[/?#]/.test(v), { + message: 'must not contain /, ? or #', + }); -const CrowterminalDataTypeSchema = z.enum([ +export const CrowterminalPlatformSchema = z.enum([ + 'TIKTOK', + 'INSTAGRAM', + 'YOUTUBE', +]); + +/** Values come from GET /api/agent/data/types, which is per-platform. */ +export const CrowterminalDataTypeSchema = z.enum([ 'retention', 'demographics', 'traffic_sources', @@ -24,7 +44,7 @@ const CrowterminalDataTypeSchema = z.enum([ 'end_screen_performance', ]); -const CrowterminalWebhookEventNameSchema = z.enum([ +export const CrowterminalWebhookEventNameSchema = z.enum([ 'skill.updated', 'skill.version_created', 'data.ingested', @@ -33,123 +53,358 @@ const CrowterminalWebhookEventNameSchema = z.enum([ 'posting.failed', ]); -const GetMemoryInputSchema = z.object({ - clientId: ClientIdSchema, +const NoInputSchema = z.object({}); +const ClientInputSchema = z.object({ clientId: IdSchema }); + +/** Every response carries `success`; failures come back as HTTP errors. */ +const ok = z.boolean().optional(); + +// ── Memory ────────────────────────────────────────────────────────────────── + +export const SkillSchema = z.looseObject({ + primaryNiche: z.string().optional(), + subNiches: z.array(z.string()).optional(), + contentStyle: z.string().optional(), + signatureStyle: z.string().optional(), + hookPatterns: z.array(z.string()).optional(), + avgEngagement: z.number().optional(), + bestPostingTimes: z + .array( + z.looseObject({ + day: z.number().int().optional(), + hour: z.number().int().optional(), + score: z.number().optional(), + }), + ) + .optional(), +}); + +export const GetMemoryInputSchema = ClientInputSchema; +export const GetMemoryResponseSchema = z.looseObject({ + success: ok, + clientId: z.string().optional(), + clientName: z.string().optional(), + version: z.number().optional(), + updatedAt: z.string().optional(), + skill: SkillSchema.nullable().optional(), +}); + +/** Up to 50 clients per call. */ +export const BulkMemoryInputSchema = z.object({ + clientIds: z.array(IdSchema).min(1).max(50), +}); +export const BulkMemoryResponseSchema = z.looseObject({ + success: ok, + total: z.number().optional(), + clients: z.array( + z.looseObject({ + clientId: z.string(), + success: ok, + skill: SkillSchema.nullable().optional(), + }), + ), +}); + +export const GetChangelogInputSchema = ClientInputSchema; +export const GetChangelogResponseSchema = z.looseObject({ + success: ok, + clientId: z.string().optional(), + message: z.string().optional(), + changelog: z.array(z.unknown()), +}); + +export const GetPatternInputSchema = z.object({ + clientId: IdSchema, + /** Skill field to trend, e.g. primaryNiche. Required by the API. */ + field: z.string().min(1), +}); +export const GetPatternResponseSchema = z.looseObject({ + success: ok, + clientId: z.string().optional(), + field: z.string().optional(), + trend: z.string().optional(), + versionsAnalyzed: z.number().optional(), + dataPoints: z.array(z.unknown()), +}); + +export const EngagementAnalysisInputSchema = z.object({ + clientId: IdSchema, + agentMd: z.record(z.string(), z.unknown()), +}); +export const EngagementAnalysisResponseSchema = z.looseObject({ + success: ok, + clientId: z.string().optional(), + /** 'insufficient_data' when the client has too few stored versions. */ + analysis: z.string().optional(), + message: z.string().optional(), + versionsAnalyzed: z.number().optional(), + overallStats: z.looseObject({}).optional(), + fieldAnalysis: z.array(z.unknown()).optional(), }); -const EngagementAnalysisInputSchema = z.object({ - clientId: ClientIdSchema, +export const CompareMdInputSchema = z.object({ + clientId: IdSchema, agentMd: z.record(z.string(), z.unknown()), }); +export const CompareMdResponseSchema = z.looseObject({ + success: ok, + clientId: z.string().optional(), + /** 'no_our_data' when CrowTerminal has nothing stored for the client. */ + comparison: z.string().optional(), + message: z.string().optional(), + recommendation: z.string().optional(), + differences: z.array(z.unknown()).optional(), + missingFields: z.array(z.string()).optional(), +}); + +const ProposedChangeSchema = z.object({ + field: z.string().min(1), + oldValue: z.unknown().optional(), + newValue: z.unknown().optional(), +}); -const IngestDataInputSchema = z.object({ - clientId: ClientIdSchema, +export const ValidateChangesInputSchema = z.object({ + clientId: IdSchema, + proposedChanges: z.array(ProposedChangeSchema).min(1), +}); +export const ValidateChangesResponseSchema = z.looseObject({ + success: ok, + clientId: z.string().optional(), + /** 'blocked' or 'no_history'. */ + validation: z.string().optional(), + message: z.string().optional(), + warnings: z.array(z.unknown()), + recommendations: z.array(z.unknown()), +}); + +// ── Data ──────────────────────────────────────────────────────────────────── + +export const IngestDataInputSchema = z.object({ + clientId: IdSchema, platform: CrowterminalPlatformSchema, dataType: CrowterminalDataTypeSchema, videoId: z.string().min(1).optional(), data: z.record(z.string(), z.unknown()), confidence: z.number().min(0).max(1).optional(), }); +export const IngestDataResponseSchema = z.looseObject({ success: ok }); -const GetStatusInputSchema = z.object({}); +/** Up to 50 points per call; the API names the array `items`. */ +export const BulkIngestInputSchema = z.object({ + items: z.array(IngestDataInputSchema).min(1).max(50), +}); +export const BulkIngestResponseSchema = z.looseObject({ + success: ok, + total: z.number().optional(), + results: z.array(z.unknown()).optional(), +}); -const CreateWebhookInputSchema = z.object({ - url: z.string().url(), - events: z.array(CrowterminalWebhookEventNameSchema), - secret: z.string().min(1).optional(), +export const GetDataTypesInputSchema = NoInputSchema; +export const GetDataTypesResponseSchema = z.looseObject({ + success: ok, + dataTypes: z.record(z.string(), z.array(z.string())), }); -const ListWebhooksInputSchema = z.object({}); +// ── Intelligence ──────────────────────────────────────────────────────────── -const UpdateWebhookInputSchema = z.object({ - webhookId: z.string().min(1), - url: z.string().url().optional(), - events: z.array(CrowterminalWebhookEventNameSchema).optional(), - isActive: z.boolean().optional(), +export const GetPlatformIntelInputSchema = NoInputSchema; +export const GetPlatformIntelResponseSchema = z.looseObject({ + success: ok, + data: z.unknown().optional(), }); -const DeleteWebhookInputSchema = z.object({ - webhookId: z.string().min(1), +export const GetByokPlatformIntelInputSchema = NoInputSchema; +export const GetByokPlatformIntelResponseSchema = z.looseObject({ + success: ok, + platforms: z.array(z.string()).optional(), + intelligence: z.unknown().optional(), }); -const TestWebhookInputSchema = z.object({ - url: z.string().url(), - secret: z.string().min(1).optional(), +// ── Status ────────────────────────────────────────────────────────────────── + +const ServiceHealthSchema = z.looseObject({ + status: z.string(), + latency: z.string().optional(), + description: z.string().optional(), +}); + +export const GetStatusInputSchema = NoInputSchema; +export const GetStatusResponseSchema = z.looseObject({ + status: z.string(), + timestamp: z.string().optional(), + version: z.string().optional(), + services: z.record(z.string(), ServiceHealthSchema).optional(), + metrics: z.looseObject({}).optional(), + endpoints: z.record(z.string(), z.string()).optional(), }); -const SkillSchema = z - .object({ - primaryNiche: z.string().optional(), - hookPatterns: z.array(z.string()).optional(), - avgEngagement: z.number().optional(), - bestPostingTimes: z - .array( - z.object({ - day: z.number().int(), - hour: z.number().int(), - score: z.number(), - }), - ) - .optional(), - }) - .loose(); - -const GetMemoryResponseSchema = z.object({ - success: z.literal(true), - version: z.number().int(), - skill: SkillSchema, -}); - -const EngagementAnalysisResponseSchema = z.object({ - success: z.literal(true), - versionsAnalyzed: z.number().int().nonnegative(), - overallStats: z.object({ - peakEngagement: z.number(), - peakVersion: z.number().int(), - yourSimilarityToTop: z.string(), - yourSimilarityToBottom: z.string(), - }), - fieldAnalysis: z.array( - z.object({ - field: z.string(), - yourValue: z.unknown(), - bestValue: z.unknown(), - bestEngagement: z.number(), - yourPredictedEngagement: z.number(), - improvement: z.string(), - confidence: z.string(), +export const PingInputSchema = NoInputSchema; +export const PingResponseSchema = z.looseObject({ + pong: z.boolean(), + timestamp: z.string().optional(), +}); + +export const GetComponentsInputSchema = NoInputSchema; +export const GetComponentsResponseSchema = z.looseObject({ + timestamp: z.string().optional(), + components: z.array( + z.looseObject({ + name: z.string(), + status: z.string(), + latency: z.string().optional(), + details: z.string().optional(), }), ), - recommendations: z.array(z.string()), + summary: z.looseObject({}).optional(), }); -const IngestDataResponseSchema = z.object({ - success: z.literal(true), - message: z.string(), - id: z.string(), - clientId: z.string(), - platform: CrowterminalPlatformSchema, - dataType: CrowterminalDataTypeSchema, - _tip: z.string().optional(), +export const GetIncidentsInputSchema = NoInputSchema; +export const GetIncidentsResponseSchema = z.looseObject({ + timestamp: z.string().optional(), + incidents: z.array( + z.looseObject({ + timestamp: z.string().optional(), + status: z.string().optional(), + duration: z.string().optional(), + components: z.array(z.string()).optional(), + }), + ), + subscribe: z.string().optional(), }); -// The docs describe status and webhook-management responses as JSON objects but -// do not publish their field shapes. Keep them object-validated without -// fabricating fields that CrowTerminal has not documented. -const DocumentedObjectResponseSchema = z.object({}).loose(); +export const GetStatusHistoryInputSchema = NoInputSchema; +export const GetStatusHistoryResponseSchema = z.looseObject({ + timestamp: z.string().optional(), + period: z.string().optional(), + dataPoints: z.array( + z.looseObject({ + date: z.string(), + uptime: z.number().optional(), + }), + ), + summary: z.looseObject({}).optional(), +}); + +export const GetUptimeInputSchema = NoInputSchema; +export const GetUptimeResponseSchema = z.looseObject({ + timestamp: z.string().optional(), + currentStatus: z.string().optional(), + uptime: z.looseObject({}), + recentIncidents: z.array(z.unknown()).optional(), +}); + +// ── Sandbox ───────────────────────────────────────────────────────────────── + +export const SandboxClientInputSchema = NoInputSchema; +export const SandboxClientResponseSchema = z.looseObject({ + success: ok, + clientId: z.string().optional(), + clientName: z.string().optional(), + version: z.number().optional(), + skill: SkillSchema.optional(), +}); + +export const SandboxMemoryInputSchema = NoInputSchema; +export const SandboxMemoryResponseSchema = SandboxClientResponseSchema; + +export const SandboxEngagementInputSchema = z.object({ + agentMd: z.record(z.string(), z.unknown()).optional(), +}); +export const SandboxEngagementResponseSchema = z.looseObject({ + success: ok, + versionsAnalyzed: z.number().optional(), + overallStats: z.looseObject({}).optional(), + fieldAnalysis: z.array(z.unknown()).optional(), +}); + +export const SandboxValidateInputSchema = z.object({ + proposedChanges: z.array(ProposedChangeSchema).min(1), +}); +export const SandboxValidateResponseSchema = z.looseObject({ + success: ok, + validation: z.string().optional(), + warnings: z.array(z.unknown()), + recommendations: z.array(z.unknown()), +}); + +// ── Agent registration ────────────────────────────────────────────────────── + +/** No auth; rate limited to 5 per hour per IP. */ +export const RegisterAgentInputSchema = z.object({ + agentName: z.string().min(1), + agentDescription: z.string().optional(), +}); +export const RegisterAgentResponseSchema = z.looseObject({ + success: ok, + message: z.string().optional(), + /** Returned once at creation and never again. */ + apiKey: z.string().optional(), + agentId: z.string().optional(), +}); + +// ── Webhooks ──────────────────────────────────────────────────────────────── + +export const CreateWebhookInputSchema = z.object({ + url: z.string().url(), + events: z.array(CrowterminalWebhookEventNameSchema).min(1), + secret: z.string().min(1).optional(), +}); +export const CreateWebhookResponseSchema = z.looseObject({ + success: ok, + id: z.string().optional(), + /** Generated when the caller did not supply one. */ + secret: z.string().optional(), +}); -const CreateWebhookResponseSchema = z - .object({ - id: z.string(), - secret: z.string(), - }) - .loose(); +export const ListWebhooksInputSchema = NoInputSchema; +export const ListWebhooksResponseSchema = z.looseObject({ + success: ok, + webhooks: z.array(z.looseObject({ id: z.string().optional() })), +}); + +export const UpdateWebhookInputSchema = z.object({ + webhookId: IdSchema, + url: z.string().url().optional(), + events: z.array(CrowterminalWebhookEventNameSchema).min(1).optional(), + isActive: z.boolean().optional(), +}); +export const UpdateWebhookResponseSchema = z.looseObject({ success: ok }); + +export const DeleteWebhookInputSchema = z.object({ webhookId: IdSchema }); +export const DeleteWebhookResponseSchema = z.looseObject({ success: ok }); + +export const TestWebhookInputSchema = z.object({ + url: z.string().url(), + secret: z.string().min(1).optional(), +}); +export const TestWebhookResponseSchema = z.looseObject({ success: ok }); + +// ── Registry ──────────────────────────────────────────────────────────────── export const CrowterminalEndpointInputSchemas = { memoryGet: GetMemoryInputSchema, + memoryGetBulk: BulkMemoryInputSchema, + memoryGetChangelog: GetChangelogInputSchema, + memoryGetPattern: GetPatternInputSchema, memoryEngagementAnalysis: EngagementAnalysisInputSchema, + memoryCompareMd: CompareMdInputSchema, + memoryValidateChanges: ValidateChangesInputSchema, dataIngest: IngestDataInputSchema, + dataIngestBulk: BulkIngestInputSchema, + dataGetTypes: GetDataTypesInputSchema, + intelGetPlatform: GetPlatformIntelInputSchema, + intelGetByokPlatform: GetByokPlatformIntelInputSchema, statusGet: GetStatusInputSchema, + statusPing: PingInputSchema, + statusGetComponents: GetComponentsInputSchema, + statusGetIncidents: GetIncidentsInputSchema, + statusGetHistory: GetStatusHistoryInputSchema, + statusGetUptime: GetUptimeInputSchema, + sandboxGetClient: SandboxClientInputSchema, + sandboxGetMemory: SandboxMemoryInputSchema, + sandboxEngagementAnalysis: SandboxEngagementInputSchema, + sandboxValidate: SandboxValidateInputSchema, + agentRegister: RegisterAgentInputSchema, webhooksCreate: CreateWebhookInputSchema, webhooksList: ListWebhooksInputSchema, webhooksUpdate: UpdateWebhookInputSchema, @@ -157,50 +412,50 @@ export const CrowterminalEndpointInputSchemas = { webhooksTest: TestWebhookInputSchema, } as const; -export type CrowterminalEndpointInputs = { - [K in keyof typeof CrowterminalEndpointInputSchemas]: z.infer< - (typeof CrowterminalEndpointInputSchemas)[K] - >; -}; - export const CrowterminalEndpointOutputSchemas = { memoryGet: GetMemoryResponseSchema, + memoryGetBulk: BulkMemoryResponseSchema, + memoryGetChangelog: GetChangelogResponseSchema, + memoryGetPattern: GetPatternResponseSchema, memoryEngagementAnalysis: EngagementAnalysisResponseSchema, + memoryCompareMd: CompareMdResponseSchema, + memoryValidateChanges: ValidateChangesResponseSchema, dataIngest: IngestDataResponseSchema, - statusGet: DocumentedObjectResponseSchema, + dataIngestBulk: BulkIngestResponseSchema, + dataGetTypes: GetDataTypesResponseSchema, + intelGetPlatform: GetPlatformIntelResponseSchema, + intelGetByokPlatform: GetByokPlatformIntelResponseSchema, + statusGet: GetStatusResponseSchema, + statusPing: PingResponseSchema, + statusGetComponents: GetComponentsResponseSchema, + statusGetIncidents: GetIncidentsResponseSchema, + statusGetHistory: GetStatusHistoryResponseSchema, + statusGetUptime: GetUptimeResponseSchema, + sandboxGetClient: SandboxClientResponseSchema, + sandboxGetMemory: SandboxMemoryResponseSchema, + sandboxEngagementAnalysis: SandboxEngagementResponseSchema, + sandboxValidate: SandboxValidateResponseSchema, + agentRegister: RegisterAgentResponseSchema, webhooksCreate: CreateWebhookResponseSchema, - webhooksList: DocumentedObjectResponseSchema, - webhooksUpdate: DocumentedObjectResponseSchema, - webhooksDelete: DocumentedObjectResponseSchema, - webhooksTest: DocumentedObjectResponseSchema, + webhooksList: ListWebhooksResponseSchema, + webhooksUpdate: UpdateWebhookResponseSchema, + webhooksDelete: DeleteWebhookResponseSchema, + webhooksTest: TestWebhookResponseSchema, } as const; +export type CrowterminalEndpointInputs = { + [K in keyof typeof CrowterminalEndpointInputSchemas]: z.infer< + (typeof CrowterminalEndpointInputSchemas)[K] + >; +}; + export type CrowterminalEndpointOutputs = { [K in keyof typeof CrowterminalEndpointOutputSchemas]: z.infer< (typeof CrowterminalEndpointOutputSchemas)[K] >; }; -export type GetMemoryInput = CrowterminalEndpointInputs['memoryGet']; -export type GetMemoryResponse = CrowterminalEndpointOutputs['memoryGet']; -export type EngagementAnalysisInput = - CrowterminalEndpointInputs['memoryEngagementAnalysis']; -export type EngagementAnalysisResponse = - CrowterminalEndpointOutputs['memoryEngagementAnalysis']; -export type IngestDataInput = CrowterminalEndpointInputs['dataIngest']; -export type IngestDataResponse = CrowterminalEndpointOutputs['dataIngest']; -export type GetStatusInput = CrowterminalEndpointInputs['statusGet']; -export type GetStatusResponse = CrowterminalEndpointOutputs['statusGet']; -export type CreateWebhookInput = CrowterminalEndpointInputs['webhooksCreate']; -export type CreateWebhookResponse = - CrowterminalEndpointOutputs['webhooksCreate']; -export type ListWebhooksInput = CrowterminalEndpointInputs['webhooksList']; -export type ListWebhooksResponse = CrowterminalEndpointOutputs['webhooksList']; -export type UpdateWebhookInput = CrowterminalEndpointInputs['webhooksUpdate']; -export type UpdateWebhookResponse = - CrowterminalEndpointOutputs['webhooksUpdate']; -export type DeleteWebhookInput = CrowterminalEndpointInputs['webhooksDelete']; -export type DeleteWebhookResponse = - CrowterminalEndpointOutputs['webhooksDelete']; -export type TestWebhookInput = CrowterminalEndpointInputs['webhooksTest']; -export type TestWebhookResponse = CrowterminalEndpointOutputs['webhooksTest']; +export type CrowterminalSkill = z.infer; +export type CrowterminalWebhookEventName = z.infer< + typeof CrowterminalWebhookEventNameSchema +>; diff --git a/packages/crowterminal/index.ts b/packages/crowterminal/index.ts index 591ce8cff..2e8bd11b1 100644 --- a/packages/crowterminal/index.ts +++ b/packages/crowterminal/index.ts @@ -15,7 +15,16 @@ import type { RequiredPluginEndpointSchemas, RequiredPluginWebhookSchemas, } from 'corsair/core'; -import { Data, Memory, Status, Webhooks } from './endpoints'; +import { AuthMissingError } from 'corsair/core'; +import { + Agent, + Data, + Intelligence, + Memory, + Sandbox, + Status, + Webhooks, +} from './endpoints'; import type { CrowterminalEndpointInputs, CrowterminalEndpointOutputs, @@ -69,9 +78,28 @@ type CrowterminalEndpoint = export type CrowterminalEndpoints = { memoryGet: CrowterminalEndpoint<'memoryGet'>; + memoryGetBulk: CrowterminalEndpoint<'memoryGetBulk'>; + memoryGetChangelog: CrowterminalEndpoint<'memoryGetChangelog'>; + memoryGetPattern: CrowterminalEndpoint<'memoryGetPattern'>; memoryEngagementAnalysis: CrowterminalEndpoint<'memoryEngagementAnalysis'>; + memoryCompareMd: CrowterminalEndpoint<'memoryCompareMd'>; + memoryValidateChanges: CrowterminalEndpoint<'memoryValidateChanges'>; dataIngest: CrowterminalEndpoint<'dataIngest'>; + dataIngestBulk: CrowterminalEndpoint<'dataIngestBulk'>; + dataGetTypes: CrowterminalEndpoint<'dataGetTypes'>; + intelGetPlatform: CrowterminalEndpoint<'intelGetPlatform'>; + intelGetByokPlatform: CrowterminalEndpoint<'intelGetByokPlatform'>; statusGet: CrowterminalEndpoint<'statusGet'>; + statusPing: CrowterminalEndpoint<'statusPing'>; + statusGetComponents: CrowterminalEndpoint<'statusGetComponents'>; + statusGetIncidents: CrowterminalEndpoint<'statusGetIncidents'>; + statusGetHistory: CrowterminalEndpoint<'statusGetHistory'>; + statusGetUptime: CrowterminalEndpoint<'statusGetUptime'>; + sandboxGetClient: CrowterminalEndpoint<'sandboxGetClient'>; + sandboxGetMemory: CrowterminalEndpoint<'sandboxGetMemory'>; + sandboxEngagementAnalysis: CrowterminalEndpoint<'sandboxEngagementAnalysis'>; + sandboxValidate: CrowterminalEndpoint<'sandboxValidate'>; + agentRegister: CrowterminalEndpoint<'agentRegister'>; webhooksCreate: CrowterminalEndpoint<'webhooksCreate'>; webhooksList: CrowterminalEndpoint<'webhooksList'>; webhooksUpdate: CrowterminalEndpoint<'webhooksUpdate'>; @@ -116,13 +144,38 @@ export type CrowterminalBoundWebhooks = BindWebhooks; const crowterminalEndpointsNested = { memory: { get: Memory.get, + getBulk: Memory.getBulk, + getChangelog: Memory.getChangelog, + getPattern: Memory.getPattern, engagementAnalysis: Memory.engagementAnalysis, + compareMd: Memory.compareMd, + validateChanges: Memory.validateChanges, }, data: { ingest: Data.ingest, + ingestBulk: Data.ingestBulk, + getTypes: Data.getTypes, + }, + intelligence: { + getPlatform: Intelligence.getPlatform, + getByokPlatform: Intelligence.getByokPlatform, }, status: { get: Status.get, + ping: Status.ping, + getComponents: Status.getComponents, + getIncidents: Status.getIncidents, + getHistory: Status.getHistory, + getUptime: Status.getUptime, + }, + sandbox: { + getClient: Sandbox.getClient, + getMemory: Sandbox.getMemory, + engagementAnalysis: Sandbox.engagementAnalysis, + validate: Sandbox.validate, + }, + agent: { + register: Agent.register, }, webhooks: { create: Webhooks.create, @@ -155,18 +208,94 @@ export const crowterminalEndpointSchemas = { input: CrowterminalEndpointInputSchemas.memoryGet, output: CrowterminalEndpointOutputSchemas.memoryGet, }, + 'memory.getBulk': { + input: CrowterminalEndpointInputSchemas.memoryGetBulk, + output: CrowterminalEndpointOutputSchemas.memoryGetBulk, + }, + 'memory.getChangelog': { + input: CrowterminalEndpointInputSchemas.memoryGetChangelog, + output: CrowterminalEndpointOutputSchemas.memoryGetChangelog, + }, + 'memory.getPattern': { + input: CrowterminalEndpointInputSchemas.memoryGetPattern, + output: CrowterminalEndpointOutputSchemas.memoryGetPattern, + }, 'memory.engagementAnalysis': { input: CrowterminalEndpointInputSchemas.memoryEngagementAnalysis, output: CrowterminalEndpointOutputSchemas.memoryEngagementAnalysis, }, + 'memory.compareMd': { + input: CrowterminalEndpointInputSchemas.memoryCompareMd, + output: CrowterminalEndpointOutputSchemas.memoryCompareMd, + }, + 'memory.validateChanges': { + input: CrowterminalEndpointInputSchemas.memoryValidateChanges, + output: CrowterminalEndpointOutputSchemas.memoryValidateChanges, + }, 'data.ingest': { input: CrowterminalEndpointInputSchemas.dataIngest, output: CrowterminalEndpointOutputSchemas.dataIngest, }, + 'data.ingestBulk': { + input: CrowterminalEndpointInputSchemas.dataIngestBulk, + output: CrowterminalEndpointOutputSchemas.dataIngestBulk, + }, + 'data.getTypes': { + input: CrowterminalEndpointInputSchemas.dataGetTypes, + output: CrowterminalEndpointOutputSchemas.dataGetTypes, + }, + 'intelligence.getPlatform': { + input: CrowterminalEndpointInputSchemas.intelGetPlatform, + output: CrowterminalEndpointOutputSchemas.intelGetPlatform, + }, + 'intelligence.getByokPlatform': { + input: CrowterminalEndpointInputSchemas.intelGetByokPlatform, + output: CrowterminalEndpointOutputSchemas.intelGetByokPlatform, + }, 'status.get': { input: CrowterminalEndpointInputSchemas.statusGet, output: CrowterminalEndpointOutputSchemas.statusGet, }, + 'status.ping': { + input: CrowterminalEndpointInputSchemas.statusPing, + output: CrowterminalEndpointOutputSchemas.statusPing, + }, + 'status.getComponents': { + input: CrowterminalEndpointInputSchemas.statusGetComponents, + output: CrowterminalEndpointOutputSchemas.statusGetComponents, + }, + 'status.getIncidents': { + input: CrowterminalEndpointInputSchemas.statusGetIncidents, + output: CrowterminalEndpointOutputSchemas.statusGetIncidents, + }, + 'status.getHistory': { + input: CrowterminalEndpointInputSchemas.statusGetHistory, + output: CrowterminalEndpointOutputSchemas.statusGetHistory, + }, + 'status.getUptime': { + input: CrowterminalEndpointInputSchemas.statusGetUptime, + output: CrowterminalEndpointOutputSchemas.statusGetUptime, + }, + 'sandbox.getClient': { + input: CrowterminalEndpointInputSchemas.sandboxGetClient, + output: CrowterminalEndpointOutputSchemas.sandboxGetClient, + }, + 'sandbox.getMemory': { + input: CrowterminalEndpointInputSchemas.sandboxGetMemory, + output: CrowterminalEndpointOutputSchemas.sandboxGetMemory, + }, + 'sandbox.engagementAnalysis': { + input: CrowterminalEndpointInputSchemas.sandboxEngagementAnalysis, + output: CrowterminalEndpointOutputSchemas.sandboxEngagementAnalysis, + }, + 'sandbox.validate': { + input: CrowterminalEndpointInputSchemas.sandboxValidate, + output: CrowterminalEndpointOutputSchemas.sandboxValidate, + }, + 'agent.register': { + input: CrowterminalEndpointInputSchemas.agentRegister, + output: CrowterminalEndpointOutputSchemas.agentRegister, + }, 'webhooks.create': { input: CrowterminalEndpointInputSchemas.webhooksCreate, output: CrowterminalEndpointOutputSchemas.webhooksCreate, @@ -231,20 +360,96 @@ const defaultAuthType: AuthTypes = 'api_key' as const; const crowterminalEndpointMeta = { 'memory.get': { riskLevel: 'read', - description: 'Get the latest version of a client skill', + description: 'Get the stored skill for one client', + }, + 'memory.getBulk': { + riskLevel: 'read', + description: 'Read stored skills for up to 50 clients', + }, + 'memory.getChangelog': { + riskLevel: 'read', + description: 'Read the change history of a client skill', + }, + 'memory.getPattern': { + riskLevel: 'read', + description: 'Trend one skill field across stored versions', }, 'memory.engagementAnalysis': { riskLevel: 'read', - description: 'Analyze a client skill against historical engagement', + description: 'Correlate every agent field with historical engagement', + }, + 'memory.compareMd': { + riskLevel: 'read', + description: 'Diff an agent markdown against all stored versions', + }, + 'memory.validateChanges': { + riskLevel: 'read', + description: 'Check proposed edits against historical outcomes', }, 'data.ingest': { riskLevel: 'write', - description: 'Ingest a platform data point for a client', + description: 'Ingest one platform analytics data point', + }, + 'data.ingestBulk': { + riskLevel: 'write', + description: 'Ingest up to 50 analytics data points at once', + }, + 'data.getTypes': { + riskLevel: 'read', + description: 'List the analytics data types each platform accepts', + }, + 'intelligence.getPlatform': { + riskLevel: 'read', + description: 'Get TikTok, Instagram and YouTube algorithm insights', + }, + 'intelligence.getByokPlatform': { + riskLevel: 'read', + description: 'Get raw algorithm context without LLM inference charges', }, 'status.get': { riskLevel: 'read', description: 'Get CrowTerminal service health', }, + 'status.ping': { + riskLevel: 'read', + description: 'Check that CrowTerminal is responding', + }, + 'status.getComponents': { + riskLevel: 'read', + description: 'Get per-component health and latency', + }, + 'status.getIncidents': { + riskLevel: 'read', + description: 'List recent incidents and affected components', + }, + 'status.getHistory': { + riskLevel: 'read', + description: 'Get seven days of uptime points for charting', + }, + 'status.getUptime': { + riskLevel: 'read', + description: 'Get 24h and 7d uptime percentages', + }, + 'sandbox.getClient': { + riskLevel: 'read', + description: 'Get mock client data for testing', + }, + 'sandbox.getMemory': { + riskLevel: 'read', + description: 'Get mock skill data for testing', + }, + 'sandbox.engagementAnalysis': { + riskLevel: 'read', + description: 'Run a mock engagement analysis', + }, + 'sandbox.validate': { + riskLevel: 'read', + description: 'Run a mock validation', + }, + 'agent.register': { + riskLevel: 'write', + description: 'Self-register an agent and receive a new API key', + }, 'webhooks.create': { riskLevel: 'write', description: 'Register a CrowTerminal webhook', @@ -322,44 +527,28 @@ export function crowterminal( if (source === 'webhook') { const res = await ctx.keys.get_webhook_signature(); - return res ?? ''; + if (!res) { + throw new AuthMissingError('crowterminal', 'webhook_signature'); + } + return res; } if (source === 'endpoint' && options.key) { return options.key; } - if (source === 'endpoint') { - const res = await ctx.keys.get_api_key(); - return res ?? ''; - } - - return ''; + const res = await ctx.keys.get_api_key(); + if (!res) throw new AuthMissingError('crowterminal', 'api_key'); + return res; }, } satisfies InternalCrowterminalPlugin; } export type { - CreateWebhookInput, - CreateWebhookResponse, CrowterminalEndpointInputs, CrowterminalEndpointOutputs, - DeleteWebhookInput, - DeleteWebhookResponse, - EngagementAnalysisInput, - EngagementAnalysisResponse, - GetMemoryInput, - GetMemoryResponse, - GetStatusInput, - GetStatusResponse, - IngestDataInput, - IngestDataResponse, - ListWebhooksInput, - ListWebhooksResponse, - TestWebhookInput, - TestWebhookResponse, - UpdateWebhookInput, - UpdateWebhookResponse, + CrowterminalSkill, + CrowterminalWebhookEventName, } from './endpoints/types'; export type { CrowterminalWebhookOutputs, diff --git a/packages/crowterminal/jest.config.cjs b/packages/crowterminal/jest.config.cjs index 8c6218f64..44557be22 100644 --- a/packages/crowterminal/jest.config.cjs +++ b/packages/crowterminal/jest.config.cjs @@ -16,6 +16,9 @@ module.exports = { '!jest.config.ts', '!tests/**', ], + // api.test.ts hits the real CrowTerminal API and needs a key; `pnpm + // test:live` runs it explicitly. CI ignores the same filename. + testPathIgnorePatterns: ['/node_modules/', '/dist/', 'api\\.test\\.ts'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], transform: { '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', diff --git a/packages/crowterminal/package.json b/packages/crowterminal/package.json index 52414a3cc..407368e55 100644 --- a/packages/crowterminal/package.json +++ b/packages/crowterminal/package.json @@ -1,7 +1,7 @@ { "name": "@corsair-dev/crowterminal", "version": "0.1.0", - "description": "Crowterminal plugin for Corsair", + "description": "CrowTerminal plugin for Corsair", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", @@ -16,7 +16,8 @@ "scripts": { "build": "rm -rf dist && tsc --build --force && tsup", "typecheck": "tsc --noEmit", - "test": "jest" + "test": "jest", + "test:live": "jest --testPathIgnorePatterns=/node_modules/ --testPathPattern=api\\.test\\.ts" }, "peerDependencies": { "corsair": ">=0.1.0", diff --git a/packages/crowterminal/schema.test.ts b/packages/crowterminal/schema.test.ts deleted file mode 100644 index 174fdba19..000000000 --- a/packages/crowterminal/schema.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CrowterminalSchema } from './schema'; - -describe('Crowterminal schema', () => { - it('declares a semver version', () => { - expect(CrowterminalSchema.version).toBeDefined(); - expect(CrowterminalSchema.version).toMatch(/^\d+\.\d+\.\d+$/); - }); - - it('declares an entities map', () => { - expect(typeof CrowterminalSchema.entities).toBe('object'); - expect(CrowterminalSchema.entities).not.toBeNull(); - expect(Array.isArray(Object.keys(CrowterminalSchema.entities))).toBe(true); - for (const entity of Object.values(CrowterminalSchema.entities)) { - expect(entity).toBeDefined(); - } - }); -}); - -// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint -// needs a corresponding test. diff --git a/packages/crowterminal/schema/database.ts b/packages/crowterminal/schema/database.ts index cb0ff5c3b..3f1fafb33 100644 --- a/packages/crowterminal/schema/database.ts +++ b/packages/crowterminal/schema/database.ts @@ -1 +1,64 @@ -export {}; +import { z } from 'zod'; + +// Entities mirror the shapes CrowTerminal returns, using its own field names +// from https://crowterminal.com/llms.txt and from live API responses. +// +// CrowTerminal is itself the persistence layer: it stores versioned skill +// history so an agent does not have to. These entities exist so a host can +// cache what it has read and reconcile webhook updates against it, not to +// become a second source of truth. + +/** A creator's learned profile at one version. */ +export const CrowterminalSkill = z.object({ + /** CrowTerminal client id. */ + id: z.string(), + clientName: z.string().nullable().optional(), + /** Increments on every stored revision. */ + version: z.number().nullable().optional(), + primaryNiche: z.string().nullable().optional(), + subNiches: z.array(z.string()).nullable().optional(), + contentStyle: z.string().nullable().optional(), + signatureStyle: z.string().nullable().optional(), + hookPatterns: z.array(z.string()).nullable().optional(), + /** Percent, as returned. */ + avgEngagement: z.number().nullable().optional(), + bestPostingTimes: z.array(z.unknown()).nullable().optional(), + updatedAt: z.coerce.date().nullable().optional(), +}); +export type CrowterminalSkill = z.infer; + +/** One analytics point pushed to CrowTerminal for a creator. */ +export const CrowterminalDataPoint = z.object({ + /** clientId:platform:dataType:videoId, since the API returns no point id. */ + id: z.string(), + clientId: z.string(), + platform: z.enum(['TIKTOK', 'INSTAGRAM', 'YOUTUBE']), + dataType: z.string(), + /** Absent for channel-level rather than video-level data. */ + videoId: z.string().nullable().optional(), + /** 0-1 as accepted by the ingest endpoint. */ + confidence: z.number().nullable().optional(), + ingestedAt: z.coerce.date().nullable().optional(), +}); +export type CrowterminalDataPoint = z.infer; + +/** A registered webhook subscription. The signing secret is never stored. */ +export const CrowterminalWebhook = z.object({ + id: z.string(), + url: z.string().nullable().optional(), + events: z.array(z.string()).nullable().optional(), + isActive: z.boolean().nullable().optional(), + createdAt: z.coerce.date().nullable().optional(), +}); +export type CrowterminalWebhook = z.infer; + +/** A service incident from the status endpoints. */ +export const CrowterminalIncident = z.object({ + /** Incidents carry no id; the start timestamp identifies them. */ + id: z.string(), + status: z.string().nullable().optional(), + duration: z.string().nullable().optional(), + components: z.array(z.string()).nullable().optional(), + startedAt: z.coerce.date().nullable().optional(), +}); +export type CrowterminalIncident = z.infer; diff --git a/packages/crowterminal/schema/index.ts b/packages/crowterminal/schema/index.ts index bc4cd7076..e8781fd79 100644 --- a/packages/crowterminal/schema/index.ts +++ b/packages/crowterminal/schema/index.ts @@ -1,4 +1,18 @@ +import { + CrowterminalDataPoint, + CrowterminalIncident, + CrowterminalSkill, + CrowterminalWebhook, +} from './database'; + export const CrowterminalSchema = { version: '1.0.0', - entities: {}, + entities: { + skills: CrowterminalSkill, + dataPoints: CrowterminalDataPoint, + webhooks: CrowterminalWebhook, + incidents: CrowterminalIncident, + }, } as const; + +export * from './database'; From 21e849186aea9fd942a0217903805e50e9e4e9a4 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 24 Aug 2026 02:09:59 +0530 Subject: [PATCH 4/7] fix(crowterminal): reject dot-segment ids and derive entity ids --- .../crowterminal/endpoints/endpoints.test.ts | 84 +++++++++++++++++++ packages/crowterminal/endpoints/shared.ts | 37 ++++---- packages/crowterminal/endpoints/types.ts | 18 ++-- packages/crowterminal/schema/database.test.ts | 72 ++++++++++++++++ packages/crowterminal/schema/database.ts | 75 ++++++++++++----- 5 files changed, 241 insertions(+), 45 deletions(-) create mode 100644 packages/crowterminal/schema/database.test.ts diff --git a/packages/crowterminal/endpoints/endpoints.test.ts b/packages/crowterminal/endpoints/endpoints.test.ts index 08a9f6aa0..8b99bdb57 100644 --- a/packages/crowterminal/endpoints/endpoints.test.ts +++ b/packages/crowterminal/endpoints/endpoints.test.ts @@ -69,6 +69,11 @@ describe('request routing', () => { ['intelGetByok', '/api/agent/byok/platform-intel', getByokPlatform], ['sandboxGetClient', '/api/agent/sandbox/client', sandboxClient], ['sandboxGetMemory', '/api/agent/sandbox/memory', sandboxMemory], + [ + 'sandboxEngagement', + '/api/agent/sandbox/engagement-analysis', + sandboxEngagement, + ], ['webhooksList', '/api/agent/webhooks', listWebhooks], ])('routes %s to %s', async (_name, path, endpoint) => { requestSpy.mockResolvedValue({ @@ -171,6 +176,24 @@ describe('path segment encoding', () => { expect(requestSpy).not.toHaveBeenCalled(); }); + // encodeURIComponent leaves dots alone, so `..` survives escaping and URL + // normalisation collapses the path onto a different route. + it('rejects bare dot segments that encoding cannot neutralise', async () => { + await expect(getMemory(ctx(), { clientId: '..' })).rejects.toThrow(); + await expect(getChangelog(ctx(), { clientId: '.' })).rejects.toThrow(); + await expect(deleteWebhook(ctx(), { webhookId: '..' })).rejects.toThrow(); + + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('still accepts ids that merely contain dots', async () => { + requestSpy.mockResolvedValue({ success: true, changelog: [] }); + + await getChangelog(ctx(), { clientId: 'a..b' }); + + expect(lastCall()?.[0]).toBe('/api/agent/memory/a..b/changelog'); + }); + it('percent-encodes characters that are legal but reserved', async () => { requestSpy.mockResolvedValue({ success: true, changelog: [] }); @@ -383,3 +406,64 @@ describe('event logging', () => { }); }); }); + +describe('sandbox engagement analysis', () => { + it('omits agentMd from the body when the caller sends none', async () => { + requestSpy.mockResolvedValue({ success: true }); + + await sandboxEngagement(ctx(), {}); + + expect(lastCall()?.[0]).toBe('/api/agent/sandbox/engagement-analysis'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual({}); + }); + + it('sends agentMd when the caller supplies it', async () => { + requestSpy.mockResolvedValue({ success: true, versionsAnalyzed: 47 }); + + const result = await sandboxEngagement(ctx(), { + agentMd: { hookPatterns: ['confession'] }, + }); + + expect(lastCall()?.[2]?.body).toEqual({ + agentMd: { hookPatterns: ['confession'] }, + }); + expect(result.versionsAnalyzed).toBe(47); + }); +}); + +describe('failure logging', () => { + it('records a failed event when the request throws', async () => { + requestSpy.mockRejectedValue(new Error('502 upstream')); + + await expect(getStatus(ctx(), {})).rejects.toThrow('502 upstream'); + + const [, event, , status] = logEventFromContext.mock.calls[0]; + expect(event).toBe('crowterminal.status.get'); + expect(status).toBe('failed'); + }); + + it('records a failed event when the response fails its schema', async () => { + requestSpy.mockResolvedValue({ currentStatus: 'operational' }); + + await expect(getStatus(ctx(), {})).rejects.toThrow(); + + expect(logEventFromContext.mock.calls[0][3]).toBe('failed'); + }); + + it('redacts the same fields on a failed call', async () => { + requestSpy.mockRejectedValue(new Error('boom')); + + await expect( + createWebhook(ctx(), { + url: 'https://example.com/hook', + events: ['skill.updated'], + secret: 'leaky-secret', + }), + ).rejects.toThrow(); + + const [, , payload, status] = logEventFromContext.mock.calls[0]; + expect(status).toBe('failed'); + expect(JSON.stringify(payload)).not.toContain('leaky-secret'); + }); +}); diff --git a/packages/crowterminal/endpoints/shared.ts b/packages/crowterminal/endpoints/shared.ts index 6b72c3b9b..1ba0b16ac 100644 --- a/packages/crowterminal/endpoints/shared.ts +++ b/packages/crowterminal/endpoints/shared.ts @@ -72,25 +72,28 @@ export async function callCrowterminal< input: z.input, ): Promise> { const parsedInput = inputSchema.parse(input); + const described = describeInput(parsedInput); - const raw = await makeCrowterminalRequest( - path(parsedInput), - ctx.key, - { - method, - body: body?.(parsedInput), - query: query?.(parsedInput), - }, - ); - - const parsed = outputSchema.parse(raw); + let parsed: z.infer; + try { + const raw = await makeCrowterminalRequest( + path(parsedInput), + ctx.key, + { + method, + body: body?.(parsedInput), + query: query?.(parsedInput), + }, + ); + parsed = outputSchema.parse(raw); + } catch (error) { + // Record the attempt before rethrowing, so a failed call leaves a trace + // rather than a silent gap in the event log. + await logEventFromContext(ctx, event, described, 'failed'); + throw error; + } - await logEventFromContext( - ctx, - event, - describeInput(parsedInput), - 'completed', - ); + await logEventFromContext(ctx, event, described, 'completed'); return parsed; } diff --git a/packages/crowterminal/endpoints/types.ts b/packages/crowterminal/endpoints/types.ts index 6573bc9a4..8f85401ac 100644 --- a/packages/crowterminal/endpoints/types.ts +++ b/packages/crowterminal/endpoints/types.ts @@ -6,15 +6,21 @@ import { z } from 'zod'; // what a plan is entitled to. /** - * Ids are spliced into request paths. `/`, `?` and `#` would retarget the - * request at a different endpoint, so they are rejected rather than escaped - * only at the call site. + * Ids are spliced into request paths, so anything that could retarget the + * request is rejected here rather than only escaped at the call site. + * + * `.` and `..` need their own check: encodeURIComponent leaves dots alone, so + * a bare `..` survives encoding and URL normalisation then collapses + * `/api/agent/memory/../changelog` down to `/api/agent/changelog`. */ const IdSchema = z .string() .min(1) .refine((v) => !/[/?#]/.test(v), { message: 'must not contain /, ? or #', + }) + .refine((v) => v !== '.' && v !== '..', { + message: 'must not be a . or .. path segment', }); export const CrowterminalPlatformSchema = z.enum([ @@ -345,7 +351,7 @@ export const RegisterAgentResponseSchema = z.looseObject({ // ── Webhooks ──────────────────────────────────────────────────────────────── export const CreateWebhookInputSchema = z.object({ - url: z.string().url(), + url: z.url(), events: z.array(CrowterminalWebhookEventNameSchema).min(1), secret: z.string().min(1).optional(), }); @@ -364,7 +370,7 @@ export const ListWebhooksResponseSchema = z.looseObject({ export const UpdateWebhookInputSchema = z.object({ webhookId: IdSchema, - url: z.string().url().optional(), + url: z.url().optional(), events: z.array(CrowterminalWebhookEventNameSchema).min(1).optional(), isActive: z.boolean().optional(), }); @@ -374,7 +380,7 @@ export const DeleteWebhookInputSchema = z.object({ webhookId: IdSchema }); export const DeleteWebhookResponseSchema = z.looseObject({ success: ok }); export const TestWebhookInputSchema = z.object({ - url: z.string().url(), + url: z.url(), secret: z.string().min(1).optional(), }); export const TestWebhookResponseSchema = z.looseObject({ success: ok }); diff --git a/packages/crowterminal/schema/database.test.ts b/packages/crowterminal/schema/database.test.ts new file mode 100644 index 000000000..a0d3a4944 --- /dev/null +++ b/packages/crowterminal/schema/database.test.ts @@ -0,0 +1,72 @@ +import { CrowterminalDataPoint, CrowterminalIncident } from './database'; + +// The ingest and status endpoints return no per-record id, so the entities +// derive a deterministic one from the fields that identify the record. + +describe('data point ids', () => { + it('derives an id from the identity fields', () => { + const row = CrowterminalDataPoint.parse({ + clientId: 'c1', + platform: 'TIKTOK', + dataType: 'retention', + videoId: 'v9', + }); + + expect(row.id).toBe('c1:TIKTOK:retention:v9'); + }); + + it('derives a stable id for channel-level data with no video', () => { + const row = CrowterminalDataPoint.parse({ + clientId: 'c1', + platform: 'YOUTUBE', + dataType: 'follower_growth', + }); + + expect(row.id).toBe('c1:YOUTUBE:follower_growth:'); + }); + + it('gives the same id for the same point twice', () => { + const input = { + clientId: 'c1', + platform: 'TIKTOK' as const, + dataType: 'retention', + }; + + expect(CrowterminalDataPoint.parse(input).id).toBe( + CrowterminalDataPoint.parse({ ...input }).id, + ); + }); + + it('keeps an id the provider did supply', () => { + expect( + CrowterminalDataPoint.parse({ + id: 'provider-id', + clientId: 'c1', + platform: 'TIKTOK', + dataType: 'retention', + }).id, + ).toBe('provider-id'); + }); + + it('still rejects a record missing its identity fields', () => { + expect(() => CrowterminalDataPoint.parse({ clientId: 'c1' })).toThrow(); + }); +}); + +describe('incident ids', () => { + // The status endpoints name the start time `timestamp`. + it('derives an id from the timestamp the API returns', () => { + const row = CrowterminalIncident.parse({ + timestamp: '2026-08-22T15:00:51.198Z', + status: 'degraded (ongoing)', + components: ['redis'], + }); + + expect(row.id).toBe('2026-08-22T15:00:51.198Z'); + expect(row.startedAt).toEqual(new Date('2026-08-22T15:00:51.198Z')); + }); + + it('rejects an incident with nothing to identify it', () => { + expect(() => CrowterminalIncident.parse({ status: 'degraded' })).toThrow(); + }); +}); diff --git a/packages/crowterminal/schema/database.ts b/packages/crowterminal/schema/database.ts index 3f1fafb33..d814fcc71 100644 --- a/packages/crowterminal/schema/database.ts +++ b/packages/crowterminal/schema/database.ts @@ -27,19 +27,36 @@ export const CrowterminalSkill = z.object({ }); export type CrowterminalSkill = z.infer; -/** One analytics point pushed to CrowTerminal for a creator. */ -export const CrowterminalDataPoint = z.object({ - /** clientId:platform:dataType:videoId, since the API returns no point id. */ - id: z.string(), - clientId: z.string(), - platform: z.enum(['TIKTOK', 'INSTAGRAM', 'YOUTUBE']), - dataType: z.string(), - /** Absent for channel-level rather than video-level data. */ - videoId: z.string().nullable().optional(), - /** 0-1 as accepted by the ingest endpoint. */ - confidence: z.number().nullable().optional(), - ingestedAt: z.coerce.date().nullable().optional(), -}); +/** + * One analytics point pushed to CrowTerminal for a creator. + * + * The ingest endpoints return no per-point id, so one is derived from the + * fields that identify the point. Deriving it here rather than at the call site + * means a raw provider record validates as-is and the same point always lands + * on the same row. + */ +export const CrowterminalDataPoint = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const row = value as Record; + if (typeof row.id === 'string' && row.id.length > 0) return row; + const parts = [row.clientId, row.platform, row.dataType, row.videoId ?? '']; + if (parts.slice(0, 3).some((p) => typeof p !== 'string')) return row; + return { ...row, id: parts.join(':') }; + }, + z.object({ + /** clientId:platform:dataType:videoId when the provider supplies none. */ + id: z.string(), + clientId: z.string(), + platform: z.enum(['TIKTOK', 'INSTAGRAM', 'YOUTUBE']), + dataType: z.string(), + /** Absent for channel-level rather than video-level data. */ + videoId: z.string().nullable().optional(), + /** 0-1 as accepted by the ingest endpoint. */ + confidence: z.number().nullable().optional(), + ingestedAt: z.coerce.date().nullable().optional(), + }), +); export type CrowterminalDataPoint = z.infer; /** A registered webhook subscription. The signing secret is never stored. */ @@ -52,13 +69,27 @@ export const CrowterminalWebhook = z.object({ }); export type CrowterminalWebhook = z.infer; -/** A service incident from the status endpoints. */ -export const CrowterminalIncident = z.object({ - /** Incidents carry no id; the start timestamp identifies them. */ - id: z.string(), - status: z.string().nullable().optional(), - duration: z.string().nullable().optional(), - components: z.array(z.string()).nullable().optional(), - startedAt: z.coerce.date().nullable().optional(), -}); +/** + * A service incident from the status endpoints. Incidents carry no id, and the + * status endpoints name the start time `timestamp`, so both are accepted and + * the id is derived from whichever is present. + */ +export const CrowterminalIncident = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const row = value as Record; + if (typeof row.id === 'string' && row.id.length > 0) return row; + const startedAt = row.startedAt ?? row.timestamp; + if (typeof startedAt !== 'string') return row; + return { ...row, id: startedAt, startedAt }; + }, + z.object({ + /** The incident start time, which is what identifies it. */ + id: z.string(), + status: z.string().nullable().optional(), + duration: z.string().nullable().optional(), + components: z.array(z.string()).nullable().optional(), + startedAt: z.coerce.date().nullable().optional(), + }), +); export type CrowterminalIncident = z.infer; From 6fa7c172176b35f6830638e2ab9fe4889af82a55 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 24 Aug 2026 02:19:57 +0530 Subject: [PATCH 5/7] Update packages/crowterminal/schema/database.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/crowterminal/schema/database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/crowterminal/schema/database.ts b/packages/crowterminal/schema/database.ts index d814fcc71..4b5e1181b 100644 --- a/packages/crowterminal/schema/database.ts +++ b/packages/crowterminal/schema/database.ts @@ -42,7 +42,7 @@ export const CrowterminalDataPoint = z.preprocess( if (typeof row.id === 'string' && row.id.length > 0) return row; const parts = [row.clientId, row.platform, row.dataType, row.videoId ?? '']; if (parts.slice(0, 3).some((p) => typeof p !== 'string')) return row; - return { ...row, id: parts.join(':') }; + return { ...row, id: JSON.stringify(parts) }; }, z.object({ /** clientId:platform:dataType:videoId when the provider supplies none. */ From 583d9bb7bbe3ab37db13844f05edc06990395b38 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 24 Aug 2026 02:55:30 +0530 Subject: [PATCH 6/7] fix(crowterminal): keep incident start time and escape id parts --- packages/crowterminal/schema/database.test.ts | 33 +++++++++++++++++++ packages/crowterminal/schema/database.ts | 18 +++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/crowterminal/schema/database.test.ts b/packages/crowterminal/schema/database.test.ts index a0d3a4944..61bbeb0c1 100644 --- a/packages/crowterminal/schema/database.test.ts +++ b/packages/crowterminal/schema/database.test.ts @@ -25,6 +25,26 @@ describe('data point ids', () => { expect(row.id).toBe('c1:YOUTUBE:follower_growth:'); }); + // A raw join lets these two distinct points share one id, because the + // separator can fall inside dataType or videoId. platform is an enum, so + // the ambiguity has to come from the free-text fields. + it('does not collide when a field contains the separator', () => { + const a = CrowterminalDataPoint.parse({ + clientId: 'c1', + platform: 'TIKTOK', + dataType: 'a', + videoId: 'b:c', + }); + const b = CrowterminalDataPoint.parse({ + clientId: 'c1', + platform: 'TIKTOK', + dataType: 'a:b', + videoId: 'c', + }); + + expect(a.id).not.toBe(b.id); + }); + it('gives the same id for the same point twice', () => { const input = { clientId: 'c1', @@ -66,6 +86,19 @@ describe('incident ids', () => { expect(row.startedAt).toEqual(new Date('2026-08-22T15:00:51.198Z')); }); + // zod strips the unrecognised `timestamp`, so normalising has to happen even + // when the incident already carries an id. + it('keeps the start time when the incident also has an id', () => { + const row = CrowterminalIncident.parse({ + id: 'inc_1', + timestamp: '2026-08-22T15:00:51.198Z', + status: 'degraded', + }); + + expect(row.id).toBe('inc_1'); + expect(row.startedAt).toEqual(new Date('2026-08-22T15:00:51.198Z')); + }); + it('rejects an incident with nothing to identify it', () => { expect(() => CrowterminalIncident.parse({ status: 'degraded' })).toThrow(); }); diff --git a/packages/crowterminal/schema/database.ts b/packages/crowterminal/schema/database.ts index 4b5e1181b..6d5d136f6 100644 --- a/packages/crowterminal/schema/database.ts +++ b/packages/crowterminal/schema/database.ts @@ -42,7 +42,12 @@ export const CrowterminalDataPoint = z.preprocess( if (typeof row.id === 'string' && row.id.length > 0) return row; const parts = [row.clientId, row.platform, row.dataType, row.videoId ?? '']; if (parts.slice(0, 3).some((p) => typeof p !== 'string')) return row; - return { ...row, id: JSON.stringify(parts) }; + // Each part is escaped before joining: a raw join lets ('a:b', 'TIKTOK') + // and ('a', 'b:TIKTOK') produce the same id. + return { + ...row, + id: parts.map((p) => encodeURIComponent(String(p))).join(':'), + }; }, z.object({ /** clientId:platform:dataType:videoId when the provider supplies none. */ @@ -78,10 +83,15 @@ export const CrowterminalIncident = z.preprocess( (value) => { if (typeof value !== 'object' || value === null) return value; const row = value as Record; - if (typeof row.id === 'string' && row.id.length > 0) return row; + // Normalise timestamp -> startedAt first: zod strips the unrecognised + // `timestamp`, so doing this only when the id is missing would drop the + // start time from any incident that already carries an id. const startedAt = row.startedAt ?? row.timestamp; - if (typeof startedAt !== 'string') return row; - return { ...row, id: startedAt, startedAt }; + const normalised = + typeof startedAt === 'string' ? { ...row, startedAt } : row; + if (typeof row.id === 'string' && row.id.length > 0) return normalised; + if (typeof startedAt !== 'string') return normalised; + return { ...normalised, id: startedAt }; }, z.object({ /** The incident start time, which is what identifies it. */ From 0589f624b2c39a6289f0ba16a685b6aa1c963224 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 24 Aug 2026 03:30:33 +0530 Subject: [PATCH 7/7] test(crowterminal): assert endpoint request mappings and avoid live billable calls --- packages/crowterminal/api.test.ts | 19 +- .../crowterminal/endpoints/endpoints.test.ts | 197 ++++++++++++++---- 2 files changed, 158 insertions(+), 58 deletions(-) diff --git a/packages/crowterminal/api.test.ts b/packages/crowterminal/api.test.ts index 3127784f2..9e997ead3 100644 --- a/packages/crowterminal/api.test.ts +++ b/packages/crowterminal/api.test.ts @@ -1,5 +1,5 @@ import { getTypes } from './endpoints/data'; -import { getByokPlatform, getPlatform } from './endpoints/intelligence'; +import { getByokPlatform } from './endpoints/intelligence'; import { getBulk as getBulkMemory, getChangelog } from './endpoints/memory'; import { getClient as sandboxClient, @@ -68,20 +68,9 @@ describeLive('CrowTerminal API', () => { ); }, 30_000); - it.each([ - ['platform intel', getPlatform], - ['byok platform intel', getByokPlatform], - ])( - 'returns %s', - async (_name, endpoint) => { - await expect( - ( - endpoint as (c: CrowterminalContext, i: unknown) => Promise - )(ctx(), {}), - ).resolves.toBeDefined(); - }, - 30_000, - ); + it('returns platform intel without LLM inference charges', async () => { + await expect(getByokPlatform(ctx(), {})).resolves.toBeDefined(); + }, 30_000); }); describe('sandbox', () => { diff --git a/packages/crowterminal/endpoints/endpoints.test.ts b/packages/crowterminal/endpoints/endpoints.test.ts index 8b99bdb57..018127451 100644 --- a/packages/crowterminal/endpoints/endpoints.test.ts +++ b/packages/crowterminal/endpoints/endpoints.test.ts @@ -49,7 +49,22 @@ const requestSpy = jest.spyOn(client, 'makeCrowterminalRequest'); const ctx = () => ({ key: 'ct_test_key' }) as unknown as CrowterminalContext; beforeEach(() => { - requestSpy.mockReset().mockResolvedValue({ success: true }); + requestSpy.mockReset().mockResolvedValue({ + success: true, + status: 'ok', + pong: true, + webhooks: [], + components: [], + incidents: [], + dataPoints: [], + uptime: {}, + dataTypes: {}, + changelog: [], + clients: [], + warnings: [], + recommendations: [], + id: 'c1', + }); logEventFromContext.mockClear(); }); @@ -58,24 +73,26 @@ const lastCall = () => requestSpy.mock.calls[0]; describe('request routing', () => { // Paths below were confirmed against live api.crowterminal.com. it.each([ - ['statusGet', '/api/agent/status', getStatus], - ['statusPing', '/api/agent/status/ping', ping], - ['statusGetComponents', '/api/agent/status/components', getComponents], - ['statusGetIncidents', '/api/agent/status/incidents', getIncidents], - ['statusGetHistory', '/api/agent/status/history', getHistory], - ['statusGetUptime', '/api/agent/status/uptime', getUptime], - ['dataGetTypes', '/api/agent/data/types', getTypes], - ['intelGetPlatform', '/api/agent/platform-intel', getPlatform], - ['intelGetByok', '/api/agent/byok/platform-intel', getByokPlatform], - ['sandboxGetClient', '/api/agent/sandbox/client', sandboxClient], - ['sandboxGetMemory', '/api/agent/sandbox/memory', sandboxMemory], + ['statusGet', '/api/agent/status', getStatus, {}], + ['statusPing', '/api/agent/status/ping', ping, {}], + ['statusGetComponents', '/api/agent/status/components', getComponents, {}], + ['statusGetIncidents', '/api/agent/status/incidents', getIncidents, {}], + ['statusGetHistory', '/api/agent/status/history', getHistory, {}], + ['statusGetUptime', '/api/agent/status/uptime', getUptime, {}], + ['dataGetTypes', '/api/agent/data/types', getTypes, {}], + ['intelGetPlatform', '/api/agent/platform-intel', getPlatform, {}], + ['intelGetByok', '/api/agent/byok/platform-intel', getByokPlatform, {}], + ['sandboxGetClient', '/api/agent/sandbox/client', sandboxClient, {}], + ['sandboxGetMemory', '/api/agent/sandbox/memory', sandboxMemory, {}], + ['webhooksList', '/api/agent/webhooks', listWebhooks, {}], + ['memoryGet', '/api/agent/memory/c1', getMemory, { clientId: 'c1' }], [ - 'sandboxEngagement', - '/api/agent/sandbox/engagement-analysis', - sandboxEngagement, + 'memoryGetChangelog', + '/api/agent/memory/c1/changelog', + getChangelog, + { clientId: 'c1' }, ], - ['webhooksList', '/api/agent/webhooks', listWebhooks], - ])('routes %s to %s', async (_name, path, endpoint) => { + ])('routes %s to %s via GET', async (_name, path, endpoint, input) => { requestSpy.mockResolvedValue({ success: true, status: 'ok', @@ -86,61 +103,148 @@ describe('request routing', () => { dataPoints: [], uptime: {}, dataTypes: {}, + changelog: [], + id: 'c1', }); await ( endpoint as (c: CrowterminalContext, i: unknown) => Promise - )(ctx(), {}); + )(ctx(), input); expect(lastCall()?.[0]).toBe(path); expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('GET'); + expect(lastCall()?.[2]?.body).toBeUndefined(); }); - it('routes the client-scoped memory reads', async () => { - requestSpy.mockResolvedValue({ success: true, changelog: [] }); - await getChangelog(ctx(), { clientId: 'c1' }); - expect(lastCall()?.[0]).toBe('/api/agent/memory/c1/changelog'); - }); - - it('sends the pattern field as a query parameter', async () => { + it('sends the pattern field as a query parameter via GET', async () => { requestSpy.mockResolvedValue({ success: true, dataPoints: [] }); await getPattern(ctx(), { clientId: 'c1', field: 'primaryNiche' }); expect(lastCall()?.[0]).toBe('/api/agent/memory/c1/pattern'); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('GET'); expect(lastCall()?.[2]?.query).toEqual({ field: 'primaryNiche' }); + expect(lastCall()?.[2]?.body).toBeUndefined(); + }); + + it('routes memoryGetBulk via POST with clientIds body', async () => { + await getBulkMemory(ctx(), { clientIds: ['c1', 'c2'] }); + + expect(lastCall()?.[0]).toBe('/api/agent/memory/bulk'); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual({ clientIds: ['c1', 'c2'] }); + }); + + it('routes memoryEngagementAnalysis via POST with agentMd body', async () => { + await engagementAnalysis(ctx(), { + clientId: 'c1', + agentMd: { hookPatterns: ['confession'] }, + }); + + expect(lastCall()?.[0]).toBe('/api/agent/memory/c1/engagement-analysis'); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual({ + agentMd: { hookPatterns: ['confession'] }, + }); }); it('posts compare-md to the documented hyphenated path', async () => { await compareMd(ctx(), { clientId: 'c1', agentMd: { a: 1 } }); expect(lastCall()?.[0]).toBe('/api/agent/memory/c1/compare-md'); - expect(lastCall()?.[2]).toMatchObject({ - method: 'POST', - body: { agentMd: { a: 1 } }, - }); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual({ agentMd: { a: 1 } }); + }); + + it('routes memoryValidateChanges via POST with proposedChanges body', async () => { + const proposedChanges = [ + { field: 'hookPatterns', oldValue: 'story', newValue: 'tutorial' }, + ]; + await validateChanges(ctx(), { clientId: 'c1', proposedChanges }); + + expect(lastCall()?.[0]).toBe('/api/agent/memory/c1/validate'); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual({ proposedChanges }); + }); + + it('routes dataIngest via POST with full body', async () => { + const input = { + clientId: 'c1', + platform: 'TIKTOK' as const, + dataType: 'retention' as const, + videoId: 'v9', + data: { avgWatchTime: 12 }, + confidence: 0.9, + }; + await ingest(ctx(), input); + + expect(lastCall()?.[0]).toBe('/api/agent/data/ingest'); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual(input); }); it('names the bulk ingest array items, as the API requires', async () => { - await ingestBulk(ctx(), { - items: [ - { - clientId: 'c1', - platform: 'TIKTOK', - dataType: 'retention', - data: { avgWatchTime: 12 }, - }, - ], - }); + const items = [ + { + clientId: 'c1', + platform: 'TIKTOK' as const, + dataType: 'retention' as const, + data: { avgWatchTime: 12 }, + }, + ]; + await ingestBulk(ctx(), { items }); expect(lastCall()?.[0]).toBe('/api/agent/data/ingest/bulk'); - expect(Object.keys(lastCall()?.[2]?.body ?? {})).toEqual(['items']); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual({ items }); + }); + + it('routes sandboxValidate via POST with proposedChanges body', async () => { + const proposedChanges = [{ field: 'hookPatterns', newValue: 'tutorial' }]; + await sandboxValidate(ctx(), { proposedChanges }); + + expect(lastCall()?.[0]).toBe('/api/agent/sandbox/validate'); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual({ proposedChanges }); + }); + + it('registers an agent against the documented path', async () => { + await register(ctx(), { agentName: 'MyBot' }); + + expect(lastCall()?.[0]).toBe('/api/agent/register'); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual({ agentName: 'MyBot' }); + }); + + it('routes webhooksCreate via POST with create body', async () => { + const input = { + url: 'https://example.com/hook', + events: ['skill.updated'] as ['skill.updated'], + secret: 'sec_123', + }; + await createWebhook(ctx(), input); + + expect(lastCall()?.[0]).toBe('/api/agent/webhooks'); + expect(lastCall()?.[1]).toBe('ct_test_key'); + expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual(input); }); it('drops webhookId from the update body and puts it in the path', async () => { await updateWebhook(ctx(), { webhookId: 'wh_1', isActive: false }); expect(lastCall()?.[0]).toBe('/api/agent/webhooks/wh_1'); + expect(lastCall()?.[1]).toBe('ct_test_key'); expect(lastCall()?.[2]?.method).toBe('PATCH'); expect(lastCall()?.[2]?.body).toEqual({ isActive: false }); }); @@ -149,15 +253,22 @@ describe('request routing', () => { await deleteWebhook(ctx(), { webhookId: 'wh_1' }); expect(lastCall()?.[0]).toBe('/api/agent/webhooks/wh_1'); + expect(lastCall()?.[1]).toBe('ct_test_key'); expect(lastCall()?.[2]?.method).toBe('DELETE'); expect(lastCall()?.[2]?.body).toBeUndefined(); }); - it('registers an agent against the documented path', async () => { - await register(ctx(), { agentName: 'MyBot' }); + it('routes webhooksTest via POST with test payload body', async () => { + const input = { + url: 'https://example.com/hook', + secret: 'sec_123', + }; + await testWebhook(ctx(), input); - expect(lastCall()?.[0]).toBe('/api/agent/register'); + expect(lastCall()?.[0]).toBe('/api/agent/webhooks/test'); + expect(lastCall()?.[1]).toBe('ct_test_key'); expect(lastCall()?.[2]?.method).toBe('POST'); + expect(lastCall()?.[2]?.body).toEqual(input); }); });