From 20ad5c76c9f83df3be683a7b0d2d2ab69e1087e4 Mon Sep 17 00:00:00 2001 From: Arjun S Pai Date: Mon, 24 Aug 2026 13:01:59 +0530 Subject: [PATCH 01/10] feat: PDFMonkey integration --- packages/corsair/core/constants.ts | 3 + packages/pdfmonkey/client.ts | 158 ++++++++ packages/pdfmonkey/endpoints/documents.ts | 201 ++++++++++ packages/pdfmonkey/endpoints/index.ts | 23 ++ packages/pdfmonkey/endpoints/templates.ts | 178 +++++++++ packages/pdfmonkey/endpoints/types.ts | 360 ++++++++++++++++++ packages/pdfmonkey/error-handlers.ts | 31 ++ packages/pdfmonkey/index.ts | 323 ++++++++++++++++ packages/pdfmonkey/jest.config.cjs | 55 +++ packages/pdfmonkey/package.json | 44 +++ packages/pdfmonkey/schema.test.ts | 20 + packages/pdfmonkey/schema/database.ts | 9 + packages/pdfmonkey/schema/index.ts | 4 + packages/pdfmonkey/tsconfig.json | 20 + packages/pdfmonkey/tsup.config.ts | 15 + packages/pdfmonkey/webhooks/example.ts | 32 ++ packages/pdfmonkey/webhooks/index.ts | 9 + .../pdfmonkey/webhooks/oauth-tenant-link.ts | 31 ++ packages/pdfmonkey/webhooks/tenant-matcher.ts | 25 ++ packages/pdfmonkey/webhooks/types.ts | 64 ++++ 20 files changed, 1605 insertions(+) create mode 100644 packages/pdfmonkey/client.ts create mode 100644 packages/pdfmonkey/endpoints/documents.ts create mode 100644 packages/pdfmonkey/endpoints/index.ts create mode 100644 packages/pdfmonkey/endpoints/templates.ts create mode 100644 packages/pdfmonkey/endpoints/types.ts create mode 100644 packages/pdfmonkey/error-handlers.ts create mode 100644 packages/pdfmonkey/index.ts create mode 100644 packages/pdfmonkey/jest.config.cjs create mode 100644 packages/pdfmonkey/package.json create mode 100644 packages/pdfmonkey/schema.test.ts create mode 100644 packages/pdfmonkey/schema/database.ts create mode 100644 packages/pdfmonkey/schema/index.ts create mode 100644 packages/pdfmonkey/tsconfig.json create mode 100644 packages/pdfmonkey/tsup.config.ts create mode 100644 packages/pdfmonkey/webhooks/example.ts create mode 100644 packages/pdfmonkey/webhooks/index.ts create mode 100644 packages/pdfmonkey/webhooks/oauth-tenant-link.ts create mode 100644 packages/pdfmonkey/webhooks/tenant-matcher.ts create mode 100644 packages/pdfmonkey/webhooks/types.ts 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/client.ts b/packages/pdfmonkey/client.ts new file mode 100644 index 000000000..8a64458ec --- /dev/null +++ b/packages/pdfmonkey/client.ts @@ -0,0 +1,158 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class Api2PdfAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + // API error bodies vary by endpoint; unknown forces callers to narrow before use. + public readonly body?: unknown; + public readonly retryAfter?: number; + + constructor( + message: string, + public readonly code?: number, + options?: { cause?: Error }, + ) { + super(message, options); + this.name = 'Api2PdfAPIError'; + + if (options?.cause instanceof ApiError) { + this.status = options.cause.status; + this.statusText = options.cause.statusText; + this.body = options.cause.body; + this.retryAfter = options.cause.retryAfter; + } + } +} + +const API2PDF_API_BASE = 'https://api.pdfmonkey.io'; + +export type Api2PdfRequestOptions = { + apiKey?: string; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + // Endpoint payloads differ per operation; Record keeps the client generic. + body?: Record; + query?: Record; +}; + +function buildConfig(apiKey?: string, isWrite = false): OpenAPIConfig { + return { + BASE: API2PDF_API_BASE, + VERSION: '2.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + ...(apiKey ? { Authorization: apiKey } : {}), + ...(isWrite ? { 'Content-Type': 'application/json' } : {}), + }, + }; +} + +// Catch values are untyped at runtime; unknown forces narrowing to ApiError/Error +// before rethrowing as Api2PdfAPIError. +async function handleRequestError(error: unknown): Promise { + if (error instanceof ApiError) { + throw new Api2PdfAPIError(error.message, error.status, { + cause: error, + }); + } + if (error instanceof Error) { + throw new Api2PdfAPIError(error.message, undefined, { cause: error }); + } + throw new Api2PdfAPIError('Unknown error'); +} + +/** + * Performs a request to the API2PDF REST API. + * + * Auth: API key via the `Authorization` header (raw key string per official SDKs). + * The `/status` health check does not require authentication. + */ +export async function makeApi2PdfRequest( + endpoint: string, + options: Api2PdfRequestOptions = {}, +): 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); + } +} + +/** Plain-text health check (returns e.g. "OK"). */ +export async function makeApi2PdfTextRequest( + endpoint: string, + options: Pick = {}, +): Promise { + const { apiKey, method = 'GET', query = {} } = options; + const config = buildConfig(apiKey); + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + query, + }; + + try { + const response = await request(config, requestOptions); + return typeof response === 'string' ? response : String(response); + } catch (error) { + return handleRequestError(error); + } +} + +export function assertApi2PdfSuccess< + // Error field shape varies by endpoint (string | object | null); unknown forces + // callers to narrow before reading it. + T extends { Success?: boolean; Error?: unknown }, +>(response: T): T { + if (response.Success === false) { + const message = + typeof response.Error === 'string' + ? response.Error + : 'API2PDF request failed'; + throw new Api2PdfAPIError(message); + } + return response; +} + +// Endpoint payloads differ per operation; Record keeps the client generic across +// chrome/pdfsharp/libreoffice field sets without a union of every wire shape. +export function buildPostPayload( + fields: Record, + options?: { + inline?: boolean; + fileName?: string; + // Headless Chrome options bag is open-ended upstream. + chromeOptions?: Record; + }, +): Record { + const payload: Record = { + inline: options?.inline ?? true, + ...fields, + }; + + if (options?.fileName) { + payload.fileName = options.fileName; + } + + if (options?.chromeOptions) { + payload.options = options.chromeOptions; + } + + return payload; +} diff --git a/packages/pdfmonkey/endpoints/documents.ts b/packages/pdfmonkey/endpoints/documents.ts new file mode 100644 index 000000000..de1002504 --- /dev/null +++ b/packages/pdfmonkey/endpoints/documents.ts @@ -0,0 +1,201 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApi2PdfRequest } from '../client'; +import type { PDFMonkeyEndpoints } from '../index'; +import type { + PDFMonkeyEndpointInputs, + PDFMonkeyEndpointOutputs, +} from './types'; + +/** Create a document (async, queues for generation) */ +export const createDocument: PDFMonkeyEndpoints['createDocument'] = async ( + ctx, + input, +) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['createDocument'] + >('/api/v1/documents', { + apiKey: ctx.key, + method: 'POST', + body: { + document: { + document_template_id: input.document.document_template_id, + status: input.document.status, + payload: input.document.payload, + meta: input.document.meta, + }, + }, + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.createDocument', + { + document_template_id: input.document.document_template_id, + status: input.document.status, + }, + 'completed', + ); + + return response; +}; + +/** Create a document synchronously (waits for generation to complete) */ +export const createDocumentSync: PDFMonkeyEndpoints['createDocumentSync'] = + async (ctx, input) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['createDocumentSync'] + >('/api/v1/documents/sync', { + apiKey: ctx.key, + method: 'POST', + body: { + document: { + document_template_id: input.document.document_template_id, + status: input.document.status, + payload: input.document.payload, + meta: input.document.meta, + }, + }, + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.createDocumentSync', + { + document_template_id: input.document.document_template_id, + status: input.document.status, + }, + 'completed', + ); + + return response; + }; + +/** Get a document card (status + download URL) */ +export const getDocumentCard: PDFMonkeyEndpoints['getDocumentCard'] = async ( + ctx, + input, +) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['getDocumentCard'] + >('/api/v1/document_cards/' + input.id, { + apiKey: ctx.key, + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.getDocumentCard', + { id: input.id }, + 'completed', + ); + + return response; +}; + +/** List document cards (paginated with filters) */ +export const listDocumentCards: PDFMonkeyEndpoints['listDocumentCards'] = + async (ctx, input) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['listDocumentCards'] + >('/api/v1/document_cards', { + apiKey: ctx.key, + method: 'GET', + query: { + page: input.page, + q_document_template_id: input.q_document_template_id, + q_status: input.q_status, + q_workspace_id: input.q_workspace_id, + q_updated_since: input.q_updated_since, + q_search: input.q_search, + }, + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.listDocumentCards', + { + page: input.page, + q_status: input.q_status, + }, + 'completed', + ); + + return response; + }; + +/** Get a full document (with payload and generation logs) */ +export const getDocument: PDFMonkeyEndpoints['getDocument'] = async ( + ctx, + input, +) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['getDocument'] + >('/api/v1/documents/' + input.id, { + apiKey: ctx.key, + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.getDocument', + { id: input.id }, + 'completed', + ); + + return response; +}; + +/** Update a document */ +export const updateDocument: PDFMonkeyEndpoints['updateDocument'] = async ( + ctx, + input, +) => { + const document = input.document!; + const body: Record = {}; + if (document.document_template_id !== undefined) + body.document_template_id = document.document_template_id; + if (document.status !== undefined) body.status = document.status; + if (document.payload !== undefined) body.payload = document.payload; + if (document.meta !== undefined) body.meta = document.meta; + + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['updateDocument'] + >('/api/v1/documents/' + input.document_id, { + apiKey: ctx.key, + method: 'PUT', + body: { + document: body, + }, + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.updateDocument', + { document_id: input.document_id }, + 'completed', + ); + + return response; +}; + +/** Delete a document */ +export const deleteDocument: PDFMonkeyEndpoints['deleteDocument'] = async ( + ctx, + input, +) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['deleteDocument'] + >('/api/v1/documents/' + input.id, { + apiKey: ctx.key, + method: 'DELETE', + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.documents.deleteDocument', + { id: input.id }, + 'completed', + ); + + return response; +}; diff --git a/packages/pdfmonkey/endpoints/index.ts b/packages/pdfmonkey/endpoints/index.ts new file mode 100644 index 000000000..6b2745034 --- /dev/null +++ b/packages/pdfmonkey/endpoints/index.ts @@ -0,0 +1,23 @@ +import * as Documents from './documents'; +import * as Templates from './templates'; +import * as Types from './types'; + +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..27f3d3763 --- /dev/null +++ b/packages/pdfmonkey/endpoints/templates.ts @@ -0,0 +1,178 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeApi2PdfRequest } from '../client'; +import type { PDFMonkeyEndpoints } from '../index'; +import type { + PDFMonkeyEndpointInputs, + PDFMonkeyEndpointOutputs, +} from './types'; + +/** List template cards (paginated) */ +export const listTemplateCards: PDFMonkeyEndpoints['listTemplateCards'] = + async (ctx, input) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['listTemplateCards'] + >('/api/v1/document_template_cards', { + apiKey: ctx.key, + method: 'GET', + query: { + q_workspace_id: input.q_workspace_id, + q_folders: input.q_folders, + page: input.page, + sort: input.sort, + }, + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.listTemplateCards', + { + q_workspace_id: input.q_workspace_id, + page: input.page, + }, + 'completed', + ); + + return response; + }; + +/** Get a template by ID */ +export const getTemplate: PDFMonkeyEndpoints['getTemplate'] = async ( + ctx, + input, +) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['getTemplate'] + >('/api/v1/document_templates/' + input.id, { + apiKey: ctx.key, + method: 'GET', + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.getTemplate', + { id: input.id }, + 'completed', + ); + + return response; +}; + +/** Create a new template */ +export const createTemplate: PDFMonkeyEndpoints['createTemplate'] = async ( + ctx, + input, +) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['createTemplate'] + >('/api/v1/document_templates', { + apiKey: ctx.key, + method: 'POST', + body: { + document: { + app_id: input.document_template.app_id, + identifier: input.document_template.identifier, + body: input.document_template.body, + body_draft: input.document_template.body_draft, + scss_style: input.document_template.scss_style, + scss_style_draft: input.document_template.scss_style_draft, + sample_data: input.document_template.sample_data, + sample_data_draft: input.document_template.sample_data_draft, + settings: input.document_template.settings, + settings_draft: input.document_template.settings_draft, + pdf_engine_id: input.document_template.pdf_engine_id, + pdf_engine_draft_id: input.document_template.pdf_engine_draft_id, + template_folder_id: input.document_template.template_folder_id, + ttl: input.document_template.ttl, + edition_mode: input.document_template.edition_mode, + output_type: input.document_template.output_type, + }, + }, + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.createTemplate', + { identifier: input.document_template.identifier }, + 'completed', + ); + + return response; +}; + +/** Update an existing template */ +export const updateTemplate: PDFMonkeyEndpoints['updateTemplate'] = async ( + ctx, + input, +) => { + const document_template = input.document_template!; + const body: Record = {}; + if (document_template.identifier !== undefined) + body.identifier = document_template.identifier; + if (document_template.body !== undefined) body.body = document_template.body; + if (document_template.body_draft !== undefined) + body.body_draft = document_template.body_draft; + if (document_template.scss_style !== undefined) + body.scss_style = document_template.scss_style; + if (document_template.scss_style_draft !== undefined) + body.scss_style_draft = document_template.scss_style_draft; + if (document_template.sample_data !== undefined) + body.sample_data = document_template.sample_data; + if (document_template.sample_data_draft !== undefined) + body.sample_data_draft = document_template.sample_data_draft; + if (document_template.settings !== undefined) + body.settings = document_template.settings; + if (document_template.settings_draft !== undefined) + body.settings_draft = document_template.settings_draft; + if (document_template.pdf_engine_id !== undefined) + body.pdf_engine_id = document_template.pdf_engine_id; + if (document_template.pdf_engine_draft_id !== undefined) + body.pdf_engine_draft_id = document_template.pdf_engine_draft_id; + if (document_template.template_folder_id !== undefined) + body.template_folder_id = document_template.template_folder_id; + if (document_template.ttl !== undefined) body.ttl = document_template.ttl; + if (document_template.edition_mode !== undefined) + body.edition_mode = document_template.edition_mode; + if (document_template.output_type !== undefined) + body.output_type = document_template.output_type; + + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['updateTemplate'] + >('/api/v1/document_templates/' + input.document_template_id, { + apiKey: ctx.key, + method: 'PUT', + body: { + document: body, + }, + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.updateTemplate', + { template_id: input.document_template_id }, + 'completed', + ); + + return response; +}; + +/** Delete a template */ +export const deleteTemplate: PDFMonkeyEndpoints['deleteTemplate'] = async ( + ctx, + input, +) => { + const response = await makeApi2PdfRequest< + PDFMonkeyEndpointOutputs['deleteTemplate'] + >('/api/v1/document_templates/' + input.id, { + apiKey: ctx.key, + method: 'DELETE', + }); + + await logEventFromContext( + ctx, + 'pdfmonkey.templates.deleteTemplate', + { id: input.id }, + 'completed', + ); + + return response; +}; diff --git a/packages/pdfmonkey/endpoints/types.ts b/packages/pdfmonkey/endpoints/types.ts new file mode 100644 index 000000000..da0a8f772 --- /dev/null +++ b/packages/pdfmonkey/endpoints/types.ts @@ -0,0 +1,360 @@ +import { z } from 'zod'; + +/** Simple success response for delete operations */ +const DeleteSuccessSchema = z.object({ success: z.boolean() }); +export type DeleteSuccess = z.infer; + +/** + * Template Card - lightweight template object for listing + */ +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; + +/** Input for listing template cards */ +export const ListTemplateCardsInputSchema = z.object({ + q_workspace_id: z.string(), + q_folders: z.string().optional(), + page: z.number().int().positive().default(1), + sort: z.string().optional(), +}); + +export type ListTemplateCardsInput = z.infer< + typeof ListTemplateCardsInputSchema +>; + +export const ListTemplateCardsOutputSchema = z.object({ + document_template_cards: z.array(DocumentTemplateCardSchema), + meta: z + .object({ + page: z.number().int().positive(), + total: z.number().int().positive(), + totalPages: z.number().int().positive(), + }) + .optional(), +}); + +export type ListTemplateCardsOutput = z.infer< + typeof ListTemplateCardsOutputSchema +>; + +/** Input for getting a single template */ +export const GetTemplateInputSchema = z.object({ + id: z.string(), +}); + +export type GetTemplateInput = z.infer; + +export const GetTemplateOutputSchema = z.object({ + document_template: 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: z.any().optional(), + settings_draft: z.any().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 GetTemplateOutput = z.infer; + +/** Input for creating a template */ +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: z.any().optional(), + settings_draft: z.any().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.infer; + +export const CreateTemplateOutputSchema = z.object({ + document_template: z.object({ + id: z.string(), + }), +}); + +export type CreateTemplateOutput = z.infer; + +/** Input for updating a template */ +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: z.any().optional(), + settings_draft: z.any().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(), + }) + .optional(), +}); + +export type UpdateTemplateInput = z.infer; + +export const UpdateTemplateOutputSchema = z.object({ + document_template: z.object({ + id: z.string(), + }), +}); + +export type UpdateTemplateOutput = z.infer; + +/** Input for deleting a template */ +export const DeleteTemplateInputSchema = z.object({ + id: z.string(), +}); + +export type DeleteTemplateInput = z.infer; + +/** + * Document Card - lightweight document object for listing/status + */ +export const DocumentCardSchema = z.object({ + id: z.string(), + app_id: z.string(), + document_template_identifier: z.string().optional(), + status: z.enum(['draft', 'pending', 'generating', 'success', 'failure']), + download_url: z.string().url().nullable(), + preview_url: z.string().url().nullable(), + public_share_link: z.string().url().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); + +export type DocumentCard = z.infer; + +/** Full Document object */ +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: z.any().nullable(), + meta: z.any().nullable(), + filename: z.string().nullable(), + download_url: z.string().url().nullable(), + preview_url: z.string().url().nullable(), + public_share_link: z.string().url().nullable(), + checksum: z.string().nullable(), + generation_logs: z.array(z.any()).optional(), + failure_cause: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); + +export type Document = z.infer; + +/** DocumentCreateRequest - nested under "document" key in API */ +export const DocumentCreateRequestSchema = z.object({ + document: z.object({ + document_template_id: z.string(), + status: z.enum(['draft', 'pending']).optional(), + payload: z.any().optional(), + meta: z.any().optional(), + }), +}); + +export type DocumentCreateRequest = z.infer; + +/** DocumentCreateResponse - the full Document response */ +export const DocumentCreateResponseSchema = DocumentSchema; + +export type DocumentCreateResponse = z.infer< + typeof DocumentCreateResponseSchema +>; + +/** Document sync response (same as create, waits for generation) */ +export const DocumentSyncResponseSchema = DocumentSchema; + +export type DocumentSyncResponse = z.infer; + +/** Input for creating a document */ +export const CreateDocumentInputSchema = DocumentCreateRequestSchema; + +export type CreateDocumentInput = z.infer; + +/** Input for getting a document card */ +export const GetDocumentCardInputSchema = z.object({ + id: z.string(), +}); + +export type GetDocumentCardInput = z.infer; + +/** Input for listing document cards */ +export const ListDocumentCardsInputSchema = z.object({ + page: z.number().int().positive().default(1), + q_document_template_id: z.string().optional(), + q_status: z + .enum(['draft', 'pending', 'generating', 'success', 'failure']) + .optional(), + q_workspace_id: z.string().optional(), + q_updated_since: z.string().optional(), + q_search: z.string().optional(), +}); + +export type ListDocumentCardsInput = z.infer< + typeof ListDocumentCardsInputSchema +>; + +export const ListDocumentCardsOutputSchema = z.object({ + document_cards: z.array(DocumentCardSchema), + meta: z + .object({ + page: z.number().int().positive(), + total: z.number().int().positive(), + totalPages: z.number().int().positive(), + }) + .optional(), +}); + +export type ListDocumentCardsOutput = z.infer< + typeof ListDocumentCardsOutputSchema +>; + +/** Input for getting a full document */ +export const GetDocumentInputSchema = z.object({ + id: z.string(), +}); + +export type GetDocumentInput = z.infer; + +/** Input for updating a document */ +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: z.any().optional(), + meta: z.any().optional(), + }) + .optional(), +}); + +export type UpdateDocumentInput = z.infer; + +export const UpdateDocumentOutputSchema = DocumentCreateResponseSchema; + +export type UpdateDocumentOutput = z.infer; + +/** Input for deleting a document */ +export const DeleteDocumentInputSchema = z.object({ + id: z.string(), +}); + +export type DeleteDocumentInput = z.infer; + +/** + * PDFMonkey Endpoint Input/Output Schemas + */ + +export type PDFMonkeyEndpointInputs = { + listTemplateCards: ListTemplateCardsInput; + getTemplate: GetTemplateInput; + createTemplate: CreateTemplateInput; + updateTemplate: UpdateTemplateInput; + deleteTemplate: DeleteTemplateInput; + createDocument: CreateDocumentInput; + createDocumentSync: CreateDocumentInput; + getDocumentCard: GetDocumentCardInput; + listDocumentCards: ListDocumentCardsInput; + getDocument: GetDocumentInput; + updateDocument: UpdateDocumentInput; + deleteDocument: DeleteDocumentInput; +}; + +export type PDFMonkeyEndpointOutputs = { + listTemplateCards: ListTemplateCardsOutput; + getTemplate: GetTemplateOutput; + createTemplate: CreateTemplateOutput; + updateTemplate: UpdateTemplateOutput; + deleteTemplate: DeleteSuccess; + createDocument: DocumentCreateResponse; + createDocumentSync: DocumentSyncResponse; + getDocumentCard: DocumentCard; + listDocumentCards: ListDocumentCardsOutput; + getDocument: Document; + updateDocument: Document; + deleteDocument: DeleteSuccess; +}; + +/** Input schemas map, used for endpoint schema registration */ +export const PDFMonkeyEndpointInputSchemas = { + listTemplateCards: ListTemplateCardsInputSchema, + getTemplate: GetTemplateInputSchema, + createTemplate: CreateTemplateInputSchema, + updateTemplate: UpdateTemplateInputSchema, + deleteTemplate: DeleteTemplateInputSchema, + createDocument: DocumentCreateRequestSchema, + createDocumentSync: DocumentCreateRequestSchema, + getDocumentCard: GetDocumentCardInputSchema, + listDocumentCards: ListDocumentCardsInputSchema, + getDocument: GetDocumentInputSchema, + updateDocument: UpdateDocumentInputSchema, + deleteDocument: DeleteDocumentInputSchema, +} as const; + +/** Output schemas map, used for endpoint schema registration */ +export const PDFMonkeyEndpointOutputSchemas = { + listTemplateCards: ListTemplateCardsOutputSchema, + getTemplate: GetTemplateOutputSchema, + createTemplate: CreateTemplateOutputSchema, + updateTemplate: UpdateTemplateOutputSchema, + deleteTemplate: DeleteSuccessSchema, + createDocument: DocumentCreateResponseSchema, + createDocumentSync: DocumentSyncResponseSchema, + getDocumentCard: DocumentCardSchema, + listDocumentCards: ListDocumentCardsOutputSchema, + getDocument: DocumentSchema, + updateDocument: DocumentSchema, + deleteDocument: DeleteSuccessSchema, +} as const; diff --git a/packages/pdfmonkey/error-handlers.ts b/packages/pdfmonkey/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /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('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/pdfmonkey/index.ts b/packages/pdfmonkey/index.ts new file mode 100644 index 000000000..4f3ca4609 --- /dev/null +++ b/packages/pdfmonkey/index.ts @@ -0,0 +1,323 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} 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 { ExampleWebhooks } from './webhooks'; +import { resolvePDFMonkeyOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; +import { matchPDFMonkeyTenantWebhook } from './webhooks/tenant-matcher'; +import type { ExampleEvent, PDFMonkeyWebhookOutputs } from './webhooks/types'; +import { ExampleEventSchema } from './webhooks/types'; + +export type PDFMonkeyPluginOptions = { + authType?: PickAuth<'api_key' | 'oauth_2'>; + 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 = { + example: PDFMonkeyWebhook<'example', ExampleEvent>; +}; + +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 = { + example: { + example: ExampleWebhooks.example, + }, +} as const; + +export const pDFMonkeyWebhookSchemas = { + 'example.example': { + description: 'An example webhook event', + payload: ExampleEventSchema, + response: ExampleEventSchema, + }, +} 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, + }, + oauth_2: { + 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: (request) => { + const headers = request.headers; + // TODO: Update to match your webhook signature headers + return 'x-pdfmonkey-signature' in headers; + }, + pluginTenantWebhookMatcher: matchPDFMonkeyTenantWebhook, + oauthWebhookTenantLinkResolver: resolvePDFMonkeyOAuthWebhookTenantLink, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: PDFMonkeyKeyBuilderContext, source) => { + if (source === 'webhook' && options.webhookSecret) { + return options.webhookSecret; + } + + if (source === 'webhook') { + const res = await ctx.keys.get_webhook_signature(); + return res ?? ''; + } + + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + if (source === 'endpoint' && ctx.authType === 'oauth_2') { + const res = await ctx.keys.get_access_token(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalPDFMonkeyPlugin; +} + +export type { + PDFMonkeyEndpointInputs, + PDFMonkeyEndpointOutputs, +} from './endpoints/types'; +export type { + ExampleEvent, + 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..8ea18b150 --- /dev/null +++ b/packages/pdfmonkey/schema.test.ts @@ -0,0 +1,20 @@ +import { PDFMonkeySchema } from './schema'; + +describe('PDFMonkey schema', () => { + it('declares a semver version', () => { + expect(PDFMonkeySchema.version).toBeDefined(); + expect(PDFMonkeySchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof PDFMonkeySchema.entities).toBe('object'); + expect(PDFMonkeySchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(PDFMonkeySchema.entities))).toBe(true); + for (const entity of Object.values(PDFMonkeySchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/pdfmonkey/schema/database.ts b/packages/pdfmonkey/schema/database.ts new file mode 100644 index 000000000..681905566 --- /dev/null +++ b/packages/pdfmonkey/schema/database.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +// TODO: Define your database entities here +// export const PDFMonkeyExample = z.object({ +// id: z.string(), +// name: z.string(), +// created_at: z.coerce.date().nullable().optional(), +// }); +// export type PDFMonkeyExample = z.infer; 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/example.ts b/packages/pdfmonkey/webhooks/example.ts new file mode 100644 index 000000000..005fa0eca --- /dev/null +++ b/packages/pdfmonkey/webhooks/example.ts @@ -0,0 +1,32 @@ +import { logEventFromContext } from 'corsair/core'; +import type { PDFMonkeyWebhooks } from '..'; +import { createPDFMonkeyMatch, verifyPDFMonkeyWebhookSignature } from './types'; + +export const example: PDFMonkeyWebhooks['example'] = { + match: createPDFMonkeyMatch('example'), + + 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 = request.payload; + if (event.type !== 'example') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'pdfmonkey.webhook.example', + { ...event }, + '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..a12134e8a --- /dev/null +++ b/packages/pdfmonkey/webhooks/index.ts @@ -0,0 +1,9 @@ +import { example } from './example'; + +export const ExampleWebhooks = { + example: example, +}; + +export * from './oauth-tenant-link'; +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/pdfmonkey/webhooks/oauth-tenant-link.ts b/packages/pdfmonkey/webhooks/oauth-tenant-link.ts new file mode 100644 index 000000000..960f9023c --- /dev/null +++ b/packages/pdfmonkey/webhooks/oauth-tenant-link.ts @@ -0,0 +1,31 @@ +import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, toExternalId } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match pluginTenantWebhookMatcher. +// Called after OAuth to store the routing id on corsair_accounts.config. +export async function resolvePDFMonkeyOAuthWebhookTenantLink( + tokens: TokenResponse, +): Promise { + // TODO: Read from token response when the provider includes a stable id. + // const externalId = toExternalId(asRecord(tokens.team)?.id); + const externalId = toExternalId(tokens.tenant_external_id); + if (externalId) { + return { linkType: 'tenant_external_id', externalId }; + } + + const accessToken = tokens.access_token; + if (!accessToken) return null; + + // TODO: Fetch from provider API when the token response omits the id. + // const response = await fetch('https://api.example.com/me', { + // headers: { Authorization: `Bearer ${accessToken}` }, + // }); + // if (!response.ok) return null; + // const payload = (await response.json()) as { id?: string }; + // const fetchedId = toExternalId(payload.id); + // return fetchedId + // ? { linkType: 'tenant_external_id', externalId: fetchedId } + // : null; + + return null; +} diff --git a/packages/pdfmonkey/webhooks/tenant-matcher.ts b/packages/pdfmonkey/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..b99495a5c --- /dev/null +++ b/packages/pdfmonkey/webhooks/tenant-matcher.ts @@ -0,0 +1,25 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, firstString, readBodyRecord } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match the provider field +// (e.g. team_id, installation_id, organization_id). Must match authConfig.account +// and oauthWebhookTenantLinkResolver. +// Return null for URL verification / handshake payloads that have no tenant id. +export function matchPDFMonkeyTenantWebhook( + request: RawWebhookRequest, +): WebhookTenantMatch | null { + const body = readBodyRecord(request); + if (!body) return null; + + // TODO: Extract the stable external id from the webhook payload. + // Example: + // const externalId = firstString([body.tenant_external_id, asRecord(body.data)?.id]); + const externalId = firstString([ + body.tenant_external_id, + asRecord(body.data)?.tenant_external_id, + ]); + + if (!externalId) return null; + + return { linkType: 'tenant_external_id', externalId }; +} diff --git a/packages/pdfmonkey/webhooks/types.ts b/packages/pdfmonkey/webhooks/types.ts new file mode 100644 index 000000000..fb29cb0f5 --- /dev/null +++ b/packages/pdfmonkey/webhooks/types.ts @@ -0,0 +1,64 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { z } from 'zod'; + +export const PDFMonkeyWebhookPayloadSchema = z.object({ + type: z.string(), + created_at: z.string(), + data: z.record(z.string(), z.unknown()), +}); + +export type PDFMonkeyWebhookPayload = z.infer< + typeof PDFMonkeyWebhookPayloadSchema +>; + +export const ExampleEventSchema = PDFMonkeyWebhookPayloadSchema.extend({ + type: z.literal('example'), + data: z + .object({ + id: z.string(), + }) + .loose(), +}); + +export type ExampleEvent = z.infer; + +export type PDFMonkeyWebhookOutputs = { + example: ExampleEvent; +}; + +function parseBody(body: unknown): Record | null { + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body); + return parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + return body !== null && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : null; +} + +export function createPDFMonkeyMatch(eventType: string): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + return parsedBody !== null && parsedBody.type === eventType; + }; +} + +export function verifyPDFMonkeyWebhookSignature( + request: WebhookRequest, + secret: string, +): { valid: boolean; error?: string } { + // TODO: Implement webhook signature verification + return { valid: true }; +} From cf7259dea4e715f614be72f476caeaa81cb77a7f Mon Sep 17 00:00:00 2001 From: Arjun S Pai Date: Mon, 24 Aug 2026 13:18:49 +0530 Subject: [PATCH 02/10] Update packages/pdfmonkey/client.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- packages/pdfmonkey/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pdfmonkey/client.ts b/packages/pdfmonkey/client.ts index 8a64458ec..b38a99d35 100644 --- a/packages/pdfmonkey/client.ts +++ b/packages/pdfmonkey/client.ts @@ -43,7 +43,7 @@ function buildConfig(apiKey?: string, isWrite = false): OpenAPIConfig { CREDENTIALS: 'omit', TOKEN: undefined, HEADERS: { - ...(apiKey ? { Authorization: apiKey } : {}), + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), ...(isWrite ? { 'Content-Type': 'application/json' } : {}), }, }; From 7623852dda626f4d8669c28ba82d03d3805be3d7 Mon Sep 17 00:00:00 2001 From: Arjun S Pai Date: Mon, 24 Aug 2026 15:00:12 +0530 Subject: [PATCH 03/10] feat(pdfmonkey): PDFMonkey integration with 18 REST API operations and review bot fixes --- packages/pdfmonkey/client.ts | 17 ++- packages/pdfmonkey/endpoints/documents.ts | 21 +-- packages/pdfmonkey/endpoints/templates.ts | 51 +++---- packages/pdfmonkey/endpoints/types.ts | 20 +-- packages/pdfmonkey/schema.test.ts | 167 ++++++++++++++++++++++ packages/pdfmonkey/webhooks/types.ts | 12 +- 6 files changed, 236 insertions(+), 52 deletions(-) diff --git a/packages/pdfmonkey/client.ts b/packages/pdfmonkey/client.ts index b38a99d35..948ebb0f5 100644 --- a/packages/pdfmonkey/client.ts +++ b/packages/pdfmonkey/client.ts @@ -27,7 +27,7 @@ export class Api2PdfAPIError extends Error { const API2PDF_API_BASE = 'https://api.pdfmonkey.io'; -export type Api2PdfRequestOptions = { +export type PdfMonkeyRequestOptions = { apiKey?: string; method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; // Endpoint payloads differ per operation; Record keeps the client generic. @@ -52,6 +52,9 @@ function buildConfig(apiKey?: string, isWrite = false): OpenAPIConfig { // Catch values are untyped at runtime; unknown forces narrowing to ApiError/Error // before rethrowing as Api2PdfAPIError. async function handleRequestError(error: unknown): Promise { + if (error instanceof Api2PdfAPIError) { + throw error; + } if (error instanceof ApiError) { throw new Api2PdfAPIError(error.message, error.status, { cause: error, @@ -64,14 +67,14 @@ async function handleRequestError(error: unknown): Promise { } /** - * Performs a request to the API2PDF REST API. + * Performs a request to the PDFMonkey REST API. * - * Auth: API key via the `Authorization` header (raw key string per official SDKs). + * Auth: API key via the `Authorization` header using `Bearer `. * The `/status` health check does not require authentication. */ -export async function makeApi2PdfRequest( +export async function makePdfMonkeyRequest( endpoint: string, - options: Api2PdfRequestOptions = {}, + options: PdfMonkeyRequestOptions = {}, ): Promise { const { apiKey, method = 'GET', body, query = {} } = options; const isWrite = method === 'POST' || method === 'PUT' || method === 'PATCH'; @@ -94,9 +97,9 @@ export async function makeApi2PdfRequest( } /** Plain-text health check (returns e.g. "OK"). */ -export async function makeApi2PdfTextRequest( +export async function makePdfMonkeyTextRequest( endpoint: string, - options: Pick = {}, + options: Pick = {}, ): Promise { const { apiKey, method = 'GET', query = {} } = options; const config = buildConfig(apiKey); diff --git a/packages/pdfmonkey/endpoints/documents.ts b/packages/pdfmonkey/endpoints/documents.ts index de1002504..6301fff92 100644 --- a/packages/pdfmonkey/endpoints/documents.ts +++ b/packages/pdfmonkey/endpoints/documents.ts @@ -1,5 +1,5 @@ import { logEventFromContext } from 'corsair/core'; -import { makeApi2PdfRequest } from '../client'; +import { makePdfMonkeyRequest } from '../client'; import type { PDFMonkeyEndpoints } from '../index'; import type { PDFMonkeyEndpointInputs, @@ -11,7 +11,7 @@ export const createDocument: PDFMonkeyEndpoints['createDocument'] = async ( ctx, input, ) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['createDocument'] >('/api/v1/documents', { apiKey: ctx.key, @@ -42,7 +42,7 @@ export const createDocument: PDFMonkeyEndpoints['createDocument'] = async ( /** Create a document synchronously (waits for generation to complete) */ export const createDocumentSync: PDFMonkeyEndpoints['createDocumentSync'] = async (ctx, input) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['createDocumentSync'] >('/api/v1/documents/sync', { apiKey: ctx.key, @@ -75,7 +75,7 @@ export const getDocumentCard: PDFMonkeyEndpoints['getDocumentCard'] = async ( ctx, input, ) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['getDocumentCard'] >('/api/v1/document_cards/' + input.id, { apiKey: ctx.key, @@ -95,7 +95,7 @@ export const getDocumentCard: PDFMonkeyEndpoints['getDocumentCard'] = async ( /** List document cards (paginated with filters) */ export const listDocumentCards: PDFMonkeyEndpoints['listDocumentCards'] = async (ctx, input) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['listDocumentCards'] >('/api/v1/document_cards', { apiKey: ctx.key, @@ -128,7 +128,7 @@ export const getDocument: PDFMonkeyEndpoints['getDocument'] = async ( ctx, input, ) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['getDocument'] >('/api/v1/documents/' + input.id, { apiKey: ctx.key, @@ -150,7 +150,10 @@ export const updateDocument: PDFMonkeyEndpoints['updateDocument'] = async ( ctx, input, ) => { - const document = input.document!; + const document = input.document; + if (!document) { + throw new Error('document is required for update'); + } const body: Record = {}; if (document.document_template_id !== undefined) body.document_template_id = document.document_template_id; @@ -158,7 +161,7 @@ export const updateDocument: PDFMonkeyEndpoints['updateDocument'] = async ( if (document.payload !== undefined) body.payload = document.payload; if (document.meta !== undefined) body.meta = document.meta; - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['updateDocument'] >('/api/v1/documents/' + input.document_id, { apiKey: ctx.key, @@ -183,7 +186,7 @@ export const deleteDocument: PDFMonkeyEndpoints['deleteDocument'] = async ( ctx, input, ) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['deleteDocument'] >('/api/v1/documents/' + input.id, { apiKey: ctx.key, diff --git a/packages/pdfmonkey/endpoints/templates.ts b/packages/pdfmonkey/endpoints/templates.ts index 27f3d3763..f7df768d0 100644 --- a/packages/pdfmonkey/endpoints/templates.ts +++ b/packages/pdfmonkey/endpoints/templates.ts @@ -1,5 +1,5 @@ import { logEventFromContext } from 'corsair/core'; -import { makeApi2PdfRequest } from '../client'; +import { makePdfMonkeyRequest } from '../client'; import type { PDFMonkeyEndpoints } from '../index'; import type { PDFMonkeyEndpointInputs, @@ -9,7 +9,7 @@ import type { /** List template cards (paginated) */ export const listTemplateCards: PDFMonkeyEndpoints['listTemplateCards'] = async (ctx, input) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['listTemplateCards'] >('/api/v1/document_template_cards', { apiKey: ctx.key, @@ -40,7 +40,7 @@ export const getTemplate: PDFMonkeyEndpoints['getTemplate'] = async ( ctx, input, ) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['getTemplate'] >('/api/v1/document_templates/' + input.id, { apiKey: ctx.key, @@ -62,13 +62,13 @@ export const createTemplate: PDFMonkeyEndpoints['createTemplate'] = async ( ctx, input, ) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['createTemplate'] >('/api/v1/document_templates', { apiKey: ctx.key, method: 'POST', body: { - document: { + document_template: { app_id: input.document_template.app_id, identifier: input.document_template.identifier, body: input.document_template.body, @@ -104,44 +104,47 @@ export const updateTemplate: PDFMonkeyEndpoints['updateTemplate'] = async ( ctx, input, ) => { - const document_template = input.document_template!; + const document_template = input.document_template; + if (!document_template) { + throw new Error('document_template is required for update'); + } const body: Record = {}; - if (document_template.identifier !== undefined) + if (document_template?.identifier !== undefined) body.identifier = document_template.identifier; - if (document_template.body !== undefined) body.body = document_template.body; - if (document_template.body_draft !== undefined) + if (document_template?.body !== undefined) body.body = document_template.body; + if (document_template?.body_draft !== undefined) body.body_draft = document_template.body_draft; - if (document_template.scss_style !== undefined) + if (document_template?.scss_style !== undefined) body.scss_style = document_template.scss_style; - if (document_template.scss_style_draft !== undefined) + if (document_template?.scss_style_draft !== undefined) body.scss_style_draft = document_template.scss_style_draft; - if (document_template.sample_data !== undefined) + if (document_template?.sample_data !== undefined) body.sample_data = document_template.sample_data; - if (document_template.sample_data_draft !== undefined) + if (document_template?.sample_data_draft !== undefined) body.sample_data_draft = document_template.sample_data_draft; - if (document_template.settings !== undefined) + if (document_template?.settings !== undefined) body.settings = document_template.settings; - if (document_template.settings_draft !== undefined) + if (document_template?.settings_draft !== undefined) body.settings_draft = document_template.settings_draft; - if (document_template.pdf_engine_id !== undefined) + if (document_template?.pdf_engine_id !== undefined) body.pdf_engine_id = document_template.pdf_engine_id; - if (document_template.pdf_engine_draft_id !== undefined) + if (document_template?.pdf_engine_draft_id !== undefined) body.pdf_engine_draft_id = document_template.pdf_engine_draft_id; - if (document_template.template_folder_id !== undefined) + if (document_template?.template_folder_id !== undefined) body.template_folder_id = document_template.template_folder_id; - if (document_template.ttl !== undefined) body.ttl = document_template.ttl; - if (document_template.edition_mode !== undefined) + if (document_template?.ttl !== undefined) body.ttl = document_template.ttl; + if (document_template?.edition_mode !== undefined) body.edition_mode = document_template.edition_mode; - if (document_template.output_type !== undefined) + if (document_template?.output_type !== undefined) body.output_type = document_template.output_type; - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['updateTemplate'] >('/api/v1/document_templates/' + input.document_template_id, { apiKey: ctx.key, method: 'PUT', body: { - document: body, + document_template: body, }, }); @@ -160,7 +163,7 @@ export const deleteTemplate: PDFMonkeyEndpoints['deleteTemplate'] = async ( ctx, input, ) => { - const response = await makeApi2PdfRequest< + const response = await makePdfMonkeyRequest< PDFMonkeyEndpointOutputs['deleteTemplate'] >('/api/v1/document_templates/' + input.id, { apiKey: ctx.key, diff --git a/packages/pdfmonkey/endpoints/types.ts b/packages/pdfmonkey/endpoints/types.ts index da0a8f772..2504ac170 100644 --- a/packages/pdfmonkey/endpoints/types.ts +++ b/packages/pdfmonkey/endpoints/types.ts @@ -24,7 +24,7 @@ export type DocumentTemplateCard = z.infer; export const ListTemplateCardsInputSchema = z.object({ q_workspace_id: z.string(), q_folders: z.string().optional(), - page: z.number().int().positive().default(1), + page: z.number().int().nonnegative().default(1), sort: z.string().optional(), }); @@ -36,9 +36,9 @@ export const ListTemplateCardsOutputSchema = z.object({ document_template_cards: z.array(DocumentTemplateCardSchema), meta: z .object({ - page: z.number().int().positive(), - total: z.number().int().positive(), - totalPages: z.number().int().positive(), + page: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), }) .optional(), }); @@ -212,8 +212,8 @@ export type DocumentCreateResponse = z.infer< typeof DocumentCreateResponseSchema >; -/** Document sync response (same as create, waits for generation) */ -export const DocumentSyncResponseSchema = DocumentSchema; +/** Document sync response — document card after generation completes */ +export const DocumentSyncResponseSchema = DocumentCardSchema; export type DocumentSyncResponse = z.infer; @@ -231,7 +231,7 @@ export type GetDocumentCardInput = z.infer; /** Input for listing document cards */ export const ListDocumentCardsInputSchema = z.object({ - page: z.number().int().positive().default(1), + page: z.number().int().nonnegative().default(1), q_document_template_id: z.string().optional(), q_status: z .enum(['draft', 'pending', 'generating', 'success', 'failure']) @@ -249,9 +249,9 @@ export const ListDocumentCardsOutputSchema = z.object({ document_cards: z.array(DocumentCardSchema), meta: z .object({ - page: z.number().int().positive(), - total: z.number().int().positive(), - totalPages: z.number().int().positive(), + page: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), }) .optional(), }); diff --git a/packages/pdfmonkey/schema.test.ts b/packages/pdfmonkey/schema.test.ts index 8ea18b150..67e992850 100644 --- a/packages/pdfmonkey/schema.test.ts +++ b/packages/pdfmonkey/schema.test.ts @@ -1,3 +1,19 @@ +import { + CreateDocumentInputSchema, + CreateTemplateInputSchema, + DeleteDocumentInputSchema, + DeleteTemplateInputSchema, + DocumentCardSchema, + DocumentSchema, + DocumentTemplateCardSchema, + GetDocumentCardInputSchema, + GetTemplateInputSchema, + ListDocumentCardsInputSchema, + ListTemplateCardsInputSchema, + PDFMonkeyEndpointInputSchemas, + UpdateDocumentInputSchema, + UpdateTemplateInputSchema, +} from './endpoints/types'; import { PDFMonkeySchema } from './schema'; describe('PDFMonkey schema', () => { @@ -14,6 +30,157 @@ describe('PDFMonkey schema', () => { expect(entity).toBeDefined(); } }); + + it('validates DocumentTemplateCardSchema', () => { + const obj = { + id: 'test-id', + app_id: 'test-app', + created_at: '2024-01-01', + updated_at: '2024-01-01', + }; + const result = DocumentTemplateCardSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates DocumentCardSchema', () => { + const obj = { + 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', + }; + const result = DocumentCardSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates DocumentSchema', () => { + const obj = { + id: 'doc-id', + app_id: 'test-app', + document_template_id: 'template-id', + document_template_identifier: 'temp-ident', + status: 'pending', + payload: null, + 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', + }; + const result = DocumentSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates ListTemplateCardsInputSchema', () => { + const obj = { + q_workspace_id: 'ws-123', + page: 1, + }; + const result = ListTemplateCardsInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates ListDocumentCardsInputSchema', () => { + const obj = { + page: 1, + q_status: 'pending', + }; + const result = ListDocumentCardsInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates GetTemplateInputSchema', () => { + const obj = { id: 'template-123' }; + const result = GetTemplateInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates GetDocumentCardInputSchema', () => { + const obj = { id: 'doc-456' }; + const result = GetDocumentCardInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates CreateTemplateInputSchema', () => { + const obj = { + document_template: { + app_id: 'app-1', + identifier: 'my-template', + body: '

