diff --git a/packages/bannerbear/client.ts b/packages/bannerbear/client.ts new file mode 100644 index 000000000..916a44eee --- /dev/null +++ b/packages/bannerbear/client.ts @@ -0,0 +1,123 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class BannerbearAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'BannerbearAPIError'; + } +} + +export const BANNERBEAR_API_BASE = 'https://api.bannerbear.com'; + +export function encodeBannerbearUid(uid: string): string { + return encodeURIComponent(uid); +} + +const READ_MAX_ATTEMPTS = 6; + +const NO_RETRY: RateLimitConfig = { + enabled: true, + maxRetries: 0, + initialRetryDelay: 0, + backoffMultiplier: 1, + headerNames: { + retryAfter: 'retry-after', + }, +}; + +function isRetryableBannerbearError(error: unknown): error is ApiError { + if (!(error instanceof ApiError) || error.status === undefined) { + return false; + } + return error.status === 429 || error.status >= 500; +} + +function retryDelayMs(error: ApiError, attempt: number): number { + if (typeof error.retryAfter === 'number' && error.retryAfter >= 0) { + return error.retryAfter; + } + return 2 ** attempt * 1000; +} + +export async function makeBannerbearRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + retries?: boolean; + } = {}, +): Promise { + const { method = 'GET', body, query, retries = method === 'GET' } = options; + + const config: OpenAPIConfig = { + BASE: BANNERBEAR_API_BASE, + VERSION: '5.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + 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, + }; + + const send = async (): Promise => { + try { + return await request(config, requestOptions, { + rateLimitConfig: NO_RETRY, + }); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + if (error instanceof Error) { + throw new BannerbearAPIError(error.message); + } + throw new BannerbearAPIError('Unknown error'); + } + }; + + if (!retries) { + return await send(); + } + + let lastError: unknown; + for (let attempt = 0; attempt < READ_MAX_ATTEMPTS; attempt++) { + try { + return await send(); + } catch (error) { + lastError = error; + if ( + !isRetryableBannerbearError(error) || + attempt === READ_MAX_ATTEMPTS - 1 + ) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, retryDelayMs(error, attempt)), + ); + } + } + throw lastError; +} diff --git a/packages/bannerbear/endpoints.test.ts b/packages/bannerbear/endpoints.test.ts new file mode 100644 index 000000000..d78ff5344 --- /dev/null +++ b/packages/bannerbear/endpoints.test.ts @@ -0,0 +1,562 @@ +import { AuthMissingError } from 'corsair/core'; +import { ApiError, request } from 'corsair/http'; +import { makeBannerbearRequest } from './client'; +import { errorHandlers } from './error-handlers'; +import type { BannerbearContext } from './index'; +import { bannerbear, bannerbearEndpointSchemas } from './index'; + +jest.mock('corsair/core', () => { + const original = jest.requireActual('corsair/core'); + return { + ...original, + logEventFromContext: jest.fn().mockResolvedValue('mock-event-id'), + }; +}); + +jest.mock('corsair/http', () => { + const original = jest.requireActual('corsair/http'); + return { + ...original, + request: jest.fn(), + }; +}); + +const mockRequest = request as jest.Mock; + +const mockCtx = { + key: 'bb_test_api_key_123', + $getAccountId: () => 'test-account-id', + options: {}, + keys: { + get_api_key: jest.fn().mockResolvedValue('bb_test_api_key_123'), + get_webhook_signature: jest.fn().mockResolvedValue('whsec_test'), + }, + logEvent: jest.fn(), + database: {}, +} as unknown as BannerbearContext; + +function expectRequest( + method: string, + url: string, + extra: Record = {}, +) { + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://api.bannerbear.com', + HEADERS: expect.objectContaining({ + Authorization: 'Bearer bb_test_api_key_123', + }), + }), + expect.objectContaining({ + method, + url, + ...extra, + }), + expect.objectContaining({ + rateLimitConfig: expect.objectContaining({ maxRetries: 0 }), + }), + ); +} + +describe('Bannerbear plugin registry', () => { + const plugin = bannerbear(); + + const endpointTree = plugin.endpoints as unknown as Record< + string, + Record + >; + + function nestedPaths(): string[] { + const paths: string[] = []; + for (const [group, leaves] of Object.entries(endpointTree)) { + for (const leaf of Object.keys(leaves)) { + paths.push(`${group}.${leaf}`); + } + } + return paths.sort(); + } + + it('registers all V5 operations in the endpoint tree', () => { + const paths = nestedPaths(); + expect(paths).toHaveLength(27); + expect(paths).toEqual([ + 'account.getAccountInfo', + 'account.getAuth', + 'animationTemplates.create', + 'animationTemplates.get', + 'animationTemplates.list', + 'animations.create', + 'animations.get', + 'animations.list', + 'images.create', + 'images.get', + 'images.list', + 'instantUrls.create', + 'instantUrls.list', + 'misc.joinPdfs', + 'templates.create', + 'templates.delete', + 'templates.get', + 'templates.import', + 'templates.list', + 'webhooksApi.create', + 'webhooksApi.delete', + 'webhooksApi.get', + 'workflows.createWorkflowRun', + 'workflows.getWorkflow', + 'workflows.getWorkflowRun', + 'workflows.listWorkflowRuns', + 'workflows.listWorkflows', + ]); + }); + + it('keeps the endpoint tree, schemas, and metadata in lockstep', () => { + const paths = nestedPaths(); + expect(Object.keys(bannerbearEndpointSchemas).sort()).toEqual(paths); + expect( + Object.keys(plugin.endpointMeta as Record).sort(), + ).toEqual(paths); + }); + + it('defines input and output schemas for every endpoint', () => { + for (const [_path, schema] of Object.entries(bannerbearEndpointSchemas)) { + expect(schema.input).toBeDefined(); + expect(schema.output).toBeDefined(); + } + }); + + it('defines metadata with riskLevel and description for every endpoint', () => { + const metaMap = (plugin.endpointMeta ?? {}) as Record< + string, + { riskLevel?: string; description?: string } + >; + for (const [_path, meta] of Object.entries(metaMap)) { + expect(['read', 'write']).toContain(meta.riskLevel); + expect(typeof meta.description).toBe('string'); + expect(meta.description?.length).toBeGreaterThan(0); + } + }); + + it('throws AuthMissingError when no API key is configured', async () => { + await expect( + plugin.keyBuilder!( + { + ...mockCtx, + authType: 'api_key', + keys: { + get_api_key: jest.fn().mockResolvedValue(undefined), + get_webhook_signature: jest.fn(), + }, + } as unknown as Parameters>[0], + 'endpoint', + ), + ).rejects.toBeInstanceOf(AuthMissingError); + }); +}); + +describe('Bannerbear client error wrapping and retries', () => { + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('rethrows ApiError directly without dropping status and retry metadata', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://api.bannerbear.com/v5/images' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://api.bannerbear.com/v5/images', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + ); + mockRequest.mockRejectedValue(apiError); + + await expect( + makeBannerbearRequest('/v5/images', 'test-key', { + method: 'GET', + retries: false, + }), + ).rejects.toThrow(apiError); + }); + + it('wraps generic errors in BannerbearAPIError', async () => { + mockRequest.mockRejectedValue(new Error('Network error')); + + await expect( + makeBannerbearRequest('/v5/images', 'test-key', { method: 'GET' }), + ).rejects.toMatchObject({ + name: 'BannerbearAPIError', + message: 'Network error', + }); + }); + + it('retries GET 429s inside the client', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://api.bannerbear.com/v5/images' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://api.bannerbear.com/v5/images', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + { retryAfter: 0 }, + ); + mockRequest + .mockRejectedValueOnce(apiError) + .mockResolvedValueOnce([{ uid: 'img_1' }]); + + const result = await makeBannerbearRequest('/v5/images', 'test-key', { + method: 'GET', + }); + expect(result).toEqual([{ uid: 'img_1' }]); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it('does not retry POST requests', async () => { + const apiError = new ApiError( + { method: 'POST', url: 'https://api.bannerbear.com/v5/images' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://api.bannerbear.com/v5/images', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + ); + mockRequest.mockRejectedValue(apiError); + + await expect( + makeBannerbearRequest('/v5/images', 'test-key', { + method: 'POST', + body: { template: 'tmpl_1', modifications: {} }, + }), + ).rejects.toThrow(apiError); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('Bannerbear binder error handlers', () => { + it('keeps 429 binder retries at zero', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://api.bannerbear.com/v5/images' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://api.bannerbear.com/v5/images', + body: {}, + }, + 'Too Many Requests', + ); + expect(errorHandlers.RATE_LIMIT_ERROR.match(apiError)).toBe(true); + await expect( + errorHandlers.RATE_LIMIT_ERROR.handler(apiError), + ).resolves.toMatchObject({ maxRetries: 0 }); + }); +}); + +describe('Bannerbear endpoint handler invocations', () => { + const plugin = bannerbear(); + const endpoints = plugin.endpoints!; + + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('invokes account.getAccountInfo', async () => { + mockRequest.mockResolvedValue({ uid: 'acc_1', plan: 'pro' }); + const result = await endpoints.account.getAccountInfo(mockCtx, {}); + expectRequest('GET', '/v5/account'); + expect(result).toEqual({ uid: 'acc_1', plan: 'pro' }); + }); + + it('invokes account.getAuth', async () => { + mockRequest.mockResolvedValue({ uid: 'acc_1' }); + const result = await endpoints.account.getAuth(mockCtx, {}); + expectRequest('GET', '/v5/account'); + expect(result).toEqual({ uid: 'acc_1' }); + }); + + it('invokes templates.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'tmpl_1', name: 'Template 1' }]); + const result = await endpoints.templates.list(mockCtx, { page: 1 }); + expectRequest('GET', '/v5/image_templates', { query: { page: 1 } }); + expect(result).toHaveLength(1); + }); + + it('invokes templates.get with encoded uid', async () => { + mockRequest.mockResolvedValue({ uid: 'tmpl/1', name: 'Template 1' }); + const result = await endpoints.templates.get(mockCtx, { + uid: 'tmpl/1', + }); + expectRequest('GET', '/v5/image_templates/tmpl%2F1'); + expect(result.name).toBe('Template 1'); + }); + + it('invokes templates.create', async () => { + mockRequest.mockResolvedValue({ + uid: 'tmpl_2', + name: 'T2', + width: 1200, + height: 630, + }); + const result = await endpoints.templates.create(mockCtx, { + name: 'T2', + width: 1200, + height: 630, + }); + expectRequest('POST', '/v5/image_templates', { + body: { name: 'T2', width: 1200, height: 630 }, + }); + expect(result.width).toBe(1200); + }); + + it('invokes templates.delete', async () => { + mockRequest.mockResolvedValue(undefined); + const result = await endpoints.templates.delete(mockCtx, { + uid: 'tmpl_1', + }); + expectRequest('DELETE', '/v5/image_templates/tmpl_1'); + expect(result).toEqual({ success: true }); + }); + + it('invokes templates.import via publications install', async () => { + mockRequest.mockResolvedValue({ uid: 'tmpl_imp', name: 'Imported' }); + const result = await endpoints.templates.import(mockCtx, { + publication_id: 'pub_123', + }); + expectRequest('POST', '/v5/publications/pub_123/install', { body: {} }); + expect(result.uid).toBe('tmpl_imp'); + }); + + it('invokes images.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'img_1', status: 'completed' }]); + const result = await endpoints.images.list(mockCtx, {}); + expectRequest('GET', '/v5/images'); + expect(result).toHaveLength(1); + }); + + it('invokes images.get', async () => { + mockRequest.mockResolvedValue({ uid: 'img_1', status: 'completed' }); + const result = await endpoints.images.get(mockCtx, { uid: 'img_1' }); + expectRequest('GET', '/v5/images/img_1'); + expect(result.uid).toBe('img_1'); + }); + + it('invokes images.create', async () => { + mockRequest.mockResolvedValue({ uid: 'img_new', status: 'pending' }); + const result = await endpoints.images.create(mockCtx, { + template: 'tmpl_1', + modifications: { objects: [] }, + }); + expectRequest('POST', '/v5/images', { + body: { + template: 'tmpl_1', + modifications: { objects: [] }, + }, + }); + expect(result.uid).toBe('img_new'); + }); + + it('invokes animations.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'an_1', status: 'completed' }]); + const result = await endpoints.animations.list(mockCtx, {}); + expectRequest('GET', '/v5/animations'); + expect(result).toHaveLength(1); + }); + + it('invokes animations.get', async () => { + mockRequest.mockResolvedValue({ uid: 'an_1', status: 'completed' }); + const result = await endpoints.animations.get(mockCtx, { uid: 'an_1' }); + expectRequest('GET', '/v5/animations/an_1'); + expect(result.uid).toBe('an_1'); + }); + + it('invokes animations.create', async () => { + mockRequest.mockResolvedValue({ uid: 'an_new', status: 'queued' }); + const result = await endpoints.animations.create(mockCtx, { + template: 'at_1', + modifications: { objects: [] }, + formats: ['mp4'], + }); + expectRequest('POST', '/v5/animations', { + body: { + template: 'at_1', + modifications: { objects: [] }, + formats: ['mp4'], + }, + }); + expect(result.uid).toBe('an_new'); + }); + + it('invokes animationTemplates.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'at_1', name: 'AT 1' }]); + const result = await endpoints.animationTemplates.list(mockCtx, {}); + expectRequest('GET', '/v5/animation_templates'); + expect(result).toHaveLength(1); + }); + + it('invokes animationTemplates.get', async () => { + mockRequest.mockResolvedValue({ uid: 'at_1', name: 'AT 1' }); + const result = await endpoints.animationTemplates.get(mockCtx, { + uid: 'at_1', + }); + expectRequest('GET', '/v5/animation_templates/at_1'); + expect(result.uid).toBe('at_1'); + }); + + it('invokes animationTemplates.create', async () => { + mockRequest.mockResolvedValue({ uid: 'at_new', name: 'New AT' }); + const result = await endpoints.animationTemplates.create(mockCtx, { + name: 'New AT', + }); + expectRequest('POST', '/v5/animation_templates', { + body: { name: 'New AT' }, + }); + expect(result.uid).toBe('at_new'); + }); + + it('invokes instantUrls.list', async () => { + mockRequest.mockResolvedValue([ + { uid: 'iu_1', base_url: 'https://on-demand.bannerbear.com/x' }, + ]); + const result = await endpoints.instantUrls.list(mockCtx, {}); + expectRequest('GET', '/v5/instant_urls'); + expect(result).toHaveLength(1); + }); + + it('invokes instantUrls.create', async () => { + mockRequest.mockResolvedValue({ + uid: 'iu_new', + name: 'Share', + template: 'tmpl_1', + base_url: 'https://on-demand.bannerbear.com/x', + }); + const result = await endpoints.instantUrls.create(mockCtx, { + name: 'Share', + template: 'tmpl_1', + }); + expectRequest('POST', '/v5/instant_urls', { + body: { name: 'Share', template: 'tmpl_1' }, + }); + expect(result.base_url).toBeDefined(); + }); + + it('invokes webhooksApi.get', async () => { + mockRequest.mockResolvedValue({ uid: 'wh_1', url: 'https://app.com/hook' }); + const result = await endpoints.webhooksApi.get(mockCtx, { uid: 'wh_1' }); + expectRequest('GET', '/v5/webhooks/wh_1'); + expect(result.uid).toBe('wh_1'); + }); + + it('invokes webhooksApi.create with required name', async () => { + mockRequest.mockResolvedValue({ + uid: 'wh_new', + name: 'Image notifications', + url: 'https://app.com/hook', + signing_key: 'whsec_abc', + }); + const result = await endpoints.webhooksApi.create(mockCtx, { + name: 'Image notifications', + url: 'https://app.com/hook', + resource: 'image', + event: 'completed', + }); + expectRequest('POST', '/v5/webhooks', { + body: { + name: 'Image notifications', + url: 'https://app.com/hook', + resource: 'image', + event: 'completed', + }, + }); + expect(result.uid).toBe('wh_new'); + }); + + it('invokes webhooksApi.delete', async () => { + mockRequest.mockResolvedValue(undefined); + const result = await endpoints.webhooksApi.delete(mockCtx, { uid: 'wh_1' }); + expectRequest('DELETE', '/v5/webhooks/wh_1'); + expect(result).toEqual({ success: true }); + }); + + it('invokes misc.joinPdfs', async () => { + mockRequest.mockResolvedValue({ + uid: 'pdf_1', + status: 'completed', + joined_pdf_url: 'https://cdn.com/doc.pdf', + }); + const result = await endpoints.misc.joinPdfs(mockCtx, { + urls: ['https://cdn.com/1.pdf', 'https://cdn.com/2.pdf'], + }); + expectRequest('POST', '/v5/tools/create_pdf', { + body: { + urls: ['https://cdn.com/1.pdf', 'https://cdn.com/2.pdf'], + }, + }); + expect(result.joined_pdf_url).toBeDefined(); + }); + + it('invokes workflows.listWorkflows', async () => { + mockRequest.mockResolvedValue([{ uid: 'wf_1', name: 'Workflow 1' }]); + const result = await endpoints.workflows.listWorkflows(mockCtx, { + page: 1, + }); + expectRequest('GET', '/v5/workflows', { query: { page: 1 } }); + expect(result).toHaveLength(1); + }); + + it('invokes workflows.getWorkflow', async () => { + mockRequest.mockResolvedValue({ uid: 'wf_1', name: 'Workflow 1' }); + const result = await endpoints.workflows.getWorkflow(mockCtx, { + uid: 'wf_1', + }); + expectRequest('GET', '/v5/workflows/wf_1'); + expect(result.name).toBe('Workflow 1'); + }); + + it('invokes workflows.createWorkflowRun', async () => { + mockRequest.mockResolvedValue({ + uid: 'wfr_1', + status: 'running', + workflow: 'wf_1', + }); + const result = await endpoints.workflows.createWorkflowRun(mockCtx, { + workflow: 'wf_1', + inputs: { title: 'Test' }, + }); + expectRequest('POST', '/v5/workflow_runs', { + body: { workflow: 'wf_1', inputs: { title: 'Test' } }, + }); + expect(result.uid).toBe('wfr_1'); + }); + + it('invokes workflows.getWorkflowRun', async () => { + mockRequest.mockResolvedValue({ + uid: 'wfr_1', + status: 'completed', + workflow: 'wf_1', + }); + const result = await endpoints.workflows.getWorkflowRun(mockCtx, { + uid: 'wfr_1', + }); + expectRequest('GET', '/v5/workflow_runs/wfr_1'); + expect(result.status).toBe('completed'); + }); + + it('invokes workflows.listWorkflowRuns', async () => { + mockRequest.mockResolvedValue([ + { uid: 'wfr_1', status: 'completed', workflow: 'wf_1' }, + ]); + const result = await endpoints.workflows.listWorkflowRuns(mockCtx, { + page: 1, + }); + expectRequest('GET', '/v5/workflow_runs', { query: { page: 1 } }); + expect(result).toHaveLength(1); + }); +}); diff --git a/packages/bannerbear/endpoints/account.ts b/packages/bannerbear/endpoints/account.ts new file mode 100644 index 000000000..02df8eb53 --- /dev/null +++ b/packages/bannerbear/endpoints/account.ts @@ -0,0 +1,27 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const getAccountInfo: BannerbearEndpoints['getAccountInfo'] = async ( + ctx, + _input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAccountInfo'] + >('/v5/account', ctx.key, { + method: 'GET', + }); + await logEventFromContext(ctx, 'bannerbear.account.get', {}, 'completed'); + return response; +}; + +export const getAuth: BannerbearEndpoints['getAuth'] = async (ctx, _input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAuth'] + >('/v5/account', ctx.key, { + method: 'GET', + }); + await logEventFromContext(ctx, 'bannerbear.auth.get', {}, 'completed'); + return response; +}; diff --git a/packages/bannerbear/endpoints/animation-templates.ts b/packages/bannerbear/endpoints/animation-templates.ts new file mode 100644 index 000000000..ae47d127a --- /dev/null +++ b/packages/bannerbear/endpoints/animation-templates.ts @@ -0,0 +1,60 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listAnimationTemplates'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listAnimationTemplates'] + >('/v5/animation_templates', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animation_templates.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getAnimationTemplate'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAnimationTemplate'] + >(`/v5/animation_templates/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); + await logEventFromContext( + ctx, + 'bannerbear.animation_templates.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createAnimationTemplate'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createAnimationTemplate'] + >('/v5/animation_templates', ctx.key, { + method: 'POST', + body: { ...input }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animation_templates.create', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/animations.ts b/packages/bannerbear/endpoints/animations.ts new file mode 100644 index 000000000..0cac5a606 --- /dev/null +++ b/packages/bannerbear/endpoints/animations.ts @@ -0,0 +1,62 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listAnimations'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listAnimations'] + >('/v5/animations', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animations.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getAnimation'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAnimation'] + >(`/v5/animations/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); + await logEventFromContext( + ctx, + 'bannerbear.animations.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createAnimation'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createAnimation'] + >('/v5/animations', ctx.key, { + method: 'POST', + body: { + template: input.template, + modifications: input.modifications, + formats: input.formats, + metadata: input.metadata, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animations.create', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/images.ts b/packages/bannerbear/endpoints/images.ts new file mode 100644 index 000000000..592f53989 --- /dev/null +++ b/packages/bannerbear/endpoints/images.ts @@ -0,0 +1,58 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listImages'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listImages'] + >('/v5/images', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.images.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getImage'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getImage'] + >(`/v5/images/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); + await logEventFromContext( + ctx, + 'bannerbear.images.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createImage'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createImage'] + >('/v5/images', ctx.key, { + method: 'POST', + body: { + template: input.template, + modifications: input.modifications, + metadata: input.metadata, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.images.create', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/index.ts b/packages/bannerbear/endpoints/index.ts new file mode 100644 index 000000000..e26ca8798 --- /dev/null +++ b/packages/bannerbear/endpoints/index.ts @@ -0,0 +1,46 @@ +import { getAccountInfo, getAuth } from './account'; +import * as AnimationTemplates from './animation-templates'; +import * as Animations from './animations'; +import * as Images from './images'; +import { joinPdfs } from './misc'; +import * as InstantUrlHandlers from './signed-urls'; +import * as Templates from './templates'; +import * as WebhooksApi from './webhooks-api'; +import { + createWorkflowRun, + getWorkflow, + getWorkflowRun, + listWorkflowRuns, + listWorkflows, +} from './workflows'; + +export const Account = { + getAccountInfo, + getAuth, +}; + +export { Templates }; +export { Images }; +export { Animations }; +export { AnimationTemplates }; + +export const InstantUrls = { + list: InstantUrlHandlers.list, + create: InstantUrlHandlers.create, +}; + +export { WebhooksApi }; + +export const Misc = { + joinPdfs, +}; + +export const Workflows = { + listWorkflows, + getWorkflow, + createWorkflowRun, + getWorkflowRun, + listWorkflowRuns, +}; + +export * from './types'; diff --git a/packages/bannerbear/endpoints/misc.ts b/packages/bannerbear/endpoints/misc.ts new file mode 100644 index 000000000..2d4531631 --- /dev/null +++ b/packages/bannerbear/endpoints/misc.ts @@ -0,0 +1,23 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const joinPdfs: BannerbearEndpoints['joinPdfs'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['joinPdfs'] + >('/v5/tools/create_pdf', ctx.key, { + method: 'POST', + body: { + urls: input.urls, + metadata: input.metadata, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.tools.create_pdf', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/signed-urls.ts b/packages/bannerbear/endpoints/signed-urls.ts new file mode 100644 index 000000000..c426c67c1 --- /dev/null +++ b/packages/bannerbear/endpoints/signed-urls.ts @@ -0,0 +1,42 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listInstantUrls'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listInstantUrls'] + >('/v5/instant_urls', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.instant_urls.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createInstantUrl'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createInstantUrl'] + >('/v5/instant_urls', ctx.key, { + method: 'POST', + body: { ...input }, + }); + await logEventFromContext( + ctx, + 'bannerbear.instant_urls.create', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/templates.ts b/packages/bannerbear/endpoints/templates.ts new file mode 100644 index 000000000..67bf1c3cf --- /dev/null +++ b/packages/bannerbear/endpoints/templates.ts @@ -0,0 +1,95 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listTemplates'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listTemplates'] + >('/v5/image_templates', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.templates.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getTemplate'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getTemplate'] + >(`/v5/image_templates/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); + await logEventFromContext( + ctx, + 'bannerbear.templates.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createTemplate'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createTemplate'] + >('/v5/image_templates', ctx.key, { + method: 'POST', + body: { ...input }, + }); + await logEventFromContext( + ctx, + 'bannerbear.templates.create', + { ...input }, + 'completed', + ); + return response; +}; + +export const deleteTemplate: BannerbearEndpoints['deleteTemplate'] = async ( + ctx, + input, +) => { + await makeBannerbearRequest( + `/v5/image_templates/${encodeBannerbearUid(input.uid)}`, + ctx.key, + { method: 'DELETE' }, + ); + await logEventFromContext( + ctx, + 'bannerbear.templates.delete', + { ...input }, + 'completed', + ); + return { success: true }; +}; + +export const importTemplate: BannerbearEndpoints['importTemplate'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['importTemplate'] + >( + `/v5/publications/${encodeBannerbearUid(input.publication_id)}/install`, + ctx.key, + { method: 'POST', body: {} }, + ); + await logEventFromContext( + ctx, + 'bannerbear.templates.import', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/types.ts b/packages/bannerbear/endpoints/types.ts new file mode 100644 index 000000000..b2e7c0e58 --- /dev/null +++ b/packages/bannerbear/endpoints/types.ts @@ -0,0 +1,378 @@ +import { z } from 'zod'; +import { + BannerbearAccount, + BannerbearAnimation, + BannerbearAnimationTemplate, + BannerbearImage, + BannerbearInstantUrl, + BannerbearPdfJoin, + BannerbearTemplate, + BannerbearWebhookObj, + BannerbearWorkflow, + BannerbearWorkflowRun, +} from '../schema/database'; + +const PageInput = z.object({ + page: z.number().optional(), +}); + +const GetAccountInfoInputSchema = z.object({}); +export type GetAccountInfoInput = z.infer; +const GetAccountInfoResponseSchema = BannerbearAccount; +export type GetAccountInfoResponse = z.infer< + typeof GetAccountInfoResponseSchema +>; + +const GetAuthInputSchema = z.object({}); +export type GetAuthInput = z.infer; +const GetAuthResponseSchema = BannerbearAccount; +export type GetAuthResponse = z.infer; + +const ListTemplatesInputSchema = PageInput; +export type ListTemplatesInput = z.infer; +const ListTemplatesResponseSchema = z.array(BannerbearTemplate); +export type ListTemplatesResponse = z.infer; + +const GetTemplateInputSchema = z.object({ + uid: z.string(), +}); +export type GetTemplateInput = z.infer; +const GetTemplateResponseSchema = BannerbearTemplate; +export type GetTemplateResponse = z.infer; + +const CreateTemplateInputSchema = z.object({ + name: z.string(), + description: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + tags: z.array(z.string()).optional(), + config: z.record(z.string(), z.unknown()).optional(), +}); +export type CreateTemplateInput = z.infer; +const CreateTemplateResponseSchema = BannerbearTemplate; +export type CreateTemplateResponse = z.infer< + typeof CreateTemplateResponseSchema +>; + +const DeleteTemplateInputSchema = z.object({ + uid: z.string(), +}); +export type DeleteTemplateInput = z.infer; +const DeleteTemplateResponseSchema = z.object({ success: z.boolean() }); +export type DeleteTemplateResponse = z.infer< + typeof DeleteTemplateResponseSchema +>; + +const ImportTemplateInputSchema = z.object({ + publication_id: z.string(), +}); +export type ImportTemplateInput = z.infer; +const ImportTemplateResponseSchema = BannerbearTemplate; +export type ImportTemplateResponse = z.infer< + typeof ImportTemplateResponseSchema +>; + +const ListImagesInputSchema = PageInput; +export type ListImagesInput = z.infer; +const ListImagesResponseSchema = z.array(BannerbearImage); +export type ListImagesResponse = z.infer; + +const GetImageInputSchema = z.object({ + uid: z.string(), +}); +export type GetImageInput = z.infer; +const GetImageResponseSchema = BannerbearImage; +export type GetImageResponse = z.infer; + +const CreateImageInputSchema = z.object({ + template: z.string(), + modifications: z.record(z.string(), z.unknown()), + metadata: z.string().optional(), +}); +export type CreateImageInput = z.infer; +const CreateImageResponseSchema = BannerbearImage; +export type CreateImageResponse = z.infer; + +const ListAnimationsInputSchema = PageInput; +export type ListAnimationsInput = z.infer; +const ListAnimationsResponseSchema = z.array(BannerbearAnimation); +export type ListAnimationsResponse = z.infer< + typeof ListAnimationsResponseSchema +>; + +const GetAnimationInputSchema = z.object({ + uid: z.string(), +}); +export type GetAnimationInput = z.infer; +const GetAnimationResponseSchema = BannerbearAnimation; +export type GetAnimationResponse = z.infer; + +const CreateAnimationInputSchema = z.object({ + template: z.string(), + modifications: z.record(z.string(), z.unknown()), + formats: z.array(z.enum(['mp4', 'mov'])).optional(), + metadata: z.string().optional(), +}); +export type CreateAnimationInput = z.infer; +const CreateAnimationResponseSchema = BannerbearAnimation; +export type CreateAnimationResponse = z.infer< + typeof CreateAnimationResponseSchema +>; + +const ListAnimationTemplatesInputSchema = PageInput; +export type ListAnimationTemplatesInput = z.infer< + typeof ListAnimationTemplatesInputSchema +>; +const ListAnimationTemplatesResponseSchema = z.array( + BannerbearAnimationTemplate, +); +export type ListAnimationTemplatesResponse = z.infer< + typeof ListAnimationTemplatesResponseSchema +>; + +const GetAnimationTemplateInputSchema = z.object({ + uid: z.string(), +}); +export type GetAnimationTemplateInput = z.infer< + typeof GetAnimationTemplateInputSchema +>; +const GetAnimationTemplateResponseSchema = BannerbearAnimationTemplate; +export type GetAnimationTemplateResponse = z.infer< + typeof GetAnimationTemplateResponseSchema +>; + +const CreateAnimationTemplateInputSchema = z.object({ + name: z.string(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + width: z.number().optional(), + height: z.number().optional(), + frame_rate: z.union([z.literal(24), z.literal(30), z.literal(60)]).optional(), +}); +export type CreateAnimationTemplateInput = z.infer< + typeof CreateAnimationTemplateInputSchema +>; +const CreateAnimationTemplateResponseSchema = BannerbearAnimationTemplate; +export type CreateAnimationTemplateResponse = z.infer< + typeof CreateAnimationTemplateResponseSchema +>; + +const ListInstantUrlsInputSchema = PageInput; +export type ListInstantUrlsInput = z.infer; +const ListInstantUrlsResponseSchema = z.array(BannerbearInstantUrl); +export type ListInstantUrlsResponse = z.infer< + typeof ListInstantUrlsResponseSchema +>; + +const CreateInstantUrlInputSchema = z.object({ + name: z.string(), + template: z.string(), + mode: z.enum(['encoded', 'named_params']).optional(), + security: z.enum(['signed', 'open']).optional(), + status: z.enum(['active', 'disabled']).optional(), + scale: z + .union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]) + .optional(), + rate_limit: z.boolean().optional(), + template_version: z.number().nullable().optional(), + max_renders: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), +}); +export type CreateInstantUrlInput = z.infer; +const CreateInstantUrlResponseSchema = BannerbearInstantUrl; +export type CreateInstantUrlResponse = z.infer< + typeof CreateInstantUrlResponseSchema +>; + +const GetWebhookInputSchema = z.object({ + uid: z.string(), +}); +export type GetWebhookInput = z.infer; +const GetWebhookResponseSchema = BannerbearWebhookObj; +export type GetWebhookResponse = z.infer; + +const CreateWebhookInputSchema = z.object({ + name: z.string(), + url: z.string(), + resource: z + .enum(['image', 'batch', 'tool_job', 'workflow_run', 'animation']) + .optional(), + event: z.enum(['all_events', 'completed', 'failed']).optional(), + status: z.enum(['active', 'disabled']).optional(), + scope: z.enum(['all_templates', 'specific_templates']).optional(), + templates: z.array(z.string()).optional(), +}); +export type CreateWebhookInput = z.infer; +const CreateWebhookResponseSchema = BannerbearWebhookObj; +export type CreateWebhookResponse = z.infer; + +const DeleteWebhookInputSchema = z.object({ + uid: z.string(), +}); +export type DeleteWebhookInput = z.infer; +const DeleteWebhookResponseSchema = z.object({ success: z.boolean() }); +export type DeleteWebhookResponse = z.infer; + +const JoinPdfsInputSchema = z.object({ + urls: z.array(z.string()), + metadata: z.string().optional(), +}); +export type JoinPdfsInput = z.infer; +const JoinPdfsResponseSchema = BannerbearPdfJoin; +export type JoinPdfsResponse = z.infer; + +const ListWorkflowsInputSchema = PageInput; +export type ListWorkflowsInput = z.infer; +const ListWorkflowsResponseSchema = z.array(BannerbearWorkflow); +export type ListWorkflowsResponse = z.infer; + +const GetWorkflowInputSchema = z.object({ uid: z.string() }); +export type GetWorkflowInput = z.infer; +const GetWorkflowResponseSchema = BannerbearWorkflow; +export type GetWorkflowResponse = z.infer; + +const CreateWorkflowRunInputSchema = z.object({ + workflow: z.string(), + inputs: z.record(z.string(), z.unknown()).optional(), +}); +export type CreateWorkflowRunInput = z.infer< + typeof CreateWorkflowRunInputSchema +>; +const CreateWorkflowRunResponseSchema = BannerbearWorkflowRun; +export type CreateWorkflowRunResponse = z.infer< + typeof CreateWorkflowRunResponseSchema +>; + +const GetWorkflowRunInputSchema = z.object({ uid: z.string() }); +export type GetWorkflowRunInput = z.infer; +const GetWorkflowRunResponseSchema = BannerbearWorkflowRun; +export type GetWorkflowRunResponse = z.infer< + typeof GetWorkflowRunResponseSchema +>; + +const ListWorkflowRunsInputSchema = PageInput; +export type ListWorkflowRunsInput = z.infer; +const ListWorkflowRunsResponseSchema = z.array(BannerbearWorkflowRun); +export type ListWorkflowRunsResponse = z.infer< + typeof ListWorkflowRunsResponseSchema +>; + +export type BannerbearEndpointInputs = { + getAccountInfo: GetAccountInfoInput; + getAuth: GetAuthInput; + listTemplates: ListTemplatesInput; + getTemplate: GetTemplateInput; + createTemplate: CreateTemplateInput; + deleteTemplate: DeleteTemplateInput; + importTemplate: ImportTemplateInput; + listImages: ListImagesInput; + getImage: GetImageInput; + createImage: CreateImageInput; + listAnimations: ListAnimationsInput; + getAnimation: GetAnimationInput; + createAnimation: CreateAnimationInput; + listAnimationTemplates: ListAnimationTemplatesInput; + getAnimationTemplate: GetAnimationTemplateInput; + createAnimationTemplate: CreateAnimationTemplateInput; + listInstantUrls: ListInstantUrlsInput; + createInstantUrl: CreateInstantUrlInput; + getWebhook: GetWebhookInput; + createWebhook: CreateWebhookInput; + deleteWebhook: DeleteWebhookInput; + joinPdfs: JoinPdfsInput; + listWorkflows: ListWorkflowsInput; + getWorkflow: GetWorkflowInput; + createWorkflowRun: CreateWorkflowRunInput; + getWorkflowRun: GetWorkflowRunInput; + listWorkflowRuns: ListWorkflowRunsInput; +}; + +export type BannerbearEndpointOutputs = { + getAccountInfo: GetAccountInfoResponse; + getAuth: GetAuthResponse; + listTemplates: ListTemplatesResponse; + getTemplate: GetTemplateResponse; + createTemplate: CreateTemplateResponse; + deleteTemplate: DeleteTemplateResponse; + importTemplate: ImportTemplateResponse; + listImages: ListImagesResponse; + getImage: GetImageResponse; + createImage: CreateImageResponse; + listAnimations: ListAnimationsResponse; + getAnimation: GetAnimationResponse; + createAnimation: CreateAnimationResponse; + listAnimationTemplates: ListAnimationTemplatesResponse; + getAnimationTemplate: GetAnimationTemplateResponse; + createAnimationTemplate: CreateAnimationTemplateResponse; + listInstantUrls: ListInstantUrlsResponse; + createInstantUrl: CreateInstantUrlResponse; + getWebhook: GetWebhookResponse; + createWebhook: CreateWebhookResponse; + deleteWebhook: DeleteWebhookResponse; + joinPdfs: JoinPdfsResponse; + listWorkflows: ListWorkflowsResponse; + getWorkflow: GetWorkflowResponse; + createWorkflowRun: CreateWorkflowRunResponse; + getWorkflowRun: GetWorkflowRunResponse; + listWorkflowRuns: ListWorkflowRunsResponse; +}; + +export const BannerbearEndpointInputSchemas = { + getAccountInfo: GetAccountInfoInputSchema, + getAuth: GetAuthInputSchema, + listTemplates: ListTemplatesInputSchema, + getTemplate: GetTemplateInputSchema, + createTemplate: CreateTemplateInputSchema, + deleteTemplate: DeleteTemplateInputSchema, + importTemplate: ImportTemplateInputSchema, + listImages: ListImagesInputSchema, + getImage: GetImageInputSchema, + createImage: CreateImageInputSchema, + listAnimations: ListAnimationsInputSchema, + getAnimation: GetAnimationInputSchema, + createAnimation: CreateAnimationInputSchema, + listAnimationTemplates: ListAnimationTemplatesInputSchema, + getAnimationTemplate: GetAnimationTemplateInputSchema, + createAnimationTemplate: CreateAnimationTemplateInputSchema, + listInstantUrls: ListInstantUrlsInputSchema, + createInstantUrl: CreateInstantUrlInputSchema, + getWebhook: GetWebhookInputSchema, + createWebhook: CreateWebhookInputSchema, + deleteWebhook: DeleteWebhookInputSchema, + joinPdfs: JoinPdfsInputSchema, + listWorkflows: ListWorkflowsInputSchema, + getWorkflow: GetWorkflowInputSchema, + createWorkflowRun: CreateWorkflowRunInputSchema, + getWorkflowRun: GetWorkflowRunInputSchema, + listWorkflowRuns: ListWorkflowRunsInputSchema, +} as const; + +export const BannerbearEndpointOutputSchemas = { + getAccountInfo: GetAccountInfoResponseSchema, + getAuth: GetAuthResponseSchema, + listTemplates: ListTemplatesResponseSchema, + getTemplate: GetTemplateResponseSchema, + createTemplate: CreateTemplateResponseSchema, + deleteTemplate: DeleteTemplateResponseSchema, + importTemplate: ImportTemplateResponseSchema, + listImages: ListImagesResponseSchema, + getImage: GetImageResponseSchema, + createImage: CreateImageResponseSchema, + listAnimations: ListAnimationsResponseSchema, + getAnimation: GetAnimationResponseSchema, + createAnimation: CreateAnimationResponseSchema, + listAnimationTemplates: ListAnimationTemplatesResponseSchema, + getAnimationTemplate: GetAnimationTemplateResponseSchema, + createAnimationTemplate: CreateAnimationTemplateResponseSchema, + listInstantUrls: ListInstantUrlsResponseSchema, + createInstantUrl: CreateInstantUrlResponseSchema, + getWebhook: GetWebhookResponseSchema, + createWebhook: CreateWebhookResponseSchema, + deleteWebhook: DeleteWebhookResponseSchema, + joinPdfs: JoinPdfsResponseSchema, + listWorkflows: ListWorkflowsResponseSchema, + getWorkflow: GetWorkflowResponseSchema, + createWorkflowRun: CreateWorkflowRunResponseSchema, + getWorkflowRun: GetWorkflowRunResponseSchema, + listWorkflowRuns: ListWorkflowRunsResponseSchema, +} as const; diff --git a/packages/bannerbear/endpoints/webhooks-api.ts b/packages/bannerbear/endpoints/webhooks-api.ts new file mode 100644 index 000000000..813379441 --- /dev/null +++ b/packages/bannerbear/endpoints/webhooks-api.ts @@ -0,0 +1,56 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const get: BannerbearEndpoints['getWebhook'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getWebhook'] + >(`/v5/webhooks/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); + await logEventFromContext( + ctx, + 'bannerbear.webhooks.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createWebhook'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createWebhook'] + >('/v5/webhooks', ctx.key, { + method: 'POST', + body: { ...input }, + }); + await logEventFromContext( + ctx, + 'bannerbear.webhooks.create', + { ...input }, + 'completed', + ); + return response; +}; + +export const deleteWebhook: BannerbearEndpoints['deleteWebhook'] = async ( + ctx, + input, +) => { + await makeBannerbearRequest( + `/v5/webhooks/${encodeBannerbearUid(input.uid)}`, + ctx.key, + { method: 'DELETE' }, + ); + await logEventFromContext( + ctx, + 'bannerbear.webhooks.delete', + { ...input }, + 'completed', + ); + return { success: true }; +}; diff --git a/packages/bannerbear/endpoints/workflows.ts b/packages/bannerbear/endpoints/workflows.ts new file mode 100644 index 000000000..dd7e055f2 --- /dev/null +++ b/packages/bannerbear/endpoints/workflows.ts @@ -0,0 +1,95 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const listWorkflows: BannerbearEndpoints['listWorkflows'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listWorkflows'] + >('/v5/workflows', ctx.key, { method: 'GET', query: { page: input.page } }); + await logEventFromContext( + ctx, + 'bannerbear.workflows.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const getWorkflow: BannerbearEndpoints['getWorkflow'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getWorkflow'] + >(`/v5/workflows/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); + await logEventFromContext( + ctx, + 'bannerbear.workflows.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const createWorkflowRun: BannerbearEndpoints['createWorkflowRun'] = + async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createWorkflowRun'] + >('/v5/workflow_runs', ctx.key, { + method: 'POST', + body: { + workflow: input.workflow, + inputs: input.inputs, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.workflow_runs.create', + { ...input }, + 'completed', + ); + return response; + }; + +export const getWorkflowRun: BannerbearEndpoints['getWorkflowRun'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getWorkflowRun'] + >(`/v5/workflow_runs/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); + await logEventFromContext( + ctx, + 'bannerbear.workflow_runs.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const listWorkflowRuns: BannerbearEndpoints['listWorkflowRuns'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listWorkflowRuns'] + >('/v5/workflow_runs', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.workflow_runs.list', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/error-handlers.ts b/packages/bannerbear/error-handlers.ts new file mode 100644 index 000000000..217f52255 --- /dev/null +++ b/packages/bannerbear/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: 0, 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/bannerbear/index.ts b/packages/bannerbear/index.ts new file mode 100644 index 000000000..1ca2f2607 --- /dev/null +++ b/packages/bannerbear/index.ts @@ -0,0 +1,581 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + Account, + Animations, + AnimationTemplates, + Images, + InstantUrls, + Misc, + Templates, + WebhooksApi, + Workflows, +} from './endpoints'; +import type { + BannerbearEndpointInputs, + BannerbearEndpointOutputs, +} from './endpoints/types'; +import { + BannerbearEndpointInputSchemas, + BannerbearEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { BannerbearSchema } from './schema'; +import { AnimationWebhooks, ImageWebhooks } from './webhooks'; +import { matchBannerbearTenantWebhook } from './webhooks/tenant-matcher'; +import type { + AnimationCompletedEvent, + BannerbearWebhookOutputs, + ImageCompletedEvent, +} from './webhooks/types'; +import { + AnimationCompletedEventSchema, + ImageCompletedEventSchema, + isBannerbearCompletionPayload, +} from './webhooks/types'; + +export type BannerbearPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + webhookSecret?: string; + hooks?: InternalBannerbearPlugin['hooks']; + webhookHooks?: InternalBannerbearPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type BannerbearContext = CorsairPluginContext< + typeof BannerbearSchema, + BannerbearPluginOptions +>; + +export type BannerbearKeyBuilderContext = + KeyBuilderContext; + +export type BannerbearBoundEndpoints = BindEndpoints< + typeof bannerbearEndpointsNested +>; + +type BannerbearEndpoint = + CorsairEndpoint< + BannerbearContext, + BannerbearEndpointInputs[K], + BannerbearEndpointOutputs[K] + >; + +export type BannerbearEndpoints = { + getAccountInfo: BannerbearEndpoint<'getAccountInfo'>; + getAuth: BannerbearEndpoint<'getAuth'>; + listTemplates: BannerbearEndpoint<'listTemplates'>; + getTemplate: BannerbearEndpoint<'getTemplate'>; + createTemplate: BannerbearEndpoint<'createTemplate'>; + deleteTemplate: BannerbearEndpoint<'deleteTemplate'>; + importTemplate: BannerbearEndpoint<'importTemplate'>; + listImages: BannerbearEndpoint<'listImages'>; + getImage: BannerbearEndpoint<'getImage'>; + createImage: BannerbearEndpoint<'createImage'>; + listAnimations: BannerbearEndpoint<'listAnimations'>; + getAnimation: BannerbearEndpoint<'getAnimation'>; + createAnimation: BannerbearEndpoint<'createAnimation'>; + listAnimationTemplates: BannerbearEndpoint<'listAnimationTemplates'>; + getAnimationTemplate: BannerbearEndpoint<'getAnimationTemplate'>; + createAnimationTemplate: BannerbearEndpoint<'createAnimationTemplate'>; + listInstantUrls: BannerbearEndpoint<'listInstantUrls'>; + createInstantUrl: BannerbearEndpoint<'createInstantUrl'>; + getWebhook: BannerbearEndpoint<'getWebhook'>; + createWebhook: BannerbearEndpoint<'createWebhook'>; + deleteWebhook: BannerbearEndpoint<'deleteWebhook'>; + joinPdfs: BannerbearEndpoint<'joinPdfs'>; + listWorkflows: BannerbearEndpoint<'listWorkflows'>; + getWorkflow: BannerbearEndpoint<'getWorkflow'>; + createWorkflowRun: BannerbearEndpoint<'createWorkflowRun'>; + getWorkflowRun: BannerbearEndpoint<'getWorkflowRun'>; + listWorkflowRuns: BannerbearEndpoint<'listWorkflowRuns'>; +}; + +type BannerbearWebhook< + K extends keyof BannerbearWebhookOutputs, + TEvent, +> = CorsairWebhook; + +export type BannerbearWebhooks = { + imageCompleted: BannerbearWebhook<'imageCompleted', ImageCompletedEvent>; + animationCompleted: BannerbearWebhook< + 'animationCompleted', + AnimationCompletedEvent + >; +}; + +export type BannerbearBoundWebhooks = BindWebhooks; + +const bannerbearEndpointsNested = { + account: { + getAccountInfo: Account.getAccountInfo, + getAuth: Account.getAuth, + }, + templates: { + list: Templates.list, + get: Templates.get, + create: Templates.create, + delete: Templates.deleteTemplate, + import: Templates.importTemplate, + }, + images: { + list: Images.list, + get: Images.get, + create: Images.create, + }, + animations: { + list: Animations.list, + get: Animations.get, + create: Animations.create, + }, + animationTemplates: { + list: AnimationTemplates.list, + get: AnimationTemplates.get, + create: AnimationTemplates.create, + }, + instantUrls: { + list: InstantUrls.list, + create: InstantUrls.create, + }, + webhooksApi: { + get: WebhooksApi.get, + create: WebhooksApi.create, + delete: WebhooksApi.deleteWebhook, + }, + misc: { + joinPdfs: Misc.joinPdfs, + }, + workflows: { + listWorkflows: Workflows.listWorkflows, + getWorkflow: Workflows.getWorkflow, + createWorkflowRun: Workflows.createWorkflowRun, + getWorkflowRun: Workflows.getWorkflowRun, + listWorkflowRuns: Workflows.listWorkflowRuns, + }, +} as const; + +const bannerbearWebhooksNested = { + image: { + imageCompleted: ImageWebhooks.imageCompleted, + }, + animation: { + animationCompleted: AnimationWebhooks.animationCompleted, + }, +} as const; + +export const bannerbearEndpointSchemas = { + 'account.getAccountInfo': { + input: BannerbearEndpointInputSchemas.getAccountInfo, + output: BannerbearEndpointOutputSchemas.getAccountInfo, + }, + 'account.getAuth': { + input: BannerbearEndpointInputSchemas.getAuth, + output: BannerbearEndpointOutputSchemas.getAuth, + }, + 'templates.list': { + input: BannerbearEndpointInputSchemas.listTemplates, + output: BannerbearEndpointOutputSchemas.listTemplates, + }, + 'templates.get': { + input: BannerbearEndpointInputSchemas.getTemplate, + output: BannerbearEndpointOutputSchemas.getTemplate, + }, + 'templates.create': { + input: BannerbearEndpointInputSchemas.createTemplate, + output: BannerbearEndpointOutputSchemas.createTemplate, + }, + 'templates.delete': { + input: BannerbearEndpointInputSchemas.deleteTemplate, + output: BannerbearEndpointOutputSchemas.deleteTemplate, + }, + 'templates.import': { + input: BannerbearEndpointInputSchemas.importTemplate, + output: BannerbearEndpointOutputSchemas.importTemplate, + }, + 'images.list': { + input: BannerbearEndpointInputSchemas.listImages, + output: BannerbearEndpointOutputSchemas.listImages, + }, + 'images.get': { + input: BannerbearEndpointInputSchemas.getImage, + output: BannerbearEndpointOutputSchemas.getImage, + }, + 'images.create': { + input: BannerbearEndpointInputSchemas.createImage, + output: BannerbearEndpointOutputSchemas.createImage, + }, + 'animations.list': { + input: BannerbearEndpointInputSchemas.listAnimations, + output: BannerbearEndpointOutputSchemas.listAnimations, + }, + 'animations.get': { + input: BannerbearEndpointInputSchemas.getAnimation, + output: BannerbearEndpointOutputSchemas.getAnimation, + }, + 'animations.create': { + input: BannerbearEndpointInputSchemas.createAnimation, + output: BannerbearEndpointOutputSchemas.createAnimation, + }, + 'animationTemplates.list': { + input: BannerbearEndpointInputSchemas.listAnimationTemplates, + output: BannerbearEndpointOutputSchemas.listAnimationTemplates, + }, + 'animationTemplates.get': { + input: BannerbearEndpointInputSchemas.getAnimationTemplate, + output: BannerbearEndpointOutputSchemas.getAnimationTemplate, + }, + 'animationTemplates.create': { + input: BannerbearEndpointInputSchemas.createAnimationTemplate, + output: BannerbearEndpointOutputSchemas.createAnimationTemplate, + }, + 'instantUrls.list': { + input: BannerbearEndpointInputSchemas.listInstantUrls, + output: BannerbearEndpointOutputSchemas.listInstantUrls, + }, + 'instantUrls.create': { + input: BannerbearEndpointInputSchemas.createInstantUrl, + output: BannerbearEndpointOutputSchemas.createInstantUrl, + }, + 'webhooksApi.get': { + input: BannerbearEndpointInputSchemas.getWebhook, + output: BannerbearEndpointOutputSchemas.getWebhook, + }, + 'webhooksApi.create': { + input: BannerbearEndpointInputSchemas.createWebhook, + output: BannerbearEndpointOutputSchemas.createWebhook, + }, + 'webhooksApi.delete': { + input: BannerbearEndpointInputSchemas.deleteWebhook, + output: BannerbearEndpointOutputSchemas.deleteWebhook, + }, + 'misc.joinPdfs': { + input: BannerbearEndpointInputSchemas.joinPdfs, + output: BannerbearEndpointOutputSchemas.joinPdfs, + }, + 'workflows.listWorkflows': { + input: BannerbearEndpointInputSchemas.listWorkflows, + output: BannerbearEndpointOutputSchemas.listWorkflows, + }, + 'workflows.getWorkflow': { + input: BannerbearEndpointInputSchemas.getWorkflow, + output: BannerbearEndpointOutputSchemas.getWorkflow, + }, + 'workflows.createWorkflowRun': { + input: BannerbearEndpointInputSchemas.createWorkflowRun, + output: BannerbearEndpointOutputSchemas.createWorkflowRun, + }, + 'workflows.getWorkflowRun': { + input: BannerbearEndpointInputSchemas.getWorkflowRun, + output: BannerbearEndpointOutputSchemas.getWorkflowRun, + }, + 'workflows.listWorkflowRuns': { + input: BannerbearEndpointInputSchemas.listWorkflowRuns, + output: BannerbearEndpointOutputSchemas.listWorkflowRuns, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof bannerbearEndpointsNested +>; + +const bannerbearWebhookSchemas = { + 'image.imageCompleted': { + description: 'Fires when a Bannerbear image finishes rendering', + payload: ImageCompletedEventSchema, + response: ImageCompletedEventSchema, + }, + 'animation.animationCompleted': { + description: 'Fires when a Bannerbear animation finishes rendering', + payload: AnimationCompletedEventSchema, + response: AnimationCompletedEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas< + typeof bannerbearWebhooksNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const bannerbearEndpointMeta = { + 'account.getAccountInfo': { + riskLevel: 'read', + description: 'Get account info including plan and quota', + }, + 'account.getAuth': { + riskLevel: 'read', + description: 'Verify API authentication against the current account', + }, + 'templates.list': { + riskLevel: 'read', + description: 'List image templates', + }, + 'templates.get': { + riskLevel: 'read', + description: 'Get an image template by UID', + }, + 'templates.create': { + riskLevel: 'write', + description: 'Create an image template', + }, + 'templates.delete': { + riskLevel: 'write', + description: 'Delete an image template by UID', + }, + 'templates.import': { + riskLevel: 'write', + description: 'Install a publication as an image template', + }, + 'images.list': { + riskLevel: 'read', + description: 'List generated images', + }, + 'images.get': { + riskLevel: 'read', + description: 'Get an image by UID', + }, + 'images.create': { + riskLevel: 'write', + description: 'Generate an image from a template', + }, + 'animations.list': { + riskLevel: 'read', + description: 'List generated animations', + }, + 'animations.get': { + riskLevel: 'read', + description: 'Get an animation by UID', + }, + 'animations.create': { + riskLevel: 'write', + description: 'Generate an animation from a template', + }, + 'animationTemplates.list': { + riskLevel: 'read', + description: 'List animation templates', + }, + 'animationTemplates.get': { + riskLevel: 'read', + description: 'Get an animation template by UID', + }, + 'animationTemplates.create': { + riskLevel: 'write', + description: 'Create an animation template', + }, + 'instantUrls.list': { + riskLevel: 'read', + description: 'List Instant URLs', + }, + 'instantUrls.create': { + riskLevel: 'write', + description: 'Create an Instant URL for an image template', + }, + 'webhooksApi.get': { + riskLevel: 'read', + description: 'Get a webhook by UID', + }, + 'webhooksApi.create': { + riskLevel: 'write', + description: 'Create a webhook', + }, + 'webhooksApi.delete': { + riskLevel: 'write', + description: 'Delete a webhook by UID', + }, + 'misc.joinPdfs': { + riskLevel: 'write', + description: 'Merge image or PDF URLs into one PDF', + }, + 'workflows.listWorkflows': { + riskLevel: 'read', + description: 'List workflows in the workspace', + }, + 'workflows.getWorkflow': { + riskLevel: 'read', + description: 'Get a workflow by UID', + }, + 'workflows.createWorkflowRun': { + riskLevel: 'write', + description: 'Run a workflow with inputs', + }, + 'workflows.getWorkflowRun': { + riskLevel: 'read', + description: 'Get a workflow run by UID', + }, + 'workflows.listWorkflowRuns': { + riskLevel: 'read', + description: 'List workflow runs', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof bannerbearEndpointsNested +>; + +export const bannerbearAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseBannerbearPlugin = + CorsairPlugin< + 'bannerbear', + typeof BannerbearSchema, + typeof bannerbearEndpointsNested, + typeof bannerbearWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalBannerbearPlugin = + BaseBannerbearPlugin; + +export type ExternalBannerbearPlugin = + BaseBannerbearPlugin; + +export function bannerbear( + incomingOptions: BannerbearPluginOptions & T = {} as BannerbearPluginOptions & + T, +): ExternalBannerbearPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'bannerbear', + authConfig: bannerbearAuthConfig, + schema: BannerbearSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: bannerbearEndpointsNested, + webhooks: bannerbearWebhooksNested, + endpointMeta: bannerbearEndpointMeta, + endpointSchemas: bannerbearEndpointSchemas, + webhookSchemas: bannerbearWebhookSchemas, + pluginWebhookMatcher: (request) => { + if ('x-bannerbear-signature' in request.headers) return true; + if ('x-webhook-signature' in request.headers) return true; + const body = + typeof request.body === 'string' + ? (() => { + try { + return JSON.parse(request.body); + } catch { + return null; + } + })() + : request.body; + return isBannerbearCompletionPayload(body); + }, + pluginTenantWebhookMatcher: matchBannerbearTenantWebhook, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: BannerbearKeyBuilderContext, source) => { + if (source === 'webhook' && options.webhookSecret) { + return options.webhookSecret; + } + + if (source === 'webhook') { + const res = await ctx.keys.get_webhook_signature(); + if (!res) { + throw new AuthMissingError('bannerbear', '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(); + if (!res) { + throw new AuthMissingError('bannerbear', 'api_key'); + } + return res; + } + + throw new AuthMissingError('bannerbear', 'api_key'); + }, + } satisfies InternalBannerbearPlugin; +} + +export type { + BannerbearEndpointInputs, + BannerbearEndpointOutputs, + CreateAnimationInput, + CreateAnimationResponse, + CreateAnimationTemplateInput, + CreateAnimationTemplateResponse, + CreateImageInput, + CreateImageResponse, + CreateInstantUrlInput, + CreateInstantUrlResponse, + CreateTemplateInput, + CreateTemplateResponse, + CreateWebhookInput, + CreateWebhookResponse, + CreateWorkflowRunInput, + CreateWorkflowRunResponse, + DeleteTemplateInput, + DeleteTemplateResponse, + DeleteWebhookInput, + DeleteWebhookResponse, + GetAccountInfoInput, + GetAccountInfoResponse, + GetAnimationInput, + GetAnimationResponse, + GetAnimationTemplateInput, + GetAnimationTemplateResponse, + GetAuthInput, + GetAuthResponse, + GetImageInput, + GetImageResponse, + GetTemplateInput, + GetTemplateResponse, + GetWebhookInput, + GetWebhookResponse, + GetWorkflowInput, + GetWorkflowResponse, + GetWorkflowRunInput, + GetWorkflowRunResponse, + ImportTemplateInput, + ImportTemplateResponse, + JoinPdfsInput, + JoinPdfsResponse, + ListAnimationsInput, + ListAnimationsResponse, + ListAnimationTemplatesInput, + ListAnimationTemplatesResponse, + ListImagesInput, + ListImagesResponse, + ListInstantUrlsInput, + ListInstantUrlsResponse, + ListTemplatesInput, + ListTemplatesResponse, + ListWorkflowRunsInput, + ListWorkflowRunsResponse, + ListWorkflowsInput, + ListWorkflowsResponse, +} from './endpoints/types'; +export type { + AnimationCompletedEvent, + BannerbearWebhookOutputs, + ImageCompletedEvent, +} from './webhooks/types'; diff --git a/packages/bannerbear/jest.config.cjs b/packages/bannerbear/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/bannerbear/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/bannerbear/package.json b/packages/bannerbear/package.json new file mode 100644 index 000000000..7a05df07d --- /dev/null +++ b/packages/bannerbear/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/bannerbear", + "version": "0.1.0", + "description": "Bannerbear 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", + "bannerbear", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/bannerbear/schema.test.ts b/packages/bannerbear/schema.test.ts new file mode 100644 index 000000000..a1c9989bf --- /dev/null +++ b/packages/bannerbear/schema.test.ts @@ -0,0 +1,20 @@ +import { BannerbearSchema } from './schema'; + +describe('Bannerbear schema', () => { + it('declares a semver version', () => { + expect(BannerbearSchema.version).toBeDefined(); + expect(BannerbearSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof BannerbearSchema.entities).toBe('object'); + expect(BannerbearSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(BannerbearSchema.entities))).toBe(true); + for (const entity of Object.values(BannerbearSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/bannerbear/schema/database.ts b/packages/bannerbear/schema/database.ts new file mode 100644 index 000000000..7baf19422 --- /dev/null +++ b/packages/bannerbear/schema/database.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; + +export const BannerbearAccount = z.object({ + created_at: z.string().nullable().optional(), + uid: z.string(), + plan: z.string().nullable().optional(), + quota: z + .object({ + max: z.number(), + current: z.number(), + remaining: z.number(), + }) + .nullable() + .optional(), + workspace: z.string().nullable().optional(), + api_key: z + .object({ + name: z.string().nullable().optional(), + scopes: z.array(z.string()).optional(), + allowed_origins: z.array(z.string()).optional(), + }) + .nullable() + .optional(), +}); +export type BannerbearAccount = z.infer; + +export const BannerbearTemplate = z.object({ + uid: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + preview: z.string().nullable().optional(), + preview_url: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), + self: z.string().nullable().optional(), +}); +export type BannerbearTemplate = z.infer; + +export const BannerbearImage = z.object({ + uid: z.string(), + status: z.string(), + template: z.string().nullable().optional(), + files: z.record(z.string(), z.string().nullable()).nullable().optional(), + metadata: z.string().nullable().optional(), + error: z.string().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearImage = z.infer; + +export const BannerbearAnimation = z.object({ + uid: z.string(), + status: z.string(), + template: z.string().nullable().optional(), + files: z.record(z.string(), z.string().nullable()).nullable().optional(), + progress: z.number().nullable().optional(), + metadata: z.string().nullable().optional(), + error: z.string().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearAnimation = z.infer; + +export const BannerbearAnimationTemplate = z.object({ + uid: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + frame_rate: z.number().nullable().optional(), + duration_seconds: z.number().nullable().optional(), + preview: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), + created_at: z.string().nullable().optional(), + self: z.string().nullable().optional(), +}); +export type BannerbearAnimationTemplate = z.infer< + typeof BannerbearAnimationTemplate +>; + +export const BannerbearInstantUrl = z.object({ + uid: z.string(), + name: z.string().optional(), + template: z.string().nullable().optional(), + base_url: z.string().optional(), + sample_url: z.string().nullable().optional(), + mode: z.string().nullable().optional(), + security: z.string().nullable().optional(), + status: z.string().nullable().optional(), + signing_key: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), +}); +export type BannerbearInstantUrl = z.infer; + +export const BannerbearWebhookObj = z.object({ + uid: z.string(), + name: z.string().optional(), + url: z.string(), + resource: z.string().nullable().optional(), + event: z.string().nullable().optional(), + status: z.string().nullable().optional(), + signing_key: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), +}); +export type BannerbearWebhookObj = z.infer; + +export const BannerbearPdfJoin = z.object({ + uid: z.string(), + status: z.string(), + joined_pdf_url: z.string().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + metadata: z.string().nullable().optional(), +}); +export type BannerbearPdfJoin = z.infer; + +export const BannerbearWorkflow = z.object({ + uid: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), + inputs: z.record(z.string(), z.unknown()).nullable().optional(), + steps: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + ui_write_access: z.string().nullable().optional(), + api_write_access: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), +}); +export type BannerbearWorkflow = z.infer; + +export const BannerbearWorkflowRun = z.object({ + uid: z.string(), + status: z.string(), + workflow: z.string().nullable().optional(), + progress: z.number().nullable().optional(), + inputs: z.record(z.string(), z.unknown()).nullable().optional(), + outputs: z.record(z.string(), z.unknown()).nullable().optional(), + steps: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + error: z.string().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearWorkflowRun = z.infer; diff --git a/packages/bannerbear/schema/index.ts b/packages/bannerbear/schema/index.ts new file mode 100644 index 000000000..5c95ef53d --- /dev/null +++ b/packages/bannerbear/schema/index.ts @@ -0,0 +1,4 @@ +export const BannerbearSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/bannerbear/tsconfig.json b/packages/bannerbear/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/bannerbear/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/bannerbear/tsup.config.ts b/packages/bannerbear/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/bannerbear/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/bannerbear/webhooks.test.ts b/packages/bannerbear/webhooks.test.ts new file mode 100644 index 000000000..2b1f5d6eb --- /dev/null +++ b/packages/bannerbear/webhooks.test.ts @@ -0,0 +1,239 @@ +import type { RawWebhookRequest, WebhookRequest } from 'corsair/core'; +import * as crypto from 'crypto'; +import type { BannerbearContext } from './index'; +import { bannerbear } from './index'; +import { + createBannerbearAnimationCompletedMatch, + createBannerbearImageCompletedMatch, + verifyBannerbearWebhookSignature, +} from './webhooks/types'; + +jest.mock('corsair/core', () => { + const original = jest.requireActual('corsair/core'); + return { + ...original, + logEventFromContext: jest.fn().mockResolvedValue('mock-event-id'), + }; +}); + +const mockCtx = { + key: 'bb_test_api_key_123', + $getAccountId: () => 'test-account-id', + options: {}, + keys: { + get_api_key: jest.fn().mockResolvedValue('bb_test_api_key_123'), + }, + logEvent: jest.fn(), + database: {}, +} as unknown as BannerbearContext; + +describe('Bannerbear webhooks verification and handlers', () => { + const plugin = bannerbear(); + const webhooks = plugin.webhooks!; + const secret = 'webhook_secret_key_xyz'; + + function createSignedWebhookRequest( + payloadObj: Record, + signingSecret = secret, + ): WebhookRequest> { + const rawBody = JSON.stringify(payloadObj); + const signature = crypto + .createHmac('sha256', signingSecret) + .update(rawBody) + .digest('hex'); + + return { + rawBody, + payload: payloadObj, + headers: { + 'x-bannerbear-signature': signature, + }, + }; + } + + it('verifies valid HMAC signature successfully', () => { + const request = createSignedWebhookRequest({ + uid: 'img_123', + status: 'completed', + }); + const result = verifyBannerbearWebhookSignature(request, secret); + expect(result.valid).toBe(true); + }); + + it('verifies valid Authorization Bearer webhook key successfully', () => { + const request: WebhookRequest> = { + rawBody: JSON.stringify({ uid: 'img_123', status: 'completed' }), + payload: { uid: 'img_123', status: 'completed' }, + headers: { + authorization: `Bearer ${secret}`, + }, + }; + const result = verifyBannerbearWebhookSignature(request, secret); + expect(result.valid).toBe(true); + }); + + it('accepts hubVerified requests even without secret', () => { + const request: WebhookRequest> = { + rawBody: JSON.stringify({ uid: 'img_123' }), + payload: { uid: 'img_123' }, + headers: {}, + hubVerified: true, + }; + const result = verifyBannerbearWebhookSignature(request, undefined); + expect(result.valid).toBe(true); + }); + + it('rejects invalid signature', () => { + const request = createSignedWebhookRequest({ + uid: 'img_123', + status: 'completed', + }); + const result = verifyBannerbearWebhookSignature(request, 'wrong_secret'); + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid webhook credentials or signature'); + }); + + it('rejects request when webhook secret is missing', () => { + const request = createSignedWebhookRequest({ + uid: 'img_123', + status: 'completed', + }); + const result = verifyBannerbearWebhookSignature(request, undefined); + expect(result.valid).toBe(false); + expect(result.error).toBe('Missing webhook secret'); + }); + + it('rejects request without signature or authorization header', () => { + const request: WebhookRequest> = { + rawBody: JSON.stringify({ uid: 'img_123' }), + payload: { uid: 'img_123' }, + headers: {}, + }; + const result = verifyBannerbearWebhookSignature(request, secret); + expect(result.valid).toBe(false); + expect(result.error).toBe( + 'Missing Authorization or webhook signature header', + ); + }); + + it('processes imageCompleted webhook handler with valid signature', async () => { + const webhookCtx = { ...mockCtx, key: secret }; + const payload = { + uid: 'img_123', + status: 'completed' as const, + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }; + const req = createSignedWebhookRequest(payload) as unknown as Parameters< + typeof webhooks.image.imageCompleted.handler + >[1]; + + const res = await webhooks.image.imageCompleted.handler(webhookCtx, req); + expect(res.success).toBe(true); + expect(res.data).toEqual(payload); + }); + + it('rejects imageCompleted handler when signature verification fails', async () => { + const webhookCtx = { ...mockCtx, key: secret }; + const payload = { + uid: 'img_123', + status: 'completed' as const, + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }; + const req = createSignedWebhookRequest( + payload, + 'wrong_secret', + ) as unknown as Parameters[1]; + + const res = await webhooks.image.imageCompleted.handler(webhookCtx, req); + expect(res.success).toBe(false); + expect(res.statusCode).toBe(401); + }); + + it('processes animationCompleted webhook handler with valid signature', async () => { + const webhookCtx = { ...mockCtx, key: secret }; + const payload = { + uid: 'an_123', + status: 'completed' as const, + files: { mp4: 'https://cdn.bannerbear.com/sample.mp4' }, + }; + const req = createSignedWebhookRequest(payload) as unknown as Parameters< + typeof webhooks.animation.animationCompleted.handler + >[1]; + + const res = await webhooks.animation.animationCompleted.handler( + webhookCtx, + req, + ); + expect(res.success).toBe(true); + expect(res.data).toEqual(payload); + }); + + it('matches image completed webhook payloads', () => { + const matcher = createBannerbearImageCompletedMatch(); + + const validImagePayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'completed', + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }), + }; + + const pendingPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'pending', + files: null, + }), + }; + + const animationPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'an_123', + status: 'completed', + files: { mp4: 'https://cdn.bannerbear.com/sample.mp4' }, + }), + }; + + expect(matcher(validImagePayload)).toBe(true); + expect(matcher(pendingPayload)).toBe(false); + expect(matcher(animationPayload)).toBe(false); + }); + + it('matches animation completed webhook payloads', () => { + const matcher = createBannerbearAnimationCompletedMatch(); + + const validAnimationPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'an_123', + status: 'completed', + files: { mp4: 'https://cdn.bannerbear.com/sample.mp4' }, + }), + }; + + const imagePayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'completed', + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }), + }; + + expect(matcher(validAnimationPayload)).toBe(true); + expect(matcher(imagePayload)).toBe(false); + }); + + it('does not treat a generic uid/status body as Bannerbear', () => { + expect( + plugin.pluginWebhookMatcher?.({ + headers: {}, + body: JSON.stringify({ uid: 'other_1', status: 'completed' }), + }), + ).toBe(false); + }); +}); diff --git a/packages/bannerbear/webhooks/animation-completed.ts b/packages/bannerbear/webhooks/animation-completed.ts new file mode 100644 index 000000000..f6cbb7f97 --- /dev/null +++ b/packages/bannerbear/webhooks/animation-completed.ts @@ -0,0 +1,35 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearWebhooks } from '..'; +import { + createBannerbearAnimationCompletedMatch, + verifyBannerbearWebhookSignature, +} from './types'; + +export const animationCompleted: BannerbearWebhooks['animationCompleted'] = { + match: createBannerbearAnimationCompletedMatch(), + + handler: async (ctx, request) => { + const verification = verifyBannerbearWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.status !== 'completed') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'bannerbear.webhook.animation_completed', + { uid: event.uid }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; diff --git a/packages/bannerbear/webhooks/image-completed.ts b/packages/bannerbear/webhooks/image-completed.ts new file mode 100644 index 000000000..16e1b2825 --- /dev/null +++ b/packages/bannerbear/webhooks/image-completed.ts @@ -0,0 +1,35 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearWebhooks } from '..'; +import { + createBannerbearImageCompletedMatch, + verifyBannerbearWebhookSignature, +} from './types'; + +export const imageCompleted: BannerbearWebhooks['imageCompleted'] = { + match: createBannerbearImageCompletedMatch(), + + handler: async (ctx, request) => { + const verification = verifyBannerbearWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.status !== 'completed') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'bannerbear.webhook.image_completed', + { uid: event.uid }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; diff --git a/packages/bannerbear/webhooks/index.ts b/packages/bannerbear/webhooks/index.ts new file mode 100644 index 000000000..4970a0ce3 --- /dev/null +++ b/packages/bannerbear/webhooks/index.ts @@ -0,0 +1,13 @@ +import { animationCompleted } from './animation-completed'; +import { imageCompleted } from './image-completed'; + +export const ImageWebhooks = { + imageCompleted, +}; + +export const AnimationWebhooks = { + animationCompleted, +}; + +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/bannerbear/webhooks/tenant-matcher.ts b/packages/bannerbear/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..b3eb9f91b --- /dev/null +++ b/packages/bannerbear/webhooks/tenant-matcher.ts @@ -0,0 +1,7 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; + +export function matchBannerbearTenantWebhook( + _request: RawWebhookRequest, +): WebhookTenantMatch | null { + return null; +} diff --git a/packages/bannerbear/webhooks/types.ts b/packages/bannerbear/webhooks/types.ts new file mode 100644 index 000000000..4bac46aa6 --- /dev/null +++ b/packages/bannerbear/webhooks/types.ts @@ -0,0 +1,187 @@ +import { timingSafeEqual } from 'node:crypto'; +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { verifyHmacSignature } from 'corsair/http'; +import { z } from 'zod'; + +export const BannerbearWebhookPayloadSchema = z + .object({ + uid: z.string(), + status: z.string().optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), + }) + .loose(); + +export type BannerbearWebhookPayload = z.infer< + typeof BannerbearWebhookPayloadSchema +>; + +export const ImageCompletedEventSchema = z + .object({ + uid: z.string(), + status: z.literal('completed'), + template: z.string().optional(), + files: z.record(z.string(), z.string().nullable()).optional(), + metadata: z.string().optional(), + self: z.string().optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), + }) + .loose(); + +export type ImageCompletedEvent = z.infer; + +export const AnimationCompletedEventSchema = z + .object({ + uid: z.string(), + status: z.literal('completed'), + template: z.string().optional(), + files: z.record(z.string(), z.string().nullable()).optional(), + progress: z.number().optional(), + metadata: z.string().optional(), + self: z.string().optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), + }) + .loose(); + +export type AnimationCompletedEvent = z.infer< + typeof AnimationCompletedEventSchema +>; + +export type BannerbearWebhookOutputs = { + imageCompleted: ImageCompletedEvent; + animationCompleted: AnimationCompletedEvent; +}; + +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; +} + +function fileKeys(files: unknown): string[] { + if (files === null || typeof files !== 'object' || Array.isArray(files)) { + return []; + } + return Object.keys(files); +} + +function isAnimationFiles(files: unknown): boolean { + const keys = fileKeys(files); + return keys.includes('mp4') || keys.includes('mov'); +} + +export function createBannerbearImageCompletedMatch(): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + if (!parsedBody) return false; + return ( + parsedBody.status === 'completed' && + 'files' in parsedBody && + !isAnimationFiles(parsedBody.files) + ); + }; +} + +export function createBannerbearAnimationCompletedMatch(): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + if (!parsedBody) return false; + return ( + parsedBody.status === 'completed' && isAnimationFiles(parsedBody.files) + ); + }; +} + +function headerValue( + headers: WebhookRequest['headers'], + name: string, +): string | undefined { + const value = headers[name]; + if (Array.isArray(value)) return value[0]; + return value; +} + +function safeEqual(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export function verifyBannerbearWebhookSignature( + request: WebhookRequest, + webhookSecret?: string, +): { valid: boolean; error?: string } { + if (request.hubVerified) { + return { valid: true }; + } + + if (!webhookSecret) { + return { valid: false, error: 'Missing webhook secret' }; + } + + const authHeader = headerValue(request.headers, 'authorization'); + + if (authHeader) { + const token = authHeader.startsWith('Bearer ') + ? authHeader.slice(7).trim() + : authHeader.trim(); + if (safeEqual(token, webhookSecret)) { + return { valid: true }; + } + } + + const signature = + headerValue(request.headers, 'x-bannerbear-signature') || + headerValue(request.headers, 'x-webhook-signature') || + headerValue(request.headers, 'signature'); + + if (signature && request.rawBody) { + const isValid = verifyHmacSignature( + request.rawBody, + webhookSecret, + signature, + 'sha256', + ); + if (isValid) { + return { valid: true }; + } + } + + if (!authHeader && !signature) { + return { + valid: false, + error: 'Missing Authorization or webhook signature header', + }; + } + + return { valid: false, error: 'Invalid webhook credentials or signature' }; +} + +export function isBannerbearCompletionPayload(body: unknown): boolean { + const parsed = parseBody(body); + if (!parsed) return false; + return ( + typeof parsed.uid === 'string' && + typeof parsed.status === 'string' && + 'files' in parsed + ); +} diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index fbbf973f2..1eecd525c 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -61,6 +61,7 @@ export const BaseProviders = [ 'attio', 'autom', 'ayrshare', + 'bannerbear', 'bart', 'basecamp', 'baselinker', @@ -256,6 +257,7 @@ export const ProviderDisplayNames = { attio: 'Attio', autom: 'Autom', ayrshare: 'Ayrshare', + bannerbear: 'Bannerbear', bart: 'BART', basecamp: 'Basecamp', baselinker: 'BaseLinker', @@ -458,6 +460,7 @@ export type AllProviders = | 'attio' | 'autom' | 'ayrshare' + | 'bannerbear' | 'bart' | 'basecamp' | 'baselinker' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 959836bdd..a45ae9c14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1040,7 +1040,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/anonyflow: + packages/anchorbrowser: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -1064,7 +1064,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/anchorbrowser: + packages/anonyflow: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -1510,6 +1510,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/bannerbear: + 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/bart: devDependencies: '@types/jest':