diff --git a/packages/appdrag/client.ts b/packages/appdrag/client.ts new file mode 100644 index 000000000..9334ad4b2 --- /dev/null +++ b/packages/appdrag/client.ts @@ -0,0 +1,60 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { request } from 'corsair/http'; + +export class AppdragAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'AppdragAPIError'; + } +} + +// TODO: Update with your API base URL +const APPDRAG_API_BASE = 'https://api.example.com'; + +export async function makeAppdragRequest( + 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: APPDRAG_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + // TODO: Add authentication headers + // '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) { + if (error instanceof Error) { + throw new AppdragAPIError(error.message); + } + throw new AppdragAPIError('Unknown error'); + } +} diff --git a/packages/appdrag/endpoints/example.ts b/packages/appdrag/endpoints/example.ts new file mode 100644 index 000000000..2bdf7016f --- /dev/null +++ b/packages/appdrag/endpoints/example.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const dragUploadEndpoint = { + method: 'POST' as const, + path: '/appdrag/upload', + input: z.object({ + fileName: z.string(), + fileSize: z.number(), + fileType: z.string(), + draggedAt: z.number().optional(), + }), + handler: async ({ input }: { input: any }) => { + // This is called when user drags a file into Corsair + console.log('File dragged:', input.fileName); + + return { + success: true, + message: `File ${input.fileName} received via drag`, + received: input, + }; + }, +}; diff --git a/packages/appdrag/endpoints/index.ts b/packages/appdrag/endpoints/index.ts new file mode 100644 index 000000000..a18b2c161 --- /dev/null +++ b/packages/appdrag/endpoints/index.ts @@ -0,0 +1 @@ +export { dragUploadEndpoint } from './example.js'; diff --git a/packages/appdrag/endpoints/types.ts b/packages/appdrag/endpoints/types.ts new file mode 100644 index 000000000..717a90339 --- /dev/null +++ b/packages/appdrag/endpoints/types.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +const ExampleGetInputSchema = z.object({ + id: z.string(), +}); + +export type ExampleGetInput = z.infer; + +const ExampleGetResponseSchema = z.object({ + id: z.string(), +}); + +export type ExampleGetResponse = z.infer; + +export type AppdragEndpointInputs = { + exampleGet: ExampleGetInput; +}; + +export type AppdragEndpointOutputs = { + exampleGet: ExampleGetResponse; +}; + +export const AppdragEndpointInputSchemas = { + exampleGet: ExampleGetInputSchema, +} as const; + +export const AppdragEndpointOutputSchemas = { + exampleGet: ExampleGetResponseSchema, +} as const; diff --git a/packages/appdrag/error-handlers.ts b/packages/appdrag/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /dev/null +++ b/packages/appdrag/error-handlers.ts @@ -0,0 +1,31 @@ +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('429'); + }, + 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/appdrag/index.ts b/packages/appdrag/index.ts new file mode 100644 index 000000000..fd304a3bf --- /dev/null +++ b/packages/appdrag/index.ts @@ -0,0 +1,206 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { Example } from './endpoints'; +import type { + AppdragEndpointInputs, + AppdragEndpointOutputs, +} from './endpoints/types'; +import { + AppdragEndpointInputSchemas, + AppdragEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AppdragSchema } from './schema'; +import { ExampleWebhooks } from './webhooks'; +import { resolveAppdragOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; +import { matchAppdragTenantWebhook } from './webhooks/tenant-matcher'; +import type { AppdragWebhookOutputs, ExampleEvent } from './webhooks/types'; +import { ExampleEventSchema } from './webhooks/types'; + +export type AppdragPluginOptions = { + authType?: PickAuth<'api_key' | 'oauth_2'>; + key?: string; + webhookSecret?: string; + hooks?: InternalAppdragPlugin['hooks']; + webhookHooks?: InternalAppdragPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AppdragContext = CorsairPluginContext< + typeof AppdragSchema, + AppdragPluginOptions +>; + +export type AppdragKeyBuilderContext = KeyBuilderContext; + +export type AppdragBoundEndpoints = BindEndpoints< + typeof appdragEndpointsNested +>; + +type AppdragEndpoint = CorsairEndpoint< + AppdragContext, + AppdragEndpointInputs[K], + AppdragEndpointOutputs[K] +>; + +export type AppdragEndpoints = { + exampleGet: AppdragEndpoint<'exampleGet'>; +}; + +type AppdragWebhook< + K extends keyof AppdragWebhookOutputs, + TEvent, +> = CorsairWebhook; + +export type AppdragWebhooks = { + example: AppdragWebhook<'example', ExampleEvent>; +}; + +export type AppdragBoundWebhooks = BindWebhooks; + +const appdragEndpointsNested = { + example: { + get: Example.get, + }, +} as const; + +const appdragWebhooksNested = { + example: { + example: ExampleWebhooks.example, + }, +} as const; + +export const appdragEndpointSchemas = { + 'example.get': { + input: AppdragEndpointInputSchemas.exampleGet, + output: AppdragEndpointOutputSchemas.exampleGet, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof appdragEndpointsNested +>; + +const appdragWebhookSchemas = { + 'example.example': { + description: 'An example webhook event', + payload: ExampleEventSchema, + response: ExampleEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const appdragEndpointMeta = { + 'example.get': { + riskLevel: 'read', + description: 'Get an example resource by ID', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export const appdragAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, + oauth_2: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseAppdragPlugin = CorsairPlugin< + 'appdrag', + typeof AppdragSchema, + typeof appdragEndpointsNested, + typeof appdragWebhooksNested, + T, + typeof defaultAuthType +>; + +export type InternalAppdragPlugin = BaseAppdragPlugin; + +export type ExternalAppdragPlugin = + BaseAppdragPlugin; + +export function appdrag( + incomingOptions: AppdragPluginOptions & T = {} as AppdragPluginOptions & T, +): ExternalAppdragPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'appdrag', + authConfig: appdragAuthConfig, + schema: AppdragSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: appdragEndpointsNested, + webhooks: appdragWebhooksNested, + endpointMeta: appdragEndpointMeta, + endpointSchemas: appdragEndpointSchemas, + webhookSchemas: appdragWebhookSchemas, + pluginWebhookMatcher: (request) => { + const headers = request.headers; + // TODO: Update to match your webhook signature headers + return 'x-appdrag-signature' in headers; + }, + pluginTenantWebhookMatcher: matchAppdragTenantWebhook, + oauthWebhookTenantLinkResolver: resolveAppdragOAuthWebhookTenantLink, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AppdragKeyBuilderContext, 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' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + if (source === 'endpoint' && ctx.authType === 'oauth_2') { + const res = await ctx.keys.get_access_token(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalAppdragPlugin; +} + +export type { + AppdragEndpointInputs, + AppdragEndpointOutputs, + ExampleGetInput, + ExampleGetResponse, +} from './endpoints/types'; +export type { + AppdragWebhookOutputs, + ExampleEvent, +} from './webhooks/types'; diff --git a/packages/appdrag/jest.config.cjs b/packages/appdrag/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/appdrag/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/appdrag/package.json b/packages/appdrag/package.json new file mode 100644 index 000000000..9ba08d0e1 --- /dev/null +++ b/packages/appdrag/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/appdrag", + "version": "0.1.0", + "description": "Appdrag 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", + "appdrag", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/appdrag/schema.test.ts b/packages/appdrag/schema.test.ts new file mode 100644 index 000000000..fd04ae2a4 --- /dev/null +++ b/packages/appdrag/schema.test.ts @@ -0,0 +1,20 @@ +import { AppdragSchema } from './schema'; + +describe('Appdrag schema', () => { + it('declares a semver version', () => { + expect(AppdragSchema.version).toBeDefined(); + expect(AppdragSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof AppdragSchema.entities).toBe('object'); + expect(AppdragSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(AppdragSchema.entities))).toBe(true); + for (const entity of Object.values(AppdragSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/appdrag/schema/database.ts b/packages/appdrag/schema/database.ts new file mode 100644 index 000000000..9f8e2c7ff --- /dev/null +++ b/packages/appdrag/schema/database.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +// TODO: Define your database entities here +// export const AppdragExample = z.object({ +// id: z.string(), +// name: z.string(), +// created_at: z.coerce.date().nullable().optional(), +// }); +// export type AppdragExample = z.infer; diff --git a/packages/appdrag/schema/index.ts b/packages/appdrag/schema/index.ts new file mode 100644 index 000000000..ffeb0371c --- /dev/null +++ b/packages/appdrag/schema/index.ts @@ -0,0 +1,4 @@ +export const AppdragSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/appdrag/tsconfig.json b/packages/appdrag/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/appdrag/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/appdrag/tsup.config.ts b/packages/appdrag/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/appdrag/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/appdrag/webhooks/example.ts b/packages/appdrag/webhooks/example.ts new file mode 100644 index 000000000..84c95b25c --- /dev/null +++ b/packages/appdrag/webhooks/example.ts @@ -0,0 +1,32 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AppdragWebhooks } from '..'; +import { createAppdragMatch, verifyAppdragWebhookSignature } from './types'; + +export const example: AppdragWebhooks['example'] = { + match: createAppdragMatch('example'), + + handler: async (ctx, request) => { + const verification = verifyAppdragWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.type !== 'example') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'appdrag.webhook.example', + { ...event }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; diff --git a/packages/appdrag/webhooks/index.ts b/packages/appdrag/webhooks/index.ts new file mode 100644 index 000000000..a12134e8a --- /dev/null +++ b/packages/appdrag/webhooks/index.ts @@ -0,0 +1,9 @@ +import { example } from './example'; + +export const ExampleWebhooks = { + example: example, +}; + +export * from './oauth-tenant-link'; +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/appdrag/webhooks/oauth-tenant-link.ts b/packages/appdrag/webhooks/oauth-tenant-link.ts new file mode 100644 index 000000000..c866eda8f --- /dev/null +++ b/packages/appdrag/webhooks/oauth-tenant-link.ts @@ -0,0 +1,31 @@ +import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, toExternalId } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match pluginTenantWebhookMatcher. +// Called after OAuth to store the routing id on corsair_accounts.config. +export async function resolveAppdragOAuthWebhookTenantLink( + tokens: TokenResponse, +): Promise { + // TODO: Read from token response when the provider includes a stable id. + // const externalId = toExternalId(asRecord(tokens.team)?.id); + const externalId = toExternalId(tokens.tenant_external_id); + if (externalId) { + return { linkType: 'tenant_external_id', externalId }; + } + + const accessToken = tokens.access_token; + if (!accessToken) return null; + + // TODO: Fetch from provider API when the token response omits the id. + // const response = await fetch('https://api.example.com/me', { + // headers: { Authorization: `Bearer ${accessToken}` }, + // }); + // if (!response.ok) return null; + // const payload = (await response.json()) as { id?: string }; + // const fetchedId = toExternalId(payload.id); + // return fetchedId + // ? { linkType: 'tenant_external_id', externalId: fetchedId } + // : null; + + return null; +} diff --git a/packages/appdrag/webhooks/tenant-matcher.ts b/packages/appdrag/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..ddb92c163 --- /dev/null +++ b/packages/appdrag/webhooks/tenant-matcher.ts @@ -0,0 +1,25 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, firstString, readBodyRecord } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match the provider field +// (e.g. team_id, installation_id, organization_id). Must match authConfig.account +// and oauthWebhookTenantLinkResolver. +// Return null for URL verification / handshake payloads that have no tenant id. +export function matchAppdragTenantWebhook( + request: RawWebhookRequest, +): WebhookTenantMatch | null { + const body = readBodyRecord(request); + if (!body) return null; + + // TODO: Extract the stable external id from the webhook payload. + // Example: + // const externalId = firstString([body.tenant_external_id, asRecord(body.data)?.id]); + const externalId = firstString([ + body.tenant_external_id, + asRecord(body.data)?.tenant_external_id, + ]); + + if (!externalId) return null; + + return { linkType: 'tenant_external_id', externalId }; +} diff --git a/packages/appdrag/webhooks/types.ts b/packages/appdrag/webhooks/types.ts new file mode 100644 index 000000000..9311fb992 --- /dev/null +++ b/packages/appdrag/webhooks/types.ts @@ -0,0 +1,62 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { z } from 'zod'; + +export const AppdragWebhookPayloadSchema = z.object({ + type: z.string(), + created_at: z.string(), + data: z.record(z.string(), z.unknown()), +}); + +export type AppdragWebhookPayload = z.infer; + +export const ExampleEventSchema = AppdragWebhookPayloadSchema.extend({ + type: z.literal('example'), + data: z + .object({ + id: z.string(), + }) + .loose(), +}); + +export type ExampleEvent = z.infer; + +export type AppdragWebhookOutputs = { + example: ExampleEvent; +}; + +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 createAppdragMatch(eventType: string): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + return parsedBody !== null && parsedBody.type === eventType; + }; +} + +export function verifyAppdragWebhookSignature( + request: WebhookRequest, + secret: string, +): { valid: boolean; error?: string } { + // TODO: Implement webhook signature verification + return { valid: true }; +} diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 509d644f2..a7a16c399 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -28,10 +28,10 @@ export const BaseProviders = [ 'agenty', 'ahrefs', 'aimlapi', - 'allimagesai', 'airtable', 'alchemy', 'algolia', + 'allimagesai', 'alphavantage', 'altoviz', 'alttextai', @@ -50,6 +50,7 @@ export const BaseProviders = [ 'apininjas', 'apipie', 'apisports', + 'appdrag', 'asana', 'asindataapi', 'asticaai', @@ -131,6 +132,7 @@ export const BaseProviders = [ 'mailchimp', 'mailtrap', 'monday', + 'myfirstplugin', 'neon', 'nextdns', 'notion', @@ -205,10 +207,10 @@ export const ProviderDisplayNames = { agenty: 'Agenty', ahrefs: 'Ahrefs', aimlapi: 'AI/ML API', - allimagesai: 'All Images AI', airtable: 'Airtable', alchemy: 'Alchemy', algolia: 'Algolia', + allimagesai: 'All Images AI', alphavantage: 'Alpha Vantage', altoviz: 'Altoviz', alttextai: 'AltText.ai', @@ -227,6 +229,7 @@ export const ProviderDisplayNames = { apininjas: 'API Ninjas', apipie: 'APIpie AI', apisports: 'API-Sports', + appdrag: 'Appdrag', asana: 'Asana', asindataapi: 'ASIN Data API', asticaai: 'Astica AI', @@ -308,6 +311,7 @@ export const ProviderDisplayNames = { mailchimp: 'Mailchimp', mailtrap: 'Mailtrap', monday: 'Monday', + myfirstplugin: 'MyFirstPlugin', neon: 'Neon', nextdns: 'NextDNS', notion: 'Notion', @@ -389,10 +393,10 @@ export type AllProviders = | 'agenty' | 'ahrefs' | 'aimlapi' - | 'allimagesai' | 'airtable' | 'alchemy' | 'algolia' + | 'allimagesai' | 'alphavantage' | 'altoviz' | 'alttextai' @@ -411,6 +415,7 @@ export type AllProviders = | 'apininjas' | 'apipie' | 'apisports' + | 'appdrag' | 'asana' | 'asindataapi' | 'asticaai' @@ -492,6 +497,7 @@ export type AllProviders = | 'mailchimp' | 'mailtrap' | 'monday' + | 'myfirstplugin' | 'neon' | 'nextdns' | 'notion' diff --git a/packages/myfirstplugin/client.ts b/packages/myfirstplugin/client.ts new file mode 100644 index 000000000..040ccb284 --- /dev/null +++ b/packages/myfirstplugin/client.ts @@ -0,0 +1,60 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { request } from 'corsair/http'; + +export class MyFirstPluginAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'MyFirstPluginAPIError'; + } +} + +// TODO: Update with your API base URL +const MYFIRSTPLUGIN_API_BASE = 'https://api.example.com'; + +export async function makeMyFirstPluginRequest( + 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: MYFIRSTPLUGIN_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + // TODO: Add authentication headers + // '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) { + if (error instanceof Error) { + throw new MyFirstPluginAPIError(error.message); + } + throw new MyFirstPluginAPIError('Unknown error'); + } +} diff --git a/packages/myfirstplugin/endpoints/example.ts b/packages/myfirstplugin/endpoints/example.ts new file mode 100644 index 000000000..12b4232d3 --- /dev/null +++ b/packages/myfirstplugin/endpoints/example.ts @@ -0,0 +1,18 @@ +import { logEventFromContext } from 'corsair/core'; +import type { MyFirstPluginEndpoints } from '..'; +import { makeMyFirstPluginRequest } from '../client'; +import type { MyFirstPluginEndpointOutputs } from './types'; + +export const get: MyFirstPluginEndpoints['exampleGet'] = async (ctx, input) => { + const response = await makeMyFirstPluginRequest< + MyFirstPluginEndpointOutputs['exampleGet'] + >(`example/${input.id}`, ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'myfirstplugin.example.get', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/myfirstplugin/endpoints/index.ts b/packages/myfirstplugin/endpoints/index.ts new file mode 100644 index 000000000..7dc74ef41 --- /dev/null +++ b/packages/myfirstplugin/endpoints/index.ts @@ -0,0 +1,7 @@ +import { get as exampleGet } from './example'; + +export const Example = { + get: exampleGet, +}; + +export * from './types'; diff --git a/packages/myfirstplugin/endpoints/types.ts b/packages/myfirstplugin/endpoints/types.ts new file mode 100644 index 000000000..da85f9100 --- /dev/null +++ b/packages/myfirstplugin/endpoints/types.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +const ExampleGetInputSchema = z.object({ + id: z.string(), +}); + +export type ExampleGetInput = z.infer; + +const ExampleGetResponseSchema = z.object({ + id: z.string(), +}); + +export type ExampleGetResponse = z.infer; + +export type MyFirstPluginEndpointInputs = { + exampleGet: ExampleGetInput; +}; + +export type MyFirstPluginEndpointOutputs = { + exampleGet: ExampleGetResponse; +}; + +export const MyFirstPluginEndpointInputSchemas = { + exampleGet: ExampleGetInputSchema, +} as const; + +export const MyFirstPluginEndpointOutputSchemas = { + exampleGet: ExampleGetResponseSchema, +} as const; diff --git a/packages/myfirstplugin/error-handlers.ts b/packages/myfirstplugin/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /dev/null +++ b/packages/myfirstplugin/error-handlers.ts @@ -0,0 +1,31 @@ +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('429'); + }, + 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/myfirstplugin/index.ts b/packages/myfirstplugin/index.ts new file mode 100644 index 000000000..dfec2e50e --- /dev/null +++ b/packages/myfirstplugin/index.ts @@ -0,0 +1,222 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { Example } from './endpoints'; +import type { + MyFirstPluginEndpointInputs, + MyFirstPluginEndpointOutputs, +} from './endpoints/types'; +import { + MyFirstPluginEndpointInputSchemas, + MyFirstPluginEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { MyFirstPluginSchema } from './schema'; +import { ExampleWebhooks } from './webhooks'; +import { resolveMyFirstPluginOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; +import { matchMyFirstPluginTenantWebhook } from './webhooks/tenant-matcher'; +import type { + ExampleEvent, + MyFirstPluginWebhookOutputs, +} from './webhooks/types'; +import { ExampleEventSchema } from './webhooks/types'; + +export type MyFirstPluginPluginOptions = { + authType?: PickAuth<'api_key' | 'oauth_2'>; + key?: string; + webhookSecret?: string; + hooks?: InternalMyFirstPluginPlugin['hooks']; + webhookHooks?: InternalMyFirstPluginPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type MyFirstPluginContext = CorsairPluginContext< + typeof MyFirstPluginSchema, + MyFirstPluginPluginOptions +>; + +export type MyFirstPluginKeyBuilderContext = + KeyBuilderContext; + +export type MyFirstPluginBoundEndpoints = BindEndpoints< + typeof myFirstPluginEndpointsNested +>; + +type MyFirstPluginEndpoint = + CorsairEndpoint< + MyFirstPluginContext, + MyFirstPluginEndpointInputs[K], + MyFirstPluginEndpointOutputs[K] + >; + +export type MyFirstPluginEndpoints = { + exampleGet: MyFirstPluginEndpoint<'exampleGet'>; +}; + +type MyFirstPluginWebhook< + K extends keyof MyFirstPluginWebhookOutputs, + TEvent, +> = CorsairWebhook< + MyFirstPluginContext, + TEvent, + MyFirstPluginWebhookOutputs[K] +>; + +export type MyFirstPluginWebhooks = { + example: MyFirstPluginWebhook<'example', ExampleEvent>; +}; + +export type MyFirstPluginBoundWebhooks = BindWebhooks; + +const myFirstPluginEndpointsNested = { + example: { + get: Example.get, + }, +} as const; + +const myFirstPluginWebhooksNested = { + example: { + example: ExampleWebhooks.example, + }, +} as const; + +export const myFirstPluginEndpointSchemas = { + 'example.get': { + input: MyFirstPluginEndpointInputSchemas.exampleGet, + output: MyFirstPluginEndpointOutputSchemas.exampleGet, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof myFirstPluginEndpointsNested +>; + +const myFirstPluginWebhookSchemas = { + 'example.example': { + description: 'An example webhook event', + payload: ExampleEventSchema, + response: ExampleEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas< + typeof myFirstPluginWebhooksNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const myFirstPluginEndpointMeta = { + 'example.get': { + riskLevel: 'read', + description: 'Get an example resource by ID', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof myFirstPluginEndpointsNested +>; + +export const myFirstPluginAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, + oauth_2: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseMyFirstPluginPlugin = + CorsairPlugin< + 'myfirstplugin', + typeof MyFirstPluginSchema, + typeof myFirstPluginEndpointsNested, + typeof myFirstPluginWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalMyFirstPluginPlugin = + BaseMyFirstPluginPlugin; + +export type ExternalMyFirstPluginPlugin = + BaseMyFirstPluginPlugin; + +export function myfirstplugin( + incomingOptions: MyFirstPluginPluginOptions & + T = {} as MyFirstPluginPluginOptions & T, +): ExternalMyFirstPluginPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'myfirstplugin', + authConfig: myFirstPluginAuthConfig, + schema: MyFirstPluginSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: myFirstPluginEndpointsNested, + webhooks: myFirstPluginWebhooksNested, + endpointMeta: myFirstPluginEndpointMeta, + endpointSchemas: myFirstPluginEndpointSchemas, + webhookSchemas: myFirstPluginWebhookSchemas, + pluginWebhookMatcher: (request) => { + const headers = request.headers; + // TODO: Update to match your webhook signature headers + return 'x-myfirstplugin-signature' in headers; + }, + pluginTenantWebhookMatcher: matchMyFirstPluginTenantWebhook, + oauthWebhookTenantLinkResolver: resolveMyFirstPluginOAuthWebhookTenantLink, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: MyFirstPluginKeyBuilderContext, 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' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + if (source === 'endpoint' && ctx.authType === 'oauth_2') { + const res = await ctx.keys.get_access_token(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalMyFirstPluginPlugin; +} + +export type { + ExampleGetInput, + ExampleGetResponse, + MyFirstPluginEndpointInputs, + MyFirstPluginEndpointOutputs, +} from './endpoints/types'; +export type { + ExampleEvent, + MyFirstPluginWebhookOutputs, +} from './webhooks/types'; diff --git a/packages/myfirstplugin/jest.config.cjs b/packages/myfirstplugin/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/myfirstplugin/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/myfirstplugin/package.json b/packages/myfirstplugin/package.json new file mode 100644 index 000000000..880b79d0d --- /dev/null +++ b/packages/myfirstplugin/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/myfirstplugin", + "version": "0.1.0", + "description": "MyFirstPlugin 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", + "myfirstplugin", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/myfirstplugin/schema.test.ts b/packages/myfirstplugin/schema.test.ts new file mode 100644 index 000000000..5960a8090 --- /dev/null +++ b/packages/myfirstplugin/schema.test.ts @@ -0,0 +1,20 @@ +import { MyFirstPluginSchema } from './schema'; + +describe('MyFirstPlugin schema', () => { + it('declares a semver version', () => { + expect(MyFirstPluginSchema.version).toBeDefined(); + expect(MyFirstPluginSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof MyFirstPluginSchema.entities).toBe('object'); + expect(MyFirstPluginSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(MyFirstPluginSchema.entities))).toBe(true); + for (const entity of Object.values(MyFirstPluginSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/myfirstplugin/schema/database.ts b/packages/myfirstplugin/schema/database.ts new file mode 100644 index 000000000..28213585d --- /dev/null +++ b/packages/myfirstplugin/schema/database.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +// TODO: Define your database entities here +// export const MyFirstPluginExample = z.object({ +// id: z.string(), +// name: z.string(), +// created_at: z.coerce.date().nullable().optional(), +// }); +// export type MyFirstPluginExample = z.infer; diff --git a/packages/myfirstplugin/schema/index.ts b/packages/myfirstplugin/schema/index.ts new file mode 100644 index 000000000..05d768d65 --- /dev/null +++ b/packages/myfirstplugin/schema/index.ts @@ -0,0 +1,4 @@ +export const MyFirstPluginSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/myfirstplugin/tsconfig.json b/packages/myfirstplugin/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/myfirstplugin/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/myfirstplugin/tsup.config.ts b/packages/myfirstplugin/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/myfirstplugin/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/myfirstplugin/webhooks/example.ts b/packages/myfirstplugin/webhooks/example.ts new file mode 100644 index 000000000..ffef1825a --- /dev/null +++ b/packages/myfirstplugin/webhooks/example.ts @@ -0,0 +1,35 @@ +import { logEventFromContext } from 'corsair/core'; +import type { MyFirstPluginWebhooks } from '..'; +import { + createMyFirstPluginMatch, + verifyMyFirstPluginWebhookSignature, +} from './types'; + +export const example: MyFirstPluginWebhooks['example'] = { + match: createMyFirstPluginMatch('example'), + + handler: async (ctx, request) => { + const verification = verifyMyFirstPluginWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.type !== 'example') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'myfirstplugin.webhook.example', + { ...event }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; diff --git a/packages/myfirstplugin/webhooks/index.ts b/packages/myfirstplugin/webhooks/index.ts new file mode 100644 index 000000000..a12134e8a --- /dev/null +++ b/packages/myfirstplugin/webhooks/index.ts @@ -0,0 +1,9 @@ +import { example } from './example'; + +export const ExampleWebhooks = { + example: example, +}; + +export * from './oauth-tenant-link'; +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/myfirstplugin/webhooks/oauth-tenant-link.ts b/packages/myfirstplugin/webhooks/oauth-tenant-link.ts new file mode 100644 index 000000000..35580ae89 --- /dev/null +++ b/packages/myfirstplugin/webhooks/oauth-tenant-link.ts @@ -0,0 +1,31 @@ +import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, toExternalId } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match pluginTenantWebhookMatcher. +// Called after OAuth to store the routing id on corsair_accounts.config. +export async function resolveMyFirstPluginOAuthWebhookTenantLink( + tokens: TokenResponse, +): Promise { + // TODO: Read from token response when the provider includes a stable id. + // const externalId = toExternalId(asRecord(tokens.team)?.id); + const externalId = toExternalId(tokens.tenant_external_id); + if (externalId) { + return { linkType: 'tenant_external_id', externalId }; + } + + const accessToken = tokens.access_token; + if (!accessToken) return null; + + // TODO: Fetch from provider API when the token response omits the id. + // const response = await fetch('https://api.example.com/me', { + // headers: { Authorization: `Bearer ${accessToken}` }, + // }); + // if (!response.ok) return null; + // const payload = (await response.json()) as { id?: string }; + // const fetchedId = toExternalId(payload.id); + // return fetchedId + // ? { linkType: 'tenant_external_id', externalId: fetchedId } + // : null; + + return null; +} diff --git a/packages/myfirstplugin/webhooks/tenant-matcher.ts b/packages/myfirstplugin/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..6f2205a48 --- /dev/null +++ b/packages/myfirstplugin/webhooks/tenant-matcher.ts @@ -0,0 +1,25 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, firstString, readBodyRecord } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match the provider field +// (e.g. team_id, installation_id, organization_id). Must match authConfig.account +// and oauthWebhookTenantLinkResolver. +// Return null for URL verification / handshake payloads that have no tenant id. +export function matchMyFirstPluginTenantWebhook( + request: RawWebhookRequest, +): WebhookTenantMatch | null { + const body = readBodyRecord(request); + if (!body) return null; + + // TODO: Extract the stable external id from the webhook payload. + // Example: + // const externalId = firstString([body.tenant_external_id, asRecord(body.data)?.id]); + const externalId = firstString([ + body.tenant_external_id, + asRecord(body.data)?.tenant_external_id, + ]); + + if (!externalId) return null; + + return { linkType: 'tenant_external_id', externalId }; +} diff --git a/packages/myfirstplugin/webhooks/types.ts b/packages/myfirstplugin/webhooks/types.ts new file mode 100644 index 000000000..dcf43c6ce --- /dev/null +++ b/packages/myfirstplugin/webhooks/types.ts @@ -0,0 +1,66 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { z } from 'zod'; + +export const MyFirstPluginWebhookPayloadSchema = z.object({ + type: z.string(), + created_at: z.string(), + data: z.record(z.string(), z.unknown()), +}); + +export type MyFirstPluginWebhookPayload = z.infer< + typeof MyFirstPluginWebhookPayloadSchema +>; + +export const ExampleEventSchema = MyFirstPluginWebhookPayloadSchema.extend({ + type: z.literal('example'), + data: z + .object({ + id: z.string(), + }) + .loose(), +}); + +export type ExampleEvent = z.infer; + +export type MyFirstPluginWebhookOutputs = { + example: ExampleEvent; +}; + +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 createMyFirstPluginMatch( + eventType: string, +): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + return parsedBody !== null && parsedBody.type === eventType; + }; +} + +export function verifyMyFirstPluginWebhookSignature( + request: WebhookRequest, + secret: string, +): { valid: boolean; error?: string } { + // TODO: Implement webhook signature verification + return { valid: true }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18845437d..acdd883f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -737,7 +737,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/alphavantage: + packages/allimagesai: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -761,7 +761,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/altoviz: + packages/alphavantage: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -785,7 +785,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/alttextai: + packages/altoviz: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -793,9 +793,6 @@ importers: corsair: specifier: workspace:* version: link:../corsair - dotenv: - specifier: ^17.2.3 - version: 17.4.2 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)) @@ -812,17 +809,17 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/amara: + packages/alttextai: devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 - '@types/node': - specifier: ^24.10.1 - version: 24.10.1 corsair: specifier: workspace:* version: link:../corsair + dotenv: + specifier: ^17.2.3 + version: 17.4.2 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)) @@ -839,11 +836,14 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambee: + packages/amara: devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 corsair: specifier: workspace:* version: link:../corsair @@ -863,7 +863,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambientweather: + packages/ambee: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -887,7 +887,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/allimagesai: + packages/ambientweather: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -1240,6 +1240,30 @@ importers: specifier: ^5.8.0 version: 5.9.3 + packages/appdrag: + 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/asana: devDependencies: '@types/jest': @@ -3455,6 +3479,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/myfirstplugin: + 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/neon: devDependencies: '@types/jest':