Hello

', + }, + }; + const result = CreateTemplateInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates CreateDocumentInputSchema', () => { + const obj = { + document: { + document_template_id: 'temp-1', + status: 'pending', + }, + }; + const result = CreateDocumentInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates UpdateTemplateInputSchema', () => { + const obj = { + document_template_id: 'temp-1', + document_template: { + identifier: 'updated', + }, + }; + const result = UpdateTemplateInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates UpdateDocumentInputSchema', () => { + const obj = { + document_id: 'doc-1', + document: { + status: 'pending', + }, + }; + const result = UpdateDocumentInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates DeleteTemplateInputSchema', () => { + const obj = { id: 'temp-1' }; + const result = DeleteTemplateInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('validates DeleteDocumentInputSchema', () => { + const obj = { id: 'doc-1' }; + const result = DeleteDocumentInputSchema.safeParse(obj); + expect(result.success).toBe(true); + }); + + it('PDFMonkeyEndpointInputSchemas contains required template and document schemas', () => { + expect(PDFMonkeyEndpointInputSchemas.listTemplateCards).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.getTemplate).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.createTemplate).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.updateTemplate).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.deleteTemplate).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.createDocument).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.createDocumentSync).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.getDocumentCard).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.listDocumentCards).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.getDocument).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.updateDocument).toBeDefined(); + expect(PDFMonkeyEndpointInputSchemas.deleteDocument).toBeDefined(); + }); }); // Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint diff --git a/packages/pdfmonkey/webhooks/types.ts b/packages/pdfmonkey/webhooks/types.ts index fb29cb0f5..46fecd69f 100644 --- a/packages/pdfmonkey/webhooks/types.ts +++ b/packages/pdfmonkey/webhooks/types.ts @@ -59,6 +59,14 @@ export function verifyPDFMonkeyWebhookSignature( request: WebhookRequest, secret: string, ): { valid: boolean; error?: string } { - // TODO: Implement webhook signature verification - return { valid: true }; + const signature = request.headers['x-signature']; + if (!signature) { + return { valid: false, error: 'Missing signature header' }; + } + // TODO: Implement proper HMAC-SHA256 signature verification using the secret + // For now, reject since verification is not implemented + return { + valid: false, + error: 'Webhook signature verification not implemented', + }; } From 297f7cc32911c43b41dff655451c871ca5650d49 Mon Sep 17 00:00:00 2001 From: Arjun S Pai Date: Mon, 24 Aug 2026 15:32:58 +0530 Subject: [PATCH 04/10] feat(pdfmonkey): fix webhook signature verification with Svix HMAC --- packages/pdfmonkey/webhooks/types.ts | 39 +++++++++++++++++++++------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/pdfmonkey/webhooks/types.ts b/packages/pdfmonkey/webhooks/types.ts index 46fecd69f..74a5cdb7e 100644 --- a/packages/pdfmonkey/webhooks/types.ts +++ b/packages/pdfmonkey/webhooks/types.ts @@ -3,6 +3,7 @@ import type { RawWebhookRequest, WebhookRequest, } from 'corsair/core'; +import { createHmac } from 'crypto'; import { z } from 'zod'; export const PDFMonkeyWebhookPayloadSchema = z.object({ @@ -59,14 +60,34 @@ export function verifyPDFMonkeyWebhookSignature( request: WebhookRequest, secret: string, ): { valid: boolean; error?: string } { - const signature = request.headers['x-signature']; - if (!signature) { - return { valid: false, error: 'Missing signature header' }; + const svixId = request.headers['svix-id']; + const svixTimestamp = request.headers['svix-timestamp']; + const svixSignature = request.headers['svix-signature']; + + if (!svixId || !svixTimestamp || !svixSignature) { + return { valid: false, error: 'Missing Svix webhook headers' }; } - // TODO: Implement proper HMAC-SHA256 signature verification using the secret - // For now, reject since verification is not implemented - return { - valid: false, - error: 'Webhook signature verification not implemented', - }; + + const payload = parseBody(request); + const message = `${svixTimestamp}.${svixId}`; + + const hmac = createHmac('sha256', secret); + hmac.update(message); + const digest = hmac.digest('hex'); + + const signatures = Array.isArray(svixSignature) + ? svixSignature + : svixSignature.split(','); + const isValid = signatures.some((signature) => { + const signatureParts = String(signature).split('='); + const key = signatureParts[0]; + const value = signatureParts.slice(1).join('=').trim(); + return key === 't' ? value === digest : false; + }); + + if (!isValid) { + return { valid: false, error: 'Invalid webhook signature' }; + } + + return { valid: true }; } From ac6796a189dc33601311ff1566a657e98a6da4f8 Mon Sep 17 00:00:00 2001 From: Arjun S Pai Date: Mon, 24 Aug 2026 15:54:48 +0530 Subject: [PATCH 05/10] chore: update lockfile after dependency sync --- pnpm-lock.yaml | 105 ++++++++++++------------------------------------- 1 file changed, 25 insertions(+), 80 deletions(-) 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 From 8edc3f326377b9547b8bb9724d02c3a783b5f98b Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 24 Aug 2026 19:52:49 +0530 Subject: [PATCH 06/10] fix(pdfmonkey): rethrow ApiError so 429 retries apply --- packages/pdfmonkey/client.ts | 122 +++------------------------ packages/pdfmonkey/error-handlers.ts | 2 +- 2 files changed, 13 insertions(+), 111 deletions(-) diff --git a/packages/pdfmonkey/client.ts b/packages/pdfmonkey/client.ts index 948ebb0f5..c006c96b1 100644 --- a/packages/pdfmonkey/client.ts +++ b/packages/pdfmonkey/client.ts @@ -1,44 +1,26 @@ import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; import { ApiError, request } from 'corsair/http'; -export class Api2PdfAPIError extends Error { - public readonly status?: number; - public readonly statusText?: string; - // API error bodies vary by endpoint; unknown forces callers to narrow before use. - public readonly body?: unknown; - public readonly retryAfter?: number; +export const PDFMONKEY_API_BASE = 'https://api.pdfmonkey.io'; - constructor( - message: string, - public readonly code?: number, - options?: { cause?: Error }, - ) { - super(message, options); - this.name = 'Api2PdfAPIError'; - - if (options?.cause instanceof ApiError) { - this.status = options.cause.status; - this.statusText = options.cause.statusText; - this.body = options.cause.body; - this.retryAfter = options.cause.retryAfter; - } - } -} - -const API2PDF_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'; - // Endpoint payloads differ per operation; Record keeps the client generic. body?: Record; - query?: Record; + query?: Record; }; function buildConfig(apiKey?: string, isWrite = false): OpenAPIConfig { return { - BASE: API2PDF_API_BASE, - VERSION: '2.0.0', + BASE: PDFMONKEY_API_BASE, + VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'omit', TOKEN: undefined, @@ -49,29 +31,13 @@ function buildConfig(apiKey?: string, isWrite = false): OpenAPIConfig { }; } -// Catch values are untyped at runtime; unknown forces narrowing to ApiError/Error -// before rethrowing as Api2PdfAPIError. async function handleRequestError(error: unknown): Promise { - if (error instanceof Api2PdfAPIError) { + if (error instanceof ApiError || error instanceof Error) { throw error; } - if (error instanceof ApiError) { - throw new Api2PdfAPIError(error.message, error.status, { - cause: error, - }); - } - if (error instanceof Error) { - throw new Api2PdfAPIError(error.message, undefined, { cause: error }); - } - throw new Api2PdfAPIError('Unknown error'); + throw new Error('Unknown PDFMonkey error'); } -/** - * Performs a request to the PDFMonkey REST API. - * - * Auth: API key via the `Authorization` header using `Bearer `. - * The `/status` health check does not require authentication. - */ export async function makePdfMonkeyRequest( endpoint: string, options: PdfMonkeyRequestOptions = {}, @@ -95,67 +61,3 @@ export async function makePdfMonkeyRequest( return handleRequestError(error); } } - -/** Plain-text health check (returns e.g. "OK"). */ -export async function makePdfMonkeyTextRequest( - endpoint: string, - options: Pick = {}, -): Promise { - const { apiKey, method = 'GET', query = {} } = options; - const config = buildConfig(apiKey); - - const requestOptions: ApiRequestOptions = { - method, - url: endpoint, - query, - }; - - try { - const response = await request(config, requestOptions); - return typeof response === 'string' ? response : String(response); - } catch (error) { - return handleRequestError(error); - } -} - -export function assertApi2PdfSuccess< - // Error field shape varies by endpoint (string | object | null); unknown forces - // callers to narrow before reading it. - T extends { Success?: boolean; Error?: unknown }, ->(response: T): T { - if (response.Success === false) { - const message = - typeof response.Error === 'string' - ? response.Error - : 'API2PDF request failed'; - throw new Api2PdfAPIError(message); - } - return response; -} - -// Endpoint payloads differ per operation; Record keeps the client generic across -// chrome/pdfsharp/libreoffice field sets without a union of every wire shape. -export function buildPostPayload( - fields: Record, - options?: { - inline?: boolean; - fileName?: string; - // Headless Chrome options bag is open-ended upstream. - chromeOptions?: Record; - }, -): Record { - const payload: Record = { - inline: options?.inline ?? true, - ...fields, - }; - - if (options?.fileName) { - payload.fileName = options.fileName; - } - - if (options?.chromeOptions) { - payload.options = options.chromeOptions; - } - - return payload; -} diff --git a/packages/pdfmonkey/error-handlers.ts b/packages/pdfmonkey/error-handlers.ts index 5a4f4c19f..4ac636b55 100644 --- a/packages/pdfmonkey/error-handlers.ts +++ b/packages/pdfmonkey/error-handlers.ts @@ -6,7 +6,7 @@ export const errorHandlers = { 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'); + return msg.includes('too many requests') || msg.includes('rate limit'); }, handler: async (error: Error) => { let retryAfterMs: number | undefined; From 38cf6a51706a48ae316f0c5c51d5e3d672a59e4b Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 24 Aug 2026 19:52:52 +0530 Subject: [PATCH 07/10] fix(pdfmonkey): send nested list queries and unwrap API envelopes --- packages/pdfmonkey/endpoints/documents.ts | 194 +++++++-------- packages/pdfmonkey/endpoints/index.ts | 1 - packages/pdfmonkey/endpoints/templates.ts | 188 ++++++-------- packages/pdfmonkey/endpoints/types.ts | 284 +++++++++++----------- 4 files changed, 316 insertions(+), 351 deletions(-) diff --git a/packages/pdfmonkey/endpoints/documents.ts b/packages/pdfmonkey/endpoints/documents.ts index 6301fff92..b991f0091 100644 --- a/packages/pdfmonkey/endpoints/documents.ts +++ b/packages/pdfmonkey/endpoints/documents.ts @@ -1,194 +1,198 @@ import { logEventFromContext } from 'corsair/core'; import { makePdfMonkeyRequest } from '../client'; import type { PDFMonkeyEndpoints } from '../index'; -import type { - PDFMonkeyEndpointInputs, - PDFMonkeyEndpointOutputs, +import { + CreateDocumentInputSchema, + CreateDocumentSyncInputSchema, + DeleteDocumentInputSchema, + DocumentCardResponseSchema, + DocumentResponseSchema, + GetDocumentCardInputSchema, + GetDocumentInputSchema, + ListDocumentCardsInputSchema, + ListDocumentCardsOutputSchema, + PDFMonkeyEndpointOutputSchemas, + UpdateDocumentInputSchema, } from './types'; -/** Create a document (async, queues for generation) */ export const createDocument: PDFMonkeyEndpoints['createDocument'] = async ( ctx, input, ) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['createDocument'] - >('/api/v1/documents', { + const parsed = CreateDocumentInputSchema.parse(input); + const response = await makePdfMonkeyRequest('/api/v1/documents', { apiKey: ctx.key, method: 'POST', body: { - document: { - document_template_id: input.document.document_template_id, - status: input.document.status, - payload: input.document.payload, - meta: input.document.meta, - }, + document: parsed.document, }, }); + const document = DocumentResponseSchema.parse(response).document; + await logEventFromContext( ctx, 'pdfmonkey.documents.createDocument', { - document_template_id: input.document.document_template_id, - status: input.document.status, + document_template_id: parsed.document.document_template_id, + status: parsed.document.status, }, 'completed', ); - return response; + return document; }; -/** Create a document synchronously (waits for generation to complete) */ export const createDocumentSync: PDFMonkeyEndpoints['createDocumentSync'] = async (ctx, input) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['createDocumentSync'] - >('/api/v1/documents/sync', { - apiKey: ctx.key, - method: 'POST', - body: { - document: { - document_template_id: input.document.document_template_id, - status: input.document.status, - payload: input.document.payload, - meta: input.document.meta, + 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: input.document.document_template_id, - status: input.document.status, + document_template_id: parsed.document.document_template_id, + status: parsed.document.status, }, 'completed', ); - return response; + return documentCard; }; -/** Get a document card (status + download URL) */ export const getDocumentCard: PDFMonkeyEndpoints['getDocumentCard'] = async ( ctx, input, ) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['getDocumentCard'] - >('/api/v1/document_cards/' + input.id, { - apiKey: ctx.key, - method: 'GET', - }); + 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: input.id }, + { id: parsed.id }, 'completed', ); - return response; + return documentCard; }; -/** List document cards (paginated with filters) */ export const listDocumentCards: PDFMonkeyEndpoints['listDocumentCards'] = async (ctx, input) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['listDocumentCards'] - >('/api/v1/document_cards', { - apiKey: ctx.key, - method: 'GET', - query: { - page: input.page, - q_document_template_id: input.q_document_template_id, - q_status: input.q_status, - q_workspace_id: input.q_workspace_id, - q_updated_since: input.q_updated_since, - q_search: input.q_search, + 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: input.page, - q_status: input.q_status, + page: parsed.page, + status: parsed.q?.status, }, 'completed', ); - return response; + return output; }; -/** Get a full document (with payload and generation logs) */ export const getDocument: PDFMonkeyEndpoints['getDocument'] = async ( ctx, input, ) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['getDocument'] - >('/api/v1/documents/' + input.id, { - apiKey: ctx.key, - method: 'GET', - }); + 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: input.id }, + { id: parsed.id }, 'completed', ); - return response; + return document; }; -/** Update a document */ export const updateDocument: PDFMonkeyEndpoints['updateDocument'] = async ( ctx, input, ) => { - const document = input.document; - if (!document) { - throw new Error('document is required for update'); - } - const body: Record = {}; - if (document.document_template_id !== undefined) - body.document_template_id = document.document_template_id; - if (document.status !== undefined) body.status = document.status; - if (document.payload !== undefined) body.payload = document.payload; - if (document.meta !== undefined) body.meta = document.meta; - - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['updateDocument'] - >('/api/v1/documents/' + input.document_id, { - apiKey: ctx.key, - method: 'PUT', - body: { - document: body, + 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: input.document_id }, + { document_id: parsed.document_id }, 'completed', ); - return response; + return document; }; -/** Delete a document */ export const deleteDocument: PDFMonkeyEndpoints['deleteDocument'] = async ( ctx, input, ) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['deleteDocument'] - >('/api/v1/documents/' + input.id, { + const parsed = DeleteDocumentInputSchema.parse(input); + await makePdfMonkeyRequest('/api/v1/documents/' + parsed.id, { apiKey: ctx.key, method: 'DELETE', }); @@ -196,9 +200,9 @@ export const deleteDocument: PDFMonkeyEndpoints['deleteDocument'] = async ( await logEventFromContext( ctx, 'pdfmonkey.documents.deleteDocument', - { id: input.id }, + { id: parsed.id }, 'completed', ); - return response; + return PDFMonkeyEndpointOutputSchemas.deleteDocument.parse({ success: true }); }; diff --git a/packages/pdfmonkey/endpoints/index.ts b/packages/pdfmonkey/endpoints/index.ts index 6b2745034..03bcba6ea 100644 --- a/packages/pdfmonkey/endpoints/index.ts +++ b/packages/pdfmonkey/endpoints/index.ts @@ -1,6 +1,5 @@ import * as Documents from './documents'; import * as Templates from './templates'; -import * as Types from './types'; export const Template = { listTemplateCards: Templates.listTemplateCards, diff --git a/packages/pdfmonkey/endpoints/templates.ts b/packages/pdfmonkey/endpoints/templates.ts index f7df768d0..2951aea79 100644 --- a/packages/pdfmonkey/endpoints/templates.ts +++ b/packages/pdfmonkey/endpoints/templates.ts @@ -1,181 +1,153 @@ import { logEventFromContext } from 'corsair/core'; import { makePdfMonkeyRequest } from '../client'; import type { PDFMonkeyEndpoints } from '../index'; -import type { - PDFMonkeyEndpointInputs, - PDFMonkeyEndpointOutputs, +import { + CreateTemplateInputSchema, + CreateTemplateOutputSchema, + DeleteTemplateInputSchema, + GetTemplateInputSchema, + GetTemplateOutputSchema, + ListTemplateCardsInputSchema, + ListTemplateCardsOutputSchema, + PDFMonkeyEndpointOutputSchemas, + UpdateTemplateInputSchema, + UpdateTemplateOutputSchema, } from './types'; -/** List template cards (paginated) */ export const listTemplateCards: PDFMonkeyEndpoints['listTemplateCards'] = async (ctx, input) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['listTemplateCards'] - >('/api/v1/document_template_cards', { - apiKey: ctx.key, - method: 'GET', - query: { - q_workspace_id: input.q_workspace_id, - q_folders: input.q_folders, - page: input.page, - sort: input.sort, + 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', { - q_workspace_id: input.q_workspace_id, - page: input.page, + workspace_id: parsed.q.workspace_id, + page: parsed.page, }, 'completed', ); - return response; + return output; }; -/** Get a template by ID */ export const getTemplate: PDFMonkeyEndpoints['getTemplate'] = async ( ctx, input, ) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['getTemplate'] - >('/api/v1/document_templates/' + input.id, { - apiKey: ctx.key, - method: 'GET', - }); + 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: input.id }, + { id: parsed.id }, 'completed', ); - return response; + return output; }; -/** Create a new template */ export const createTemplate: PDFMonkeyEndpoints['createTemplate'] = async ( ctx, input, ) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['createTemplate'] - >('/api/v1/document_templates', { - apiKey: ctx.key, - method: 'POST', - body: { - document_template: { - app_id: input.document_template.app_id, - identifier: input.document_template.identifier, - body: input.document_template.body, - body_draft: input.document_template.body_draft, - scss_style: input.document_template.scss_style, - scss_style_draft: input.document_template.scss_style_draft, - sample_data: input.document_template.sample_data, - sample_data_draft: input.document_template.sample_data_draft, - settings: input.document_template.settings, - settings_draft: input.document_template.settings_draft, - pdf_engine_id: input.document_template.pdf_engine_id, - pdf_engine_draft_id: input.document_template.pdf_engine_draft_id, - template_folder_id: input.document_template.template_folder_id, - ttl: input.document_template.ttl, - edition_mode: input.document_template.edition_mode, - output_type: input.document_template.output_type, + 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: input.document_template.identifier }, + { identifier: parsed.document_template.identifier }, 'completed', ); - return response; + return output; }; -/** Update an existing template */ export const updateTemplate: PDFMonkeyEndpoints['updateTemplate'] = async ( ctx, input, ) => { - const document_template = input.document_template; - if (!document_template) { - throw new Error('document_template is required for update'); - } - const body: Record = {}; - if (document_template?.identifier !== undefined) - body.identifier = document_template.identifier; - if (document_template?.body !== undefined) body.body = document_template.body; - if (document_template?.body_draft !== undefined) - body.body_draft = document_template.body_draft; - if (document_template?.scss_style !== undefined) - body.scss_style = document_template.scss_style; - if (document_template?.scss_style_draft !== undefined) - body.scss_style_draft = document_template.scss_style_draft; - if (document_template?.sample_data !== undefined) - body.sample_data = document_template.sample_data; - if (document_template?.sample_data_draft !== undefined) - body.sample_data_draft = document_template.sample_data_draft; - if (document_template?.settings !== undefined) - body.settings = document_template.settings; - if (document_template?.settings_draft !== undefined) - body.settings_draft = document_template.settings_draft; - if (document_template?.pdf_engine_id !== undefined) - body.pdf_engine_id = document_template.pdf_engine_id; - if (document_template?.pdf_engine_draft_id !== undefined) - body.pdf_engine_draft_id = document_template.pdf_engine_draft_id; - if (document_template?.template_folder_id !== undefined) - body.template_folder_id = document_template.template_folder_id; - if (document_template?.ttl !== undefined) body.ttl = document_template.ttl; - if (document_template?.edition_mode !== undefined) - body.edition_mode = document_template.edition_mode; - if (document_template?.output_type !== undefined) - body.output_type = document_template.output_type; - - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['updateTemplate'] - >('/api/v1/document_templates/' + input.document_template_id, { - apiKey: ctx.key, - method: 'PUT', - body: { - document_template: body, + 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: input.document_template_id }, + { template_id: parsed.document_template_id }, 'completed', ); - return response; + return output; }; -/** Delete a template */ export const deleteTemplate: PDFMonkeyEndpoints['deleteTemplate'] = async ( ctx, input, ) => { - const response = await makePdfMonkeyRequest< - PDFMonkeyEndpointOutputs['deleteTemplate'] - >('/api/v1/document_templates/' + input.id, { - apiKey: ctx.key, - method: 'DELETE', - }); + 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: input.id }, + { id: parsed.id }, 'completed', ); - return response; + return PDFMonkeyEndpointOutputSchemas.deleteTemplate.parse({ success: true }); }; diff --git a/packages/pdfmonkey/endpoints/types.ts b/packages/pdfmonkey/endpoints/types.ts index 2504ac170..a823c1eec 100644 --- a/packages/pdfmonkey/endpoints/types.ts +++ b/packages/pdfmonkey/endpoints/types.ts @@ -1,12 +1,19 @@ import { z } from 'zod'; -/** Simple success response for delete operations */ const DeleteSuccessSchema = z.object({ success: z.boolean() }); export type DeleteSuccess = z.infer; -/** - * Template Card - lightweight template object for listing - */ +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(), @@ -20,68 +27,65 @@ export const DocumentTemplateCardSchema = z.object({ export type DocumentTemplateCard = z.infer; -/** Input for listing template cards */ +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({ - q_workspace_id: z.string(), - q_folders: z.string().optional(), - page: z.number().int().nonnegative().default(1), + 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.infer< +export type ListTemplateCardsInput = z.input< typeof ListTemplateCardsInputSchema >; export const ListTemplateCardsOutputSchema = z.object({ document_template_cards: z.array(DocumentTemplateCardSchema), - meta: z - .object({ - page: z.number().int().nonnegative(), - total: z.number().int().nonnegative(), - totalPages: z.number().int().nonnegative(), - }) - .optional(), + meta: PaginationMetaSchema.optional(), }); export type ListTemplateCardsOutput = z.infer< typeof ListTemplateCardsOutputSchema >; -/** Input for getting a single template */ export const GetTemplateInputSchema = z.object({ id: z.string(), }); -export type GetTemplateInput = z.infer; +export type GetTemplateInput = z.input; export const GetTemplateOutputSchema = z.object({ - document_template: 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: z.any().optional(), - settings_draft: z.any().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(), - }), + document_template: DocumentTemplateSchema, }); export type GetTemplateOutput = z.infer; -/** Input for creating a template */ export const CreateTemplateInputSchema = z.object({ document_template: z.object({ app_id: z.string(), @@ -92,8 +96,8 @@ export const CreateTemplateInputSchema = z.object({ scss_style_draft: z.string().optional(), sample_data: z.string().optional(), sample_data_draft: z.string().optional(), - settings: z.any().optional(), - settings_draft: z.any().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(), @@ -103,7 +107,7 @@ export const CreateTemplateInputSchema = z.object({ }), }); -export type CreateTemplateInput = z.infer; +export type CreateTemplateInput = z.input; export const CreateTemplateOutputSchema = z.object({ document_template: z.object({ @@ -113,189 +117,177 @@ export const CreateTemplateOutputSchema = z.object({ export type CreateTemplateOutput = z.infer; -/** Input for updating a template */ 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: z.any().optional(), - settings_draft: z.any().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(), - }) - .optional(), -}); - -export type UpdateTemplateInput = z.infer; - -export const UpdateTemplateOutputSchema = z.object({ document_template: z.object({ - 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().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; -/** Input for deleting a template */ export const DeleteTemplateInputSchema = z.object({ id: z.string(), }); -export type DeleteTemplateInput = z.infer; +export type DeleteTemplateInput = z.input; -/** - * Document Card - lightweight document object for listing/status - */ 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']), - download_url: z.string().url().nullable(), - preview_url: z.string().url().nullable(), - public_share_link: z.string().url().nullable(), + filename: z.string().nullable().optional(), + download_url: z.string().url().nullable().optional(), + preview_url: z.string().url().nullable().optional(), + public_share_link: z.string().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; -/** Full Document object */ 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: z.any().nullable(), - meta: z.any().nullable(), + payload: JsonValueSchema.nullable(), + meta: JsonValueSchema.nullable(), filename: z.string().nullable(), download_url: z.string().url().nullable(), preview_url: z.string().url().nullable(), public_share_link: z.string().url().nullable(), checksum: z.string().nullable(), - generation_logs: z.array(z.any()).optional(), + 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; -/** DocumentCreateRequest - nested under "document" key in API */ +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: z.any().optional(), - meta: z.any().optional(), + payload: JsonValueSchema.optional(), + meta: JsonValueSchema.optional(), }), }); export type DocumentCreateRequest = z.infer; -/** DocumentCreateResponse - the full Document response */ -export const DocumentCreateResponseSchema = DocumentSchema; - -export type DocumentCreateResponse = z.infer< - typeof DocumentCreateResponseSchema ->; - -/** Document sync response — document card after generation completes */ -export const DocumentSyncResponseSchema = DocumentCardSchema; +export const CreateDocumentInputSchema = DocumentCreateRequestSchema; -export type DocumentSyncResponse = z.infer; +export type CreateDocumentInput = z.input; -/** Input for creating a document */ -export const CreateDocumentInputSchema = DocumentCreateRequestSchema; +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 CreateDocumentInput = z.infer; +export type CreateDocumentSyncInput = z.input< + typeof CreateDocumentSyncInputSchema +>; -/** Input for getting a document card */ export const GetDocumentCardInputSchema = z.object({ id: z.string(), }); -export type GetDocumentCardInput = z.infer; +export type GetDocumentCardInput = z.input; -/** Input for listing document cards */ export const ListDocumentCardsInputSchema = z.object({ - page: z.number().int().nonnegative().default(1), - q_document_template_id: z.string().optional(), - q_status: z - .enum(['draft', 'pending', 'generating', 'success', 'failure']) + 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(), - q_workspace_id: z.string().optional(), - q_updated_since: z.string().optional(), - q_search: z.string().optional(), }); -export type ListDocumentCardsInput = z.infer< +export type ListDocumentCardsInput = z.input< typeof ListDocumentCardsInputSchema >; export const ListDocumentCardsOutputSchema = z.object({ document_cards: z.array(DocumentCardSchema), - meta: z - .object({ - page: z.number().int().nonnegative(), - total: z.number().int().nonnegative(), - totalPages: z.number().int().nonnegative(), - }) - .optional(), + meta: PaginationMetaSchema.optional(), }); export type ListDocumentCardsOutput = z.infer< typeof ListDocumentCardsOutputSchema >; -/** Input for getting a full document */ export const GetDocumentInputSchema = z.object({ id: z.string(), }); -export type GetDocumentInput = z.infer; +export type GetDocumentInput = z.input; -/** Input for updating a document */ 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: z.any().optional(), - meta: z.any().optional(), - }) - .optional(), + 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.infer; - -export const UpdateDocumentOutputSchema = DocumentCreateResponseSchema; +export type UpdateDocumentInput = z.input; -export type UpdateDocumentOutput = z.infer; - -/** Input for deleting a document */ export const DeleteDocumentInputSchema = z.object({ id: z.string(), }); -export type DeleteDocumentInput = z.infer; - -/** - * PDFMonkey Endpoint Input/Output Schemas - */ +export type DeleteDocumentInput = z.input; export type PDFMonkeyEndpointInputs = { listTemplateCards: ListTemplateCardsInput; @@ -304,7 +296,7 @@ export type PDFMonkeyEndpointInputs = { updateTemplate: UpdateTemplateInput; deleteTemplate: DeleteTemplateInput; createDocument: CreateDocumentInput; - createDocumentSync: CreateDocumentInput; + createDocumentSync: CreateDocumentSyncInput; getDocumentCard: GetDocumentCardInput; listDocumentCards: ListDocumentCardsInput; getDocument: GetDocumentInput; @@ -318,8 +310,8 @@ export type PDFMonkeyEndpointOutputs = { createTemplate: CreateTemplateOutput; updateTemplate: UpdateTemplateOutput; deleteTemplate: DeleteSuccess; - createDocument: DocumentCreateResponse; - createDocumentSync: DocumentSyncResponse; + createDocument: Document; + createDocumentSync: DocumentCard; getDocumentCard: DocumentCard; listDocumentCards: ListDocumentCardsOutput; getDocument: Document; @@ -327,15 +319,14 @@ export type PDFMonkeyEndpointOutputs = { deleteDocument: DeleteSuccess; }; -/** Input schemas map, used for endpoint schema registration */ export const PDFMonkeyEndpointInputSchemas = { listTemplateCards: ListTemplateCardsInputSchema, getTemplate: GetTemplateInputSchema, createTemplate: CreateTemplateInputSchema, updateTemplate: UpdateTemplateInputSchema, deleteTemplate: DeleteTemplateInputSchema, - createDocument: DocumentCreateRequestSchema, - createDocumentSync: DocumentCreateRequestSchema, + createDocument: CreateDocumentInputSchema, + createDocumentSync: CreateDocumentSyncInputSchema, getDocumentCard: GetDocumentCardInputSchema, listDocumentCards: ListDocumentCardsInputSchema, getDocument: GetDocumentInputSchema, @@ -343,15 +334,14 @@ export const PDFMonkeyEndpointInputSchemas = { deleteDocument: DeleteDocumentInputSchema, } as const; -/** Output schemas map, used for endpoint schema registration */ export const PDFMonkeyEndpointOutputSchemas = { listTemplateCards: ListTemplateCardsOutputSchema, getTemplate: GetTemplateOutputSchema, createTemplate: CreateTemplateOutputSchema, updateTemplate: UpdateTemplateOutputSchema, deleteTemplate: DeleteSuccessSchema, - createDocument: DocumentCreateResponseSchema, - createDocumentSync: DocumentSyncResponseSchema, + createDocument: DocumentSchema, + createDocumentSync: DocumentCardSchema, getDocumentCard: DocumentCardSchema, listDocumentCards: ListDocumentCardsOutputSchema, getDocument: DocumentSchema, From f32fa7493189a0654b8ec33987c7c6e226c94ec5 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 24 Aug 2026 19:52:56 +0530 Subject: [PATCH 08/10] fix(pdfmonkey): verify Svix HMAC and drop generator leftovers --- packages/pdfmonkey/index.ts | 89 +++++---- packages/pdfmonkey/schema/database.ts | 10 +- packages/pdfmonkey/webhooks/documents.ts | 60 ++++++ packages/pdfmonkey/webhooks/example.ts | 32 --- packages/pdfmonkey/webhooks/index.ts | 8 +- .../pdfmonkey/webhooks/oauth-tenant-link.ts | 31 --- packages/pdfmonkey/webhooks/tenant-matcher.ts | 14 +- packages/pdfmonkey/webhooks/types.ts | 189 +++++++++++++----- 8 files changed, 256 insertions(+), 177 deletions(-) create mode 100644 packages/pdfmonkey/webhooks/documents.ts delete mode 100644 packages/pdfmonkey/webhooks/example.ts delete mode 100644 packages/pdfmonkey/webhooks/oauth-tenant-link.ts diff --git a/packages/pdfmonkey/index.ts b/packages/pdfmonkey/index.ts index 4f3ca4609..840fd99d9 100644 --- a/packages/pdfmonkey/index.ts +++ b/packages/pdfmonkey/index.ts @@ -15,6 +15,7 @@ import type { RequiredPluginEndpointSchemas, RequiredPluginWebhookSchemas, } from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; import { Document, Template } from './endpoints'; import type { PDFMonkeyEndpointInputs, @@ -26,14 +27,21 @@ import { } from './endpoints/types'; import { errorHandlers } from './error-handlers'; import { PDFMonkeySchema } from './schema'; -import { ExampleWebhooks } from './webhooks'; -import { resolvePDFMonkeyOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; +import { DocumentWebhooks } from './webhooks'; import { matchPDFMonkeyTenantWebhook } from './webhooks/tenant-matcher'; -import type { ExampleEvent, PDFMonkeyWebhookOutputs } from './webhooks/types'; -import { ExampleEventSchema } from './webhooks/types'; +import type { + DocumentGenerationFailureEvent, + DocumentGenerationSuccessEvent, + PDFMonkeyWebhookOutputs, +} from './webhooks/types'; +import { + DocumentGenerationFailureEventSchema, + DocumentGenerationSuccessEventSchema, + matchPDFMonkeyPluginWebhook, +} from './webhooks/types'; export type PDFMonkeyPluginOptions = { - authType?: PickAuth<'api_key' | 'oauth_2'>; + authType?: PickAuth<'api_key'>; key?: string; webhookSecret?: string; hooks?: InternalPDFMonkeyPlugin['hooks']; @@ -82,7 +90,14 @@ type PDFMonkeyWebhook< > = CorsairWebhook; export type PDFMonkeyWebhooks = { - example: PDFMonkeyWebhook<'example', ExampleEvent>; + generationSuccess: PDFMonkeyWebhook< + 'generationSuccess', + DocumentGenerationSuccessEvent + >; + generationFailure: PDFMonkeyWebhook< + 'generationFailure', + DocumentGenerationFailureEvent + >; }; export type PDFMonkeyBoundWebhooks = BindWebhooks; @@ -158,16 +173,22 @@ export const pDFMonkeyEndpointSchemas = { } satisfies RequiredPluginEndpointSchemas; const pDFMonkeyWebhooksNested = { - example: { - example: ExampleWebhooks.example, + documents: { + generationSuccess: DocumentWebhooks.generationSuccess, + generationFailure: DocumentWebhooks.generationFailure, }, } as const; export const pDFMonkeyWebhookSchemas = { - 'example.example': { - description: 'An example webhook event', - payload: ExampleEventSchema, - response: ExampleEventSchema, + '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 @@ -232,9 +253,6 @@ export const pDFMonkeyAuthConfig = { api_key: { account: ['tenant_external_id'] as const, }, - oauth_2: { - account: ['tenant_external_id'] as const, - }, } as const satisfies PluginAuthConfig; export type BasePDFMonkeyPlugin = @@ -273,17 +291,16 @@ export function pdfmonkey( endpointMeta: pDFMonkeyEndpointMeta, endpointSchemas: pDFMonkeyEndpointSchemas, webhookSchemas: pDFMonkeyWebhookSchemas, - pluginWebhookMatcher: (request) => { - const headers = request.headers; - // TODO: Update to match your webhook signature headers - return 'x-pdfmonkey-signature' in headers; - }, + pluginWebhookMatcher: matchPDFMonkeyPluginWebhook, pluginTenantWebhookMatcher: matchPDFMonkeyTenantWebhook, - oauthWebhookTenantLinkResolver: resolvePDFMonkeyOAuthWebhookTenantLink, - errorHandlers: { - ...errorHandlers, - ...options.errorHandlers, - }, + 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; @@ -291,7 +308,12 @@ export function pdfmonkey( if (source === 'webhook') { const res = await ctx.keys.get_webhook_signature(); - return res ?? ''; + if (!res) { + throw new Error( + '[auth-missing:pdfmonkey:webhook_signature]: PDFMonkey webhook signature is missing', + ); + } + return res; } if (source === 'endpoint' && options.key) { @@ -300,15 +322,13 @@ export function pdfmonkey( if (source === 'endpoint' && ctx.authType === 'api_key') { const res = await ctx.keys.get_api_key(); - return res ?? ''; - } - - if (source === 'endpoint' && ctx.authType === 'oauth_2') { - const res = await ctx.keys.get_access_token(); - return res ?? ''; + if (!res) { + throw new AuthMissingError('pdfmonkey', 'api_key'); + } + return res; } - return ''; + throw new AuthMissingError('pdfmonkey', 'api_key'); }, } satisfies InternalPDFMonkeyPlugin; } @@ -318,6 +338,7 @@ export type { PDFMonkeyEndpointOutputs, } from './endpoints/types'; export type { - ExampleEvent, + DocumentGenerationFailureEvent, + DocumentGenerationSuccessEvent, PDFMonkeyWebhookOutputs, } from './webhooks/types'; diff --git a/packages/pdfmonkey/schema/database.ts b/packages/pdfmonkey/schema/database.ts index 681905566..cb0ff5c3b 100644 --- a/packages/pdfmonkey/schema/database.ts +++ b/packages/pdfmonkey/schema/database.ts @@ -1,9 +1 @@ -import { z } from 'zod'; - -// TODO: Define your database entities here -// export const PDFMonkeyExample = z.object({ -// id: z.string(), -// name: z.string(), -// created_at: z.coerce.date().nullable().optional(), -// }); -// export type PDFMonkeyExample = z.infer; +export {}; 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/example.ts b/packages/pdfmonkey/webhooks/example.ts deleted file mode 100644 index 005fa0eca..000000000 --- a/packages/pdfmonkey/webhooks/example.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { logEventFromContext } from 'corsair/core'; -import type { PDFMonkeyWebhooks } from '..'; -import { createPDFMonkeyMatch, verifyPDFMonkeyWebhookSignature } from './types'; - -export const example: PDFMonkeyWebhooks['example'] = { - match: createPDFMonkeyMatch('example'), - - 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 = request.payload; - if (event.type !== 'example') { - return { success: true, data: undefined }; - } - - await logEventFromContext( - ctx, - 'pdfmonkey.webhook.example', - { ...event }, - 'completed', - ); - - return { success: true, data: event }; - }, -}; diff --git a/packages/pdfmonkey/webhooks/index.ts b/packages/pdfmonkey/webhooks/index.ts index a12134e8a..d165cc24f 100644 --- a/packages/pdfmonkey/webhooks/index.ts +++ b/packages/pdfmonkey/webhooks/index.ts @@ -1,9 +1,9 @@ -import { example } from './example'; +import { generationFailure, generationSuccess } from './documents'; -export const ExampleWebhooks = { - example: example, +export const DocumentWebhooks = { + generationSuccess, + generationFailure, }; -export * from './oauth-tenant-link'; export * from './tenant-matcher'; export * from './types'; diff --git a/packages/pdfmonkey/webhooks/oauth-tenant-link.ts b/packages/pdfmonkey/webhooks/oauth-tenant-link.ts deleted file mode 100644 index 960f9023c..000000000 --- a/packages/pdfmonkey/webhooks/oauth-tenant-link.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; -import { asRecord, toExternalId } from 'corsair/core'; - -// TODO: Rename linkType 'tenant_external_id' to match pluginTenantWebhookMatcher. -// Called after OAuth to store the routing id on corsair_accounts.config. -export async function resolvePDFMonkeyOAuthWebhookTenantLink( - tokens: TokenResponse, -): Promise { - // TODO: Read from token response when the provider includes a stable id. - // const externalId = toExternalId(asRecord(tokens.team)?.id); - const externalId = toExternalId(tokens.tenant_external_id); - if (externalId) { - return { linkType: 'tenant_external_id', externalId }; - } - - const accessToken = tokens.access_token; - if (!accessToken) return null; - - // TODO: Fetch from provider API when the token response omits the id. - // const response = await fetch('https://api.example.com/me', { - // headers: { Authorization: `Bearer ${accessToken}` }, - // }); - // if (!response.ok) return null; - // const payload = (await response.json()) as { id?: string }; - // const fetchedId = toExternalId(payload.id); - // return fetchedId - // ? { linkType: 'tenant_external_id', externalId: fetchedId } - // : null; - - return null; -} diff --git a/packages/pdfmonkey/webhooks/tenant-matcher.ts b/packages/pdfmonkey/webhooks/tenant-matcher.ts index b99495a5c..b843def9e 100644 --- a/packages/pdfmonkey/webhooks/tenant-matcher.ts +++ b/packages/pdfmonkey/webhooks/tenant-matcher.ts @@ -1,24 +1,14 @@ import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; import { asRecord, firstString, readBodyRecord } from 'corsair/core'; -// TODO: Rename linkType 'tenant_external_id' to match the provider field -// (e.g. team_id, installation_id, organization_id). Must match authConfig.account -// and oauthWebhookTenantLinkResolver. -// Return null for URL verification / handshake payloads that have no tenant id. export function matchPDFMonkeyTenantWebhook( request: RawWebhookRequest, ): WebhookTenantMatch | null { const body = readBodyRecord(request); if (!body) return null; - // TODO: Extract the stable external id from the webhook payload. - // Example: - // const externalId = firstString([body.tenant_external_id, asRecord(body.data)?.id]); - const externalId = firstString([ - body.tenant_external_id, - asRecord(body.data)?.tenant_external_id, - ]); - + 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.ts b/packages/pdfmonkey/webhooks/types.ts index 74a5cdb7e..6b36e59e5 100644 --- a/packages/pdfmonkey/webhooks/types.ts +++ b/packages/pdfmonkey/webhooks/types.ts @@ -3,90 +3,169 @@ import type { RawWebhookRequest, WebhookRequest, } from 'corsair/core'; -import { createHmac } from 'crypto'; +import { createHmac, timingSafeEqual } from 'crypto'; import { z } from 'zod'; +import { DocumentCardSchema } from '../endpoints/types'; -export const PDFMonkeyWebhookPayloadSchema = z.object({ - type: z.string(), - created_at: z.string(), - data: z.record(z.string(), z.unknown()), -}); - -export type PDFMonkeyWebhookPayload = z.infer< - typeof PDFMonkeyWebhookPayloadSchema ->; - -export const ExampleEventSchema = PDFMonkeyWebhookPayloadSchema.extend({ - type: z.literal('example'), - data: z - .object({ - id: z.string(), - }) - .loose(), -}); - -export type ExampleEvent = z.infer; - -export type PDFMonkeyWebhookOutputs = { - example: ExampleEvent; -}; +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 parsed !== null && - typeof parsed === 'object' && - !Array.isArray(parsed) - ? (parsed as Record) - : null; + return isRecord(parsed) ? parsed : null; } catch { return null; } } - return body !== null && typeof body === 'object' && !Array.isArray(body) - ? (body as Record) - : null; + return isRecord(body) ? body : null; } -export function createPDFMonkeyMatch(eventType: string): CorsairWebhookMatcher { +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); - return parsedBody !== null && parsedBody.type === eventType; + 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, + request: WebhookRequest, + secret?: string, ): { valid: boolean; error?: string } { - const svixId = request.headers['svix-id']; - const svixTimestamp = request.headers['svix-timestamp']; - const svixSignature = request.headers['svix-signature']; + 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' }; + } - if (!svixId || !svixTimestamp || !svixSignature) { - return { valid: false, error: 'Missing Svix webhook headers' }; + 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' }; } - const payload = parseBody(request); - const message = `${svixTimestamp}.${svixId}`; + if (!secret.startsWith('whsec_')) { + return { valid: false, error: 'Malformed webhook secret' }; + } + const secretBase64 = secret.slice('whsec_'.length); + if (!secretBase64) { + return { valid: false, error: 'Malformed webhook secret' }; + } + + const signatures = extractSvixSignatures(svixSignature); + if (signatures.length === 0) { + return { valid: false, error: 'Malformed svix-signature header' }; + } - const hmac = createHmac('sha256', secret); - hmac.update(message); - const digest = hmac.digest('hex'); + const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`; + const expected = createHmac('sha256', Buffer.from(secretBase64, 'base64')) + .update(signedContent) + .digest(); - const signatures = Array.isArray(svixSignature) - ? svixSignature - : svixSignature.split(','); const isValid = signatures.some((signature) => { - const signatureParts = String(signature).split('='); - const key = signatureParts[0]; - const value = signatureParts.slice(1).join('=').trim(); - return key === 't' ? value === digest : false; + const received = Buffer.from(signature, 'base64'); + return ( + received.length === expected.length && timingSafeEqual(received, expected) + ); }); if (!isValid) { - return { valid: false, error: 'Invalid webhook signature' }; + return { valid: false, error: 'Invalid signature' }; } return { valid: true }; From e9d29ffba4ffc3e5eee49015f266e04e58af79fa Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 24 Aug 2026 19:52:59 +0530 Subject: [PATCH 09/10] test(pdfmonkey): cover handlers, 429 routing, and Svix signatures --- packages/pdfmonkey/api.test.ts | 465 ++++++++++++++++++++++ packages/pdfmonkey/error-handlers.test.ts | 53 +++ packages/pdfmonkey/schema.test.ts | 243 ++++++----- packages/pdfmonkey/webhooks/types.test.ts | 188 +++++++++ 4 files changed, 826 insertions(+), 123 deletions(-) create mode 100644 packages/pdfmonkey/api.test.ts create mode 100644 packages/pdfmonkey/error-handlers.test.ts create mode 100644 packages/pdfmonkey/webhooks/types.test.ts diff --git a/packages/pdfmonkey/api.test.ts b/packages/pdfmonkey/api.test.ts new file mode 100644 index 000000000..51dff120d --- /dev/null +++ b/packages/pdfmonkey/api.test.ts @@ -0,0 +1,465 @@ +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); + }); +}); + +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/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/schema.test.ts b/packages/pdfmonkey/schema.test.ts index 67e992850..a62435bc0 100644 --- a/packages/pdfmonkey/schema.test.ts +++ b/packages/pdfmonkey/schema.test.ts @@ -1,5 +1,6 @@ import { CreateDocumentInputSchema, + CreateDocumentSyncInputSchema, CreateTemplateInputSchema, DeleteDocumentInputSchema, DeleteTemplateInputSchema, @@ -11,39 +12,30 @@ import { ListDocumentCardsInputSchema, ListTemplateCardsInputSchema, PDFMonkeyEndpointInputSchemas, + PDFMonkeyEndpointOutputSchemas, UpdateDocumentInputSchema, UpdateTemplateInputSchema, } from './endpoints/types'; import { PDFMonkeySchema } from './schema'; describe('PDFMonkey schema', () => { - it('declares a semver version', () => { - expect(PDFMonkeySchema.version).toBeDefined(); + it('declares a semver version and empty entities', () => { expect(PDFMonkeySchema.version).toMatch(/^\d+\.\d+\.\d+$/); - }); - - it('declares an entities map', () => { - expect(typeof PDFMonkeySchema.entities).toBe('object'); - expect(PDFMonkeySchema.entities).not.toBeNull(); - expect(Array.isArray(Object.keys(PDFMonkeySchema.entities))).toBe(true); - for (const entity of Object.values(PDFMonkeySchema.entities)) { - expect(entity).toBeDefined(); - } + expect(PDFMonkeySchema.entities).toEqual({}); }); it('validates DocumentTemplateCardSchema', () => { - const obj = { + const result = DocumentTemplateCardSchema.safeParse({ id: 'test-id', app_id: 'test-app', created_at: '2024-01-01', updated_at: '2024-01-01', - }; - const result = DocumentTemplateCardSchema.safeParse(obj); + }); expect(result.success).toBe(true); }); it('validates DocumentCardSchema', () => { - const obj = { + const result = DocumentCardSchema.safeParse({ id: 'doc-id', app_id: 'test-app', status: 'draft', @@ -52,19 +44,17 @@ describe('PDFMonkey schema', () => { public_share_link: null, created_at: '2024-01-01', updated_at: '2024-01-01', - }; - const result = DocumentCardSchema.safeParse(obj); + }); expect(result.success).toBe(true); }); it('validates DocumentSchema', () => { - const obj = { + const result = DocumentSchema.safeParse({ id: 'doc-id', app_id: 'test-app', document_template_id: 'template-id', - document_template_identifier: 'temp-ident', status: 'pending', - payload: null, + payload: { clientName: 'Ada' }, meta: null, filename: null, download_url: null, @@ -75,113 +65,120 @@ describe('PDFMonkey schema', () => { failure_cause: null, created_at: '2024-01-01', updated_at: '2024-01-01', - }; - const result = DocumentSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates ListTemplateCardsInputSchema', () => { - const obj = { - q_workspace_id: 'ws-123', - page: 1, - }; - const result = ListTemplateCardsInputSchema.safeParse(obj); + }); expect(result.success).toBe(true); }); - it('validates ListDocumentCardsInputSchema', () => { - const obj = { + it('validates nested list query inputs', () => { + expect( + ListTemplateCardsInputSchema.parse({ + q: { workspace_id: 'ws-123' }, + }), + ).toMatchObject({ page: 1, - q_status: 'pending', - }; - const result = ListDocumentCardsInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates GetTemplateInputSchema', () => { - const obj = { id: 'template-123' }; - const result = GetTemplateInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates GetDocumentCardInputSchema', () => { - const obj = { id: 'doc-456' }; - const result = GetDocumentCardInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates CreateTemplateInputSchema', () => { - const obj = { - document_template: { - app_id: 'app-1', - identifier: 'my-template', - body: '

Hello

', - }, - }; - const result = CreateTemplateInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates CreateDocumentInputSchema', () => { - const obj = { - document: { + 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', - status: 'pending', - }, - }; - const result = CreateDocumentInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates UpdateTemplateInputSchema', () => { - const obj = { - document_template_id: 'temp-1', - document_template: { - identifier: 'updated', - }, - }; - const result = UpdateTemplateInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates UpdateDocumentInputSchema', () => { - const obj = { - document_id: 'doc-1', - document: { - status: 'pending', - }, - }; - const result = UpdateDocumentInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates DeleteTemplateInputSchema', () => { - const obj = { id: 'temp-1' }; - const result = DeleteTemplateInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('validates DeleteDocumentInputSchema', () => { - const obj = { id: 'doc-1' }; - const result = DeleteDocumentInputSchema.safeParse(obj); - expect(result.success).toBe(true); - }); - - it('PDFMonkeyEndpointInputSchemas contains required template and document schemas', () => { - expect(PDFMonkeyEndpointInputSchemas.listTemplateCards).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.getTemplate).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.createTemplate).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.updateTemplate).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.deleteTemplate).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.createDocument).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.createDocumentSync).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.getDocumentCard).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.listDocumentCards).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.getDocument).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.updateDocument).toBeDefined(); - expect(PDFMonkeyEndpointInputSchemas.deleteDocument).toBeDefined(); + }).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(); + } }); }); - -// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint -// needs a corresponding test. diff --git a/packages/pdfmonkey/webhooks/types.test.ts b/packages/pdfmonkey/webhooks/types.test.ts new file mode 100644 index 000000000..e3c185917 --- /dev/null +++ b/packages/pdfmonkey/webhooks/types.test.ts @@ -0,0 +1,188 @@ +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 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' }); + }); +}); From cebaeff61867e5b674457082244cf46828ba3f28 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 24 Aug 2026 20:09:54 +0530 Subject: [PATCH 10/10] fix(pdfmonkey): reject empty Svix keys and use AuthMissingError --- packages/pdfmonkey/api.test.ts | 19 +++++++++++++++++++ packages/pdfmonkey/endpoints/types.ts | 12 ++++++------ packages/pdfmonkey/index.ts | 4 +--- packages/pdfmonkey/webhooks/types.test.ts | 13 +++++++++++++ packages/pdfmonkey/webhooks/types.ts | 5 +++-- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/pdfmonkey/api.test.ts b/packages/pdfmonkey/api.test.ts index 51dff120d..3f30cb6c2 100644 --- a/packages/pdfmonkey/api.test.ts +++ b/packages/pdfmonkey/api.test.ts @@ -129,6 +129,25 @@ describe('PDFMonkey plugin shape', () => { ), ).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', () => { diff --git a/packages/pdfmonkey/endpoints/types.ts b/packages/pdfmonkey/endpoints/types.ts index a823c1eec..3622fde6a 100644 --- a/packages/pdfmonkey/endpoints/types.ts +++ b/packages/pdfmonkey/endpoints/types.ts @@ -157,9 +157,9 @@ export const DocumentCardSchema = z.object({ document_template_identifier: z.string().optional(), status: z.enum(['draft', 'pending', 'generating', 'success', 'failure']), filename: z.string().nullable().optional(), - download_url: z.string().url().nullable().optional(), - preview_url: z.string().url().nullable().optional(), - public_share_link: z.string().url().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(), @@ -178,9 +178,9 @@ export const DocumentSchema = z.object({ payload: JsonValueSchema.nullable(), meta: JsonValueSchema.nullable(), filename: z.string().nullable(), - download_url: z.string().url().nullable(), - preview_url: z.string().url().nullable(), - public_share_link: z.string().url().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(), diff --git a/packages/pdfmonkey/index.ts b/packages/pdfmonkey/index.ts index 840fd99d9..74ca0cc22 100644 --- a/packages/pdfmonkey/index.ts +++ b/packages/pdfmonkey/index.ts @@ -309,9 +309,7 @@ export function pdfmonkey( if (source === 'webhook') { const res = await ctx.keys.get_webhook_signature(); if (!res) { - throw new Error( - '[auth-missing:pdfmonkey:webhook_signature]: PDFMonkey webhook signature is missing', - ); + throw new AuthMissingError('pdfmonkey', 'webhook_signature'); } return res; } diff --git a/packages/pdfmonkey/webhooks/types.test.ts b/packages/pdfmonkey/webhooks/types.test.ts index e3c185917..e530b2106 100644 --- a/packages/pdfmonkey/webhooks/types.test.ts +++ b/packages/pdfmonkey/webhooks/types.test.ts @@ -87,6 +87,19 @@ describe('verifyPDFMonkeyWebhookSignature', () => { }); }); + 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( diff --git a/packages/pdfmonkey/webhooks/types.ts b/packages/pdfmonkey/webhooks/types.ts index 6b36e59e5..f546a196b 100644 --- a/packages/pdfmonkey/webhooks/types.ts +++ b/packages/pdfmonkey/webhooks/types.ts @@ -143,7 +143,8 @@ export function verifyPDFMonkeyWebhookSignature( return { valid: false, error: 'Malformed webhook secret' }; } const secretBase64 = secret.slice('whsec_'.length); - if (!secretBase64) { + const secretKey = Buffer.from(secretBase64, 'base64'); + if (!secretBase64 || secretKey.length === 0) { return { valid: false, error: 'Malformed webhook secret' }; } @@ -153,7 +154,7 @@ export function verifyPDFMonkeyWebhookSignature( } const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`; - const expected = createHmac('sha256', Buffer.from(secretBase64, 'base64')) + const expected = createHmac('sha256', secretKey) .update(signedContent) .digest();