diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index b5dca6fd6..ee603c04f 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -153,6 +153,7 @@ export const BaseProviders = [ 'oura', 'outlook', 'pagerduty', + 'pdfmonkey', 'perplexityai', 'posthog', 'razorpay', @@ -341,6 +342,7 @@ export const ProviderDisplayNames = { oura: 'Oura', outlook: 'Outlook', pagerduty: 'PagerDuty', + pdfmonkey: 'PDFMonkey', perplexityai: 'Perplexity AI', posthog: 'PostHog', razorpay: 'Razorpay', @@ -536,6 +538,7 @@ export type AllProviders = | 'oura' | 'outlook' | 'pagerduty' + | 'pdfmonkey' | 'perplexityai' | 'posthog' | 'razorpay' diff --git a/packages/pdfmonkey/api.test.ts b/packages/pdfmonkey/api.test.ts new file mode 100644 index 000000000..3f30cb6c2 --- /dev/null +++ b/packages/pdfmonkey/api.test.ts @@ -0,0 +1,484 @@ +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { ApiError, request } from 'corsair/http'; +import { PDFMONKEY_API_BASE } from './client'; +import type { PDFMonkeyContext } from './index'; +import { pdfmonkey } from './index'; + +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn(async () => undefined), +})); + +jest.mock('corsair/http', () => ({ + ...jest.requireActual('corsair/http'), + request: jest.fn(), +})); + +const mockRequest = request as jest.Mock; +const mockLog = logEventFromContext as jest.MockedFunction< + typeof logEventFromContext +>; + +const DOCUMENT = { + id: 'doc-1', + app_id: 'app-1', + document_template_id: 'tpl-1', + status: 'pending' as const, + payload: { clientName: 'Ada' }, + meta: null, + filename: null, + download_url: null, + preview_url: null, + public_share_link: null, + checksum: null, + generation_logs: [], + failure_cause: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +}; + +const DOCUMENT_CARD = { + id: 'doc-1', + app_id: 'app-1', + document_template_id: 'tpl-1', + status: 'success' as const, + download_url: 'https://files.example.com/doc.pdf', + preview_url: 'https://preview.pdfmonkey.io/doc', + public_share_link: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +}; + +const TEMPLATE = { + id: 'tpl-1', + app_id: 'app-1', + identifier: 'invoice', + body: '

Hello

', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +}; + +const LIST_META = { + current_page: 1, + next_page: 2, + prev_page: null, + total_pages: 4, +}; + +function testContext(): PDFMonkeyContext { + return { + key: 'test-key', + options: { authType: 'api_key' }, + db: {}, + logEvent: jest.fn(), + } as unknown as PDFMonkeyContext; +} + +function lastCall() { + expect(mockRequest).toHaveBeenCalled(); + return mockRequest.mock.calls[mockRequest.mock.calls.length - 1] as [ + { BASE: string; HEADERS?: Record }, + { + method: string; + url: string; + body?: unknown; + query?: unknown; + }, + ]; +} + +describe('PDFMonkey plugin shape', () => { + it('registers 12 endpoints, generation webhooks, and api_key auth', () => { + const plugin = pdfmonkey(); + expect(plugin.id).toBe('pdfmonkey'); + expect(plugin.options?.authType).toBe('api_key'); + expect(plugin.authConfig).toEqual({ + api_key: { account: ['tenant_external_id'] }, + }); + expect(Object.keys(plugin.endpointMeta ?? {}).sort()).toEqual([ + 'documents.createDocument', + 'documents.createDocumentSync', + 'documents.deleteDocument', + 'documents.getDocument', + 'documents.getDocumentCard', + 'documents.listDocumentCards', + 'documents.updateDocument', + 'templates.createTemplate', + 'templates.deleteTemplate', + 'templates.getTemplate', + 'templates.listTemplateCards', + 'templates.updateTemplate', + ]); + expect(Object.keys(plugin.webhookSchemas ?? {}).sort()).toEqual([ + 'documents.generationFailure', + 'documents.generationSuccess', + ]); + }); + + it('throws AuthMissingError when no API key is available', async () => { + const plugin = pdfmonkey(); + await expect( + plugin.keyBuilder?.( + { + authType: 'api_key', + keys: { + get_api_key: async () => undefined, + }, + } as never, + 'endpoint', + ), + ).rejects.toBeInstanceOf(AuthMissingError); + }); + + it('throws AuthMissingError when the webhook signature is missing', async () => { + const plugin = pdfmonkey(); + await expect( + plugin.keyBuilder?.( + { + authType: 'api_key', + keys: { + get_webhook_signature: async () => undefined, + }, + } as never, + 'webhook', + ), + ).rejects.toMatchObject({ + name: 'AuthMissingError', + pluginId: 'pdfmonkey', + authType: 'webhook_signature', + }); + }); +}); + +describe('PDFMonkey endpoints', () => { + const ctx = testContext(); + const plugin = pdfmonkey({ key: 'test-key' }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lists template cards with page[number] and q[workspace_id]', async () => { + mockRequest.mockResolvedValueOnce({ + document_template_cards: [ + { + id: 'tpl-1', + app_id: 'app-1', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ], + meta: LIST_META, + }); + + const result = await plugin.endpoints!.templates.listTemplateCards(ctx, { + q: { workspace_id: 'ws-1' }, + }); + + expect(result.meta).toEqual(LIST_META); + expect(result.document_template_cards[0]?.id).toBe('tpl-1'); + const [config, options] = lastCall(); + expect(config.BASE).toBe(PDFMONKEY_API_BASE); + expect(config.HEADERS).toEqual( + expect.objectContaining({ Authorization: 'Bearer test-key' }), + ); + expect(options).toEqual( + expect.objectContaining({ + method: 'GET', + url: '/api/v1/document_template_cards', + query: { + page: { number: 1 }, + q: { workspace_id: 'ws-1', folders: undefined }, + sort: undefined, + }, + }), + ); + expect(mockLog).toHaveBeenCalled(); + }); + + it('gets a wrapped template', async () => { + mockRequest.mockResolvedValueOnce({ document_template: TEMPLATE }); + + const result = await plugin.endpoints!.templates.getTemplate(ctx, { + id: 'tpl-1', + }); + + expect(result.document_template.id).toBe('tpl-1'); + expect(lastCall()[1]).toEqual( + expect.objectContaining({ + method: 'GET', + url: '/api/v1/document_templates/tpl-1', + }), + ); + }); + + it('creates a template', async () => { + mockRequest.mockResolvedValueOnce({ document_template: { id: 'tpl-2' } }); + + const result = await plugin.endpoints!.templates.createTemplate(ctx, { + document_template: { + app_id: 'app-1', + identifier: 'invoice', + body: '

Hi

', + }, + }); + + expect(result.document_template.id).toBe('tpl-2'); + const [, options] = lastCall(); + expect(options.method).toBe('POST'); + expect(options.url).toBe('/api/v1/document_templates'); + expect(options.body).toEqual( + expect.objectContaining({ + document_template: expect.objectContaining({ + identifier: 'invoice', + edition_mode: 'code', + output_type: 'pdf', + }), + }), + ); + }); + + it('updates a template and rejects a missing body', async () => { + mockRequest.mockResolvedValueOnce({ document_template: { id: 'tpl-1' } }); + + await expect( + plugin.endpoints!.templates.updateTemplate(ctx, { + document_template_id: 'tpl-1', + } as never), + ).rejects.toThrow(); + expect(mockRequest).not.toHaveBeenCalled(); + + const result = await plugin.endpoints!.templates.updateTemplate(ctx, { + document_template_id: 'tpl-1', + document_template: { identifier: 'updated' }, + }); + expect(result.document_template.id).toBe('tpl-1'); + expect(lastCall()[1]).toEqual( + expect.objectContaining({ + method: 'PUT', + url: '/api/v1/document_templates/tpl-1', + body: { document_template: { identifier: 'updated' } }, + }), + ); + }); + + it('maps template DELETE 204 to { success: true }', async () => { + mockRequest.mockResolvedValueOnce(undefined); + + const result = await plugin.endpoints!.templates.deleteTemplate(ctx, { + id: 'tpl-1', + }); + + expect(result).toEqual({ success: true }); + expect(lastCall()[1]).toEqual( + expect.objectContaining({ + method: 'DELETE', + url: '/api/v1/document_templates/tpl-1', + }), + ); + }); + + it('creates a document and unwraps { document }', async () => { + mockRequest.mockResolvedValueOnce({ document: DOCUMENT }); + + const result = await plugin.endpoints!.documents.createDocument(ctx, { + document: { + document_template_id: 'tpl-1', + status: 'pending', + payload: { clientName: 'Ada' }, + }, + }); + + expect(result.id).toBe('doc-1'); + expect(result.document_template_id).toBe('tpl-1'); + expect(lastCall()[1]).toEqual( + expect.objectContaining({ + method: 'POST', + url: '/api/v1/documents', + body: { + document: { + document_template_id: 'tpl-1', + status: 'pending', + payload: { clientName: 'Ada' }, + }, + }, + }), + ); + }); + + it('creates a sync document and unwraps { document_card }', async () => { + mockRequest.mockResolvedValueOnce({ document_card: DOCUMENT_CARD }); + + const result = await plugin.endpoints!.documents.createDocumentSync(ctx, { + document: { document_template_id: 'tpl-1' }, + }); + + expect(result.id).toBe('doc-1'); + expect(result.status).toBe('success'); + expect(lastCall()[1]).toEqual( + expect.objectContaining({ + method: 'POST', + url: '/api/v1/documents/sync', + body: { + document: { + document_template_id: 'tpl-1', + status: 'pending', + }, + }, + }), + ); + }); + + it('gets a document card and unwraps { document_card }', async () => { + mockRequest.mockResolvedValueOnce({ document_card: DOCUMENT_CARD }); + + const result = await plugin.endpoints!.documents.getDocumentCard(ctx, { + id: 'doc-1', + }); + + expect(result.id).toBe('doc-1'); + expect(lastCall()[1].url).toBe('/api/v1/document_cards/doc-1'); + }); + + it('lists document cards with nested page and q filters', async () => { + mockRequest.mockResolvedValueOnce({ + document_cards: [DOCUMENT_CARD], + meta: LIST_META, + }); + + const result = await plugin.endpoints!.documents.listDocumentCards(ctx, { + page: 3, + q: { status: 'success', document_template_id: 'tpl-1' }, + }); + + expect(result.document_cards[0]?.id).toBe('doc-1'); + expect(result.meta?.next_page).toBe(2); + expect(lastCall()[1]).toEqual( + expect.objectContaining({ + method: 'GET', + url: '/api/v1/document_cards', + query: { + page: { number: 3 }, + q: { + document_template_id: 'tpl-1', + status: 'success', + workspace_id: undefined, + updated_since: undefined, + search: undefined, + }, + }, + }), + ); + }); + + it('gets a full document and unwraps { document }', async () => { + mockRequest.mockResolvedValueOnce({ document: DOCUMENT }); + + const result = await plugin.endpoints!.documents.getDocument(ctx, { + id: 'doc-1', + }); + + expect(result.id).toBe('doc-1'); + expect(result.payload).toEqual({ clientName: 'Ada' }); + expect(lastCall()[1].url).toBe('/api/v1/documents/doc-1'); + }); + + it('updates a document', async () => { + mockRequest.mockResolvedValueOnce({ + document: { ...DOCUMENT, status: 'draft' }, + }); + + const result = await plugin.endpoints!.documents.updateDocument(ctx, { + document_id: 'doc-1', + document: { status: 'draft' }, + }); + + expect(result.status).toBe('draft'); + expect(lastCall()[1]).toEqual( + expect.objectContaining({ + method: 'PUT', + url: '/api/v1/documents/doc-1', + body: { document: { status: 'draft' } }, + }), + ); + }); + + it('maps document DELETE 204 to { success: true }', async () => { + mockRequest.mockResolvedValueOnce(undefined); + + const result = await plugin.endpoints!.documents.deleteDocument(ctx, { + id: 'doc-1', + }); + + expect(result).toEqual({ success: true }); + expect(lastCall()[1].method).toBe('DELETE'); + }); + + it('verifies Svix signatures on generation webhooks', async () => { + const crypto = await import('crypto'); + const secretBytes = Buffer.from('pdfmonkey-test-secret', 'utf8'); + const secret = `whsec_${secretBytes.toString('base64')}`; + const timestamp = String(Math.floor(Date.now() / 1000)); + const payload = { + document: { + id: 'doc-1', + app_id: 'app-1', + status: 'success' as const, + download_url: 'https://files.example.com/doc.pdf', + preview_url: null, + public_share_link: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }; + const rawBody = JSON.stringify(payload); + const signature = `v1,${crypto + .createHmac('sha256', secretBytes) + .update(`msg_1.${timestamp}.${rawBody}`) + .digest('base64')}`; + const webhookPlugin = pdfmonkey({ webhookSecret: secret }); + const webhookCtx = { + ...ctx, + key: secret, + } as unknown as PDFMonkeyContext; + + const result = + await webhookPlugin.webhooks!.documents.generationSuccess.handler( + webhookCtx, + { + payload, + headers: { + 'svix-id': 'msg_1', + 'svix-timestamp': timestamp, + 'svix-signature': signature, + }, + rawBody, + }, + ); + + expect(result).toMatchObject({ + success: true, + data: { document: { id: 'doc-1', status: 'success' } }, + }); + }); + + it('surfaces ApiError from the client so 429 handlers can match', async () => { + const error = new ApiError( + { method: 'GET', url: '/api/v1/documents' }, + { + url: 'https://api.pdfmonkey.io/api/v1/documents', + ok: false, + status: 429, + statusText: 'Too Many Requests', + body: {}, + }, + 'Too Many Requests', + { retryAfter: 2000 }, + ); + mockRequest.mockRejectedValueOnce(error); + + await expect( + plugin.endpoints!.documents.getDocument(ctx, { id: 'doc-1' }), + ).rejects.toBe(error); + }); +}); diff --git a/packages/pdfmonkey/client.ts b/packages/pdfmonkey/client.ts new file mode 100644 index 000000000..c006c96b1 --- /dev/null +++ b/packages/pdfmonkey/client.ts @@ -0,0 +1,63 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export const PDFMONKEY_API_BASE = 'https://api.pdfmonkey.io'; + +export type PdfMonkeyQueryValue = + | string + | number + | boolean + | undefined + | Record; + +export type PdfMonkeyRequestOptions = { + apiKey?: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; +}; + +function buildConfig(apiKey?: string, isWrite = false): OpenAPIConfig { + return { + BASE: PDFMONKEY_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + ...(isWrite ? { 'Content-Type': 'application/json' } : {}), + }, + }; +} + +async function handleRequestError(error: unknown): Promise { + if (error instanceof ApiError || error instanceof Error) { + throw error; + } + throw new Error('Unknown PDFMonkey error'); +} + +export async function makePdfMonkeyRequest( + endpoint: string, + options: PdfMonkeyRequestOptions = {}, +): Promise { + const { apiKey, method = 'GET', body, query = {} } = options; + const isWrite = method === 'POST' || method === 'PUT' || method === 'PATCH'; + + const config = buildConfig(apiKey, isWrite); + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: isWrite ? body : undefined, + mediaType: isWrite ? 'application/json; charset=utf-8' : undefined, + query, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + return handleRequestError(error); + } +} diff --git a/packages/pdfmonkey/endpoints/documents.ts b/packages/pdfmonkey/endpoints/documents.ts new file mode 100644 index 000000000..b991f0091 --- /dev/null +++ b/packages/pdfmonkey/endpoints/documents.ts @@ -0,0 +1,208 @@ +import { logEventFromContext } from 'corsair/core'; +import { makePdfMonkeyRequest } from '../client'; +import type { PDFMonkeyEndpoints } from '../index'; +import { + CreateDocumentInputSchema, + CreateDocumentSyncInputSchema, + DeleteDocumentInputSchema, + DocumentCardResponseSchema, + DocumentResponseSchema, + GetDocumentCardInputSchema, + GetDocumentInputSchema, + ListDocumentCardsInputSchema, + ListDocumentCardsOutputSchema, + PDFMonkeyEndpointOutputSchemas, + UpdateDocumentInputSchema, +} from './types'; + +export const createDocument: PDFMonkeyEndpoints['createDocument'] = async ( + ctx, + input, +) => { + const parsed = CreateDocumentInputSchema.parse(input); + const response = await makePdfMonkeyRequest('/api/v1/documents', { + apiKey: ctx.key, + method: 'POST', + body: { + document: parsed.document, + }, + }); + + const document = DocumentResponseSchema.parse(response).document; + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.createDocument', + { + document_template_id: parsed.document.document_template_id, + status: parsed.document.status, + }, + 'completed', + ); + + return document; +}; + +export const createDocumentSync: PDFMonkeyEndpoints['createDocumentSync'] = + async (ctx, input) => { + const parsed = CreateDocumentSyncInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/documents/sync', + { + apiKey: ctx.key, + method: 'POST', + body: { + document: parsed.document, + }, + }, + ); + + const documentCard = + DocumentCardResponseSchema.parse(response).document_card; + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.createDocumentSync', + { + document_template_id: parsed.document.document_template_id, + status: parsed.document.status, + }, + 'completed', + ); + + return documentCard; + }; + +export const getDocumentCard: PDFMonkeyEndpoints['getDocumentCard'] = async ( + ctx, + input, +) => { + const parsed = GetDocumentCardInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/document_cards/' + parsed.id, + { + apiKey: ctx.key, + method: 'GET', + }, + ); + + const documentCard = DocumentCardResponseSchema.parse(response).document_card; + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.getDocumentCard', + { id: parsed.id }, + 'completed', + ); + + return documentCard; +}; + +export const listDocumentCards: PDFMonkeyEndpoints['listDocumentCards'] = + async (ctx, input) => { + const parsed = ListDocumentCardsInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/document_cards', + { + apiKey: ctx.key, + method: 'GET', + query: { + page: { number: parsed.page }, + q: { + document_template_id: parsed.q?.document_template_id, + status: parsed.q?.status, + workspace_id: parsed.q?.workspace_id, + updated_since: parsed.q?.updated_since, + search: parsed.q?.search, + }, + }, + }, + ); + + const output = ListDocumentCardsOutputSchema.parse(response); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.listDocumentCards', + { + page: parsed.page, + status: parsed.q?.status, + }, + 'completed', + ); + + return output; + }; + +export const getDocument: PDFMonkeyEndpoints['getDocument'] = async ( + ctx, + input, +) => { + const parsed = GetDocumentInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/documents/' + parsed.id, + { + apiKey: ctx.key, + method: 'GET', + }, + ); + + const document = DocumentResponseSchema.parse(response).document; + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.getDocument', + { id: parsed.id }, + 'completed', + ); + + return document; +}; + +export const updateDocument: PDFMonkeyEndpoints['updateDocument'] = async ( + ctx, + input, +) => { + const parsed = UpdateDocumentInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/documents/' + parsed.document_id, + { + apiKey: ctx.key, + method: 'PUT', + body: { + document: parsed.document, + }, + }, + ); + + const document = DocumentResponseSchema.parse(response).document; + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.updateDocument', + { document_id: parsed.document_id }, + 'completed', + ); + + return document; +}; + +export const deleteDocument: PDFMonkeyEndpoints['deleteDocument'] = async ( + ctx, + input, +) => { + const parsed = DeleteDocumentInputSchema.parse(input); + await makePdfMonkeyRequest('/api/v1/documents/' + parsed.id, { + apiKey: ctx.key, + method: 'DELETE', + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.deleteDocument', + { id: parsed.id }, + 'completed', + ); + + return PDFMonkeyEndpointOutputSchemas.deleteDocument.parse({ success: true }); +}; diff --git a/packages/pdfmonkey/endpoints/index.ts b/packages/pdfmonkey/endpoints/index.ts new file mode 100644 index 000000000..03bcba6ea --- /dev/null +++ b/packages/pdfmonkey/endpoints/index.ts @@ -0,0 +1,22 @@ +import * as Documents from './documents'; +import * as Templates from './templates'; + +export const Template = { + listTemplateCards: Templates.listTemplateCards, + getTemplate: Templates.getTemplate, + createTemplate: Templates.createTemplate, + updateTemplate: Templates.updateTemplate, + deleteTemplate: Templates.deleteTemplate, +}; + +export const Document = { + createDocument: Documents.createDocument, + createDocumentSync: Documents.createDocumentSync, + getDocumentCard: Documents.getDocumentCard, + listDocumentCards: Documents.listDocumentCards, + getDocument: Documents.getDocument, + updateDocument: Documents.updateDocument, + deleteDocument: Documents.deleteDocument, +}; + +export * from './types'; diff --git a/packages/pdfmonkey/endpoints/templates.ts b/packages/pdfmonkey/endpoints/templates.ts new file mode 100644 index 000000000..2951aea79 --- /dev/null +++ b/packages/pdfmonkey/endpoints/templates.ts @@ -0,0 +1,153 @@ +import { logEventFromContext } from 'corsair/core'; +import { makePdfMonkeyRequest } from '../client'; +import type { PDFMonkeyEndpoints } from '../index'; +import { + CreateTemplateInputSchema, + CreateTemplateOutputSchema, + DeleteTemplateInputSchema, + GetTemplateInputSchema, + GetTemplateOutputSchema, + ListTemplateCardsInputSchema, + ListTemplateCardsOutputSchema, + PDFMonkeyEndpointOutputSchemas, + UpdateTemplateInputSchema, + UpdateTemplateOutputSchema, +} from './types'; + +export const listTemplateCards: PDFMonkeyEndpoints['listTemplateCards'] = + async (ctx, input) => { + const parsed = ListTemplateCardsInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/document_template_cards', + { + apiKey: ctx.key, + method: 'GET', + query: { + page: { number: parsed.page }, + q: { + workspace_id: parsed.q.workspace_id, + folders: parsed.q.folders, + }, + sort: parsed.sort, + }, + }, + ); + + const output = ListTemplateCardsOutputSchema.parse(response); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.listTemplateCards', + { + workspace_id: parsed.q.workspace_id, + page: parsed.page, + }, + 'completed', + ); + + return output; + }; + +export const getTemplate: PDFMonkeyEndpoints['getTemplate'] = async ( + ctx, + input, +) => { + const parsed = GetTemplateInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/document_templates/' + parsed.id, + { + apiKey: ctx.key, + method: 'GET', + }, + ); + + const output = GetTemplateOutputSchema.parse(response); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.getTemplate', + { id: parsed.id }, + 'completed', + ); + + return output; +}; + +export const createTemplate: PDFMonkeyEndpoints['createTemplate'] = async ( + ctx, + input, +) => { + const parsed = CreateTemplateInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/document_templates', + { + apiKey: ctx.key, + method: 'POST', + body: { + document_template: parsed.document_template, + }, + }, + ); + + const output = CreateTemplateOutputSchema.parse(response); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.createTemplate', + { identifier: parsed.document_template.identifier }, + 'completed', + ); + + return output; +}; + +export const updateTemplate: PDFMonkeyEndpoints['updateTemplate'] = async ( + ctx, + input, +) => { + const parsed = UpdateTemplateInputSchema.parse(input); + const response = await makePdfMonkeyRequest( + '/api/v1/document_templates/' + parsed.document_template_id, + { + apiKey: ctx.key, + method: 'PUT', + body: { + document_template: parsed.document_template, + }, + }, + ); + + const output = UpdateTemplateOutputSchema.parse(response); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.updateTemplate', + { template_id: parsed.document_template_id }, + 'completed', + ); + + return output; +}; + +export const deleteTemplate: PDFMonkeyEndpoints['deleteTemplate'] = async ( + ctx, + input, +) => { + const parsed = DeleteTemplateInputSchema.parse(input); + await makePdfMonkeyRequest( + '/api/v1/document_templates/' + parsed.id, + { + apiKey: ctx.key, + method: 'DELETE', + }, + ); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.deleteTemplate', + { id: parsed.id }, + 'completed', + ); + + return PDFMonkeyEndpointOutputSchemas.deleteTemplate.parse({ success: true }); +}; diff --git a/packages/pdfmonkey/endpoints/types.ts b/packages/pdfmonkey/endpoints/types.ts new file mode 100644 index 000000000..3622fde6a --- /dev/null +++ b/packages/pdfmonkey/endpoints/types.ts @@ -0,0 +1,350 @@ +import { z } from 'zod'; + +const DeleteSuccessSchema = z.object({ success: z.boolean() }); +export type DeleteSuccess = z.infer; + +const JsonValueSchema = z.unknown(); + +export const PaginationMetaSchema = z.object({ + current_page: z.number().int().nonnegative(), + next_page: z.number().int().nullable(), + prev_page: z.number().int().nullable(), + total_pages: z.number().int().nonnegative(), +}); + +export type PaginationMeta = z.infer; + +export const DocumentTemplateCardSchema = z.object({ + id: z.string(), + app_id: z.string(), + identifier: z.string().optional(), + edition_mode: z.enum(['code', 'builder']).optional(), + output_type: z.enum(['pdf', 'image']).optional(), + is_draft: z.boolean().optional(), + created_at: z.string(), + updated_at: z.string(), +}); + +export type DocumentTemplateCard = z.infer; + +export const DocumentTemplateSchema = z.object({ + id: z.string(), + app_id: z.string(), + identifier: z.string().optional(), + body: z.string().optional(), + body_draft: z.string().optional(), + scss_style: z.string().optional(), + scss_style_draft: z.string().optional(), + sample_data: z.string().optional(), + sample_data_draft: z.string().optional(), + settings: JsonValueSchema.optional(), + settings_draft: JsonValueSchema.optional(), + pdf_engine_id: z.string().nullable().optional(), + pdf_engine_draft_id: z.string().nullable().optional(), + template_folder_id: z.string().nullable().optional(), + template_folder_identifier: z.string().optional(), + ttl: z.number().int().nullable().optional(), + edition_mode: z.enum(['code', 'builder']).optional(), + output_type: z.enum(['pdf', 'image']).optional(), + created_at: z.string(), + updated_at: z.string(), +}); + +export type DocumentTemplate = z.infer; + +export const ListTemplateCardsInputSchema = z.object({ + page: z.number().int().positive().default(1), + q: z.object({ + workspace_id: z.string(), + folders: z.string().optional(), + }), + sort: z.string().optional(), +}); + +export type ListTemplateCardsInput = z.input< + typeof ListTemplateCardsInputSchema +>; + +export const ListTemplateCardsOutputSchema = z.object({ + document_template_cards: z.array(DocumentTemplateCardSchema), + meta: PaginationMetaSchema.optional(), +}); + +export type ListTemplateCardsOutput = z.infer< + typeof ListTemplateCardsOutputSchema +>; + +export const GetTemplateInputSchema = z.object({ + id: z.string(), +}); + +export type GetTemplateInput = z.input; + +export const GetTemplateOutputSchema = z.object({ + document_template: DocumentTemplateSchema, +}); + +export type GetTemplateOutput = z.infer; + +export const CreateTemplateInputSchema = z.object({ + document_template: z.object({ + app_id: z.string(), + identifier: z.string(), + body: z.string(), + body_draft: z.string().optional(), + scss_style: z.string().optional(), + scss_style_draft: z.string().optional(), + sample_data: z.string().optional(), + sample_data_draft: z.string().optional(), + settings: JsonValueSchema.optional(), + settings_draft: JsonValueSchema.optional(), + pdf_engine_id: z.string().optional(), + pdf_engine_draft_id: z.string().optional(), + template_folder_id: z.string().optional(), + ttl: z.number().int().nullable().optional(), + edition_mode: z.enum(['code', 'builder']).optional().default('code'), + output_type: z.enum(['pdf', 'image']).optional().default('pdf'), + }), +}); + +export type CreateTemplateInput = z.input; + +export const CreateTemplateOutputSchema = z.object({ + document_template: z.object({ + id: z.string(), + }), +}); + +export type CreateTemplateOutput = z.infer; + +export const UpdateTemplateInputSchema = z.object({ + document_template_id: z.string(), + document_template: z.object({ + identifier: z.string().optional(), + body: z.string().optional(), + body_draft: z.string().optional(), + scss_style: z.string().optional(), + scss_style_draft: z.string().optional(), + sample_data: z.string().optional(), + sample_data_draft: z.string().optional(), + settings: JsonValueSchema.optional(), + settings_draft: JsonValueSchema.optional(), + pdf_engine_id: z.string().optional(), + pdf_engine_draft_id: z.string().optional(), + template_folder_id: z.string().optional(), + ttl: z.number().int().nullable().optional(), + edition_mode: z.enum(['code', 'builder']).optional(), + output_type: z.enum(['pdf', 'image']).optional(), + }), +}); + +export type UpdateTemplateInput = z.input; + +export const UpdateTemplateOutputSchema = CreateTemplateOutputSchema; + +export type UpdateTemplateOutput = z.infer; + +export const DeleteTemplateInputSchema = z.object({ + id: z.string(), +}); + +export type DeleteTemplateInput = z.input; + +export const DocumentCardSchema = z.object({ + id: z.string(), + app_id: z.string(), + document_template_id: z.string().optional(), + document_template_identifier: z.string().optional(), + status: z.enum(['draft', 'pending', 'generating', 'success', 'failure']), + filename: z.string().nullable().optional(), + download_url: z.url().nullable().optional(), + preview_url: z.url().nullable().optional(), + public_share_link: z.url().nullable().optional(), + failure_cause: z.string().nullable().optional(), + meta: JsonValueSchema.nullable().optional(), + output_type: z.enum(['pdf', 'image']).optional(), + created_at: z.string(), + updated_at: z.string(), +}); + +export type DocumentCard = z.infer; + +export const DocumentSchema = z.object({ + id: z.string(), + app_id: z.string(), + document_template_id: z.string(), + document_template_identifier: z.string().optional(), + status: z.enum(['draft', 'pending', 'generating', 'success', 'failure']), + payload: JsonValueSchema.nullable(), + meta: JsonValueSchema.nullable(), + filename: z.string().nullable(), + download_url: z.url().nullable(), + preview_url: z.url().nullable(), + public_share_link: z.url().nullable(), + checksum: z.string().nullable(), + generation_logs: z.array(JsonValueSchema).optional(), + failure_cause: z.string().nullable(), + output_type: z.enum(['pdf', 'image']).optional(), + created_at: z.string(), + updated_at: z.string(), +}); + +export type Document = z.infer; + +export const DocumentResponseSchema = z.object({ + document: DocumentSchema, +}); + +export type DocumentResponse = z.infer; + +export const DocumentCardResponseSchema = z.object({ + document_card: DocumentCardSchema, +}); + +export type DocumentCardResponse = z.infer; + +export const DocumentCreateRequestSchema = z.object({ + document: z.object({ + document_template_id: z.string(), + status: z.enum(['draft', 'pending']).optional(), + payload: JsonValueSchema.optional(), + meta: JsonValueSchema.optional(), + }), +}); + +export type DocumentCreateRequest = z.infer; + +export const CreateDocumentInputSchema = DocumentCreateRequestSchema; + +export type CreateDocumentInput = z.input; + +export const CreateDocumentSyncInputSchema = z.object({ + document: z.object({ + document_template_id: z.string(), + status: z.enum(['draft', 'pending']).optional().default('pending'), + payload: JsonValueSchema.optional(), + meta: JsonValueSchema.optional(), + }), +}); + +export type CreateDocumentSyncInput = z.input< + typeof CreateDocumentSyncInputSchema +>; + +export const GetDocumentCardInputSchema = z.object({ + id: z.string(), +}); + +export type GetDocumentCardInput = z.input; + +export const ListDocumentCardsInputSchema = z.object({ + page: z.number().int().positive().default(1), + q: z + .object({ + document_template_id: z.string().optional(), + status: z + .enum(['draft', 'pending', 'generating', 'success', 'failure']) + .optional(), + workspace_id: z.string().optional(), + updated_since: z.string().optional(), + search: z.string().optional(), + }) + .optional(), +}); + +export type ListDocumentCardsInput = z.input< + typeof ListDocumentCardsInputSchema +>; + +export const ListDocumentCardsOutputSchema = z.object({ + document_cards: z.array(DocumentCardSchema), + meta: PaginationMetaSchema.optional(), +}); + +export type ListDocumentCardsOutput = z.infer< + typeof ListDocumentCardsOutputSchema +>; + +export const GetDocumentInputSchema = z.object({ + id: z.string(), +}); + +export type GetDocumentInput = z.input; + +export const UpdateDocumentInputSchema = z.object({ + document_id: z.string(), + document: z.object({ + document_template_id: z.string().optional(), + status: z.enum(['draft', 'pending']).optional(), + payload: JsonValueSchema.optional(), + meta: JsonValueSchema.optional(), + }), +}); + +export type UpdateDocumentInput = z.input; + +export const DeleteDocumentInputSchema = z.object({ + id: z.string(), +}); + +export type DeleteDocumentInput = z.input; + +export type PDFMonkeyEndpointInputs = { + listTemplateCards: ListTemplateCardsInput; + getTemplate: GetTemplateInput; + createTemplate: CreateTemplateInput; + updateTemplate: UpdateTemplateInput; + deleteTemplate: DeleteTemplateInput; + createDocument: CreateDocumentInput; + createDocumentSync: CreateDocumentSyncInput; + getDocumentCard: GetDocumentCardInput; + listDocumentCards: ListDocumentCardsInput; + getDocument: GetDocumentInput; + updateDocument: UpdateDocumentInput; + deleteDocument: DeleteDocumentInput; +}; + +export type PDFMonkeyEndpointOutputs = { + listTemplateCards: ListTemplateCardsOutput; + getTemplate: GetTemplateOutput; + createTemplate: CreateTemplateOutput; + updateTemplate: UpdateTemplateOutput; + deleteTemplate: DeleteSuccess; + createDocument: Document; + createDocumentSync: DocumentCard; + getDocumentCard: DocumentCard; + listDocumentCards: ListDocumentCardsOutput; + getDocument: Document; + updateDocument: Document; + deleteDocument: DeleteSuccess; +}; + +export const PDFMonkeyEndpointInputSchemas = { + listTemplateCards: ListTemplateCardsInputSchema, + getTemplate: GetTemplateInputSchema, + createTemplate: CreateTemplateInputSchema, + updateTemplate: UpdateTemplateInputSchema, + deleteTemplate: DeleteTemplateInputSchema, + createDocument: CreateDocumentInputSchema, + createDocumentSync: CreateDocumentSyncInputSchema, + getDocumentCard: GetDocumentCardInputSchema, + listDocumentCards: ListDocumentCardsInputSchema, + getDocument: GetDocumentInputSchema, + updateDocument: UpdateDocumentInputSchema, + deleteDocument: DeleteDocumentInputSchema, +} as const; + +export const PDFMonkeyEndpointOutputSchemas = { + listTemplateCards: ListTemplateCardsOutputSchema, + getTemplate: GetTemplateOutputSchema, + createTemplate: CreateTemplateOutputSchema, + updateTemplate: UpdateTemplateOutputSchema, + deleteTemplate: DeleteSuccessSchema, + createDocument: DocumentSchema, + createDocumentSync: DocumentCardSchema, + getDocumentCard: DocumentCardSchema, + listDocumentCards: ListDocumentCardsOutputSchema, + getDocument: DocumentSchema, + updateDocument: DocumentSchema, + deleteDocument: DeleteSuccessSchema, +} as const; diff --git a/packages/pdfmonkey/error-handlers.test.ts b/packages/pdfmonkey/error-handlers.test.ts new file mode 100644 index 000000000..54eee111b --- /dev/null +++ b/packages/pdfmonkey/error-handlers.test.ts @@ -0,0 +1,53 @@ +import { ApiError } from 'corsair/http'; +import { errorHandlers } from './error-handlers'; + +function apiError(status: number, message: string, retryAfter?: number) { + return new ApiError( + { method: 'GET', url: '/api/v1/documents' }, + { + url: 'https://api.pdfmonkey.io/api/v1/documents', + ok: false, + status, + statusText: 'Error', + body: { message }, + }, + message, + { retryAfter }, + ); +} + +function route(error: Error): string { + const match = Object.entries(errorHandlers).find(([, entry]) => + entry.match(error), + ); + if (!match) throw new Error('no handler matched'); + return match[0]; +} + +describe('PDFMonkey errorHandlers', () => { + it('routes a 429 Too Many Requests to RATE_LIMIT_ERROR and keeps retryAfter', async () => { + const error = apiError(429, 'Too Many Requests', 1500); + + expect(route(error)).toBe('RATE_LIMIT_ERROR'); + expect(await errorHandlers.RATE_LIMIT_ERROR.handler(error)).toEqual({ + maxRetries: 5, + headersRetryAfterMs: 1500, + }); + }); + + it('routes rate-limit message text without a status', () => { + expect(route(new Error('too many requests'))).toBe('RATE_LIMIT_ERROR'); + }); + + it('routes 401 to AUTH_ERROR', () => { + expect(route(apiError(401, 'Unauthorized'))).toBe('AUTH_ERROR'); + }); + + it('routes unknown errors to DEFAULT with no retries', async () => { + const error = apiError(500, 'Internal Server Error'); + expect(route(error)).toBe('DEFAULT'); + expect(await errorHandlers.DEFAULT.handler()).toEqual({ + maxRetries: 0, + }); + }); +}); diff --git a/packages/pdfmonkey/error-handlers.ts b/packages/pdfmonkey/error-handlers.ts new file mode 100644 index 000000000..4ac636b55 --- /dev/null +++ b/packages/pdfmonkey/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('too many requests') || msg.includes('rate limit'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/pdfmonkey/index.ts b/packages/pdfmonkey/index.ts new file mode 100644 index 000000000..74ca0cc22 --- /dev/null +++ b/packages/pdfmonkey/index.ts @@ -0,0 +1,342 @@ +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 { Document, Template } from './endpoints'; +import type { + PDFMonkeyEndpointInputs, + PDFMonkeyEndpointOutputs, +} from './endpoints/types'; +import { + PDFMonkeyEndpointInputSchemas, + PDFMonkeyEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { PDFMonkeySchema } from './schema'; +import { DocumentWebhooks } from './webhooks'; +import { matchPDFMonkeyTenantWebhook } from './webhooks/tenant-matcher'; +import type { + DocumentGenerationFailureEvent, + DocumentGenerationSuccessEvent, + PDFMonkeyWebhookOutputs, +} from './webhooks/types'; +import { + DocumentGenerationFailureEventSchema, + DocumentGenerationSuccessEventSchema, + matchPDFMonkeyPluginWebhook, +} from './webhooks/types'; + +export type PDFMonkeyPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + webhookSecret?: string; + hooks?: InternalPDFMonkeyPlugin['hooks']; + webhookHooks?: InternalPDFMonkeyPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type PDFMonkeyContext = CorsairPluginContext< + typeof PDFMonkeySchema, + PDFMonkeyPluginOptions +>; + +export type PDFMonkeyKeyBuilderContext = + KeyBuilderContext; + +export type PDFMonkeyBoundEndpoints = BindEndpoints< + typeof pDFMonkeyEndpointsNested +>; + +type PDFMonkeyEndpoint = + CorsairEndpoint< + PDFMonkeyContext, + PDFMonkeyEndpointInputs[K], + PDFMonkeyEndpointOutputs[K] + >; + +export type PDFMonkeyEndpoints = { + listTemplateCards: PDFMonkeyEndpoint<'listTemplateCards'>; + getTemplate: PDFMonkeyEndpoint<'getTemplate'>; + createTemplate: PDFMonkeyEndpoint<'createTemplate'>; + updateTemplate: PDFMonkeyEndpoint<'updateTemplate'>; + deleteTemplate: PDFMonkeyEndpoint<'deleteTemplate'>; + createDocument: PDFMonkeyEndpoint<'createDocument'>; + createDocumentSync: PDFMonkeyEndpoint<'createDocumentSync'>; + getDocumentCard: PDFMonkeyEndpoint<'getDocumentCard'>; + listDocumentCards: PDFMonkeyEndpoint<'listDocumentCards'>; + getDocument: PDFMonkeyEndpoint<'getDocument'>; + updateDocument: PDFMonkeyEndpoint<'updateDocument'>; + deleteDocument: PDFMonkeyEndpoint<'deleteDocument'>; +}; + +type PDFMonkeyWebhook< + K extends keyof PDFMonkeyWebhookOutputs, + TEvent, +> = CorsairWebhook; + +export type PDFMonkeyWebhooks = { + generationSuccess: PDFMonkeyWebhook< + 'generationSuccess', + DocumentGenerationSuccessEvent + >; + generationFailure: PDFMonkeyWebhook< + 'generationFailure', + DocumentGenerationFailureEvent + >; +}; + +export type PDFMonkeyBoundWebhooks = BindWebhooks; + +const pDFMonkeyEndpointsNested = { + templates: { + listTemplateCards: Template.listTemplateCards, + getTemplate: Template.getTemplate, + createTemplate: Template.createTemplate, + updateTemplate: Template.updateTemplate, + deleteTemplate: Template.deleteTemplate, + }, + documents: { + createDocument: Document.createDocument, + createDocumentSync: Document.createDocumentSync, + getDocumentCard: Document.getDocumentCard, + listDocumentCards: Document.listDocumentCards, + getDocument: Document.getDocument, + updateDocument: Document.updateDocument, + deleteDocument: Document.deleteDocument, + }, +} as const; + +export const pDFMonkeyEndpointSchemas = { + 'templates.listTemplateCards': { + input: PDFMonkeyEndpointInputSchemas.listTemplateCards, + output: PDFMonkeyEndpointOutputSchemas.listTemplateCards, + }, + 'templates.getTemplate': { + input: PDFMonkeyEndpointInputSchemas.getTemplate, + output: PDFMonkeyEndpointOutputSchemas.getTemplate, + }, + 'templates.createTemplate': { + input: PDFMonkeyEndpointInputSchemas.createTemplate, + output: PDFMonkeyEndpointOutputSchemas.createTemplate, + }, + 'templates.updateTemplate': { + input: PDFMonkeyEndpointInputSchemas.updateTemplate, + output: PDFMonkeyEndpointOutputSchemas.updateTemplate, + }, + 'templates.deleteTemplate': { + input: PDFMonkeyEndpointInputSchemas.deleteTemplate, + output: PDFMonkeyEndpointOutputSchemas.deleteTemplate, + }, + 'documents.createDocument': { + input: PDFMonkeyEndpointInputSchemas.createDocument, + output: PDFMonkeyEndpointOutputSchemas.createDocument, + }, + 'documents.createDocumentSync': { + input: PDFMonkeyEndpointInputSchemas.createDocumentSync, + output: PDFMonkeyEndpointOutputSchemas.createDocumentSync, + }, + 'documents.getDocumentCard': { + input: PDFMonkeyEndpointInputSchemas.getDocumentCard, + output: PDFMonkeyEndpointOutputSchemas.getDocumentCard, + }, + 'documents.listDocumentCards': { + input: PDFMonkeyEndpointInputSchemas.listDocumentCards, + output: PDFMonkeyEndpointOutputSchemas.listDocumentCards, + }, + 'documents.getDocument': { + input: PDFMonkeyEndpointInputSchemas.getDocument, + output: PDFMonkeyEndpointOutputSchemas.getDocument, + }, + 'documents.updateDocument': { + input: PDFMonkeyEndpointInputSchemas.updateDocument, + output: PDFMonkeyEndpointOutputSchemas.updateDocument, + }, + 'documents.deleteDocument': { + input: PDFMonkeyEndpointInputSchemas.deleteDocument, + output: PDFMonkeyEndpointOutputSchemas.deleteDocument, + }, +} satisfies RequiredPluginEndpointSchemas; + +const pDFMonkeyWebhooksNested = { + documents: { + generationSuccess: DocumentWebhooks.generationSuccess, + generationFailure: DocumentWebhooks.generationFailure, + }, +} as const; + +export const pDFMonkeyWebhookSchemas = { + 'documents.generationSuccess': { + description: 'A document finished generating successfully', + payload: DocumentGenerationSuccessEventSchema, + response: DocumentGenerationSuccessEventSchema, + }, + 'documents.generationFailure': { + description: 'A document failed to generate', + payload: DocumentGenerationFailureEventSchema, + response: DocumentGenerationFailureEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas< + typeof pDFMonkeyWebhooksNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const pDFMonkeyEndpointMeta = { + 'templates.listTemplateCards': { + riskLevel: 'read', + description: 'List template cards for a workspace', + }, + 'templates.getTemplate': { + riskLevel: 'read', + description: 'Get a template by ID', + }, + 'templates.createTemplate': { + riskLevel: 'write', + description: 'Create a new document template', + }, + 'templates.updateTemplate': { + riskLevel: 'write', + description: 'Update an existing template', + }, + 'templates.deleteTemplate': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a template [DESTRUCTIVE · IRREVERSIBLE]', + }, + 'documents.createDocument': { + riskLevel: 'write', + description: 'Create a document and queue it for PDF generation', + }, + 'documents.createDocumentSync': { + riskLevel: 'write', + description: 'Create a document and wait for generation to complete', + }, + 'documents.getDocumentCard': { + riskLevel: 'read', + description: 'Get a document card with status and download URL', + }, + 'documents.listDocumentCards': { + riskLevel: 'read', + description: 'List document cards with pagination and filters', + }, + 'documents.getDocument': { + riskLevel: 'read', + description: 'Get a full document including payload and generation logs', + }, + 'documents.updateDocument': { + riskLevel: 'write', + description: "Update a document's payload, metadata, or template", + }, + 'documents.deleteDocument': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a document [DESTRUCTIVE · IRREVERSIBLE]', + }, +} satisfies RequiredPluginEndpointMeta; + +export const pDFMonkeyAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BasePDFMonkeyPlugin = + CorsairPlugin< + 'pdfmonkey', + typeof PDFMonkeySchema, + typeof pDFMonkeyEndpointsNested, + typeof pDFMonkeyWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalPDFMonkeyPlugin = + BasePDFMonkeyPlugin; + +export type ExternalPDFMonkeyPlugin = + BasePDFMonkeyPlugin; + +export function pdfmonkey( + incomingOptions: PDFMonkeyPluginOptions & T = {} as PDFMonkeyPluginOptions & + T, +): ExternalPDFMonkeyPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'pdfmonkey', + authConfig: pDFMonkeyAuthConfig, + schema: PDFMonkeySchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: pDFMonkeyEndpointsNested, + webhooks: pDFMonkeyWebhooksNested, + endpointMeta: pDFMonkeyEndpointMeta, + endpointSchemas: pDFMonkeyEndpointSchemas, + webhookSchemas: pDFMonkeyWebhookSchemas, + pluginWebhookMatcher: matchPDFMonkeyPluginWebhook, + pluginTenantWebhookMatcher: matchPDFMonkeyTenantWebhook, + errorHandlers: (() => { + const { DEFAULT: defaultHandler, ...specificDefaults } = errorHandlers; + return { + ...specificDefaults, + ...(options.errorHandlers || {}), + DEFAULT: options.errorHandlers?.DEFAULT || defaultHandler, + }; + })(), + keyBuilder: async (ctx: PDFMonkeyKeyBuilderContext, 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('pdfmonkey', '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('pdfmonkey', 'api_key'); + } + return res; + } + + throw new AuthMissingError('pdfmonkey', 'api_key'); + }, + } satisfies InternalPDFMonkeyPlugin; +} + +export type { + PDFMonkeyEndpointInputs, + PDFMonkeyEndpointOutputs, +} from './endpoints/types'; +export type { + DocumentGenerationFailureEvent, + DocumentGenerationSuccessEvent, + PDFMonkeyWebhookOutputs, +} from './webhooks/types'; diff --git a/packages/pdfmonkey/jest.config.cjs b/packages/pdfmonkey/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/pdfmonkey/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/pdfmonkey/package.json b/packages/pdfmonkey/package.json new file mode 100644 index 000000000..fe5eebfa4 --- /dev/null +++ b/packages/pdfmonkey/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/pdfmonkey", + "version": "0.1.0", + "description": "PDFMonkey 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", + "pdfmonkey", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/pdfmonkey/schema.test.ts b/packages/pdfmonkey/schema.test.ts new file mode 100644 index 000000000..a62435bc0 --- /dev/null +++ b/packages/pdfmonkey/schema.test.ts @@ -0,0 +1,184 @@ +import { + CreateDocumentInputSchema, + CreateDocumentSyncInputSchema, + CreateTemplateInputSchema, + DeleteDocumentInputSchema, + DeleteTemplateInputSchema, + DocumentCardSchema, + DocumentSchema, + DocumentTemplateCardSchema, + GetDocumentCardInputSchema, + GetTemplateInputSchema, + ListDocumentCardsInputSchema, + ListTemplateCardsInputSchema, + PDFMonkeyEndpointInputSchemas, + PDFMonkeyEndpointOutputSchemas, + UpdateDocumentInputSchema, + UpdateTemplateInputSchema, +} from './endpoints/types'; +import { PDFMonkeySchema } from './schema'; + +describe('PDFMonkey schema', () => { + it('declares a semver version and empty entities', () => { + expect(PDFMonkeySchema.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(PDFMonkeySchema.entities).toEqual({}); + }); + + it('validates DocumentTemplateCardSchema', () => { + const result = DocumentTemplateCardSchema.safeParse({ + id: 'test-id', + app_id: 'test-app', + created_at: '2024-01-01', + updated_at: '2024-01-01', + }); + expect(result.success).toBe(true); + }); + + it('validates DocumentCardSchema', () => { + const result = DocumentCardSchema.safeParse({ + id: 'doc-id', + app_id: 'test-app', + status: 'draft', + download_url: null, + preview_url: null, + public_share_link: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }); + expect(result.success).toBe(true); + }); + + it('validates DocumentSchema', () => { + const result = DocumentSchema.safeParse({ + id: 'doc-id', + app_id: 'test-app', + document_template_id: 'template-id', + status: 'pending', + payload: { clientName: 'Ada' }, + meta: null, + filename: null, + download_url: null, + preview_url: null, + public_share_link: null, + checksum: 'abc123', + generation_logs: [], + failure_cause: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }); + expect(result.success).toBe(true); + }); + + it('validates nested list query inputs', () => { + expect( + ListTemplateCardsInputSchema.parse({ + q: { workspace_id: 'ws-123' }, + }), + ).toMatchObject({ + page: 1, + q: { workspace_id: 'ws-123' }, + }); + expect( + ListDocumentCardsInputSchema.parse({ + page: 2, + q: { status: 'pending' }, + }), + ).toMatchObject({ + page: 2, + q: { status: 'pending' }, + }); + }); + + it('rejects list template cards without workspace_id', () => { + expect(ListTemplateCardsInputSchema.safeParse({ page: 1 }).success).toBe( + false, + ); + }); + + it('requires update bodies', () => { + expect( + UpdateTemplateInputSchema.safeParse({ + document_template_id: 'temp-1', + }).success, + ).toBe(false); + expect( + UpdateDocumentInputSchema.safeParse({ document_id: 'doc-1' }).success, + ).toBe(false); + expect( + UpdateTemplateInputSchema.parse({ + document_template_id: 'temp-1', + document_template: { identifier: 'updated' }, + }), + ).toMatchObject({ + document_template: { identifier: 'updated' }, + }); + expect( + UpdateDocumentInputSchema.parse({ + document_id: 'doc-1', + document: { status: 'pending' }, + }), + ).toMatchObject({ + document: { status: 'pending' }, + }); + }); + + it('defaults createDocumentSync status to pending', () => { + expect( + CreateDocumentSyncInputSchema.parse({ + document: { document_template_id: 'temp-1' }, + }), + ).toMatchObject({ + document: { document_template_id: 'temp-1', status: 'pending' }, + }); + }); + + it('validates remaining input schemas', () => { + expect(GetTemplateInputSchema.parse({ id: 'template-123' }).id).toBe( + 'template-123', + ); + expect(GetDocumentCardInputSchema.parse({ id: 'doc-456' }).id).toBe( + 'doc-456', + ); + expect( + CreateTemplateInputSchema.parse({ + document_template: { + app_id: 'app-1', + identifier: 'my-template', + body: '

Hello

', + }, + }).document_template.identifier, + ).toBe('my-template'); + expect( + CreateDocumentInputSchema.parse({ + document: { + document_template_id: 'temp-1', + status: 'pending', + }, + }).document.document_template_id, + ).toBe('temp-1'); + expect(DeleteTemplateInputSchema.parse({ id: 'temp-1' }).id).toBe('temp-1'); + expect(DeleteDocumentInputSchema.parse({ id: 'doc-1' }).id).toBe('doc-1'); + }); + + it('registers input and output schemas for every operation', () => { + const operations = [ + 'listTemplateCards', + 'getTemplate', + 'createTemplate', + 'updateTemplate', + 'deleteTemplate', + 'createDocument', + 'createDocumentSync', + 'getDocumentCard', + 'listDocumentCards', + 'getDocument', + 'updateDocument', + 'deleteDocument', + ] as const; + + for (const operation of operations) { + expect(PDFMonkeyEndpointInputSchemas[operation]).toBeDefined(); + expect(PDFMonkeyEndpointOutputSchemas[operation]).toBeDefined(); + } + }); +}); diff --git a/packages/pdfmonkey/schema/database.ts b/packages/pdfmonkey/schema/database.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/pdfmonkey/schema/database.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/pdfmonkey/schema/index.ts b/packages/pdfmonkey/schema/index.ts new file mode 100644 index 000000000..757884ce4 --- /dev/null +++ b/packages/pdfmonkey/schema/index.ts @@ -0,0 +1,4 @@ +export const PDFMonkeySchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/pdfmonkey/tsconfig.json b/packages/pdfmonkey/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/pdfmonkey/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/pdfmonkey/tsup.config.ts b/packages/pdfmonkey/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/pdfmonkey/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/pdfmonkey/webhooks/documents.ts b/packages/pdfmonkey/webhooks/documents.ts new file mode 100644 index 000000000..b2899718a --- /dev/null +++ b/packages/pdfmonkey/webhooks/documents.ts @@ -0,0 +1,60 @@ +import { logEventFromContext } from 'corsair/core'; +import type { PDFMonkeyWebhooks } from '../index'; +import { + createPDFMonkeyMatch, + DocumentGenerationFailureEventSchema, + DocumentGenerationSuccessEventSchema, + verifyPDFMonkeyWebhookSignature, +} from './types'; + +export const generationSuccess: PDFMonkeyWebhooks['generationSuccess'] = { + match: createPDFMonkeyMatch('success'), + + handler: async (ctx, request) => { + const verification = verifyPDFMonkeyWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = DocumentGenerationSuccessEventSchema.parse(request.payload); + + await logEventFromContext( + ctx, + 'pdfmonkey.webhook.generationSuccess', + { id: event.document.id, status: event.document.status }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; + +export const generationFailure: PDFMonkeyWebhooks['generationFailure'] = { + match: createPDFMonkeyMatch('failure'), + + handler: async (ctx, request) => { + const verification = verifyPDFMonkeyWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = DocumentGenerationFailureEventSchema.parse(request.payload); + + await logEventFromContext( + ctx, + 'pdfmonkey.webhook.generationFailure', + { id: event.document.id, status: event.document.status }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; diff --git a/packages/pdfmonkey/webhooks/index.ts b/packages/pdfmonkey/webhooks/index.ts new file mode 100644 index 000000000..d165cc24f --- /dev/null +++ b/packages/pdfmonkey/webhooks/index.ts @@ -0,0 +1,9 @@ +import { generationFailure, generationSuccess } from './documents'; + +export const DocumentWebhooks = { + generationSuccess, + generationFailure, +}; + +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/pdfmonkey/webhooks/tenant-matcher.ts b/packages/pdfmonkey/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..b843def9e --- /dev/null +++ b/packages/pdfmonkey/webhooks/tenant-matcher.ts @@ -0,0 +1,15 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, firstString, readBodyRecord } from 'corsair/core'; + +export function matchPDFMonkeyTenantWebhook( + request: RawWebhookRequest, +): WebhookTenantMatch | null { + const body = readBodyRecord(request); + if (!body) return null; + + const document = asRecord(body.document); + const externalId = firstString([document?.app_id, body.app_id]); + if (!externalId) return null; + + return { linkType: 'tenant_external_id', externalId }; +} diff --git a/packages/pdfmonkey/webhooks/types.test.ts b/packages/pdfmonkey/webhooks/types.test.ts new file mode 100644 index 000000000..e530b2106 --- /dev/null +++ b/packages/pdfmonkey/webhooks/types.test.ts @@ -0,0 +1,201 @@ +import type { WebhookRequest } from 'corsair/core'; +import { createHmac } from 'crypto'; +import { matchPDFMonkeyTenantWebhook } from './tenant-matcher'; +import { + createPDFMonkeyMatch, + matchPDFMonkeyPluginWebhook, + verifyPDFMonkeyWebhookSignature, +} from './types'; + +const SECRET_BYTES = Buffer.from('pdfmonkey-test-secret', 'utf8'); +const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`; +const SVIX_ID = 'msg_test_1'; + +const successPayload = { + document: { + id: 'doc-1', + app_id: 'app-1', + status: 'success', + download_url: 'https://files.example.com/doc.pdf', + preview_url: null, + public_share_link: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, +}; + +const rawBody = JSON.stringify(successPayload); + +function sign(id: string, timestamp: string, body: string, secret = SECRET) { + const key = Buffer.from(secret.slice('whsec_'.length), 'base64'); + const digest = createHmac('sha256', key) + .update(`${id}.${timestamp}.${body}`) + .digest('base64'); + return `v1,${digest}`; +} + +function requestWith( + headers: Record, + body: string | null = rawBody, +): WebhookRequest { + return { + payload: successPayload, + headers, + rawBody: body === null ? undefined : body, + }; +} + +describe('verifyPDFMonkeyWebhookSignature', () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + + it('rejects a missing secret', () => { + expect( + verifyPDFMonkeyWebhookSignature( + requestWith({ + 'svix-id': SVIX_ID, + 'svix-timestamp': timestamp, + 'svix-signature': sign(SVIX_ID, timestamp, rawBody), + }), + undefined, + ), + ).toEqual({ valid: false, error: 'Missing webhook secret' }); + }); + + it('rejects a missing raw body', () => { + expect( + verifyPDFMonkeyWebhookSignature( + requestWith( + { + 'svix-id': SVIX_ID, + 'svix-timestamp': timestamp, + 'svix-signature': sign(SVIX_ID, timestamp, rawBody), + }, + null, + ), + SECRET, + ), + ).toEqual({ + valid: false, + error: 'Missing raw body for signature verification', + }); + }); + + it('rejects missing Svix headers', () => { + expect(verifyPDFMonkeyWebhookSignature(requestWith({}), SECRET)).toEqual({ + valid: false, + error: 'Missing svix-id header', + }); + }); + + it('rejects a malformed webhook secret that would decode to an empty key', () => { + expect( + verifyPDFMonkeyWebhookSignature( + requestWith({ + 'svix-id': SVIX_ID, + 'svix-timestamp': timestamp, + 'svix-signature': sign(SVIX_ID, timestamp, rawBody), + }), + 'whsec_!!!!', + ), + ).toEqual({ valid: false, error: 'Malformed webhook secret' }); + }); + + it('rejects a stale timestamp', () => { + const stale = String(Math.floor(Date.now() / 1000) - 10 * 60); + expect( + verifyPDFMonkeyWebhookSignature( + requestWith({ + 'svix-id': SVIX_ID, + 'svix-timestamp': stale, + 'svix-signature': sign(SVIX_ID, stale, rawBody), + }), + SECRET, + ), + ).toEqual({ + valid: false, + error: 'Webhook timestamp is too old or invalid', + }); + }); + + it('accepts a correctly signed Svix request', () => { + expect( + verifyPDFMonkeyWebhookSignature( + requestWith({ + 'svix-id': SVIX_ID, + 'svix-timestamp': timestamp, + 'svix-signature': sign(SVIX_ID, timestamp, rawBody), + }), + SECRET, + ), + ).toEqual({ valid: true }); + }); + + it('rejects a signature over the wrong content', () => { + expect( + verifyPDFMonkeyWebhookSignature( + requestWith({ + 'svix-id': SVIX_ID, + 'svix-timestamp': timestamp, + 'svix-signature': sign(SVIX_ID, timestamp, '{"tampered":true}'), + }), + SECRET, + ), + ).toEqual({ valid: false, error: 'Invalid signature' }); + }); +}); + +describe('PDFMonkey webhook matchers', () => { + it('plugin matcher accepts Svix document payloads and rejects Resend events', () => { + expect( + matchPDFMonkeyPluginWebhook({ + headers: { + 'svix-id': SVIX_ID, + 'svix-timestamp': '1', + 'svix-signature': 'v1,abc', + }, + body: successPayload, + }), + ).toBe(true); + expect( + matchPDFMonkeyPluginWebhook({ + headers: { 'x-pdfmonkey-signature': 'nope' }, + body: successPayload, + }), + ).toBe(false); + expect( + matchPDFMonkeyPluginWebhook({ + headers: { + 'svix-id': SVIX_ID, + 'svix-timestamp': '1', + 'svix-signature': 'v1,abc', + }, + body: { type: 'email.sent', data: {} }, + }), + ).toBe(false); + }); + + it('event matcher uses document.status', () => { + const success = createPDFMonkeyMatch('success'); + const failure = createPDFMonkeyMatch('failure'); + const headers = { 'svix-signature': 'v1,abc' }; + expect(success({ headers, body: successPayload })).toBe(true); + expect(failure({ headers, body: successPayload })).toBe(false); + expect( + failure({ + headers, + body: { + document: { ...successPayload.document, status: 'failure' }, + }, + }), + ).toBe(true); + }); + + it('tenant matcher reads document.app_id', () => { + expect( + matchPDFMonkeyTenantWebhook({ + headers: {}, + body: successPayload, + }), + ).toEqual({ linkType: 'tenant_external_id', externalId: 'app-1' }); + }); +}); diff --git a/packages/pdfmonkey/webhooks/types.ts b/packages/pdfmonkey/webhooks/types.ts new file mode 100644 index 000000000..f546a196b --- /dev/null +++ b/packages/pdfmonkey/webhooks/types.ts @@ -0,0 +1,173 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { z } from 'zod'; +import { DocumentCardSchema } from '../endpoints/types'; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function parseBody(body: unknown): Record | null { + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } + } + return isRecord(body) ? body : null; +} + +function getHeader( + headers: WebhookRequest['headers'], + name: string, +): string | undefined { + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== lower) continue; + return Array.isArray(value) ? value[0] : value; + } + return undefined; +} + +function extractSvixSignatures(signatureHeader: string): string[] { + return signatureHeader + .split(' ') + .map((part) => part.trim()) + .filter(Boolean) + .flatMap((part) => { + const [version, signature] = part.split(',', 2); + return version === 'v1' && signature ? [signature] : []; + }); +} + +export const DocumentGenerationSuccessEventSchema = z.object({ + type: z.literal('documents.generation.success').optional(), + document: DocumentCardSchema.extend({ + status: z.literal('success'), + }), +}); + +export type DocumentGenerationSuccessEvent = z.infer< + typeof DocumentGenerationSuccessEventSchema +>; + +export const DocumentGenerationFailureEventSchema = z.object({ + type: z.literal('documents.generation.failure').optional(), + document: DocumentCardSchema.extend({ + status: z.literal('failure'), + }), +}); + +export type DocumentGenerationFailureEvent = z.infer< + typeof DocumentGenerationFailureEventSchema +>; + +export type PDFMonkeyWebhookOutputs = { + generationSuccess: DocumentGenerationSuccessEvent; + generationFailure: DocumentGenerationFailureEvent; +}; + +export function createPDFMonkeyMatch( + status: 'success' | 'failure', +): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + if (!getHeader(request.headers, 'svix-signature')) return false; + const parsedBody = parseBody(request.body); + if (!parsedBody) return false; + const document = parsedBody.document; + return isRecord(document) && document.status === status; + }; +} + +export function matchPDFMonkeyPluginWebhook( + request: RawWebhookRequest, +): boolean { + if (!getHeader(request.headers, 'svix-signature')) return false; + if (!getHeader(request.headers, 'svix-id')) return false; + if (!getHeader(request.headers, 'svix-timestamp')) return false; + const parsedBody = parseBody(request.body); + if (!parsedBody) return false; + const document = parsedBody.document; + return ( + isRecord(document) && + typeof document.id === 'string' && + typeof document.status === 'string' + ); +} + +export function verifyPDFMonkeyWebhookSignature( + request: WebhookRequest, + secret?: string, +): { valid: boolean; error?: string } { + if (!secret) { + return { valid: false, error: 'Missing webhook secret' }; + } + + const rawBody = request.rawBody; + if (!rawBody) { + return { + valid: false, + error: 'Missing raw body for signature verification', + }; + } + + const svixId = getHeader(request.headers, 'svix-id'); + const svixTimestamp = getHeader(request.headers, 'svix-timestamp'); + const svixSignature = getHeader(request.headers, 'svix-signature'); + + if (!svixId) { + return { valid: false, error: 'Missing svix-id header' }; + } + if (!svixTimestamp) { + return { valid: false, error: 'Missing svix-timestamp header' }; + } + if (!svixSignature) { + return { valid: false, error: 'Missing svix-signature header' }; + } + + const timestampMs = Number.parseInt(svixTimestamp, 10) * 1000; + if ( + Number.isNaN(timestampMs) || + Math.abs(Date.now() - timestampMs) > 5 * 60 * 1000 + ) { + return { valid: false, error: 'Webhook timestamp is too old or invalid' }; + } + + if (!secret.startsWith('whsec_')) { + return { valid: false, error: 'Malformed webhook secret' }; + } + const secretBase64 = secret.slice('whsec_'.length); + const secretKey = Buffer.from(secretBase64, 'base64'); + if (!secretBase64 || secretKey.length === 0) { + return { valid: false, error: 'Malformed webhook secret' }; + } + + const signatures = extractSvixSignatures(svixSignature); + if (signatures.length === 0) { + return { valid: false, error: 'Malformed svix-signature header' }; + } + + const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`; + const expected = createHmac('sha256', secretKey) + .update(signedContent) + .digest(); + + const isValid = signatures.some((signature) => { + const received = Buffer.from(signature, 'base64'); + return ( + received.length === expected.length && timingSafeEqual(received, expected) + ); + }); + + if (!isValid) { + return { valid: false, error: 'Invalid signature' }; + } + + return { valid: true }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd3d10ddc..e7ad98839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,7 +130,7 @@ importers: version: link:../../packages/slack corsair: specifier: ^0.1.4 - version: 0.1.119(postgres@3.4.7)(react@19.2.7) + version: link:../../packages/corsair dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -3992,6 +3992,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/pdfmonkey: + 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/perplexityai: devDependencies: '@types/jest': @@ -6494,36 +6518,6 @@ packages: '@codemirror/view@6.43.3': resolution: {integrity: sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng==} - '@corsair-dev/frpc-darwin-arm64@0.1.117': - resolution: {integrity: sha512-PrxfqHlRKzm5xW5J2wnZ1knVF4xV6svgmeo11YvHMAAUqsNBY6mczMQFlu1HJcMvZ9yysqMiTZBKdjAC+DnhlQ==} - cpu: [arm64] - os: [darwin] - - '@corsair-dev/frpc-darwin-x64@0.1.117': - resolution: {integrity: sha512-8GZP5G5kPhu/TPGLnSgecvyyxXB6+ZdwDmXAhz3PvqiE8mmQ2PRa7FaoVYdOa4nY8XWa5YmnXC/DcjsTLIfPmQ==} - cpu: [x64] - os: [darwin] - - '@corsair-dev/frpc-linux-arm64@0.1.117': - resolution: {integrity: sha512-TwVeRBYd17hy6YJByCUouMVBo2itK4fPuvm3TPwdq5ZVxN64PEKE2KHQIo+uW7opxcMXre6DVSzLQyFmrqI/Xg==} - cpu: [arm64] - os: [linux] - - '@corsair-dev/frpc-linux-x64@0.1.117': - resolution: {integrity: sha512-Ch98qSFQXYSA6sg2ysTKrRCZ08tNWfXjSEHKT3nMM7vYhuDXkQcBmXM4tbPEO8SI6BzLa9/g1lsXT5T0hx48dQ==} - cpu: [x64] - os: [linux] - - '@corsair-dev/frpc-win32-arm64@0.1.117': - resolution: {integrity: sha512-QyLWoaVNrq1C5V81d7DIOLm63n4+0YSKcKJK1MUTdaQAEroaejybeAuskiYhq11LCJ2wb+WTkv3qaWF0yLgg6g==} - cpu: [arm64] - os: [win32] - - '@corsair-dev/frpc-win32-x64@0.1.117': - resolution: {integrity: sha512-V4sI1JUwlNBi9nAJHedXPXzmivfi5DHaU2yJMwnf++5PQvVcxRMJQcec1p9+Q8bPyAZ7gtq+4XdRuBJVZXdFkg==} - cpu: [x64] - os: [win32] - '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -11578,14 +11572,6 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - corsair@0.1.119: - resolution: {integrity: sha512-Cl86/VhMNOdzTbUzmeb9RwlhuucojM1gEmgTQJsQYdcxP2qFiPPly2VGK9M2h5GkHLZ3kZ4HRALTQP3wIhepBQ==} - peerDependencies: - react: '>=18.0.0' - peerDependenciesMeta: - react: - optional: true - cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -17973,24 +17959,6 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@corsair-dev/frpc-darwin-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-darwin-x64@0.1.117': - optional: true - - '@corsair-dev/frpc-linux-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-linux-x64@0.1.117': - optional: true - - '@corsair-dev/frpc-win32-arm64@0.1.117': - optional: true - - '@corsair-dev/frpc-win32-x64@0.1.117': - optional: true - '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -23559,23 +23527,6 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - corsair@0.1.119(postgres@3.4.7)(react@19.2.7): - dependencies: - kysely: 0.28.17 - kysely-postgres-js: 3.0.0(kysely@0.28.17)(postgres@3.4.7) - uuid: 13.0.0 - zod: 4.4.3 - optionalDependencies: - '@corsair-dev/frpc-darwin-arm64': 0.1.117 - '@corsair-dev/frpc-darwin-x64': 0.1.117 - '@corsair-dev/frpc-linux-arm64': 0.1.117 - '@corsair-dev/frpc-linux-x64': 0.1.117 - '@corsair-dev/frpc-win32-arm64': 0.1.117 - '@corsair-dev/frpc-win32-x64': 0.1.117 - react: 19.2.7 - transitivePeerDependencies: - - postgres - cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -25911,12 +25862,6 @@ snapshots: kleur@4.1.5: {} - kysely-postgres-js@3.0.0(kysely@0.28.17)(postgres@3.4.7): - dependencies: - kysely: 0.28.17 - optionalDependencies: - postgres: 3.4.7 - kysely-postgres-js@3.0.0(kysely@0.28.9)(postgres@3.4.7): dependencies: kysely: 0.28.9