diff --git a/packages/ashby/client.test.ts b/packages/ashby/client.test.ts new file mode 100644 index 000000000..af5936ba4 --- /dev/null +++ b/packages/ashby/client.test.ts @@ -0,0 +1,188 @@ +import { + ASHBY_API_BASE, + AshbyAPIError, + buildAshbyBasicAuthHeader, + makeAshbyRequest, +} from './client'; + +type Captured = { + url: string; + method: string; + headers: Record; + body?: string; +}; + +type MockResponse = { + ok?: boolean; + status?: number; + body?: unknown; + headers?: Record; +}; + +let captured: Captured | undefined; +let attempts = 0; + +function mockFetchSequence(responses: MockResponse[]) { + captured = undefined; + attempts = 0; + global.fetch = (async (url: unknown, init?: RequestInit) => { + const headers: Record = {}; + const raw = init?.headers; + if (raw instanceof Headers) { + raw.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } else { + for (const [key, value] of Object.entries( + (raw ?? {}) as Record, + )) { + headers[key.toLowerCase()] = value; + } + } + captured = { + url: String(url), + method: init?.method ?? 'GET', + headers, + body: typeof init?.body === 'string' ? init.body : undefined, + }; + + const response = + responses[Math.min(attempts, responses.length - 1)] ?? + ({} as MockResponse); + attempts++; + + const status = response.status ?? 200; + const payload = response.body ?? {}; + return { + ok: response.ok ?? status < 400, + status, + statusText: 'OK', + url: String(url), + headers: new Headers({ + 'Content-Type': 'application/json', + ...response.headers, + }), + json: async () => payload, + text: async () => + typeof payload === 'string' ? payload : JSON.stringify(payload), + }; + }) as unknown as typeof global.fetch; +} + +function mockFetch(response: MockResponse) { + mockFetchSequence([response]); +} + +describe('Ashby Client', () => { + describe('buildAshbyBasicAuthHeader', () => { + it('formats API key as HTTP Basic Auth with key as username and empty password', () => { + const apiKey = 'test-api-key-12345'; + const expectedEncoded = Buffer.from('test-api-key-12345:').toString( + 'base64', + ); + expect(buildAshbyBasicAuthHeader(apiKey)).toBe( + `Basic ${expectedEncoded}`, + ); + }); + }); + + describe('makeAshbyRequest', () => { + it('targets the Ashby API base URL with POST method and Basic auth', async () => { + mockFetch({ body: { success: true, results: { id: 'cand_123' } } }); + + const apiKey = 'sec_key_abc'; + const result = await makeAshbyRequest<{ + success: boolean; + results: { id: string }; + }>('candidate.info', apiKey, { + body: { candidateId: 'cand_123' }, + }); + + expect(captured?.url).toBe(`${ASHBY_API_BASE}/candidate.info`); + expect(captured?.method).toBe('POST'); + expect(captured?.headers.authorization).toBe( + `Basic ${Buffer.from('sec_key_abc:').toString('base64')}`, + ); + expect(captured?.headers['content-type']).toContain('application/json'); + expect(JSON.parse(captured?.body ?? '{}')).toEqual({ + candidateId: 'cand_123', + }); + expect(result.results.id).toBe('cand_123'); + }); + + it('handles endpoints with leading slash gracefully', async () => { + mockFetch({ body: { success: true, results: [] } }); + + await makeAshbyRequest('/candidate.list', 'test-key', { + body: { limit: 10 }, + }); + + expect(captured?.url).toBe(`${ASHBY_API_BASE}/candidate.list`); + expect(captured?.method).toBe('POST'); + }); + + it('throws AshbyAPIError when response has success: false envelope', async () => { + mockFetch({ + body: { + success: false, + errors: [ + { + code: 'missing_endpoint_permission', + message: 'Missing candidate write permission', + }, + ], + }, + }); + + await expect( + makeAshbyRequest('candidate.create', 'test-key', { + body: { name: 'Test' }, + }), + ).rejects.toThrow(AshbyAPIError); + }); + + it('retries upon 429 Too Many Requests and respects Retry-After', async () => { + mockFetchSequence([ + { status: 429, body: {}, headers: { 'Retry-After': '1' } }, + { status: 200, body: { success: true, results: { id: '1' } } }, + ]); + + const result = await makeAshbyRequest<{ + success: boolean; + results: { id: string }; + }>('candidate.info', 'test-key', { + body: { candidateId: '1' }, + }); + + expect(attempts).toBe(2); + expect(result.results.id).toBe('1'); + }); + + it('parses HTTP 403 ApiError into AshbyAPIError with status and code', async () => { + mockFetch({ + status: 403, + body: { + success: false, + errors: [ + { + code: 'missing_endpoint_permission', + message: 'Access forbidden', + }, + ], + }, + }); + + try { + await makeAshbyRequest('candidate.anonymize', 'test-key', { + body: { candidateId: '123' }, + }); + fail('Expected makeAshbyRequest to throw'); + } catch (error) { + expect(error).toBeInstanceOf(AshbyAPIError); + const ashbyErr = error as AshbyAPIError; + expect(ashbyErr.status).toBe(403); + expect(ashbyErr.code).toBe('missing_endpoint_permission'); + } + }); + }); +}); diff --git a/packages/ashby/client.ts b/packages/ashby/client.ts new file mode 100644 index 000000000..790009829 --- /dev/null +++ b/packages/ashby/client.ts @@ -0,0 +1,154 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export const ASHBY_API_BASE = 'https://api.ashbyhq.com'; + +/** + * Ashby API rate limiting configuration. + * When encountering 429 Too Many Requests, Corsair will retry with exponential backoff, + * respecting the Retry-After header if present. + */ +export const ASHBY_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 3, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +export type AshbyErrorItem = { + code?: string; + message?: string; +}; + +/** + * Custom error class representing an error returned by the Ashby API or transport layer. + */ +export class AshbyAPIError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly code?: string, + public readonly errors?: AshbyErrorItem[], + ) { + super(message); + this.name = 'AshbyAPIError'; + } +} + +export type AshbyRequestOptions = { + body?: Record; + headers?: Record; +}; + +/** + * Encodes the Ashby API key into an HTTP Basic Authorization header. + * Ashby expects the API key as the username with an empty password. + */ +export function buildAshbyBasicAuthHeader(apiKey: string): string { + const encoded = Buffer.from(`${apiKey}:`).toString('base64'); + return `Basic ${encoded}`; +} + +/** + * Makes an RPC-style HTTP POST request to the Ashby API. + * All Ashby API endpoints use the POST method with JSON bodies. + */ +export async function makeAshbyRequest( + endpoint: string, + apiKey: string, + options: AshbyRequestOptions = {}, +): Promise { + const normalizedEndpoint = endpoint.startsWith('/') + ? endpoint + : `/${endpoint}`; + + const config: OpenAPIConfig = { + BASE: ASHBY_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: buildAshbyBasicAuthHeader(apiKey), + ...options.headers, + }, + }; + + const requestOptions: ApiRequestOptions = { + method: 'POST', + url: normalizedEndpoint, + body: options.body ?? {}, + mediaType: 'application/json; charset=utf-8', + }; + + try { + const response = await request(config, requestOptions, { + rateLimitConfig: ASHBY_RATE_LIMIT_CONFIG, + }); + + // Check if response contains Ashby failure envelope { success: false, errors: [...], error: "..." } + if ( + response && + typeof response === 'object' && + 'success' in response && + (response as { success: boolean }).success === false + ) { + const failed = response as { + success: false; + errors?: AshbyErrorItem[]; + error?: string; + }; + const firstError = failed.errors?.[0]; + const message = + firstError?.message || failed.error || 'Ashby API request failed'; + const code = firstError?.code; + throw new AshbyAPIError(message, 400, code, failed.errors); + } + + return response; + } catch (error) { + if (error instanceof AshbyAPIError) { + throw error; + } + + if (error instanceof ApiError) { + const status = error.status; + let parsedErrors: AshbyErrorItem[] | undefined; + let parsedCode: string | undefined; + let message = error.message; + + if (error.body && typeof error.body === 'object') { + const bodyObj = error.body as { + errors?: AshbyErrorItem[]; + error?: string; + message?: string; + }; + if (Array.isArray(bodyObj.errors) && bodyObj.errors.length > 0) { + parsedErrors = bodyObj.errors; + parsedCode = bodyObj.errors[0]?.code; + message = bodyObj.errors[0]?.message || message; + } else if (typeof bodyObj.error === 'string') { + message = bodyObj.error; + } else if (typeof bodyObj.message === 'string') { + message = bodyObj.message; + } + } + + throw new AshbyAPIError(message, status, parsedCode, parsedErrors); + } + + if (error instanceof Error) { + throw new AshbyAPIError(error.message); + } + throw new AshbyAPIError('Unknown Ashby error'); + } +} diff --git a/packages/ashby/endpoints.test.ts b/packages/ashby/endpoints.test.ts new file mode 100644 index 000000000..ac3bb30b0 --- /dev/null +++ b/packages/ashby/endpoints.test.ts @@ -0,0 +1,610 @@ +import { ApiError } from 'corsair/http'; +import { + ApiKey, + Application, + Candidate, + CustomField, + Department, + Interview, + Job, + JobPosting, + Location, + Offer, + User, + Webhook, +} from './endpoints'; +import { errorHandlers } from './error-handlers'; +import type { AshbyContext } from './index'; + +type Captured = { + url: string; + method: string; + body?: string; +}; + +let captured: Captured | undefined; + +function mockFetchResponse(payload: unknown) { + captured = undefined; + global.fetch = (async (url: unknown, init?: RequestInit) => { + captured = { + url: String(url), + method: init?.method ?? 'GET', + body: typeof init?.body === 'string' ? init.body : undefined, + }; + return { + ok: true, + status: 200, + statusText: 'OK', + url: String(url), + headers: new Headers({ 'Content-Type': 'application/json' }), + json: async () => payload, + text: async () => JSON.stringify(payload), + }; + }) as unknown as typeof global.fetch; +} + +function makeCtx(key = 'test-api-key'): AshbyContext { + return { + key, + options: { key }, + keys: { + get_api_key: async () => key, + get_webhook_signature: async () => 'test-webhook-secret', + }, + $getAccountId: async () => 'test_account', + db: {} as any, + } as unknown as AshbyContext; +} + +describe('Ashby Endpoints', () => { + const ctx = makeCtx(); + + describe('Candidate Endpoints', () => { + it('calls candidate.info', async () => { + mockFetchResponse({ + success: true, + results: { id: 'cand_1', name: 'John' }, + }); + const res = await Candidate.info(ctx, { candidateId: 'cand_1' }); + expect(captured?.url).toContain('/candidate.info'); + expect(JSON.parse(captured?.body ?? '{}')).toEqual({ + candidateId: 'cand_1', + }); + expect(res.results.id).toBe('cand_1'); + }); + + it('calls candidate.list with pagination', async () => { + mockFetchResponse({ + success: true, + results: [{ id: 'cand_1', name: 'John' }], + moreDataAvailable: false, + }); + const res = await Candidate.list(ctx, { limit: 10, cursor: 'c_1' }); + expect(captured?.url).toContain('/candidate.list'); + expect(JSON.parse(captured?.body ?? '{}')).toEqual({ + limit: 10, + cursor: 'c_1', + }); + expect(res.results).toHaveLength(1); + }); + + it('calls candidate.search', async () => { + mockFetchResponse({ + success: true, + results: [{ id: 'cand_1', name: 'John' }], + }); + const res = await Candidate.search(ctx, { name: 'John' }); + expect(captured?.url).toContain('/candidate.search'); + expect(res.results[0]?.name).toBe('John'); + }); + + it('calls candidate.create', async () => { + mockFetchResponse({ + success: true, + results: { id: 'cand_1', name: 'Alice' }, + }); + const res = await Candidate.create(ctx, { + name: 'Alice', + email: 'alice@example.com', + }); + expect(captured?.url).toContain('/candidate.create'); + expect(res.results.name).toBe('Alice'); + }); + + it('calls candidate.update', async () => { + mockFetchResponse({ + success: true, + results: { id: 'cand_1', name: 'Alice Smith' }, + }); + const res = await Candidate.update(ctx, { + candidateId: 'cand_1', + name: 'Alice Smith', + }); + expect(captured?.url).toContain('/candidate.update'); + expect(res.results.name).toBe('Alice Smith'); + }); + + it('calls candidate.addTag and candidate.removeTag', async () => { + mockFetchResponse({ + success: true, + results: { id: 'cand_1', name: 'Alice', tags: ['Eng'] }, + }); + await Candidate.addTag(ctx, { candidateId: 'cand_1', tag: 'Eng' }); + expect(captured?.url).toContain('/candidate.addTag'); + + mockFetchResponse({ + success: true, + results: { id: 'cand_1', name: 'Alice', tags: [] }, + }); + await Candidate.removeTag(ctx, { candidateId: 'cand_1', tag: 'Eng' }); + expect(captured?.url).toContain('/candidate.removeTag'); + }); + + it('calls candidate.createNote and candidate.listNotes', async () => { + mockFetchResponse({ + success: true, + results: { + id: 'n_1', + candidateId: 'cand_1', + note: 'Great candidate', + }, + }); + await Candidate.createNote(ctx, { + candidateId: 'cand_1', + note: 'Great candidate', + }); + expect(captured?.url).toContain('/candidate.createNote'); + + mockFetchResponse({ + success: true, + results: [ + { + id: 'n_1', + candidateId: 'cand_1', + note: 'Great candidate', + }, + ], + }); + const list = await Candidate.listNotes(ctx, { candidateId: 'cand_1' }); + expect(captured?.url).toContain('/candidate.listNotes'); + expect(list.results).toHaveLength(1); + }); + + it('calls candidate.anonymize', async () => { + mockFetchResponse({ + success: true, + results: { candidateId: 'cand_1' }, + }); + const res = await Candidate.anonymize(ctx, { candidateId: 'cand_1' }); + expect(captured?.url).toContain('/candidate.anonymize'); + expect(res.results.candidateId).toBe('cand_1'); + }); + }); + + describe('Application Endpoints', () => { + it('calls application.info, list, and create', async () => { + mockFetchResponse({ + success: true, + results: { id: 'app_1', candidateId: 'c_1', jobId: 'j_1' }, + }); + await Application.info(ctx, { applicationId: 'app_1' }); + expect(captured?.url).toContain('/application.info'); + + mockFetchResponse({ + success: true, + results: [{ id: 'app_1', candidateId: 'c_1', jobId: 'j_1' }], + }); + await Application.list(ctx, { candidateId: 'c_1' }); + expect(captured?.url).toContain('/application.list'); + + mockFetchResponse({ + success: true, + results: { id: 'app_1', candidateId: 'c_1', jobId: 'j_1' }, + }); + await Application.create(ctx, { candidateId: 'c_1', jobId: 'j_1' }); + expect(captured?.url).toContain('/application.create'); + }); + + it('calls application.changeStage, update, and transfer', async () => { + mockFetchResponse({ + success: true, + results: { + id: 'app_1', + candidateId: 'c_1', + jobId: 'j_1', + currentInterviewStageId: 'stg_2', + }, + }); + await Application.changeStage(ctx, { + applicationId: 'app_1', + interviewStageId: 'stg_2', + }); + expect(captured?.url).toContain('/application.changeStage'); + + mockFetchResponse({ + success: true, + results: { id: 'app_1', candidateId: 'c_1', jobId: 'j_1' }, + }); + await Application.update(ctx, { + applicationId: 'app_1', + archiveReasonId: 'reason_1', + }); + expect(captured?.url).toContain('/application.update'); + + mockFetchResponse({ + success: true, + results: { id: 'app_1', candidateId: 'c_1', jobId: 'j_2' }, + }); + await Application.transfer(ctx, { + applicationId: 'app_1', + jobId: 'j_2', + }); + expect(captured?.url).toContain('/application.transfer'); + }); + }); + + describe('Job & Job Posting Endpoints', () => { + it('calls job.info, list, create, update, and search', async () => { + mockFetchResponse({ + success: true, + results: { id: 'job_1', title: 'SWE' }, + }); + await Job.info(ctx, { jobId: 'job_1' }); + expect(captured?.url).toContain('/job.info'); + + mockFetchResponse({ + success: true, + results: [{ id: 'job_1', title: 'SWE' }], + }); + await Job.list(ctx, { status: 'Open' }); + expect(captured?.url).toContain('/job.list'); + + mockFetchResponse({ + success: true, + results: { id: 'job_1', title: 'PM' }, + }); + await Job.create(ctx, { title: 'PM' }); + expect(captured?.url).toContain('/job.create'); + + mockFetchResponse({ + success: true, + results: { id: 'job_1', title: 'Senior PM' }, + }); + await Job.update(ctx, { jobId: 'job_1', title: 'Senior PM' }); + expect(captured?.url).toContain('/job.update'); + + mockFetchResponse({ + success: true, + results: [{ id: 'job_1', title: 'Senior PM' }], + }); + await Job.search(ctx, { title: 'Senior PM' }); + expect(captured?.url).toContain('/job.search'); + }); + + it('calls jobPosting.info and jobPosting.list', async () => { + mockFetchResponse({ + success: true, + results: { id: 'jp_1', title: 'SWE', jobId: 'job_1' }, + }); + await JobPosting.info(ctx, { jobPostingId: 'jp_1' }); + expect(captured?.url).toContain('/jobPosting.info'); + + mockFetchResponse({ + success: true, + results: [{ id: 'jp_1', title: 'SWE', jobId: 'job_1' }], + }); + await JobPosting.list(ctx, { listedOnly: true }); + expect(captured?.url).toContain('/jobPosting.list'); + }); + }); + + describe('Interview, Offer, Department, Location, User, CustomField, ApiKey, Webhook Endpoints', () => { + it('calls interview endpoints', async () => { + mockFetchResponse({ + success: true, + results: { id: 'int_1', title: 'Technical Screen' }, + }); + await Interview.info(ctx, { interviewId: 'int_1' }); + expect(captured?.url).toContain('/interview.info'); + + mockFetchResponse({ + success: true, + results: [{ id: 'int_1', title: 'Technical Screen' }], + }); + await Interview.list(ctx, {}); + expect(captured?.url).toContain('/interview.list'); + + mockFetchResponse({ + success: true, + results: { id: 'sched_1', applicationId: 'app_1' }, + }); + await Interview.scheduleInfo(ctx, { interviewScheduleId: 'sched_1' }); + expect(captured?.url).toContain('/interviewSchedule.info'); + + mockFetchResponse({ + success: true, + results: [{ id: 'sched_1', applicationId: 'app_1' }], + }); + await Interview.scheduleList(ctx, { applicationId: 'app_1' }); + expect(captured?.url).toContain('/interviewSchedule.list'); + + mockFetchResponse({ + success: true, + results: [{ id: 'stg_1', title: 'Screen' }], + }); + await Interview.stageList(ctx, { jobId: 'job_1' }); + expect(captured?.url).toContain('/interviewStage.list'); + }); + + it('calls offer endpoints', async () => { + mockFetchResponse({ + success: true, + results: { id: 'off_1', applicationId: 'app_1', salary: 150000 }, + }); + await Offer.info(ctx, { offerId: 'off_1' }); + expect(captured?.url).toContain('/offer.info'); + + mockFetchResponse({ + success: true, + results: [{ id: 'off_1', applicationId: 'app_1' }], + }); + await Offer.list(ctx, { applicationId: 'app_1' }); + expect(captured?.url).toContain('/offer.list'); + + mockFetchResponse({ + success: true, + results: { id: 'off_1', applicationId: 'app_1', salary: 160000 }, + }); + await Offer.create(ctx, { applicationId: 'app_1', salary: 160000 }); + expect(captured?.url).toContain('/offer.create'); + + mockFetchResponse({ + success: true, + results: { + id: 'off_1', + applicationId: 'app_1', + status: 'Accepted', + }, + }); + await Offer.update(ctx, { offerId: 'off_1', status: 'Accepted' }); + expect(captured?.url).toContain('/offer.update'); + }); + + it('calls department and location endpoints', async () => { + mockFetchResponse({ + success: true, + results: { id: 'dept_1', name: 'Engineering' }, + }); + await Department.info(ctx, { departmentId: 'dept_1' }); + expect(captured?.url).toContain('/department.info'); + + mockFetchResponse({ + success: true, + results: [{ id: 'dept_1', name: 'Engineering' }], + }); + await Department.list(ctx, {}); + expect(captured?.url).toContain('/department.list'); + + mockFetchResponse({ + success: true, + results: { id: 'dept_1', name: 'Eng' }, + }); + await Department.create(ctx, { name: 'Eng' }); + expect(captured?.url).toContain('/department.create'); + + await Department.update(ctx, { + departmentId: 'dept_1', + name: 'Engineering', + }); + expect(captured?.url).toContain('/department.update'); + + await Department.archive(ctx, { departmentId: 'dept_1' }); + expect(captured?.url).toContain('/department.archive'); + + mockFetchResponse({ + success: true, + results: { id: 'loc_1', name: 'NYC' }, + }); + await Location.info(ctx, { locationId: 'loc_1' }); + expect(captured?.url).toContain('/location.info'); + + mockFetchResponse({ + success: true, + results: [{ id: 'loc_1', name: 'NYC' }], + }); + await Location.list(ctx, {}); + expect(captured?.url).toContain('/location.list'); + + mockFetchResponse({ + success: true, + results: { id: 'loc_1', name: 'NYC' }, + }); + await Location.create(ctx, { name: 'NYC' }); + expect(captured?.url).toContain('/location.create'); + + mockFetchResponse({ + success: true, + results: { id: 'loc_1', name: 'New York' }, + }); + await Location.update(ctx, { locationId: 'loc_1', name: 'New York' }); + expect(captured?.url).toContain('/location.update'); + + mockFetchResponse({ + success: true, + results: { id: 'loc_1', name: 'New York' }, + }); + await Location.archive(ctx, { locationId: 'loc_1' }); + expect(captured?.url).toContain('/location.archive'); + }); + + it('calls user endpoints', async () => { + mockFetchResponse({ + success: true, + results: { + id: 'usr_1', + name: 'User One', + email: 'u@example.com', + }, + }); + await User.info(ctx, { userId: 'usr_1' }); + expect(captured?.url).toContain('/user.info'); + + mockFetchResponse({ + success: true, + results: [ + { + id: 'usr_1', + name: 'User One', + email: 'u@example.com', + }, + ], + }); + await User.list(ctx, { isEnabled: true }); + expect(captured?.url).toContain('/user.list'); + + mockFetchResponse({ + success: true, + results: [ + { + id: 'usr_1', + name: 'User One', + email: 'u@example.com', + }, + ], + }); + await User.search(ctx, { email: 'u@example.com' }); + expect(captured?.url).toContain('/user.search'); + }); + + it('calls customField, apiKey, and webhook endpoints', async () => { + mockFetchResponse({ + success: true, + results: { + id: 'cf_1', + title: 'Clearance', + objectType: 'Candidate', + fieldType: 'String', + }, + }); + await CustomField.info(ctx, { customFieldDefinitionId: 'cf_1' }); + expect(captured?.url).toContain('/customField.info'); + + mockFetchResponse({ + success: true, + results: [ + { + id: 'cf_1', + title: 'Clearance', + objectType: 'Candidate', + fieldType: 'String', + }, + ], + }); + await CustomField.list(ctx, { objectType: 'Candidate' }); + expect(captured?.url).toContain('/customField.list'); + + mockFetchResponse({ success: true, results: {} }); + await CustomField.setValue(ctx, { + objectType: 'Candidate', + objectId: 'c_1', + customFieldDefinitionId: 'cf_1', + value: 'Secret', + }); + expect(captured?.url).toContain('/customField.setValue'); + + mockFetchResponse({ + success: true, + results: { scopes: ['candidatesRead'] }, + }); + await ApiKey.info(ctx, {}); + expect(captured?.url).toContain('/apiKey.info'); + + mockFetchResponse({ + success: true, + results: { id: 'wh_1', url: 'https://example.com' }, + }); + await Webhook.info(ctx, { webhookId: 'wh_1' }); + expect(captured?.url).toContain('/webhook.info'); + + await Webhook.create(ctx, { + url: 'https://example.com/webhook', + requestActionNames: ['candidateStageChange'], + }); + expect(captured?.url).toContain('/webhook.create'); + + await Webhook.delete(ctx, { webhookId: 'wh_1' }); + expect(captured?.url).toContain('/webhook.delete'); + }); + }); + + describe('Schema Validation in Shared Call', () => { + it('validates input schema before making request', async () => { + // @ts-expect-error test invalid candidateId input + await expect(Candidate.info(ctx, { candidateId: 123 })).rejects.toThrow(); + }); + + it('validates output response schema after request completes', async () => { + mockFetchResponse({ + success: true, + results: { id: 'cand_1' }, // Missing required 'name' field + }); + await expect( + Candidate.info(ctx, { candidateId: 'cand_1' }), + ).rejects.toThrow(); + }); + }); + + describe('Error Handlers', () => { + it('handles RATE_LIMIT_ERROR with maxRetries 0 and forwards headersRetryAfterMs', async () => { + const apiErr = new ApiError( + { method: 'POST', url: 'https://api.ashbyhq.com/candidate.info' }, + { + url: 'https://api.ashbyhq.com/candidate.info', + ok: false, + status: 429, + statusText: 'Too Many Requests', + body: undefined, + }, + 'Rate limit exceeded', + { retryAfter: 2500 }, + ); + + const match = errorHandlers.RATE_LIMIT_ERROR.match(apiErr); + expect(match).toBe(true); + + const res = await errorHandlers.RATE_LIMIT_ERROR.handler(apiErr); + expect(res).toEqual({ + maxRetries: 0, + headersRetryAfterMs: 2500, + }); + }); + + it('handles DEFAULT error without logging context.input', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const err = new Error('Unexpected database failure'); + const context = { + pluginId: 'ashby', + operation: 'candidate.create', + input: { secretToken: 'do-not-log-me', name: 'Secret Candidate' }, + originalError: err, + }; + + const match = errorHandlers.DEFAULT.match(); + expect(match).toBe(true); + + const res = await errorHandlers.DEFAULT.handler(err, context); + expect(res).toEqual({ maxRetries: 0 }); + + expect(errorSpy).toHaveBeenCalledWith( + '[corsair:ashby:candidate.create]', + { + error: 'Unexpected database failure', + }, + ); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/ashby/endpoints/api-keys.ts b/packages/ashby/endpoints/api-keys.ts new file mode 100644 index 000000000..37fbf6ea7 --- /dev/null +++ b/packages/ashby/endpoints/api-keys.ts @@ -0,0 +1,7 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { ApiKeyInfoResponse } from './types'; + +export const info: AshbyEndpoints['apiKey.info'] = async (ctx, _input) => { + return await ashbyCall(ctx, 'apiKey.info', {}); +}; diff --git a/packages/ashby/endpoints/applications.ts b/packages/ashby/endpoints/applications.ts new file mode 100644 index 000000000..52fad87ec --- /dev/null +++ b/packages/ashby/endpoints/applications.ts @@ -0,0 +1,81 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + ApplicationChangeStageResponse, + ApplicationCreateResponse, + ApplicationInfoResponse, + ApplicationListResponse, + ApplicationTransferResponse, + ApplicationUpdateResponse, +} from './types'; + +export const info: AshbyEndpoints['application.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'application.info', { + applicationId: input.applicationId, + }); +}; + +export const list: AshbyEndpoints['application.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'application.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + candidateId: input.candidateId, + jobId: input.jobId, + status: input.status, + }); +}; + +export const create: AshbyEndpoints['application.create'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'application.create', { + candidateId: input.candidateId, + jobId: input.jobId, + interviewStageId: input.interviewStageId, + sourceId: input.sourceId, + customFields: input.customFields, + }); +}; + +export const changeStage: AshbyEndpoints['application.changeStage'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'application.changeStage', + { + applicationId: input.applicationId, + interviewStageId: input.interviewStageId, + archiveReasonId: input.archiveReasonId, + }, + ); +}; + +export const update: AshbyEndpoints['application.update'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'application.update', { + applicationId: input.applicationId, + archiveReasonId: input.archiveReasonId, + customFields: input.customFields, + }); +}; + +export const transfer: AshbyEndpoints['application.transfer'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'application.transfer', + { + applicationId: input.applicationId, + jobId: input.jobId, + interviewStageId: input.interviewStageId, + }, + ); +}; diff --git a/packages/ashby/endpoints/candidates.ts b/packages/ashby/endpoints/candidates.ts new file mode 100644 index 000000000..3ac720b01 --- /dev/null +++ b/packages/ashby/endpoints/candidates.ts @@ -0,0 +1,134 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + CandidateAddTagResponse, + CandidateAnonymizeResponse, + CandidateCreateNoteResponse, + CandidateCreateResponse, + CandidateInfoResponse, + CandidateListNotesResponse, + CandidateListResponse, + CandidateRemoveTagResponse, + CandidateSearchResponse, + CandidateUpdateResponse, +} from './types'; + +export const info: AshbyEndpoints['candidate.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'candidate.info', { + candidateId: input.candidateId, + }); +}; + +export const list: AshbyEndpoints['candidate.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'candidate.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + createdAfter: input.createdAfter, + updatedAfter: input.updatedAfter, + }); +}; + +export const search: AshbyEndpoints['candidate.search'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'candidate.search', { + email: input.email, + name: input.name, + phone: input.phone, + }); +}; + +export const create: AshbyEndpoints['candidate.create'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'candidate.create', { + name: input.name, + email: input.email, + phoneNumber: input.phoneNumber, + socialLinks: input.socialLinks, + tags: input.tags, + customFields: input.customFields, + notes: input.notes, + }); +}; + +export const update: AshbyEndpoints['candidate.update'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'candidate.update', { + candidateId: input.candidateId, + name: input.name, + primaryEmailAddress: input.primaryEmailAddress, + primaryPhoneNumber: input.primaryPhoneNumber, + tags: input.tags, + customFields: input.customFields, + }); +}; + +export const addTag: AshbyEndpoints['candidate.addTag'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'candidate.addTag', { + candidateId: input.candidateId, + tag: input.tag, + }); +}; + +export const removeTag: AshbyEndpoints['candidate.removeTag'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'candidate.removeTag', + { + candidateId: input.candidateId, + tag: input.tag, + }, + ); +}; + +export const createNote: AshbyEndpoints['candidate.createNote'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'candidate.createNote', + { + candidateId: input.candidateId, + note: input.note, + }, + ); +}; + +export const listNotes: AshbyEndpoints['candidate.listNotes'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'candidate.listNotes', + { + candidateId: input.candidateId, + }, + ); +}; + +export const anonymize: AshbyEndpoints['candidate.anonymize'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'candidate.anonymize', + { + candidateId: input.candidateId, + }, + ); +}; diff --git a/packages/ashby/endpoints/custom-fields.ts b/packages/ashby/endpoints/custom-fields.ts new file mode 100644 index 000000000..4070937d4 --- /dev/null +++ b/packages/ashby/endpoints/custom-fields.ts @@ -0,0 +1,38 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + CustomFieldInfoResponse, + CustomFieldListResponse, + CustomFieldSetValueResponse, +} from './types'; + +export const info: AshbyEndpoints['customField.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'customField.info', { + customFieldDefinitionId: input.customFieldDefinitionId, + }); +}; + +export const list: AshbyEndpoints['customField.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'customField.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + objectType: input.objectType, + }); +}; + +export const setValue: AshbyEndpoints['customField.setValue'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'customField.setValue', + { + objectType: input.objectType, + objectId: input.objectId, + customFieldDefinitionId: input.customFieldDefinitionId, + value: input.value, + }, + ); +}; diff --git a/packages/ashby/endpoints/departments.ts b/packages/ashby/endpoints/departments.ts new file mode 100644 index 000000000..1e2ea98b0 --- /dev/null +++ b/packages/ashby/endpoints/departments.ts @@ -0,0 +1,54 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + DepartmentArchiveResponse, + DepartmentCreateResponse, + DepartmentInfoResponse, + DepartmentListResponse, + DepartmentUpdateResponse, +} from './types'; + +export const info: AshbyEndpoints['department.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'department.info', { + departmentId: input.departmentId, + }); +}; + +export const list: AshbyEndpoints['department.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'department.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + includeArchived: input.includeArchived, + }); +}; + +export const create: AshbyEndpoints['department.create'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'department.create', { + name: input.name, + parentId: input.parentId, + }); +}; + +export const update: AshbyEndpoints['department.update'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'department.update', { + departmentId: input.departmentId, + name: input.name, + parentId: input.parentId, + }); +}; + +export const archive: AshbyEndpoints['department.archive'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'department.archive', { + departmentId: input.departmentId, + }); +}; diff --git a/packages/ashby/endpoints/index.ts b/packages/ashby/endpoints/index.ts new file mode 100644 index 000000000..00272f636 --- /dev/null +++ b/packages/ashby/endpoints/index.ts @@ -0,0 +1,161 @@ +import { info as apiKeyInfo } from './api-keys'; +import { + changeStage as applicationChangeStage, + create as applicationCreate, + info as applicationInfo, + list as applicationList, + transfer as applicationTransfer, + update as applicationUpdate, +} from './applications'; +import { + addTag as candidateAddTag, + anonymize as candidateAnonymize, + create as candidateCreate, + createNote as candidateCreateNote, + info as candidateInfo, + list as candidateList, + listNotes as candidateListNotes, + removeTag as candidateRemoveTag, + search as candidateSearch, + update as candidateUpdate, +} from './candidates'; +import { + info as customFieldInfo, + list as customFieldList, + setValue as customFieldSetValue, +} from './custom-fields'; +import { + archive as departmentArchive, + create as departmentCreate, + info as departmentInfo, + list as departmentList, + update as departmentUpdate, +} from './departments'; +import { + info as interviewInfo, + list as interviewList, + scheduleInfo as interviewScheduleInfo, + scheduleList as interviewScheduleList, + stageList as interviewStageList, +} from './interviews'; +import { info as jobPostingInfo, list as jobPostingList } from './job-postings'; +import { + create as jobCreate, + info as jobInfo, + list as jobList, + search as jobSearch, + update as jobUpdate, +} from './jobs'; +import { + archive as locationArchive, + create as locationCreate, + info as locationInfo, + list as locationList, + update as locationUpdate, +} from './locations'; +import { + create as offerCreate, + info as offerInfo, + list as offerList, + update as offerUpdate, +} from './offers'; +import { + info as userInfo, + list as userList, + search as userSearch, +} from './users'; +import { + create as webhookCreate, + remove as webhookDelete, + info as webhookInfo, +} from './webhooks'; + +export const Candidate = { + info: candidateInfo, + list: candidateList, + search: candidateSearch, + create: candidateCreate, + update: candidateUpdate, + addTag: candidateAddTag, + removeTag: candidateRemoveTag, + createNote: candidateCreateNote, + listNotes: candidateListNotes, + anonymize: candidateAnonymize, +}; + +export const Application = { + info: applicationInfo, + list: applicationList, + create: applicationCreate, + changeStage: applicationChangeStage, + update: applicationUpdate, + transfer: applicationTransfer, +}; + +export const Job = { + info: jobInfo, + list: jobList, + create: jobCreate, + update: jobUpdate, + search: jobSearch, +}; + +export const JobPosting = { + info: jobPostingInfo, + list: jobPostingList, +}; + +export const Interview = { + info: interviewInfo, + list: interviewList, + scheduleInfo: interviewScheduleInfo, + scheduleList: interviewScheduleList, + stageList: interviewStageList, +}; + +export const Offer = { + info: offerInfo, + list: offerList, + create: offerCreate, + update: offerUpdate, +}; + +export const Department = { + info: departmentInfo, + list: departmentList, + create: departmentCreate, + update: departmentUpdate, + archive: departmentArchive, +}; + +export const Location = { + info: locationInfo, + list: locationList, + create: locationCreate, + update: locationUpdate, + archive: locationArchive, +}; + +export const User = { + info: userInfo, + list: userList, + search: userSearch, +}; + +export const CustomField = { + info: customFieldInfo, + list: customFieldList, + setValue: customFieldSetValue, +}; + +export const ApiKey = { + info: apiKeyInfo, +}; + +export const Webhook = { + info: webhookInfo, + create: webhookCreate, + delete: webhookDelete, +}; + +export * from './types'; diff --git a/packages/ashby/endpoints/interviews.ts b/packages/ashby/endpoints/interviews.ts new file mode 100644 index 000000000..1aeaef8ab --- /dev/null +++ b/packages/ashby/endpoints/interviews.ts @@ -0,0 +1,67 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + InterviewInfoResponse, + InterviewListResponse, + InterviewScheduleInfoResponse, + InterviewScheduleListResponse, + InterviewStageListResponse, +} from './types'; + +export const info: AshbyEndpoints['interview.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'interview.info', { + interviewId: input.interviewId, + }); +}; + +export const list: AshbyEndpoints['interview.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'interview.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + interviewPlanId: input.interviewPlanId, + }); +}; + +export const scheduleInfo: AshbyEndpoints['interview.scheduleInfo'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'interviewSchedule.info', + { + interviewScheduleId: input.interviewScheduleId, + }, + ); +}; + +export const scheduleList: AshbyEndpoints['interview.scheduleList'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'interviewSchedule.list', + { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + applicationId: input.applicationId, + }, + ); +}; + +export const stageList: AshbyEndpoints['interview.stageList'] = async ( + ctx, + input, +) => { + return await ashbyCall( + ctx, + 'interviewStage.list', + { + jobId: input.jobId, + interviewPlanId: input.interviewPlanId, + }, + ); +}; diff --git a/packages/ashby/endpoints/job-postings.ts b/packages/ashby/endpoints/job-postings.ts new file mode 100644 index 000000000..ac0e70782 --- /dev/null +++ b/packages/ashby/endpoints/job-postings.ts @@ -0,0 +1,21 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { JobPostingInfoResponse, JobPostingListResponse } from './types'; + +export const info: AshbyEndpoints['jobPosting.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'jobPosting.info', { + jobPostingId: input.jobPostingId, + }); +}; + +export const list: AshbyEndpoints['jobPosting.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'jobPosting.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + jobId: input.jobId, + departmentId: input.departmentId, + locationId: input.locationId, + listedOnly: input.listedOnly, + }); +}; diff --git a/packages/ashby/endpoints/jobs.ts b/packages/ashby/endpoints/jobs.ts new file mode 100644 index 000000000..2c7a3a465 --- /dev/null +++ b/packages/ashby/endpoints/jobs.ts @@ -0,0 +1,54 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + JobCreateResponse, + JobInfoResponse, + JobListResponse, + JobSearchResponse, + JobUpdateResponse, +} from './types'; + +export const info: AshbyEndpoints['job.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'job.info', { + jobId: input.jobId, + }); +}; + +export const list: AshbyEndpoints['job.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'job.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + status: input.status, + departmentId: input.departmentId, + locationId: input.locationId, + }); +}; + +export const create: AshbyEndpoints['job.create'] = async (ctx, input) => { + return await ashbyCall(ctx, 'job.create', { + title: input.title, + departmentId: input.departmentId, + locationId: input.locationId, + status: input.status, + customFields: input.customFields, + }); +}; + +export const update: AshbyEndpoints['job.update'] = async (ctx, input) => { + return await ashbyCall(ctx, 'job.update', { + jobId: input.jobId, + title: input.title, + departmentId: input.departmentId, + locationId: input.locationId, + status: input.status, + customFields: input.customFields, + }); +}; + +export const search: AshbyEndpoints['job.search'] = async (ctx, input) => { + return await ashbyCall(ctx, 'job.search', { + title: input.title, + status: input.status, + }); +}; diff --git a/packages/ashby/endpoints/locations.ts b/packages/ashby/endpoints/locations.ts new file mode 100644 index 000000000..48c930956 --- /dev/null +++ b/packages/ashby/endpoints/locations.ts @@ -0,0 +1,48 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + LocationArchiveResponse, + LocationCreateResponse, + LocationInfoResponse, + LocationListResponse, + LocationUpdateResponse, +} from './types'; + +export const info: AshbyEndpoints['location.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'location.info', { + locationId: input.locationId, + }); +}; + +export const list: AshbyEndpoints['location.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'location.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + includeArchived: input.includeArchived, + }); +}; + +export const create: AshbyEndpoints['location.create'] = async (ctx, input) => { + return await ashbyCall(ctx, 'location.create', { + name: input.name, + parentId: input.parentId, + }); +}; + +export const update: AshbyEndpoints['location.update'] = async (ctx, input) => { + return await ashbyCall(ctx, 'location.update', { + locationId: input.locationId, + name: input.name, + parentId: input.parentId, + }); +}; + +export const archive: AshbyEndpoints['location.archive'] = async ( + ctx, + input, +) => { + return await ashbyCall(ctx, 'location.archive', { + locationId: input.locationId, + }); +}; diff --git a/packages/ashby/endpoints/offers.ts b/packages/ashby/endpoints/offers.ts new file mode 100644 index 000000000..007f87fd0 --- /dev/null +++ b/packages/ashby/endpoints/offers.ts @@ -0,0 +1,45 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + OfferCreateResponse, + OfferInfoResponse, + OfferListResponse, + OfferUpdateResponse, +} from './types'; + +export const info: AshbyEndpoints['offer.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'offer.info', { + offerId: input.offerId, + }); +}; + +export const list: AshbyEndpoints['offer.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'offer.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + applicationId: input.applicationId, + status: input.status, + }); +}; + +export const create: AshbyEndpoints['offer.create'] = async (ctx, input) => { + return await ashbyCall(ctx, 'offer.create', { + applicationId: input.applicationId, + salary: input.salary, + currency: input.currency, + startDate: input.startDate, + customFields: input.customFields, + }); +}; + +export const update: AshbyEndpoints['offer.update'] = async (ctx, input) => { + return await ashbyCall(ctx, 'offer.update', { + offerId: input.offerId, + salary: input.salary, + currency: input.currency, + startDate: input.startDate, + status: input.status, + customFields: input.customFields, + }); +}; diff --git a/packages/ashby/endpoints/shared.ts b/packages/ashby/endpoints/shared.ts new file mode 100644 index 000000000..67c526acc --- /dev/null +++ b/packages/ashby/endpoints/shared.ts @@ -0,0 +1,54 @@ +import { AuthMissingError } from 'corsair/core'; +import { makeAshbyRequest } from '../client'; +import type { AshbyContext } from '../index'; +import { AshbyEndpointInputSchemas, AshbyEndpointOutputSchemas } from './types'; + +const ENDPOINT_KEY_MAP: Record = + { + 'interviewSchedule.info': 'interview.scheduleInfo', + 'interviewSchedule.list': 'interview.scheduleList', + 'interviewStage.list': 'interview.stageList', + }; + +/** + * Resolves the API key from plugin options or context keys. + */ +export async function getAshbyApiKey(ctx: AshbyContext): Promise { + if (ctx.options.key) { + return ctx.options.key; + } + + const key = await ctx.keys.get_api_key(); + if (!key) { + throw new AuthMissingError('ashby', 'api_key'); + } + return key; +} + +/** + * Dispatches an Ashby RPC request with key resolution and schema validation. + */ +export async function ashbyCall( + ctx: AshbyContext, + endpoint: string, + body: Record = {}, +): Promise { + const apiKey = await getAshbyApiKey(ctx); + const schemaKey = + ENDPOINT_KEY_MAP[endpoint] ?? + (endpoint as keyof typeof AshbyEndpointInputSchemas); + + const inputSchema = AshbyEndpointInputSchemas[schemaKey]; + const outputSchema = AshbyEndpointOutputSchemas[schemaKey]; + + const parsedInput = inputSchema + ? (inputSchema.parse(body) as Record) + : body; + + const raw = await makeAshbyRequest(endpoint, apiKey, { + body: parsedInput, + }); + + const parsedOutput = outputSchema ? outputSchema.parse(raw) : raw; + return parsedOutput as T; +} diff --git a/packages/ashby/endpoints/types.ts b/packages/ashby/endpoints/types.ts new file mode 100644 index 000000000..154655623 --- /dev/null +++ b/packages/ashby/endpoints/types.ts @@ -0,0 +1,1167 @@ +import { z } from 'zod'; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared / Base Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyPaginationInputSchema = z.object({ + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().optional(), + syncToken: z.string().optional(), +}); +export type AshbyPaginationInput = z.infer; + +export const AshbyContactInfoSchema = z.object({ + value: z.string(), + type: z.string().optional(), + isPrimary: z.boolean().optional(), +}); +export type AshbyContactInfo = z.infer; + +export const AshbySocialLinkSchema = z.object({ + url: z.string(), + type: z.string().optional(), +}); +export type AshbySocialLink = z.infer; + +export const AshbyFileSchema = z.object({ + id: z.string(), + name: z.string(), + handle: z.string().optional(), + mimeType: z.string().optional(), + createdAt: z.string().optional(), +}); +export type AshbyFile = z.infer; + +export const AshbyCustomFieldValueSchema = z.object({ + value: z.unknown(), + customFieldDefinitionId: z.string(), + title: z.string().optional(), +}); +export type AshbyCustomFieldValue = z.infer; + +export const AshbyHiringTeamMemberSchema = z.object({ + userId: z.string(), + role: z.string(), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), +}); +export type AshbyHiringTeamMember = z.infer; + +function makeListResponseSchema(itemSchema: T) { + return z.object({ + success: z.boolean(), + results: z.array(itemSchema), + moreDataAvailable: z.boolean().optional(), + nextCursor: z.string().optional(), + syncToken: z.string().optional(), + }); +} + +function makeSingleResponseSchema(itemSchema: T) { + return z.object({ + success: z.boolean(), + results: itemSchema, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Candidate Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyCandidateSchema = z + .object({ + id: z.string(), + name: z.string(), + primaryEmailAddress: AshbyContactInfoSchema.nullable().optional(), + emailAddresses: z.array(AshbyContactInfoSchema).optional(), + primaryPhoneNumber: AshbyContactInfoSchema.nullable().optional(), + phoneNumbers: z.array(AshbyContactInfoSchema).optional(), + socialLinks: z.array(AshbySocialLinkSchema).optional(), + tags: z.array(z.string()).optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), + applicationIds: z.array(z.string()).optional(), + fileIds: z.array(z.string()).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + anonymizedAt: z.string().nullable().optional(), + }) + .loose(); +export type AshbyCandidate = z.infer; + +export const AshbyCandidateNoteSchema = z + .object({ + id: z.string(), + candidateId: z.string(), + note: z.string(), + authorUserId: z.string().optional(), + createdAt: z.string().optional(), + }) + .loose(); +export type AshbyCandidateNote = z.infer; + +export const CandidateInfoInputSchema = z.object({ + candidateId: z.string(), +}); +export type CandidateInfoInput = z.infer; +export const CandidateInfoResponseSchema = + makeSingleResponseSchema(AshbyCandidateSchema); +export type CandidateInfoResponse = z.infer; + +export const CandidateListInputSchema = AshbyPaginationInputSchema.extend({ + createdAfter: z.string().optional(), + updatedAfter: z.string().optional(), +}); +export type CandidateListInput = z.infer; +export const CandidateListResponseSchema = + makeListResponseSchema(AshbyCandidateSchema); +export type CandidateListResponse = z.infer; + +export const CandidateSearchInputSchema = z.object({ + email: z.string().optional(), + name: z.string().optional(), + phone: z.string().optional(), +}); +export type CandidateSearchInput = z.infer; +export const CandidateSearchResponseSchema = makeSingleResponseSchema( + z.array(AshbyCandidateSchema), +); +export type CandidateSearchResponse = z.infer< + typeof CandidateSearchResponseSchema +>; + +export const CandidateCreateInputSchema = z.object({ + name: z.string(), + email: z.string().optional(), + phoneNumber: z.string().optional(), + socialLinks: z.array(AshbySocialLinkSchema).optional(), + tags: z.array(z.string()).optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), + notes: z.string().optional(), +}); +export type CandidateCreateInput = z.infer; +export const CandidateCreateResponseSchema = + makeSingleResponseSchema(AshbyCandidateSchema); +export type CandidateCreateResponse = z.infer< + typeof CandidateCreateResponseSchema +>; + +export const CandidateUpdateInputSchema = z.object({ + candidateId: z.string(), + name: z.string().optional(), + primaryEmailAddress: z.string().optional(), + primaryPhoneNumber: z.string().optional(), + tags: z.array(z.string()).optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), +}); +export type CandidateUpdateInput = z.infer; +export const CandidateUpdateResponseSchema = + makeSingleResponseSchema(AshbyCandidateSchema); +export type CandidateUpdateResponse = z.infer< + typeof CandidateUpdateResponseSchema +>; + +export const CandidateAddTagInputSchema = z.object({ + candidateId: z.string(), + tag: z.string(), +}); +export type CandidateAddTagInput = z.infer; +export const CandidateAddTagResponseSchema = + makeSingleResponseSchema(AshbyCandidateSchema); +export type CandidateAddTagResponse = z.infer< + typeof CandidateAddTagResponseSchema +>; + +export const CandidateRemoveTagInputSchema = z.object({ + candidateId: z.string(), + tag: z.string(), +}); +export type CandidateRemoveTagInput = z.infer< + typeof CandidateRemoveTagInputSchema +>; +export const CandidateRemoveTagResponseSchema = + makeSingleResponseSchema(AshbyCandidateSchema); +export type CandidateRemoveTagResponse = z.infer< + typeof CandidateRemoveTagResponseSchema +>; + +export const CandidateCreateNoteInputSchema = z.object({ + candidateId: z.string(), + note: z.string(), +}); +export type CandidateCreateNoteInput = z.infer< + typeof CandidateCreateNoteInputSchema +>; +export const CandidateCreateNoteResponseSchema = makeSingleResponseSchema( + AshbyCandidateNoteSchema, +); +export type CandidateCreateNoteResponse = z.infer< + typeof CandidateCreateNoteResponseSchema +>; + +export const CandidateListNotesInputSchema = z.object({ + candidateId: z.string(), +}); +export type CandidateListNotesInput = z.infer< + typeof CandidateListNotesInputSchema +>; +export const CandidateListNotesResponseSchema = makeSingleResponseSchema( + z.array(AshbyCandidateNoteSchema), +); +export type CandidateListNotesResponse = z.infer< + typeof CandidateListNotesResponseSchema +>; + +export const CandidateAnonymizeInputSchema = z.object({ + candidateId: z.string(), +}); +export type CandidateAnonymizeInput = z.infer< + typeof CandidateAnonymizeInputSchema +>; +export const CandidateAnonymizeResponseSchema = z.object({ + success: z.boolean(), + results: z + .object({ + candidateId: z.string(), + anonymizedAt: z.string().optional(), + }) + .loose(), +}); +export type CandidateAnonymizeResponse = z.infer< + typeof CandidateAnonymizeResponseSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Application Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyApplicationSchema = z + .object({ + id: z.string(), + candidateId: z.string(), + jobId: z.string(), + status: z.string().optional(), + currentInterviewStageId: z.string().nullable().optional(), + archiveReasonId: z.string().nullable().optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), + hiringTeam: z.array(AshbyHiringTeamMemberSchema).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + .loose(); +export type AshbyApplication = z.infer; + +export const ApplicationInfoInputSchema = z.object({ + applicationId: z.string(), +}); +export type ApplicationInfoInput = z.infer; +export const ApplicationInfoResponseSchema = makeSingleResponseSchema( + AshbyApplicationSchema, +); +export type ApplicationInfoResponse = z.infer< + typeof ApplicationInfoResponseSchema +>; + +export const ApplicationListInputSchema = AshbyPaginationInputSchema.extend({ + candidateId: z.string().optional(), + jobId: z.string().optional(), + status: z.string().optional(), +}); +export type ApplicationListInput = z.infer; +export const ApplicationListResponseSchema = makeListResponseSchema( + AshbyApplicationSchema, +); +export type ApplicationListResponse = z.infer< + typeof ApplicationListResponseSchema +>; + +export const ApplicationCreateInputSchema = z.object({ + candidateId: z.string(), + jobId: z.string(), + interviewStageId: z.string().optional(), + sourceId: z.string().optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), +}); +export type ApplicationCreateInput = z.infer< + typeof ApplicationCreateInputSchema +>; +export const ApplicationCreateResponseSchema = makeSingleResponseSchema( + AshbyApplicationSchema, +); +export type ApplicationCreateResponse = z.infer< + typeof ApplicationCreateResponseSchema +>; + +export const ApplicationChangeStageInputSchema = z.object({ + applicationId: z.string(), + interviewStageId: z.string(), + archiveReasonId: z.string().optional(), +}); +export type ApplicationChangeStageInput = z.infer< + typeof ApplicationChangeStageInputSchema +>; +export const ApplicationChangeStageResponseSchema = makeSingleResponseSchema( + AshbyApplicationSchema, +); +export type ApplicationChangeStageResponse = z.infer< + typeof ApplicationChangeStageResponseSchema +>; + +export const ApplicationUpdateInputSchema = z.object({ + applicationId: z.string(), + archiveReasonId: z.string().optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), +}); +export type ApplicationUpdateInput = z.infer< + typeof ApplicationUpdateInputSchema +>; +export const ApplicationUpdateResponseSchema = makeSingleResponseSchema( + AshbyApplicationSchema, +); +export type ApplicationUpdateResponse = z.infer< + typeof ApplicationUpdateResponseSchema +>; + +export const ApplicationTransferInputSchema = z.object({ + applicationId: z.string(), + jobId: z.string(), + interviewStageId: z.string().optional(), +}); +export type ApplicationTransferInput = z.infer< + typeof ApplicationTransferInputSchema +>; +export const ApplicationTransferResponseSchema = makeSingleResponseSchema( + AshbyApplicationSchema, +); +export type ApplicationTransferResponse = z.infer< + typeof ApplicationTransferResponseSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Job Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyJobOpeningSchema = z + .object({ + id: z.string(), + identifier: z.string().optional(), + isArchived: z.boolean().optional(), + targetStartDate: z.string().nullable().optional(), + }) + .loose(); +export type AshbyJobOpening = z.infer; + +export const AshbyJobSchema = z + .object({ + id: z.string(), + title: z.string(), + status: z.string().optional(), + departmentId: z.string().nullable().optional(), + locationId: z.string().nullable().optional(), + hiringTeam: z.array(AshbyHiringTeamMemberSchema).optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), + openings: z.array(AshbyJobOpeningSchema).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + .loose(); +export type AshbyJob = z.infer; + +export const JobInfoInputSchema = z.object({ + jobId: z.string(), +}); +export type JobInfoInput = z.infer; +export const JobInfoResponseSchema = makeSingleResponseSchema(AshbyJobSchema); +export type JobInfoResponse = z.infer; + +export const JobListInputSchema = AshbyPaginationInputSchema.extend({ + status: z.string().optional(), + departmentId: z.string().optional(), + locationId: z.string().optional(), +}); +export type JobListInput = z.infer; +export const JobListResponseSchema = makeListResponseSchema(AshbyJobSchema); +export type JobListResponse = z.infer; + +export const JobCreateInputSchema = z.object({ + title: z.string(), + departmentId: z.string().optional(), + locationId: z.string().optional(), + status: z.string().optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), +}); +export type JobCreateInput = z.infer; +export const JobCreateResponseSchema = makeSingleResponseSchema(AshbyJobSchema); +export type JobCreateResponse = z.infer; + +export const JobUpdateInputSchema = z.object({ + jobId: z.string(), + title: z.string().optional(), + departmentId: z.string().optional(), + locationId: z.string().optional(), + status: z.string().optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), +}); +export type JobUpdateInput = z.infer; +export const JobUpdateResponseSchema = makeSingleResponseSchema(AshbyJobSchema); +export type JobUpdateResponse = z.infer; + +export const JobSearchInputSchema = z.object({ + title: z.string().optional(), + status: z.string().optional(), +}); +export type JobSearchInput = z.infer; +export const JobSearchResponseSchema = makeSingleResponseSchema( + z.array(AshbyJobSchema), +); +export type JobSearchResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Job Posting Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyJobPostingSchema = z + .object({ + id: z.string(), + title: z.string(), + jobId: z.string(), + departmentId: z.string().nullable().optional(), + locationId: z.string().nullable().optional(), + secondaryLocationIds: z.array(z.string()).optional(), + isListed: z.boolean().optional(), + publishedDate: z.string().nullable().optional(), + teamNameHierarchy: z.array(z.string()).optional(), + descriptionHtml: z.string().optional(), + }) + .loose(); +export type AshbyJobPosting = z.infer; + +export const JobPostingInfoInputSchema = z.object({ + jobPostingId: z.string(), +}); +export type JobPostingInfoInput = z.infer; +export const JobPostingInfoResponseSchema = makeSingleResponseSchema( + AshbyJobPostingSchema, +); +export type JobPostingInfoResponse = z.infer< + typeof JobPostingInfoResponseSchema +>; + +export const JobPostingListInputSchema = AshbyPaginationInputSchema.extend({ + jobId: z.string().optional(), + departmentId: z.string().optional(), + locationId: z.string().optional(), + listedOnly: z.boolean().optional(), +}); +export type JobPostingListInput = z.infer; +export const JobPostingListResponseSchema = makeListResponseSchema( + AshbyJobPostingSchema, +); +export type JobPostingListResponse = z.infer< + typeof JobPostingListResponseSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Interview Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyInterviewStageSchema = z + .object({ + id: z.string(), + title: z.string(), + type: z.string().optional(), + orderInJob: z.number().optional(), + jobId: z.string().optional(), + }) + .loose(); +export type AshbyInterviewStage = z.infer; + +export const AshbyInterviewScheduleSchema = z + .object({ + id: z.string(), + applicationId: z.string(), + interviewStageId: z.string().optional(), + scheduledStartTime: z.string().nullable().optional(), + scheduledEndTime: z.string().nullable().optional(), + status: z.string().optional(), + interviewers: z.array(z.object({ userId: z.string() }).loose()).optional(), + }) + .loose(); +export type AshbyInterviewSchedule = z.infer< + typeof AshbyInterviewScheduleSchema +>; + +export const AshbyInterviewSchema = z + .object({ + id: z.string(), + title: z.string(), + interviewStageId: z.string().optional(), + interviewPlanId: z.string().optional(), + }) + .loose(); +export type AshbyInterview = z.infer; + +export const InterviewInfoInputSchema = z.object({ + interviewId: z.string(), +}); +export type InterviewInfoInput = z.infer; +export const InterviewInfoResponseSchema = + makeSingleResponseSchema(AshbyInterviewSchema); +export type InterviewInfoResponse = z.infer; + +export const InterviewListInputSchema = AshbyPaginationInputSchema.extend({ + interviewPlanId: z.string().optional(), +}); +export type InterviewListInput = z.infer; +export const InterviewListResponseSchema = + makeListResponseSchema(AshbyInterviewSchema); +export type InterviewListResponse = z.infer; + +export const InterviewScheduleInfoInputSchema = z.object({ + interviewScheduleId: z.string(), +}); +export type InterviewScheduleInfoInput = z.infer< + typeof InterviewScheduleInfoInputSchema +>; +export const InterviewScheduleInfoResponseSchema = makeSingleResponseSchema( + AshbyInterviewScheduleSchema, +); +export type InterviewScheduleInfoResponse = z.infer< + typeof InterviewScheduleInfoResponseSchema +>; + +export const InterviewScheduleListInputSchema = + AshbyPaginationInputSchema.extend({ + applicationId: z.string().optional(), + }); +export type InterviewScheduleListInput = z.infer< + typeof InterviewScheduleListInputSchema +>; +export const InterviewScheduleListResponseSchema = makeListResponseSchema( + AshbyInterviewScheduleSchema, +); +export type InterviewScheduleListResponse = z.infer< + typeof InterviewScheduleListResponseSchema +>; + +export const InterviewStageListInputSchema = z.object({ + jobId: z.string().optional(), + interviewPlanId: z.string().optional(), +}); +export type InterviewStageListInput = z.infer< + typeof InterviewStageListInputSchema +>; +export const InterviewStageListResponseSchema = makeSingleResponseSchema( + z.array(AshbyInterviewStageSchema), +); +export type InterviewStageListResponse = z.infer< + typeof InterviewStageListResponseSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Offer Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyOfferSchema = z + .object({ + id: z.string(), + applicationId: z.string(), + status: z.string().optional(), + salary: z.number().nullable().optional(), + currency: z.string().nullable().optional(), + startDate: z.string().nullable().optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + .loose(); +export type AshbyOffer = z.infer; + +export const OfferInfoInputSchema = z.object({ + offerId: z.string(), +}); +export type OfferInfoInput = z.infer; +export const OfferInfoResponseSchema = + makeSingleResponseSchema(AshbyOfferSchema); +export type OfferInfoResponse = z.infer; + +export const OfferListInputSchema = AshbyPaginationInputSchema.extend({ + applicationId: z.string().optional(), + status: z.string().optional(), +}); +export type OfferListInput = z.infer; +export const OfferListResponseSchema = makeListResponseSchema(AshbyOfferSchema); +export type OfferListResponse = z.infer; + +export const OfferCreateInputSchema = z.object({ + applicationId: z.string(), + salary: z.number().optional(), + currency: z.string().optional(), + startDate: z.string().optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), +}); +export type OfferCreateInput = z.infer; +export const OfferCreateResponseSchema = + makeSingleResponseSchema(AshbyOfferSchema); +export type OfferCreateResponse = z.infer; + +export const OfferUpdateInputSchema = z.object({ + offerId: z.string(), + salary: z.number().optional(), + currency: z.string().optional(), + startDate: z.string().optional(), + status: z.string().optional(), + customFields: z.array(AshbyCustomFieldValueSchema).optional(), +}); +export type OfferUpdateInput = z.infer; +export const OfferUpdateResponseSchema = + makeSingleResponseSchema(AshbyOfferSchema); +export type OfferUpdateResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Department Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyDepartmentSchema = z + .object({ + id: z.string(), + name: z.string(), + parentId: z.string().nullable().optional(), + isArchived: z.boolean().optional(), + }) + .loose(); +export type AshbyDepartment = z.infer; + +export const DepartmentInfoInputSchema = z.object({ + departmentId: z.string(), +}); +export type DepartmentInfoInput = z.infer; +export const DepartmentInfoResponseSchema = makeSingleResponseSchema( + AshbyDepartmentSchema, +); +export type DepartmentInfoResponse = z.infer< + typeof DepartmentInfoResponseSchema +>; + +export const DepartmentListInputSchema = AshbyPaginationInputSchema.extend({ + includeArchived: z.boolean().optional(), +}); +export type DepartmentListInput = z.infer; +export const DepartmentListResponseSchema = makeListResponseSchema( + AshbyDepartmentSchema, +); +export type DepartmentListResponse = z.infer< + typeof DepartmentListResponseSchema +>; + +export const DepartmentCreateInputSchema = z.object({ + name: z.string(), + parentId: z.string().optional(), +}); +export type DepartmentCreateInput = z.infer; +export const DepartmentCreateResponseSchema = makeSingleResponseSchema( + AshbyDepartmentSchema, +); +export type DepartmentCreateResponse = z.infer< + typeof DepartmentCreateResponseSchema +>; + +export const DepartmentUpdateInputSchema = z.object({ + departmentId: z.string(), + name: z.string().optional(), + parentId: z.string().optional(), +}); +export type DepartmentUpdateInput = z.infer; +export const DepartmentUpdateResponseSchema = makeSingleResponseSchema( + AshbyDepartmentSchema, +); +export type DepartmentUpdateResponse = z.infer< + typeof DepartmentUpdateResponseSchema +>; + +export const DepartmentArchiveInputSchema = z.object({ + departmentId: z.string(), +}); +export type DepartmentArchiveInput = z.infer< + typeof DepartmentArchiveInputSchema +>; +export const DepartmentArchiveResponseSchema = makeSingleResponseSchema( + AshbyDepartmentSchema, +); +export type DepartmentArchiveResponse = z.infer< + typeof DepartmentArchiveResponseSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// 8. Location Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyLocationSchema = z + .object({ + id: z.string(), + name: z.string(), + parentId: z.string().nullable().optional(), + isArchived: z.boolean().optional(), + }) + .loose(); +export type AshbyLocation = z.infer; + +export const LocationInfoInputSchema = z.object({ + locationId: z.string(), +}); +export type LocationInfoInput = z.infer; +export const LocationInfoResponseSchema = + makeSingleResponseSchema(AshbyLocationSchema); +export type LocationInfoResponse = z.infer; + +export const LocationListInputSchema = AshbyPaginationInputSchema.extend({ + includeArchived: z.boolean().optional(), +}); +export type LocationListInput = z.infer; +export const LocationListResponseSchema = + makeListResponseSchema(AshbyLocationSchema); +export type LocationListResponse = z.infer; + +export const LocationCreateInputSchema = z.object({ + name: z.string(), + parentId: z.string().optional(), +}); +export type LocationCreateInput = z.infer; +export const LocationCreateResponseSchema = + makeSingleResponseSchema(AshbyLocationSchema); +export type LocationCreateResponse = z.infer< + typeof LocationCreateResponseSchema +>; + +export const LocationUpdateInputSchema = z.object({ + locationId: z.string(), + name: z.string().optional(), + parentId: z.string().optional(), +}); +export type LocationUpdateInput = z.infer; +export const LocationUpdateResponseSchema = + makeSingleResponseSchema(AshbyLocationSchema); +export type LocationUpdateResponse = z.infer< + typeof LocationUpdateResponseSchema +>; + +export const LocationArchiveInputSchema = z.object({ + locationId: z.string(), +}); +export type LocationArchiveInput = z.infer; +export const LocationArchiveResponseSchema = + makeSingleResponseSchema(AshbyLocationSchema); +export type LocationArchiveResponse = z.infer< + typeof LocationArchiveResponseSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// 9. User Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyUserSchema = z + .object({ + id: z.string(), + name: z.string(), + email: z.string(), + globalRole: z.string().optional(), + isEnabled: z.boolean().optional(), + }) + .loose(); +export type AshbyUser = z.infer; + +export const UserInfoInputSchema = z.object({ + userId: z.string(), +}); +export type UserInfoInput = z.infer; +export const UserInfoResponseSchema = makeSingleResponseSchema(AshbyUserSchema); +export type UserInfoResponse = z.infer; + +export const UserListInputSchema = AshbyPaginationInputSchema.extend({ + isEnabled: z.boolean().optional(), +}); +export type UserListInput = z.infer; +export const UserListResponseSchema = makeListResponseSchema(AshbyUserSchema); +export type UserListResponse = z.infer; + +export const UserSearchInputSchema = z.object({ + email: z.string().optional(), + name: z.string().optional(), +}); +export type UserSearchInput = z.infer; +export const UserSearchResponseSchema = makeSingleResponseSchema( + z.array(AshbyUserSchema), +); +export type UserSearchResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// 10. Custom Field Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyCustomFieldDefinitionSchema = z + .object({ + id: z.string(), + title: z.string(), + objectType: z.string(), + fieldType: z.string(), + isArchived: z.boolean().optional(), + }) + .loose(); +export type AshbyCustomFieldDefinition = z.infer< + typeof AshbyCustomFieldDefinitionSchema +>; + +export const CustomFieldInfoInputSchema = z.object({ + customFieldDefinitionId: z.string(), +}); +export type CustomFieldInfoInput = z.infer; +export const CustomFieldInfoResponseSchema = makeSingleResponseSchema( + AshbyCustomFieldDefinitionSchema, +); +export type CustomFieldInfoResponse = z.infer< + typeof CustomFieldInfoResponseSchema +>; + +export const CustomFieldListInputSchema = AshbyPaginationInputSchema.extend({ + objectType: z.string().optional(), +}); +export type CustomFieldListInput = z.infer; +export const CustomFieldListResponseSchema = makeListResponseSchema( + AshbyCustomFieldDefinitionSchema, +); +export type CustomFieldListResponse = z.infer< + typeof CustomFieldListResponseSchema +>; + +export const CustomFieldSetValueInputSchema = z.object({ + objectType: z.string(), + objectId: z.string(), + customFieldDefinitionId: z.string(), + value: z.unknown(), +}); +export type CustomFieldSetValueInput = z.infer< + typeof CustomFieldSetValueInputSchema +>; +export const CustomFieldSetValueResponseSchema = z.object({ + success: z.boolean(), + results: z.record(z.string(), z.unknown()).optional(), +}); +export type CustomFieldSetValueResponse = z.infer< + typeof CustomFieldSetValueResponseSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// 11. API Key Info Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyApiKeyInfoSchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + scopes: z.array(z.string()).optional(), + }) + .loose(); +export type AshbyApiKeyInfo = z.infer; + +export const ApiKeyInfoInputSchema = z.object({}); +export type ApiKeyInfoInput = z.infer; +export const ApiKeyInfoResponseSchema = makeSingleResponseSchema( + AshbyApiKeyInfoSchema, +); +export type ApiKeyInfoResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// 12. Webhook Management Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyWebhookConfigSchema = z + .object({ + id: z.string(), + url: z.string(), + description: z.string().optional(), + requestActionNames: z.array(z.string()).optional(), + isEnabled: z.boolean().optional(), + secretToken: z.string().optional(), + }) + .loose(); +export type AshbyWebhookConfig = z.infer; + +export const WebhookInfoInputSchema = z.object({ + webhookId: z.string(), +}); +export type WebhookInfoInput = z.infer; +export const WebhookInfoResponseSchema = makeSingleResponseSchema( + AshbyWebhookConfigSchema, +); +export type WebhookInfoResponse = z.infer; + +export const WebhookCreateInputSchema = z.object({ + url: z.string(), + requestActionNames: z.array(z.string()), + description: z.string().optional(), + secretToken: z.string().optional(), +}); +export type WebhookCreateInput = z.infer; +export const WebhookCreateResponseSchema = makeSingleResponseSchema( + AshbyWebhookConfigSchema, +); +export type WebhookCreateResponse = z.infer; + +export const WebhookDeleteInputSchema = z.object({ + webhookId: z.string(), +}); +export type WebhookDeleteInput = z.infer; +export const WebhookDeleteResponseSchema = z.object({ + success: z.boolean(), + results: z.record(z.string(), z.unknown()).optional(), +}); +export type WebhookDeleteResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Endpoint Map +// ───────────────────────────────────────────────────────────────────────────── + +export type AshbyEndpointInputs = { + // Candidates + 'candidate.info': CandidateInfoInput; + 'candidate.list': CandidateListInput; + 'candidate.search': CandidateSearchInput; + 'candidate.create': CandidateCreateInput; + 'candidate.update': CandidateUpdateInput; + 'candidate.addTag': CandidateAddTagInput; + 'candidate.removeTag': CandidateRemoveTagInput; + 'candidate.createNote': CandidateCreateNoteInput; + 'candidate.listNotes': CandidateListNotesInput; + 'candidate.anonymize': CandidateAnonymizeInput; + // Applications + 'application.info': ApplicationInfoInput; + 'application.list': ApplicationListInput; + 'application.create': ApplicationCreateInput; + 'application.changeStage': ApplicationChangeStageInput; + 'application.update': ApplicationUpdateInput; + 'application.transfer': ApplicationTransferInput; + // Jobs + 'job.info': JobInfoInput; + 'job.list': JobListInput; + 'job.create': JobCreateInput; + 'job.update': JobUpdateInput; + 'job.search': JobSearchInput; + // Job Postings + 'jobPosting.info': JobPostingInfoInput; + 'jobPosting.list': JobPostingListInput; + // Interviews + 'interview.info': InterviewInfoInput; + 'interview.list': InterviewListInput; + 'interview.scheduleInfo': InterviewScheduleInfoInput; + 'interview.scheduleList': InterviewScheduleListInput; + 'interview.stageList': InterviewStageListInput; + // Offers + 'offer.info': OfferInfoInput; + 'offer.list': OfferListInput; + 'offer.create': OfferCreateInput; + 'offer.update': OfferUpdateInput; + // Departments + 'department.info': DepartmentInfoInput; + 'department.list': DepartmentListInput; + 'department.create': DepartmentCreateInput; + 'department.update': DepartmentUpdateInput; + 'department.archive': DepartmentArchiveInput; + // Locations + 'location.info': LocationInfoInput; + 'location.list': LocationListInput; + 'location.create': LocationCreateInput; + 'location.update': LocationUpdateInput; + 'location.archive': LocationArchiveInput; + // Users + 'user.info': UserInfoInput; + 'user.list': UserListInput; + 'user.search': UserSearchInput; + // Custom Fields + 'customField.info': CustomFieldInfoInput; + 'customField.list': CustomFieldListInput; + 'customField.setValue': CustomFieldSetValueInput; + // API Keys + 'apiKey.info': ApiKeyInfoInput; + // Webhook Management + 'webhook.info': WebhookInfoInput; + 'webhook.create': WebhookCreateInput; + 'webhook.delete': WebhookDeleteInput; +}; + +export type AshbyEndpointOutputs = { + // Candidates + 'candidate.info': CandidateInfoResponse; + 'candidate.list': CandidateListResponse; + 'candidate.search': CandidateSearchResponse; + 'candidate.create': CandidateCreateResponse; + 'candidate.update': CandidateUpdateResponse; + 'candidate.addTag': CandidateAddTagResponse; + 'candidate.removeTag': CandidateRemoveTagResponse; + 'candidate.createNote': CandidateCreateNoteResponse; + 'candidate.listNotes': CandidateListNotesResponse; + 'candidate.anonymize': CandidateAnonymizeResponse; + // Applications + 'application.info': ApplicationInfoResponse; + 'application.list': ApplicationListResponse; + 'application.create': ApplicationCreateResponse; + 'application.changeStage': ApplicationChangeStageResponse; + 'application.update': ApplicationUpdateResponse; + 'application.transfer': ApplicationTransferResponse; + // Jobs + 'job.info': JobInfoResponse; + 'job.list': JobListResponse; + 'job.create': JobCreateResponse; + 'job.update': JobUpdateResponse; + 'job.search': JobSearchResponse; + // Job Postings + 'jobPosting.info': JobPostingInfoResponse; + 'jobPosting.list': JobPostingListResponse; + // Interviews + 'interview.info': InterviewInfoResponse; + 'interview.list': InterviewListResponse; + 'interview.scheduleInfo': InterviewScheduleInfoResponse; + 'interview.scheduleList': InterviewScheduleListResponse; + 'interview.stageList': InterviewStageListResponse; + // Offers + 'offer.info': OfferInfoResponse; + 'offer.list': OfferListResponse; + 'offer.create': OfferCreateResponse; + 'offer.update': OfferUpdateResponse; + // Departments + 'department.info': DepartmentInfoResponse; + 'department.list': DepartmentListResponse; + 'department.create': DepartmentCreateResponse; + 'department.update': DepartmentUpdateResponse; + 'department.archive': DepartmentArchiveResponse; + // Locations + 'location.info': LocationInfoResponse; + 'location.list': LocationListResponse; + 'location.create': LocationCreateResponse; + 'location.update': LocationUpdateResponse; + 'location.archive': LocationArchiveResponse; + // Users + 'user.info': UserInfoResponse; + 'user.list': UserListResponse; + 'user.search': UserSearchResponse; + // Custom Fields + 'customField.info': CustomFieldInfoResponse; + 'customField.list': CustomFieldListResponse; + 'customField.setValue': CustomFieldSetValueResponse; + // API Keys + 'apiKey.info': ApiKeyInfoResponse; + // Webhook Management + 'webhook.info': WebhookInfoResponse; + 'webhook.create': WebhookCreateResponse; + 'webhook.delete': WebhookDeleteResponse; +}; + +export const AshbyEndpointInputSchemas = { + 'candidate.info': CandidateInfoInputSchema, + 'candidate.list': CandidateListInputSchema, + 'candidate.search': CandidateSearchInputSchema, + 'candidate.create': CandidateCreateInputSchema, + 'candidate.update': CandidateUpdateInputSchema, + 'candidate.addTag': CandidateAddTagInputSchema, + 'candidate.removeTag': CandidateRemoveTagInputSchema, + 'candidate.createNote': CandidateCreateNoteInputSchema, + 'candidate.listNotes': CandidateListNotesInputSchema, + 'candidate.anonymize': CandidateAnonymizeInputSchema, + 'application.info': ApplicationInfoInputSchema, + 'application.list': ApplicationListInputSchema, + 'application.create': ApplicationCreateInputSchema, + 'application.changeStage': ApplicationChangeStageInputSchema, + 'application.update': ApplicationUpdateInputSchema, + 'application.transfer': ApplicationTransferInputSchema, + 'job.info': JobInfoInputSchema, + 'job.list': JobListInputSchema, + 'job.create': JobCreateInputSchema, + 'job.update': JobUpdateInputSchema, + 'job.search': JobSearchInputSchema, + 'jobPosting.info': JobPostingInfoInputSchema, + 'jobPosting.list': JobPostingListInputSchema, + 'interview.info': InterviewInfoInputSchema, + 'interview.list': InterviewListInputSchema, + 'interview.scheduleInfo': InterviewScheduleInfoInputSchema, + 'interview.scheduleList': InterviewScheduleListInputSchema, + 'interview.stageList': InterviewStageListInputSchema, + 'offer.info': OfferInfoInputSchema, + 'offer.list': OfferListInputSchema, + 'offer.create': OfferCreateInputSchema, + 'offer.update': OfferUpdateInputSchema, + 'department.info': DepartmentInfoInputSchema, + 'department.list': DepartmentListInputSchema, + 'department.create': DepartmentCreateInputSchema, + 'department.update': DepartmentUpdateInputSchema, + 'department.archive': DepartmentArchiveInputSchema, + 'location.info': LocationInfoInputSchema, + 'location.list': LocationListInputSchema, + 'location.create': LocationCreateInputSchema, + 'location.update': LocationUpdateInputSchema, + 'location.archive': LocationArchiveInputSchema, + 'user.info': UserInfoInputSchema, + 'user.list': UserListInputSchema, + 'user.search': UserSearchInputSchema, + 'customField.info': CustomFieldInfoInputSchema, + 'customField.list': CustomFieldListInputSchema, + 'customField.setValue': CustomFieldSetValueInputSchema, + 'apiKey.info': ApiKeyInfoInputSchema, + 'webhook.info': WebhookInfoInputSchema, + 'webhook.create': WebhookCreateInputSchema, + 'webhook.delete': WebhookDeleteInputSchema, +} as const; + +export const AshbyEndpointOutputSchemas = { + 'candidate.info': CandidateInfoResponseSchema, + 'candidate.list': CandidateListResponseSchema, + 'candidate.search': CandidateSearchResponseSchema, + 'candidate.create': CandidateCreateResponseSchema, + 'candidate.update': CandidateUpdateResponseSchema, + 'candidate.addTag': CandidateAddTagResponseSchema, + 'candidate.removeTag': CandidateRemoveTagResponseSchema, + 'candidate.createNote': CandidateCreateNoteResponseSchema, + 'candidate.listNotes': CandidateListNotesResponseSchema, + 'candidate.anonymize': CandidateAnonymizeResponseSchema, + 'application.info': ApplicationInfoResponseSchema, + 'application.list': ApplicationListResponseSchema, + 'application.create': ApplicationCreateResponseSchema, + 'application.changeStage': ApplicationChangeStageResponseSchema, + 'application.update': ApplicationUpdateResponseSchema, + 'application.transfer': ApplicationTransferResponseSchema, + 'job.info': JobInfoResponseSchema, + 'job.list': JobListResponseSchema, + 'job.create': JobCreateResponseSchema, + 'job.update': JobUpdateResponseSchema, + 'job.search': JobSearchResponseSchema, + 'jobPosting.info': JobPostingInfoResponseSchema, + 'jobPosting.list': JobPostingListResponseSchema, + 'interview.info': InterviewInfoResponseSchema, + 'interview.list': InterviewListResponseSchema, + 'interview.scheduleInfo': InterviewScheduleInfoResponseSchema, + 'interview.scheduleList': InterviewScheduleListResponseSchema, + 'interview.stageList': InterviewStageListResponseSchema, + 'offer.info': OfferInfoResponseSchema, + 'offer.list': OfferListResponseSchema, + 'offer.create': OfferCreateResponseSchema, + 'offer.update': OfferUpdateResponseSchema, + 'department.info': DepartmentInfoResponseSchema, + 'department.list': DepartmentListResponseSchema, + 'department.create': DepartmentCreateResponseSchema, + 'department.update': DepartmentUpdateResponseSchema, + 'department.archive': DepartmentArchiveResponseSchema, + 'location.info': LocationInfoResponseSchema, + 'location.list': LocationListResponseSchema, + 'location.create': LocationCreateResponseSchema, + 'location.update': LocationUpdateResponseSchema, + 'location.archive': LocationArchiveResponseSchema, + 'user.info': UserInfoResponseSchema, + 'user.list': UserListResponseSchema, + 'user.search': UserSearchResponseSchema, + 'customField.info': CustomFieldInfoResponseSchema, + 'customField.list': CustomFieldListResponseSchema, + 'customField.setValue': CustomFieldSetValueResponseSchema, + 'apiKey.info': ApiKeyInfoResponseSchema, + 'webhook.info': WebhookInfoResponseSchema, + 'webhook.create': WebhookCreateResponseSchema, + 'webhook.delete': WebhookDeleteResponseSchema, +} as const; diff --git a/packages/ashby/endpoints/users.ts b/packages/ashby/endpoints/users.ts new file mode 100644 index 000000000..1b7cc11aa --- /dev/null +++ b/packages/ashby/endpoints/users.ts @@ -0,0 +1,29 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + UserInfoResponse, + UserListResponse, + UserSearchResponse, +} from './types'; + +export const info: AshbyEndpoints['user.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'user.info', { + userId: input.userId, + }); +}; + +export const list: AshbyEndpoints['user.list'] = async (ctx, input) => { + return await ashbyCall(ctx, 'user.list', { + limit: input.limit, + cursor: input.cursor, + syncToken: input.syncToken, + isEnabled: input.isEnabled, + }); +}; + +export const search: AshbyEndpoints['user.search'] = async (ctx, input) => { + return await ashbyCall(ctx, 'user.search', { + email: input.email, + name: input.name, + }); +}; diff --git a/packages/ashby/endpoints/webhooks.ts b/packages/ashby/endpoints/webhooks.ts new file mode 100644 index 000000000..553748c17 --- /dev/null +++ b/packages/ashby/endpoints/webhooks.ts @@ -0,0 +1,28 @@ +import type { AshbyEndpoints } from '../index'; +import { ashbyCall } from './shared'; +import type { + WebhookCreateResponse, + WebhookDeleteResponse, + WebhookInfoResponse, +} from './types'; + +export const info: AshbyEndpoints['webhook.info'] = async (ctx, input) => { + return await ashbyCall(ctx, 'webhook.info', { + webhookId: input.webhookId, + }); +}; + +export const create: AshbyEndpoints['webhook.create'] = async (ctx, input) => { + return await ashbyCall(ctx, 'webhook.create', { + url: input.url, + requestActionNames: input.requestActionNames, + description: input.description, + secretToken: input.secretToken, + }); +}; + +export const remove: AshbyEndpoints['webhook.delete'] = async (ctx, input) => { + return await ashbyCall(ctx, 'webhook.delete', { + webhookId: input.webhookId, + }); +}; diff --git a/packages/ashby/error-handlers.ts b/packages/ashby/error-handlers.ts new file mode 100644 index 000000000..a8fe90205 --- /dev/null +++ b/packages/ashby/error-handlers.ts @@ -0,0 +1,157 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; +import { AshbyAPIError } from './client'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error) => { + if (error instanceof ApiError && error.status === 429) { + return true; + } + if (error instanceof AshbyAPIError && error.status === 429) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('rate limit') || + errorMessage.includes('too many requests') || + (error instanceof AshbyAPIError && error.code === 'rate_limit_exceeded') + ); + }, + handler: async (error) => { + const headersRetryAfterMs = + error instanceof ApiError ? error.retryAfter : undefined; + + return { + maxRetries: 0, + headersRetryAfterMs, + }; + }, + }, + AUTH_ERROR: { + match: (error) => { + if ( + (error instanceof ApiError && error.status === 401) || + (error instanceof AshbyAPIError && error.status === 401) + ) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('unauthorized') || + errorMessage.includes('invalid api key') || + errorMessage.includes('missing api key') + ); + }, + handler: async () => { + return { + maxRetries: 0, + }; + }, + }, + PERMISSION_ERROR: { + match: (error) => { + if ( + (error instanceof ApiError && error.status === 403) || + (error instanceof AshbyAPIError && error.status === 403) + ) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('forbidden') || + errorMessage.includes('permission denied') || + errorMessage.includes('access denied') || + errorMessage.includes('missing_endpoint_permission') || + (error instanceof AshbyAPIError && + error.code === 'missing_endpoint_permission') + ); + }, + handler: async () => { + return { + maxRetries: 0, + }; + }, + }, + NOT_FOUND_ERROR: { + match: (error) => { + if ( + (error instanceof ApiError && error.status === 404) || + (error instanceof AshbyAPIError && error.status === 404) + ) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('not found') || + (error instanceof AshbyAPIError && error.code === 'resource_not_found') + ); + }, + handler: async () => { + return { + maxRetries: 0, + }; + }, + }, + BAD_REQUEST_ERROR: { + match: (error) => { + if ( + (error instanceof ApiError && + (error.status === 400 || error.status === 422)) || + (error instanceof AshbyAPIError && + (error.status === 400 || error.status === 422)) + ) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('bad request') || + errorMessage.includes('validation error') || + errorMessage.includes('invalid parameter') || + errorMessage.includes('next_cursor_expired') || + errorMessage.includes('incremental_sync_too_large') + ); + }, + handler: async () => { + return { + maxRetries: 0, + }; + }, + }, + SERVER_ERROR: { + match: (error) => { + if ( + (error instanceof ApiError && error.status && error.status >= 500) || + (error instanceof AshbyAPIError && error.status && error.status >= 500) + ) { + return true; + } + const errorMessage = error.message.toLowerCase(); + return ( + errorMessage.includes('internal server error') || + errorMessage.includes('service unavailable') || + errorMessage.includes('gateway timeout') + ); + }, + handler: async () => { + return { + maxRetries: 2, + backoffMs: 1000, + }; + }, + }, + DEFAULT: { + match: () => { + return true; + }, + handler: async (error, context) => { + console.error(`[corsair:${context.pluginId}:${context.operation}]`, { + error: error.message, + }); + + return { + maxRetries: 0, + }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/ashby/index.ts b/packages/ashby/index.ts new file mode 100644 index 000000000..09469ce02 --- /dev/null +++ b/packages/ashby/index.ts @@ -0,0 +1,876 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + ApiKey, + Application, + Candidate, + CustomField, + Department, + Interview, + Job, + JobPosting, + Location, + Offer, + User, + Webhook, +} from './endpoints'; +import type { + AshbyEndpointInputs, + AshbyEndpointOutputs, +} from './endpoints/types'; +import { + AshbyEndpointInputSchemas, + AshbyEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AshbySchema } from './schema'; +import { + ApplicationWebhooks, + CandidateWebhooks, + InterviewWebhooks, + OfferWebhooks, +} from './webhooks'; +import { matchAshbyTenantWebhook } from './webhooks/tenant-matcher'; +import type { + ApplicationSubmitEvent, + ApplicationUpdateEvent, + AshbyWebhookOutputs, + CandidateHireEvent, + CandidateStageChangeEvent, + InterviewPlanTransitionEvent, + InterviewScheduleCreateEvent, + InterviewScheduleUpdateEvent, + OfferCreateEvent, + OfferDeleteEvent, + OfferUpdateEvent, +} from './webhooks/types'; +import { + ApplicationSubmitEventSchema, + ApplicationUpdateEventSchema, + CandidateHireEventSchema, + CandidateStageChangeEventSchema, + InterviewPlanTransitionEventSchema, + InterviewScheduleCreateEventSchema, + InterviewScheduleUpdateEventSchema, + OfferCreateEventSchema, + OfferDeleteEventSchema, + OfferUpdateEventSchema, +} from './webhooks/types'; + +export type AshbyPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + webhookSecret?: string; + hooks?: InternalAshbyPlugin['hooks']; + webhookHooks?: InternalAshbyPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AshbyContext = CorsairPluginContext< + typeof AshbySchema, + AshbyPluginOptions +>; + +export type AshbyKeyBuilderContext = KeyBuilderContext; + +export type AshbyBoundEndpoints = BindEndpoints; + +type AshbyEndpoint = CorsairEndpoint< + AshbyContext, + AshbyEndpointInputs[K], + AshbyEndpointOutputs[K] +>; + +export type AshbyEndpoints = { + // Candidates + 'candidate.info': AshbyEndpoint<'candidate.info'>; + 'candidate.list': AshbyEndpoint<'candidate.list'>; + 'candidate.search': AshbyEndpoint<'candidate.search'>; + 'candidate.create': AshbyEndpoint<'candidate.create'>; + 'candidate.update': AshbyEndpoint<'candidate.update'>; + 'candidate.addTag': AshbyEndpoint<'candidate.addTag'>; + 'candidate.removeTag': AshbyEndpoint<'candidate.removeTag'>; + 'candidate.createNote': AshbyEndpoint<'candidate.createNote'>; + 'candidate.listNotes': AshbyEndpoint<'candidate.listNotes'>; + 'candidate.anonymize': AshbyEndpoint<'candidate.anonymize'>; + // Applications + 'application.info': AshbyEndpoint<'application.info'>; + 'application.list': AshbyEndpoint<'application.list'>; + 'application.create': AshbyEndpoint<'application.create'>; + 'application.changeStage': AshbyEndpoint<'application.changeStage'>; + 'application.update': AshbyEndpoint<'application.update'>; + 'application.transfer': AshbyEndpoint<'application.transfer'>; + // Jobs + 'job.info': AshbyEndpoint<'job.info'>; + 'job.list': AshbyEndpoint<'job.list'>; + 'job.create': AshbyEndpoint<'job.create'>; + 'job.update': AshbyEndpoint<'job.update'>; + 'job.search': AshbyEndpoint<'job.search'>; + // Job Postings + 'jobPosting.info': AshbyEndpoint<'jobPosting.info'>; + 'jobPosting.list': AshbyEndpoint<'jobPosting.list'>; + // Interviews + 'interview.info': AshbyEndpoint<'interview.info'>; + 'interview.list': AshbyEndpoint<'interview.list'>; + 'interview.scheduleInfo': AshbyEndpoint<'interview.scheduleInfo'>; + 'interview.scheduleList': AshbyEndpoint<'interview.scheduleList'>; + 'interview.stageList': AshbyEndpoint<'interview.stageList'>; + // Offers + 'offer.info': AshbyEndpoint<'offer.info'>; + 'offer.list': AshbyEndpoint<'offer.list'>; + 'offer.create': AshbyEndpoint<'offer.create'>; + 'offer.update': AshbyEndpoint<'offer.update'>; + // Departments + 'department.info': AshbyEndpoint<'department.info'>; + 'department.list': AshbyEndpoint<'department.list'>; + 'department.create': AshbyEndpoint<'department.create'>; + 'department.update': AshbyEndpoint<'department.update'>; + 'department.archive': AshbyEndpoint<'department.archive'>; + // Locations + 'location.info': AshbyEndpoint<'location.info'>; + 'location.list': AshbyEndpoint<'location.list'>; + 'location.create': AshbyEndpoint<'location.create'>; + 'location.update': AshbyEndpoint<'location.update'>; + 'location.archive': AshbyEndpoint<'location.archive'>; + // Users + 'user.info': AshbyEndpoint<'user.info'>; + 'user.list': AshbyEndpoint<'user.list'>; + 'user.search': AshbyEndpoint<'user.search'>; + // Custom Fields + 'customField.info': AshbyEndpoint<'customField.info'>; + 'customField.list': AshbyEndpoint<'customField.list'>; + 'customField.setValue': AshbyEndpoint<'customField.setValue'>; + // API Keys + 'apiKey.info': AshbyEndpoint<'apiKey.info'>; + // Webhooks + 'webhook.info': AshbyEndpoint<'webhook.info'>; + 'webhook.create': AshbyEndpoint<'webhook.create'>; + 'webhook.delete': AshbyEndpoint<'webhook.delete'>; +}; + +type AshbyWebhook = CorsairWebhook< + AshbyContext, + TEvent, + AshbyWebhookOutputs[K] +>; + +export type AshbyWebhooks = { + 'candidate.stageChange': AshbyWebhook< + 'candidate.stageChange', + CandidateStageChangeEvent + >; + 'candidate.hire': AshbyWebhook<'candidate.hire', CandidateHireEvent>; + 'application.submit': AshbyWebhook< + 'application.submit', + ApplicationSubmitEvent + >; + 'application.update': AshbyWebhook< + 'application.update', + ApplicationUpdateEvent + >; + 'offer.create': AshbyWebhook<'offer.create', OfferCreateEvent>; + 'offer.update': AshbyWebhook<'offer.update', OfferUpdateEvent>; + 'offer.delete': AshbyWebhook<'offer.delete', OfferDeleteEvent>; + 'interview.scheduleCreate': AshbyWebhook< + 'interview.scheduleCreate', + InterviewScheduleCreateEvent + >; + 'interview.scheduleUpdate': AshbyWebhook< + 'interview.scheduleUpdate', + InterviewScheduleUpdateEvent + >; + 'interview.planTransition': AshbyWebhook< + 'interview.planTransition', + InterviewPlanTransitionEvent + >; +}; + +export type AshbyBoundWebhooks = BindWebhooks; + +const ashbyEndpointsNested = { + candidate: { + info: Candidate.info, + list: Candidate.list, + search: Candidate.search, + create: Candidate.create, + update: Candidate.update, + addTag: Candidate.addTag, + removeTag: Candidate.removeTag, + createNote: Candidate.createNote, + listNotes: Candidate.listNotes, + anonymize: Candidate.anonymize, + }, + application: { + info: Application.info, + list: Application.list, + create: Application.create, + changeStage: Application.changeStage, + update: Application.update, + transfer: Application.transfer, + }, + job: { + info: Job.info, + list: Job.list, + create: Job.create, + update: Job.update, + search: Job.search, + }, + jobPosting: { + info: JobPosting.info, + list: JobPosting.list, + }, + interview: { + info: Interview.info, + list: Interview.list, + scheduleInfo: Interview.scheduleInfo, + scheduleList: Interview.scheduleList, + stageList: Interview.stageList, + }, + offer: { + info: Offer.info, + list: Offer.list, + create: Offer.create, + update: Offer.update, + }, + department: { + info: Department.info, + list: Department.list, + create: Department.create, + update: Department.update, + archive: Department.archive, + }, + location: { + info: Location.info, + list: Location.list, + create: Location.create, + update: Location.update, + archive: Location.archive, + }, + user: { + info: User.info, + list: User.list, + search: User.search, + }, + customField: { + info: CustomField.info, + list: CustomField.list, + setValue: CustomField.setValue, + }, + apiKey: { + info: ApiKey.info, + }, + webhook: { + info: Webhook.info, + create: Webhook.create, + delete: Webhook.delete, + }, +} as const; + +const ashbyWebhooksNested = { + candidate: { + stageChange: CandidateWebhooks.stageChange, + hire: CandidateWebhooks.hire, + }, + application: { + submit: ApplicationWebhooks.submit, + update: ApplicationWebhooks.update, + }, + offer: { + create: OfferWebhooks.create, + update: OfferWebhooks.update, + delete: OfferWebhooks.delete, + }, + interview: { + scheduleCreate: InterviewWebhooks.scheduleCreate, + scheduleUpdate: InterviewWebhooks.scheduleUpdate, + planTransition: InterviewWebhooks.planTransition, + }, +} as const; + +export const ashbyEndpointSchemas = { + 'candidate.info': { + input: AshbyEndpointInputSchemas['candidate.info'], + output: AshbyEndpointOutputSchemas['candidate.info'], + }, + 'candidate.list': { + input: AshbyEndpointInputSchemas['candidate.list'], + output: AshbyEndpointOutputSchemas['candidate.list'], + }, + 'candidate.search': { + input: AshbyEndpointInputSchemas['candidate.search'], + output: AshbyEndpointOutputSchemas['candidate.search'], + }, + 'candidate.create': { + input: AshbyEndpointInputSchemas['candidate.create'], + output: AshbyEndpointOutputSchemas['candidate.create'], + }, + 'candidate.update': { + input: AshbyEndpointInputSchemas['candidate.update'], + output: AshbyEndpointOutputSchemas['candidate.update'], + }, + 'candidate.addTag': { + input: AshbyEndpointInputSchemas['candidate.addTag'], + output: AshbyEndpointOutputSchemas['candidate.addTag'], + }, + 'candidate.removeTag': { + input: AshbyEndpointInputSchemas['candidate.removeTag'], + output: AshbyEndpointOutputSchemas['candidate.removeTag'], + }, + 'candidate.createNote': { + input: AshbyEndpointInputSchemas['candidate.createNote'], + output: AshbyEndpointOutputSchemas['candidate.createNote'], + }, + 'candidate.listNotes': { + input: AshbyEndpointInputSchemas['candidate.listNotes'], + output: AshbyEndpointOutputSchemas['candidate.listNotes'], + }, + 'candidate.anonymize': { + input: AshbyEndpointInputSchemas['candidate.anonymize'], + output: AshbyEndpointOutputSchemas['candidate.anonymize'], + }, + 'application.info': { + input: AshbyEndpointInputSchemas['application.info'], + output: AshbyEndpointOutputSchemas['application.info'], + }, + 'application.list': { + input: AshbyEndpointInputSchemas['application.list'], + output: AshbyEndpointOutputSchemas['application.list'], + }, + 'application.create': { + input: AshbyEndpointInputSchemas['application.create'], + output: AshbyEndpointOutputSchemas['application.create'], + }, + 'application.changeStage': { + input: AshbyEndpointInputSchemas['application.changeStage'], + output: AshbyEndpointOutputSchemas['application.changeStage'], + }, + 'application.update': { + input: AshbyEndpointInputSchemas['application.update'], + output: AshbyEndpointOutputSchemas['application.update'], + }, + 'application.transfer': { + input: AshbyEndpointInputSchemas['application.transfer'], + output: AshbyEndpointOutputSchemas['application.transfer'], + }, + 'job.info': { + input: AshbyEndpointInputSchemas['job.info'], + output: AshbyEndpointOutputSchemas['job.info'], + }, + 'job.list': { + input: AshbyEndpointInputSchemas['job.list'], + output: AshbyEndpointOutputSchemas['job.list'], + }, + 'job.create': { + input: AshbyEndpointInputSchemas['job.create'], + output: AshbyEndpointOutputSchemas['job.create'], + }, + 'job.update': { + input: AshbyEndpointInputSchemas['job.update'], + output: AshbyEndpointOutputSchemas['job.update'], + }, + 'job.search': { + input: AshbyEndpointInputSchemas['job.search'], + output: AshbyEndpointOutputSchemas['job.search'], + }, + 'jobPosting.info': { + input: AshbyEndpointInputSchemas['jobPosting.info'], + output: AshbyEndpointOutputSchemas['jobPosting.info'], + }, + 'jobPosting.list': { + input: AshbyEndpointInputSchemas['jobPosting.list'], + output: AshbyEndpointOutputSchemas['jobPosting.list'], + }, + 'interview.info': { + input: AshbyEndpointInputSchemas['interview.info'], + output: AshbyEndpointOutputSchemas['interview.info'], + }, + 'interview.list': { + input: AshbyEndpointInputSchemas['interview.list'], + output: AshbyEndpointOutputSchemas['interview.list'], + }, + 'interview.scheduleInfo': { + input: AshbyEndpointInputSchemas['interview.scheduleInfo'], + output: AshbyEndpointOutputSchemas['interview.scheduleInfo'], + }, + 'interview.scheduleList': { + input: AshbyEndpointInputSchemas['interview.scheduleList'], + output: AshbyEndpointOutputSchemas['interview.scheduleList'], + }, + 'interview.stageList': { + input: AshbyEndpointInputSchemas['interview.stageList'], + output: AshbyEndpointOutputSchemas['interview.stageList'], + }, + 'offer.info': { + input: AshbyEndpointInputSchemas['offer.info'], + output: AshbyEndpointOutputSchemas['offer.info'], + }, + 'offer.list': { + input: AshbyEndpointInputSchemas['offer.list'], + output: AshbyEndpointOutputSchemas['offer.list'], + }, + 'offer.create': { + input: AshbyEndpointInputSchemas['offer.create'], + output: AshbyEndpointOutputSchemas['offer.create'], + }, + 'offer.update': { + input: AshbyEndpointInputSchemas['offer.update'], + output: AshbyEndpointOutputSchemas['offer.update'], + }, + 'department.info': { + input: AshbyEndpointInputSchemas['department.info'], + output: AshbyEndpointOutputSchemas['department.info'], + }, + 'department.list': { + input: AshbyEndpointInputSchemas['department.list'], + output: AshbyEndpointOutputSchemas['department.list'], + }, + 'department.create': { + input: AshbyEndpointInputSchemas['department.create'], + output: AshbyEndpointOutputSchemas['department.create'], + }, + 'department.update': { + input: AshbyEndpointInputSchemas['department.update'], + output: AshbyEndpointOutputSchemas['department.update'], + }, + 'department.archive': { + input: AshbyEndpointInputSchemas['department.archive'], + output: AshbyEndpointOutputSchemas['department.archive'], + }, + 'location.info': { + input: AshbyEndpointInputSchemas['location.info'], + output: AshbyEndpointOutputSchemas['location.info'], + }, + 'location.list': { + input: AshbyEndpointInputSchemas['location.list'], + output: AshbyEndpointOutputSchemas['location.list'], + }, + 'location.create': { + input: AshbyEndpointInputSchemas['location.create'], + output: AshbyEndpointOutputSchemas['location.create'], + }, + 'location.update': { + input: AshbyEndpointInputSchemas['location.update'], + output: AshbyEndpointOutputSchemas['location.update'], + }, + 'location.archive': { + input: AshbyEndpointInputSchemas['location.archive'], + output: AshbyEndpointOutputSchemas['location.archive'], + }, + 'user.info': { + input: AshbyEndpointInputSchemas['user.info'], + output: AshbyEndpointOutputSchemas['user.info'], + }, + 'user.list': { + input: AshbyEndpointInputSchemas['user.list'], + output: AshbyEndpointOutputSchemas['user.list'], + }, + 'user.search': { + input: AshbyEndpointInputSchemas['user.search'], + output: AshbyEndpointOutputSchemas['user.search'], + }, + 'customField.info': { + input: AshbyEndpointInputSchemas['customField.info'], + output: AshbyEndpointOutputSchemas['customField.info'], + }, + 'customField.list': { + input: AshbyEndpointInputSchemas['customField.list'], + output: AshbyEndpointOutputSchemas['customField.list'], + }, + 'customField.setValue': { + input: AshbyEndpointInputSchemas['customField.setValue'], + output: AshbyEndpointOutputSchemas['customField.setValue'], + }, + 'apiKey.info': { + input: AshbyEndpointInputSchemas['apiKey.info'], + output: AshbyEndpointOutputSchemas['apiKey.info'], + }, + 'webhook.info': { + input: AshbyEndpointInputSchemas['webhook.info'], + output: AshbyEndpointOutputSchemas['webhook.info'], + }, + 'webhook.create': { + input: AshbyEndpointInputSchemas['webhook.create'], + output: AshbyEndpointOutputSchemas['webhook.create'], + }, + 'webhook.delete': { + input: AshbyEndpointInputSchemas['webhook.delete'], + output: AshbyEndpointOutputSchemas['webhook.delete'], + }, +} as const satisfies RequiredPluginEndpointSchemas; + +const ashbyWebhookSchemas = { + 'candidate.stageChange': { + description: + 'Triggered when a candidate moves to a different interview stage', + payload: CandidateStageChangeEventSchema, + response: CandidateStageChangeEventSchema, + }, + 'candidate.hire': { + description: 'Triggered when a candidate is hired', + payload: CandidateHireEventSchema, + response: CandidateHireEventSchema, + }, + 'application.submit': { + description: 'Triggered when a candidate application is submitted', + payload: ApplicationSubmitEventSchema, + response: ApplicationSubmitEventSchema, + }, + 'application.update': { + description: 'Triggered when an application is updated', + payload: ApplicationUpdateEventSchema, + response: ApplicationUpdateEventSchema, + }, + 'offer.create': { + description: 'Triggered when a job offer is created', + payload: OfferCreateEventSchema, + response: OfferCreateEventSchema, + }, + 'offer.update': { + description: 'Triggered when a job offer is updated', + payload: OfferUpdateEventSchema, + response: OfferUpdateEventSchema, + }, + 'offer.delete': { + description: 'Triggered when a job offer is deleted', + payload: OfferDeleteEventSchema, + response: OfferDeleteEventSchema, + }, + 'interview.scheduleCreate': { + description: 'Triggered when an interview schedule is created', + payload: InterviewScheduleCreateEventSchema, + response: InterviewScheduleCreateEventSchema, + }, + 'interview.scheduleUpdate': { + description: 'Triggered when an interview schedule is updated', + payload: InterviewScheduleUpdateEventSchema, + response: InterviewScheduleUpdateEventSchema, + }, + 'interview.planTransition': { + description: 'Triggered during interview plan transitions', + payload: InterviewPlanTransitionEventSchema, + response: InterviewPlanTransitionEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const ashbyEndpointMeta = { + 'candidate.info': { + riskLevel: 'read', + description: 'Retrieve detailed candidate information by ID', + }, + 'candidate.list': { + riskLevel: 'read', + description: + 'List candidates with cursor-based pagination and time filters', + }, + 'candidate.search': { + riskLevel: 'read', + description: 'Search candidates by name, email address, or phone number', + }, + 'candidate.create': { + riskLevel: 'write', + description: 'Create a new candidate in Ashby', + }, + 'candidate.update': { + riskLevel: 'write', + description: 'Update candidate profile information and custom fields', + }, + 'candidate.addTag': { + riskLevel: 'write', + description: 'Add a tag to a candidate', + }, + 'candidate.removeTag': { + riskLevel: 'write', + description: 'Remove a tag from a candidate', + }, + 'candidate.createNote': { + riskLevel: 'write', + description: 'Create a note on a candidate record', + }, + 'candidate.listNotes': { + riskLevel: 'read', + description: 'List all notes for a specific candidate', + }, + 'candidate.anonymize': { + riskLevel: 'destructive', + description: + 'Anonymize candidate personally identifiable data for GDPR compliance', + }, + 'application.info': { + riskLevel: 'read', + description: 'Retrieve details for a specific application', + }, + 'application.list': { + riskLevel: 'read', + description: 'List applications filtered by candidate, job, or status', + }, + 'application.create': { + riskLevel: 'write', + description: 'Create an application linking a candidate to a job', + }, + 'application.changeStage': { + riskLevel: 'write', + description: 'Move an application to a different interview stage', + }, + 'application.update': { + riskLevel: 'write', + description: 'Update application metadata or archive status', + }, + 'application.transfer': { + riskLevel: 'write', + description: 'Transfer an application to another job', + }, + 'job.info': { + riskLevel: 'read', + description: 'Retrieve job details by job ID', + }, + 'job.list': { + riskLevel: 'read', + description: 'List jobs with status, department, and location filters', + }, + 'job.create': { + riskLevel: 'write', + description: 'Create a new job in Ashby', + }, + 'job.update': { + riskLevel: 'write', + description: 'Update job details, department, or status', + }, + 'job.search': { + riskLevel: 'read', + description: 'Search jobs by title or status', + }, + 'jobPosting.info': { + riskLevel: 'read', + description: 'Retrieve job posting information by ID', + }, + 'jobPosting.list': { + riskLevel: 'read', + description: 'List published and unpublished job postings', + }, + 'interview.info': { + riskLevel: 'read', + description: 'Retrieve interview details by ID', + }, + 'interview.list': { + riskLevel: 'read', + description: 'List interviews for an interview plan', + }, + 'interview.scheduleInfo': { + riskLevel: 'read', + description: 'Retrieve details of an interview schedule', + }, + 'interview.scheduleList': { + riskLevel: 'read', + description: 'List scheduled interviews for an application', + }, + 'interview.stageList': { + riskLevel: 'read', + description: 'List interview stages for a job or interview plan', + }, + 'offer.info': { + riskLevel: 'read', + description: 'Retrieve details for a specific offer', + }, + 'offer.list': { + riskLevel: 'read', + description: 'List offers filtered by application or status', + }, + 'offer.create': { + riskLevel: 'write', + description: 'Create a new job offer for an application', + }, + 'offer.update': { + riskLevel: 'write', + description: 'Update job offer details or status', + }, + 'department.info': { + riskLevel: 'read', + description: 'Retrieve department details by ID', + }, + 'department.list': { + riskLevel: 'read', + description: 'List all departments in the organization', + }, + 'department.create': { + riskLevel: 'write', + description: 'Create a new department', + }, + 'department.update': { + riskLevel: 'write', + description: 'Update department name or parent department', + }, + 'department.archive': { + riskLevel: 'destructive', + description: 'Archive a department', + }, + 'location.info': { + riskLevel: 'read', + description: 'Retrieve location details by ID', + }, + 'location.list': { + riskLevel: 'read', + description: 'List all locations in the organization', + }, + 'location.create': { + riskLevel: 'write', + description: 'Create a new location', + }, + 'location.update': { + riskLevel: 'write', + description: 'Update location name or hierarchy', + }, + 'location.archive': { + riskLevel: 'destructive', + description: 'Archive a location', + }, + 'user.info': { + riskLevel: 'read', + description: 'Retrieve organization user details by ID', + }, + 'user.list': { + riskLevel: 'read', + description: 'List users in the organization', + }, + 'user.search': { + riskLevel: 'read', + description: 'Search users by name or email address', + }, + 'customField.info': { + riskLevel: 'read', + description: 'Retrieve custom field definition details', + }, + 'customField.list': { + riskLevel: 'read', + description: 'List custom field definitions filtered by object type', + }, + 'customField.setValue': { + riskLevel: 'write', + description: + 'Set a custom field value on a candidate, application, job, or offer', + }, + 'apiKey.info': { + riskLevel: 'read', + description: + 'Retrieve information and permission scopes for the current API key', + }, + 'webhook.info': { + riskLevel: 'read', + description: 'Retrieve webhook configuration details by ID', + }, + 'webhook.create': { + riskLevel: 'write', + description: 'Register a new webhook subscription in Ashby', + }, + 'webhook.delete': { + riskLevel: 'destructive', + description: 'Delete a webhook subscription', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export const ashbyAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseAshbyPlugin = CorsairPlugin< + 'ashby', + typeof AshbySchema, + typeof ashbyEndpointsNested, + typeof ashbyWebhooksNested, + T, + typeof defaultAuthType +>; + +export type InternalAshbyPlugin = BaseAshbyPlugin; + +export type ExternalAshbyPlugin = + BaseAshbyPlugin; + +export function ashby( + incomingOptions: AshbyPluginOptions & T = {} as AshbyPluginOptions & T, +): ExternalAshbyPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'ashby', + authConfig: ashbyAuthConfig, + schema: AshbySchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: ashbyEndpointsNested, + webhooks: ashbyWebhooksNested, + endpointMeta: ashbyEndpointMeta, + endpointSchemas: ashbyEndpointSchemas, + webhookSchemas: ashbyWebhookSchemas, + pluginWebhookMatcher: (request) => { + const headers = request.headers; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'ashby-signature') { + return true; + } + } + return false; + }, + pluginTenantWebhookMatcher: matchAshbyTenantWebhook, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AshbyKeyBuilderContext, 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(); + if (!res) { + throw new AuthMissingError('ashby', 'api_key'); + } + return res; + } + + throw new AuthMissingError('ashby', 'api_key'); + }, + } satisfies InternalAshbyPlugin; +} + +export * from './endpoints/types'; +export * from './schema'; +export * from './webhooks/types'; +export { + createAshbyEventMatch, + createAshbyMatch, + verifyAshbyWebhookSignature, +} from './webhooks/types'; diff --git a/packages/ashby/jest.config.cjs b/packages/ashby/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/ashby/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/ashby/package.json b/packages/ashby/package.json new file mode 100644 index 000000000..e35eacdd6 --- /dev/null +++ b/packages/ashby/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/ashby", + "version": "0.1.0", + "description": "Ashby 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", + "ashby", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/ashby/schema.test.ts b/packages/ashby/schema.test.ts new file mode 100644 index 000000000..e24ef331b --- /dev/null +++ b/packages/ashby/schema.test.ts @@ -0,0 +1,338 @@ +import { + ApplicationInfoResponseSchema, + ApplicationListResponseSchema, + CandidateInfoResponseSchema, + CandidateListResponseSchema, + DepartmentInfoResponseSchema, + InterviewScheduleInfoResponseSchema, + JobInfoResponseSchema, + JobListResponseSchema, + JobPostingInfoResponseSchema, + LocationInfoResponseSchema, + OfferInfoResponseSchema, + UserInfoResponseSchema, +} from './endpoints/types'; +import { AshbySchema } from './schema'; +import { + ApplicationSubmitEventSchema, + CandidateHireEventSchema, + CandidateStageChangeEventSchema, + InterviewScheduleCreateEventSchema, + OfferCreateEventSchema, +} from './webhooks/types'; + +describe('Ashby Schema & Entity Definitions', () => { + it('declares a valid semver version and entities map', () => { + expect(AshbySchema.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(Object.keys(AshbySchema.entities).length).toBeGreaterThanOrEqual(7); + + for (const entity of Object.values(AshbySchema.entities)) { + expect(entity).toBeDefined(); + } + }); + + it('validates candidate info wire response payload', () => { + const payload = { + success: true, + results: { + id: 'cand_123', + name: 'Jane Doe', + primaryEmailAddress: { + value: 'jane.doe@example.com', + type: 'Personal', + isPrimary: true, + }, + emailAddresses: [ + { value: 'jane.doe@example.com', type: 'Personal', isPrimary: true }, + { value: 'jane.work@example.com', type: 'Work', isPrimary: false }, + ], + primaryPhoneNumber: { + value: '+15551234567', + type: 'Mobile', + isPrimary: true, + }, + socialLinks: [ + { url: 'https://linkedin.com/in/janedoe', type: 'LinkedIn' }, + ], + tags: ['Engineering', 'Senior'], + customFields: [ + { + value: 'San Francisco', + customFieldDefinitionId: 'cf_123', + title: 'Preferred Location', + }, + ], + applicationIds: ['app_123'], + createdAt: '2026-08-01T12:00:00.000Z', + updatedAt: '2026-08-02T15:30:00.000Z', + }, + }; + + const parsed = CandidateInfoResponseSchema.parse(payload); + expect(parsed.success).toBe(true); + expect(parsed.results.id).toBe('cand_123'); + expect(parsed.results.name).toBe('Jane Doe'); + expect(parsed.results.primaryEmailAddress?.value).toBe( + 'jane.doe@example.com', + ); + }); + + it('validates candidate list wire response payload with cursor', () => { + const payload = { + success: true, + results: [ + { + id: 'cand_1', + name: 'Alice', + }, + { + id: 'cand_2', + name: 'Bob', + }, + ], + moreDataAvailable: true, + nextCursor: 'cursor_xyz_next', + }; + + const parsed = CandidateListResponseSchema.parse(payload); + expect(parsed.success).toBe(true); + expect(parsed.results).toHaveLength(2); + expect(parsed.moreDataAvailable).toBe(true); + expect(parsed.nextCursor).toBe('cursor_xyz_next'); + }); + + it('validates application info wire response payload', () => { + const payload = { + success: true, + results: { + id: 'app_123', + candidateId: 'cand_123', + jobId: 'job_456', + status: 'Active', + currentInterviewStageId: 'stage_789', + hiringTeam: [ + { + userId: 'user_1', + role: 'Recruiter', + email: 'recruiter@example.com', + }, + ], + createdAt: '2026-08-01T10:00:00.000Z', + }, + }; + + const parsed = ApplicationInfoResponseSchema.parse(payload); + expect(parsed.results.id).toBe('app_123'); + expect(parsed.results.candidateId).toBe('cand_123'); + expect(parsed.results.hiringTeam?.[0]?.role).toBe('Recruiter'); + }); + + it('validates application list wire response payload', () => { + const payload = { + success: true, + results: [ + { + id: 'app_1', + candidateId: 'cand_1', + jobId: 'job_1', + status: 'Active', + }, + ], + moreDataAvailable: false, + }; + + const parsed = ApplicationListResponseSchema.parse(payload); + expect(parsed.results).toHaveLength(1); + }); + + it('validates job info and job list response payloads', () => { + const jobPayload = { + success: true, + results: { + id: 'job_123', + title: 'Senior Software Engineer', + status: 'Open', + departmentId: 'dept_1', + locationId: 'loc_1', + openings: [ + { + id: 'op_1', + identifier: 'OPEN-001', + isArchived: false, + }, + ], + }, + }; + + const parsed = JobInfoResponseSchema.parse(jobPayload); + expect(parsed.results.title).toBe('Senior Software Engineer'); + expect(parsed.results.openings?.[0]?.identifier).toBe('OPEN-001'); + + const listPayload = { + success: true, + results: [jobPayload.results], + }; + const parsedList = JobListResponseSchema.parse(listPayload); + expect(parsedList.results).toHaveLength(1); + }); + + it('validates job posting info response payload', () => { + const payload = { + success: true, + results: { + id: 'jp_123', + title: 'Frontend Engineer', + jobId: 'job_123', + isListed: true, + teamNameHierarchy: ['Engineering', 'Web'], + }, + }; + + const parsed = JobPostingInfoResponseSchema.parse(payload); + expect(parsed.results.isListed).toBe(true); + expect(parsed.results.teamNameHierarchy).toEqual(['Engineering', 'Web']); + }); + + it('validates interview schedule response payload', () => { + const payload = { + success: true, + results: { + id: 'sched_123', + applicationId: 'app_123', + scheduledStartTime: '2026-08-25T14:00:00Z', + scheduledEndTime: '2026-08-25T15:00:00Z', + status: 'Scheduled', + interviewers: [{ userId: 'user_10' }], + }, + }; + + const parsed = InterviewScheduleInfoResponseSchema.parse(payload); + expect(parsed.results.id).toBe('sched_123'); + expect(parsed.results.interviewers?.[0]?.userId).toBe('user_10'); + }); + + it('validates offer info response payload', () => { + const payload = { + success: true, + results: { + id: 'off_123', + applicationId: 'app_123', + status: 'Accepted', + salary: 180000, + currency: 'USD', + startDate: '2026-09-15', + }, + }; + + const parsed = OfferInfoResponseSchema.parse(payload); + expect(parsed.results.salary).toBe(180000); + expect(parsed.results.currency).toBe('USD'); + }); + + it('validates department and location response payloads', () => { + const deptPayload = { + success: true, + results: { + id: 'dept_1', + name: 'Product Design', + isArchived: false, + }, + }; + expect(DepartmentInfoResponseSchema.parse(deptPayload).results.name).toBe( + 'Product Design', + ); + + const locPayload = { + success: true, + results: { + id: 'loc_1', + name: 'San Francisco, CA', + isArchived: false, + }, + }; + expect(LocationInfoResponseSchema.parse(locPayload).results.name).toBe( + 'San Francisco, CA', + ); + }); + + it('validates user info response payload', () => { + const userPayload = { + success: true, + results: { + id: 'usr_1', + name: 'Alice Admin', + email: 'alice@example.com', + globalRole: 'Admin', + isEnabled: true, + }, + }; + expect(UserInfoResponseSchema.parse(userPayload).results.email).toBe( + 'alice@example.com', + ); + }); + + it('validates Ashby webhook event schemas', () => { + const stageChangePayload = { + webhookActionId: 'wh_act_1', + action: 'candidateStageChange', + data: { + candidateId: 'cand_1', + applicationId: 'app_1', + currentInterviewStageId: 'stage_2', + }, + }; + expect( + CandidateStageChangeEventSchema.parse(stageChangePayload).action, + ).toBe('candidateStageChange'); + + const appSubmitPayload = { + webhookActionId: 'wh_act_2', + action: 'applicationSubmit', + data: { + applicationId: 'app_2', + candidateId: 'cand_2', + jobId: 'job_2', + }, + }; + expect(ApplicationSubmitEventSchema.parse(appSubmitPayload).action).toBe( + 'applicationSubmit', + ); + + const hirePayload = { + webhookActionId: 'wh_act_3', + action: 'candidateHire', + data: { + candidateId: 'cand_3', + applicationId: 'app_3', + offerId: 'off_3', + }, + }; + expect(CandidateHireEventSchema.parse(hirePayload).action).toBe( + 'candidateHire', + ); + + const offerCreatePayload = { + webhookActionId: 'wh_act_4', + action: 'offerCreate', + data: { + offerId: 'off_4', + applicationId: 'app_4', + }, + }; + expect(OfferCreateEventSchema.parse(offerCreatePayload).action).toBe( + 'offerCreate', + ); + + const schedulePayload = { + webhookActionId: 'wh_act_5', + action: 'interviewScheduleCreate', + data: { + interviewScheduleId: 'sched_5', + applicationId: 'app_5', + }, + }; + expect( + InterviewScheduleCreateEventSchema.parse(schedulePayload).action, + ).toBe('interviewScheduleCreate'); + }); +}); diff --git a/packages/ashby/schema/database.ts b/packages/ashby/schema/database.ts new file mode 100644 index 000000000..9d89b65eb --- /dev/null +++ b/packages/ashby/schema/database.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; + +/** + * Locally persisted Ashby entities. + * + * Slow-changing structural records are mirrored: candidates, applications, jobs, + * job postings, offers, departments, locations, and users. + */ + +const S = z.string().nullable().optional(); +const B = z.boolean().nullable().optional(); +const N = z.number().nullable().optional(); + +export const AshbyCandidateEntity = z + .object({ + id: z.string(), + name: z.string(), + primary_email_address: S, + primary_phone_number: S, + created_at: z.coerce.date().nullable().optional(), + updated_at: z.coerce.date().nullable().optional(), + tags: z.array(z.string()).nullable().optional(), + application_ids: z.array(z.string()).nullable().optional(), + }) + .loose(); +export type AshbyCandidateEntity = z.infer; + +export const AshbyApplicationEntity = z + .object({ + id: z.string(), + candidate_id: z.string(), + job_id: z.string(), + status: S, + current_interview_stage_id: S, + archive_reason_id: S, + created_at: z.coerce.date().nullable().optional(), + updated_at: z.coerce.date().nullable().optional(), + }) + .loose(); +export type AshbyApplicationEntity = z.infer; + +export const AshbyJobEntity = z + .object({ + id: z.string(), + title: z.string(), + status: S, + department_id: S, + location_id: S, + created_at: z.coerce.date().nullable().optional(), + updated_at: z.coerce.date().nullable().optional(), + }) + .loose(); +export type AshbyJobEntity = z.infer; + +export const AshbyJobPostingEntity = z + .object({ + id: z.string(), + title: z.string(), + job_id: z.string(), + department_id: S, + location_id: S, + is_listed: B, + published_date: z.coerce.date().nullable().optional(), + }) + .loose(); +export type AshbyJobPostingEntity = z.infer; + +export const AshbyOfferEntity = z + .object({ + id: z.string(), + application_id: z.string(), + status: S, + salary: N, + currency: S, + start_date: z.coerce.date().nullable().optional(), + created_at: z.coerce.date().nullable().optional(), + updated_at: z.coerce.date().nullable().optional(), + }) + .loose(); +export type AshbyOfferEntity = z.infer; + +export const AshbyDepartmentEntity = z + .object({ + id: z.string(), + name: z.string(), + parent_id: S, + is_archived: B, + }) + .loose(); +export type AshbyDepartmentEntity = z.infer; + +export const AshbyLocationEntity = z + .object({ + id: z.string(), + name: z.string(), + parent_id: S, + is_archived: B, + }) + .loose(); +export type AshbyLocationEntity = z.infer; + +export const AshbyUserEntity = z + .object({ + id: z.string(), + name: z.string(), + email: z.string(), + global_role: S, + is_enabled: B, + }) + .loose(); +export type AshbyUserEntity = z.infer; diff --git a/packages/ashby/schema/index.ts b/packages/ashby/schema/index.ts new file mode 100644 index 000000000..e0647a9ca --- /dev/null +++ b/packages/ashby/schema/index.ts @@ -0,0 +1,26 @@ +import { + AshbyApplicationEntity, + AshbyCandidateEntity, + AshbyDepartmentEntity, + AshbyJobEntity, + AshbyJobPostingEntity, + AshbyLocationEntity, + AshbyOfferEntity, + AshbyUserEntity, +} from './database'; + +export const AshbySchema = { + version: '1.0.0', + entities: { + candidates: AshbyCandidateEntity, + applications: AshbyApplicationEntity, + jobs: AshbyJobEntity, + jobPostings: AshbyJobPostingEntity, + offers: AshbyOfferEntity, + departments: AshbyDepartmentEntity, + locations: AshbyLocationEntity, + users: AshbyUserEntity, + }, +} as const; + +export * from './database'; diff --git a/packages/ashby/tsconfig.json b/packages/ashby/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/ashby/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/ashby/tsup.config.ts b/packages/ashby/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/ashby/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/ashby/webhooks.test.ts b/packages/ashby/webhooks.test.ts new file mode 100644 index 000000000..85236dc37 --- /dev/null +++ b/packages/ashby/webhooks.test.ts @@ -0,0 +1,225 @@ +import { createHmac } from 'node:crypto'; +import { ashby } from './index'; +import { + ApplicationWebhooks, + CandidateWebhooks, + createAshbyMatch, + matchAshbyTenantWebhook, + verifyAshbyWebhookSignature, +} from './webhooks'; + +describe('Ashby Webhooks Subsystem', () => { + const secret = 'whsec_test_secret_12345'; + const payloadObj = { + webhookActionId: 'wh_act_123', + action: 'candidateStageChange', + data: { + candidateId: 'cand_123', + applicationId: 'app_123', + currentInterviewStageId: 'stage_456', + }, + }; + const rawPayload = JSON.stringify(payloadObj); + const validHmac = createHmac('sha256', secret) + .update(rawPayload) + .digest('hex'); + const validSignatureHeader = `sha256=${validHmac}`; + + describe('verifyAshbyWebhookSignature', () => { + it('verifies valid HMAC-SHA256 signature from Ashby-Signature header', () => { + const req = { + headers: { + 'ashby-signature': validSignatureHeader, + }, + body: rawPayload, + } as any; + + const result = verifyAshbyWebhookSignature(req, secret); + expect(result.valid).toBe(true); + }); + + it('verifies valid signature regardless of header case', () => { + const req = { + headers: { + 'Ashby-Signature': validSignatureHeader, + }, + body: rawPayload, + } as any; + + const result = verifyAshbyWebhookSignature(req, secret); + expect(result.valid).toBe(true); + }); + + it('rejects tampered or mismatched payload', () => { + const tamperedPayload = JSON.stringify({ + ...payloadObj, + action: 'tampered', + }); + const req = { + headers: { + 'ashby-signature': validSignatureHeader, + }, + body: tamperedPayload, + } as any; + + const result = verifyAshbyWebhookSignature(req, secret); + expect(result.valid).toBe(false); + expect(result.error).toBe('Signature mismatch'); + }); + + it('rejects missing header or missing secret', () => { + const reqWithoutHeader = { + headers: {}, + body: rawPayload, + } as any; + expect(verifyAshbyWebhookSignature(reqWithoutHeader, secret).valid).toBe( + false, + ); + + const reqWithHeader = { + headers: { 'ashby-signature': validSignatureHeader }, + body: rawPayload, + } as any; + expect(verifyAshbyWebhookSignature(reqWithHeader, '').valid).toBe(false); + }); + + it('rejects malformed signature length', () => { + const req = { + headers: { + 'ashby-signature': 'sha256=tooshort', + }, + body: rawPayload, + } as any; + + const result = verifyAshbyWebhookSignature(req, secret); + expect(result.valid).toBe(false); + expect(result.error).toBe('Signature length mismatch'); + }); + }); + + describe('createAshbyMatch', () => { + it('matches webhook events by action field', () => { + const matcher = createAshbyMatch('candidateStageChange'); + expect( + matcher({ + body: rawPayload, + headers: {}, + url: '', + method: 'POST', + } as any), + ).toBe(true); + + expect( + matcher({ + body: JSON.stringify({ action: 'applicationSubmit' }), + headers: {}, + url: '', + method: 'POST', + } as any), + ).toBe(false); + }); + }); + + describe('matchAshbyTenantWebhook', () => { + it('returns null as Ashby uses per-endpoint signing secret routing', () => { + const result = matchAshbyTenantWebhook({ + body: rawPayload, + headers: {}, + url: '', + method: 'POST', + } as any); + expect(result).toBeNull(); + }); + }); + + describe('Webhook Handlers', () => { + const ctx = { + key: secret, + options: { webhookSecret: secret }, + $getAccountId: async () => 'test_account', + db: { + candidates: { + findById: jest.fn(async (id) => ({ id })), + }, + applications: { + findById: jest.fn(async (id) => ({ id })), + }, + offers: { + findById: jest.fn(async (id) => ({ id })), + deleteById: jest.fn(async () => true), + }, + }, + } as any; + + it('handles candidateStageChange successfully', async () => { + const res = await CandidateWebhooks.stageChange.handler(ctx, { + headers: { 'ashby-signature': validSignatureHeader }, + body: rawPayload, + payload: payloadObj as any, + } as any); + + expect(res.success).toBe(true); + expect(res.corsairEntityId).toBe('cand_123'); + }); + + it('rejects candidateStageChange on invalid signature', async () => { + const res = await CandidateWebhooks.stageChange.handler(ctx, { + headers: { 'ashby-signature': 'sha256=' + '0'.repeat(64) }, + body: rawPayload, + payload: payloadObj as any, + } as any); + + expect(res.success).toBe(false); + expect(res.statusCode).toBe(401); + }); + + it('handles candidateHire and applicationSubmit', async () => { + const hirePayload = { + webhookActionId: 'wh_2', + action: 'candidateHire', + data: { candidateId: 'cand_123', offerId: 'off_123' }, + }; + const hireRaw = JSON.stringify(hirePayload); + const hireSig = `sha256=${createHmac('sha256', secret).update(hireRaw).digest('hex')}`; + + const hireRes = await CandidateWebhooks.hire.handler(ctx, { + headers: { 'ashby-signature': hireSig }, + body: hireRaw, + payload: hirePayload as any, + } as any); + expect(hireRes.success).toBe(true); + + const submitPayload = { + webhookActionId: 'wh_3', + action: 'applicationSubmit', + data: { applicationId: 'app_123' }, + }; + const submitRaw = JSON.stringify(submitPayload); + const submitSig = `sha256=${createHmac('sha256', secret).update(submitRaw).digest('hex')}`; + + const submitRes = await ApplicationWebhooks.submit.handler(ctx, { + headers: { 'ashby-signature': submitSig }, + body: submitRaw, + payload: submitPayload as any, + } as any); + expect(submitRes.success).toBe(true); + }); + }); + + describe('Plugin Instance Integration', () => { + it('identifies Ashby webhooks by ashby-signature header', () => { + const plugin = ashby({ key: 'test-key', webhookSecret: secret }); + expect( + plugin.pluginWebhookMatcher?.({ + headers: { 'ashby-signature': 'sha256=123' }, + } as any), + ).toBe(true); + + expect( + plugin.pluginWebhookMatcher?.({ + headers: { 'x-other-signature': '123' }, + } as any), + ).toBe(false); + }); + }); +}); diff --git a/packages/ashby/webhooks/applications.ts b/packages/ashby/webhooks/applications.ts new file mode 100644 index 000000000..9b04594b8 --- /dev/null +++ b/packages/ashby/webhooks/applications.ts @@ -0,0 +1,109 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AshbyWebhooks } from '../index'; +import { createAshbyMatch, verifyAshbyWebhookSignature } from './types'; + +export const submit: AshbyWebhooks['application.submit'] = { + match: createAshbyMatch('applicationSubmit'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'applicationSubmit') { + return { + success: true, + data: undefined, + }; + } + + let corsairEntityId = ''; + if (ctx.db.applications && event.data.applicationId) { + try { + const entity = await ctx.db.applications.findById( + event.data.applicationId, + ); + corsairEntityId = entity?.id || ''; + } catch (error) { + console.warn('Failed to find application in database:', error); + } + } + + await logEventFromContext( + ctx, + 'ashby.webhook.applicationSubmit', + { + applicationId: event.data.applicationId, + candidateId: event.data.candidateId, + jobId: event.data.jobId, + }, + 'completed', + ); + + return { + success: true, + corsairEntityId, + data: event, + }; + }, +}; + +export const update: AshbyWebhooks['application.update'] = { + match: createAshbyMatch('applicationUpdate'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'applicationUpdate') { + return { + success: true, + data: undefined, + }; + } + + let corsairEntityId = ''; + if (ctx.db.applications && event.data.applicationId) { + try { + const entity = await ctx.db.applications.findById( + event.data.applicationId, + ); + corsairEntityId = entity?.id || ''; + } catch (error) { + console.warn('Failed to find application in database:', error); + } + } + + await logEventFromContext( + ctx, + 'ashby.webhook.applicationUpdate', + { + applicationId: event.data.applicationId, + candidateId: event.data.candidateId, + status: event.data.status, + }, + 'completed', + ); + + return { + success: true, + corsairEntityId, + data: event, + }; + }, +}; diff --git a/packages/ashby/webhooks/candidates.ts b/packages/ashby/webhooks/candidates.ts new file mode 100644 index 000000000..fd4766cf9 --- /dev/null +++ b/packages/ashby/webhooks/candidates.ts @@ -0,0 +1,105 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AshbyWebhooks } from '../index'; +import { createAshbyMatch, verifyAshbyWebhookSignature } from './types'; + +export const stageChange: AshbyWebhooks['candidate.stageChange'] = { + match: createAshbyMatch('candidateStageChange'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'candidateStageChange') { + return { + success: true, + data: undefined, + }; + } + + let corsairEntityId = ''; + if (ctx.db.candidates && event.data.candidateId) { + try { + const entity = await ctx.db.candidates.findById(event.data.candidateId); + corsairEntityId = entity?.id || ''; + } catch (error) { + console.warn('Failed to find candidate in database:', error); + } + } + + await logEventFromContext( + ctx, + 'ashby.webhook.candidateStageChange', + { + candidateId: event.data.candidateId, + applicationId: event.data.applicationId, + currentInterviewStageId: event.data.currentInterviewStageId, + }, + 'completed', + ); + + return { + success: true, + corsairEntityId, + data: event, + }; + }, +}; + +export const hire: AshbyWebhooks['candidate.hire'] = { + match: createAshbyMatch('candidateHire'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'candidateHire') { + return { + success: true, + data: undefined, + }; + } + + let corsairEntityId = ''; + if (ctx.db.candidates && event.data.candidateId) { + try { + const entity = await ctx.db.candidates.findById(event.data.candidateId); + corsairEntityId = entity?.id || ''; + } catch (error) { + console.warn('Failed to find candidate in database:', error); + } + } + + await logEventFromContext( + ctx, + 'ashby.webhook.candidateHire', + { + candidateId: event.data.candidateId, + applicationId: event.data.applicationId, + offerId: event.data.offerId, + }, + 'completed', + ); + + return { + success: true, + corsairEntityId, + data: event, + }; + }, +}; diff --git a/packages/ashby/webhooks/index.ts b/packages/ashby/webhooks/index.ts new file mode 100644 index 000000000..877c96e45 --- /dev/null +++ b/packages/ashby/webhooks/index.ts @@ -0,0 +1,43 @@ +import { + submit as applicationSubmit, + update as applicationUpdate, +} from './applications'; +import { + hire as candidateHire, + stageChange as candidateStageChange, +} from './candidates'; +import { + planTransition as interviewPlanTransition, + scheduleCreate as interviewScheduleCreate, + scheduleUpdate as interviewScheduleUpdate, +} from './interviews'; +import { + create as offerCreate, + remove as offerDelete, + update as offerUpdate, +} from './offers'; + +export const CandidateWebhooks = { + stageChange: candidateStageChange, + hire: candidateHire, +}; + +export const ApplicationWebhooks = { + submit: applicationSubmit, + update: applicationUpdate, +}; + +export const OfferWebhooks = { + create: offerCreate, + update: offerUpdate, + delete: offerDelete, +}; + +export const InterviewWebhooks = { + scheduleCreate: interviewScheduleCreate, + scheduleUpdate: interviewScheduleUpdate, + planTransition: interviewPlanTransition, +}; + +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/ashby/webhooks/interviews.ts b/packages/ashby/webhooks/interviews.ts new file mode 100644 index 000000000..abdbdee05 --- /dev/null +++ b/packages/ashby/webhooks/interviews.ts @@ -0,0 +1,121 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AshbyWebhooks } from '../index'; +import { createAshbyMatch, verifyAshbyWebhookSignature } from './types'; + +export const scheduleCreate: AshbyWebhooks['interview.scheduleCreate'] = { + match: createAshbyMatch('interviewScheduleCreate'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'interviewScheduleCreate') { + return { + success: true, + data: undefined, + }; + } + + await logEventFromContext( + ctx, + 'ashby.webhook.interviewScheduleCreate', + { + interviewScheduleId: event.data.interviewScheduleId, + applicationId: event.data.applicationId, + }, + 'completed', + ); + + return { + success: true, + data: event, + }; + }, +}; + +export const scheduleUpdate: AshbyWebhooks['interview.scheduleUpdate'] = { + match: createAshbyMatch('interviewScheduleUpdate'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'interviewScheduleUpdate') { + return { + success: true, + data: undefined, + }; + } + + await logEventFromContext( + ctx, + 'ashby.webhook.interviewScheduleUpdate', + { + interviewScheduleId: event.data.interviewScheduleId, + applicationId: event.data.applicationId, + status: event.data.status, + }, + 'completed', + ); + + return { + success: true, + data: event, + }; + }, +}; + +export const planTransition: AshbyWebhooks['interview.planTransition'] = { + match: createAshbyMatch('interviewPlanTransition'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'interviewPlanTransition') { + return { + success: true, + data: undefined, + }; + } + + await logEventFromContext( + ctx, + 'ashby.webhook.interviewPlanTransition', + { + applicationId: event.data.applicationId, + interviewPlanId: event.data.interviewPlanId, + }, + 'completed', + ); + + return { + success: true, + data: event, + }; + }, +}; diff --git a/packages/ashby/webhooks/offers.ts b/packages/ashby/webhooks/offers.ts new file mode 100644 index 000000000..9aefc302a --- /dev/null +++ b/packages/ashby/webhooks/offers.ts @@ -0,0 +1,151 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AshbyWebhooks } from '../index'; +import { createAshbyMatch, verifyAshbyWebhookSignature } from './types'; + +export const create: AshbyWebhooks['offer.create'] = { + match: createAshbyMatch('offerCreate'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'offerCreate') { + return { + success: true, + data: undefined, + }; + } + + let corsairEntityId = ''; + if (ctx.db.offers && event.data.offerId) { + try { + const entity = await ctx.db.offers.findById(event.data.offerId); + corsairEntityId = entity?.id || ''; + } catch (error) { + console.warn('Failed to find offer in database:', error); + } + } + + await logEventFromContext( + ctx, + 'ashby.webhook.offerCreate', + { + offerId: event.data.offerId, + applicationId: event.data.applicationId, + }, + 'completed', + ); + + return { + success: true, + corsairEntityId, + data: event, + }; + }, +}; + +export const update: AshbyWebhooks['offer.update'] = { + match: createAshbyMatch('offerUpdate'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'offerUpdate') { + return { + success: true, + data: undefined, + }; + } + + let corsairEntityId = ''; + if (ctx.db.offers && event.data.offerId) { + try { + const entity = await ctx.db.offers.findById(event.data.offerId); + corsairEntityId = entity?.id || ''; + } catch (error) { + console.warn('Failed to find offer in database:', error); + } + } + + await logEventFromContext( + ctx, + 'ashby.webhook.offerUpdate', + { + offerId: event.data.offerId, + applicationId: event.data.applicationId, + status: event.data.status, + }, + 'completed', + ); + + return { + success: true, + corsairEntityId, + data: event, + }; + }, +}; + +export const remove: AshbyWebhooks['offer.delete'] = { + match: createAshbyMatch('offerDelete'), + + handler: async (ctx, request) => { + const webhookSecret = ctx.key; + const verification = verifyAshbyWebhookSignature(request, webhookSecret); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.action !== 'offerDelete') { + return { + success: true, + data: undefined, + }; + } + + if (ctx.db.offers && event.data.offerId) { + try { + await ctx.db.offers.deleteById(event.data.offerId); + } catch (error) { + console.warn('Failed to delete offer from database:', error); + } + } + + await logEventFromContext( + ctx, + 'ashby.webhook.offerDelete', + { + offerId: event.data.offerId, + applicationId: event.data.applicationId, + }, + 'completed', + ); + + return { + success: true, + data: event, + }; + }, +}; diff --git a/packages/ashby/webhooks/tenant-matcher.ts b/packages/ashby/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..ab53e09b5 --- /dev/null +++ b/packages/ashby/webhooks/tenant-matcher.ts @@ -0,0 +1,11 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; + +/** + * Ashby webhook payloads identify events and specific entity IDs, not the owning tenant account ID. + * Webhook routing is based on the per-endpoint signing secret. + */ +export function matchAshbyTenantWebhook( + _request: RawWebhookRequest, +): WebhookTenantMatch | null { + return null; +} diff --git a/packages/ashby/webhooks/types.ts b/packages/ashby/webhooks/types.ts new file mode 100644 index 000000000..a59f8875a --- /dev/null +++ b/packages/ashby/webhooks/types.ts @@ -0,0 +1,268 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { z } from 'zod'; + +// ───────────────────────────────────────────────────────────────────────────── +// Base Webhook Payload Schema +// ───────────────────────────────────────────────────────────────────────────── + +export const AshbyWebhookPayloadSchema = z.object({ + webhookActionId: z.string(), + action: z.string(), + data: z.record(z.string(), z.unknown()), +}); +export type AshbyWebhookPayload = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Event Payload Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const CandidateStageChangeEventSchema = AshbyWebhookPayloadSchema.extend( + { + action: z.literal('candidateStageChange'), + data: z + .object({ + candidateId: z.string().optional(), + applicationId: z.string().optional(), + jobId: z.string().optional(), + previousInterviewStageId: z.string().nullable().optional(), + currentInterviewStageId: z.string().optional(), + }) + .loose(), + }, +); +export type CandidateStageChangeEvent = z.infer< + typeof CandidateStageChangeEventSchema +>; + +export const ApplicationSubmitEventSchema = AshbyWebhookPayloadSchema.extend({ + action: z.literal('applicationSubmit'), + data: z + .object({ + applicationId: z.string().optional(), + candidateId: z.string().optional(), + jobId: z.string().optional(), + }) + .loose(), +}); +export type ApplicationSubmitEvent = z.infer< + typeof ApplicationSubmitEventSchema +>; + +export const ApplicationUpdateEventSchema = AshbyWebhookPayloadSchema.extend({ + action: z.literal('applicationUpdate'), + data: z + .object({ + applicationId: z.string().optional(), + candidateId: z.string().optional(), + jobId: z.string().optional(), + status: z.string().optional(), + }) + .loose(), +}); +export type ApplicationUpdateEvent = z.infer< + typeof ApplicationUpdateEventSchema +>; + +export const CandidateHireEventSchema = AshbyWebhookPayloadSchema.extend({ + action: z.literal('candidateHire'), + data: z + .object({ + candidateId: z.string().optional(), + applicationId: z.string().optional(), + offerId: z.string().optional(), + }) + .loose(), +}); +export type CandidateHireEvent = z.infer; + +export const OfferCreateEventSchema = AshbyWebhookPayloadSchema.extend({ + action: z.literal('offerCreate'), + data: z + .object({ + offerId: z.string().optional(), + applicationId: z.string().optional(), + }) + .loose(), +}); +export type OfferCreateEvent = z.infer; + +export const OfferUpdateEventSchema = AshbyWebhookPayloadSchema.extend({ + action: z.literal('offerUpdate'), + data: z + .object({ + offerId: z.string().optional(), + applicationId: z.string().optional(), + status: z.string().optional(), + }) + .loose(), +}); +export type OfferUpdateEvent = z.infer; + +export const OfferDeleteEventSchema = AshbyWebhookPayloadSchema.extend({ + action: z.literal('offerDelete'), + data: z + .object({ + offerId: z.string().optional(), + applicationId: z.string().optional(), + }) + .loose(), +}); +export type OfferDeleteEvent = z.infer; + +export const InterviewScheduleCreateEventSchema = + AshbyWebhookPayloadSchema.extend({ + action: z.literal('interviewScheduleCreate'), + data: z + .object({ + interviewScheduleId: z.string().optional(), + applicationId: z.string().optional(), + }) + .loose(), + }); +export type InterviewScheduleCreateEvent = z.infer< + typeof InterviewScheduleCreateEventSchema +>; + +export const InterviewScheduleUpdateEventSchema = + AshbyWebhookPayloadSchema.extend({ + action: z.literal('interviewScheduleUpdate'), + data: z + .object({ + interviewScheduleId: z.string().optional(), + applicationId: z.string().optional(), + status: z.string().optional(), + }) + .loose(), + }); +export type InterviewScheduleUpdateEvent = z.infer< + typeof InterviewScheduleUpdateEventSchema +>; + +export const InterviewPlanTransitionEventSchema = + AshbyWebhookPayloadSchema.extend({ + action: z.literal('interviewPlanTransition'), + data: z + .object({ + applicationId: z.string().optional(), + interviewPlanId: z.string().optional(), + }) + .loose(), + }); +export type InterviewPlanTransitionEvent = z.infer< + typeof InterviewPlanTransitionEventSchema +>; + +export type AshbyWebhookOutputs = { + 'candidate.stageChange': CandidateStageChangeEvent; + 'candidate.hire': CandidateHireEvent; + 'application.submit': ApplicationSubmitEvent; + 'application.update': ApplicationUpdateEvent; + 'offer.create': OfferCreateEvent; + 'offer.update': OfferUpdateEvent; + 'offer.delete': OfferDeleteEvent; + 'interview.scheduleCreate': InterviewScheduleCreateEvent; + 'interview.scheduleUpdate': InterviewScheduleUpdateEvent; + 'interview.planTransition': InterviewPlanTransitionEvent; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers & Signature Verification +// ───────────────────────────────────────────────────────────────────────────── + +export 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 createAshbyMatch(action: string): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsed = parseBody(request.body); + return ( + parsed !== null && + typeof parsed.action === 'string' && + parsed.action === action + ); + }; +} + +export const createAshbyEventMatch = createAshbyMatch; + +/** + * Verifies the Ashby webhook signature from the `Ashby-Signature` header. + * Ashby generates an HMAC-SHA256 signature in the format `sha256=`. + */ +export function verifyAshbyWebhookSignature( + request: WebhookRequest | RawWebhookRequest, + secret: string, +): { valid: boolean; error?: string } { + if ('hubVerified' in request && request.hubVerified) { + return { valid: true }; + } + + if (!secret) { + return { valid: false, error: 'Missing webhook secret' }; + } + + const headers = request.headers; + let signatureHeader: string | undefined; + + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === 'ashby-signature') { + signatureHeader = Array.isArray(value) ? value[0] : value; + break; + } + } + + if (!signatureHeader) { + return { valid: false, error: 'Missing Ashby-Signature header' }; + } + + let rawBody = ''; + if ('rawBody' in request && typeof request.rawBody === 'string') { + rawBody = request.rawBody; + } else if ('body' in request) { + if (typeof request.body === 'string') { + rawBody = request.body; + } else if (request.body !== undefined && request.body !== null) { + rawBody = JSON.stringify(request.body); + } + } else if ('payload' in request && request.payload !== undefined) { + rawBody = JSON.stringify(request.payload); + } + + const computedHmac = createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + const expected = `sha256=${computedHmac}`; + + const providedBuf = Buffer.from(signatureHeader); + const expectedBuf = Buffer.from(expected); + + if (providedBuf.length !== expectedBuf.length) { + return { valid: false, error: 'Signature length mismatch' }; + } + + if (!timingSafeEqual(providedBuf, expectedBuf)) { + return { valid: false, error: 'Signature mismatch' }; + } + + return { valid: true }; +} diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index b5dca6fd6..f5d157cbc 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -53,6 +53,7 @@ export const BaseProviders = [ 'apipie', 'apisports', 'asana', + 'ashby', 'asindataapi', 'asticaai', 'asyncinterview', @@ -241,6 +242,7 @@ export const ProviderDisplayNames = { apipie: 'APIpie AI', apisports: 'API-Sports', asana: 'Asana', + ashby: 'Ashby', asindataapi: 'ASIN Data API', asticaai: 'Astica AI', asyncinterview: 'Async Interview', @@ -436,6 +438,7 @@ export type AllProviders = | 'apipie' | 'apisports' | 'asana' + | 'ashby' | 'asindataapi' | 'asticaai' | 'asyncinterview' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd3d10ddc..ce5ac03e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1318,6 +1318,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/ashby: + 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/asindataapi: devDependencies: '@types/jest':