From 2098b3d97ddbe70e80d4a9191c4405e9b98ecbf0 Mon Sep 17 00:00:00 2001 From: RavaniRoshan Date: Sat, 29 Aug 2026 15:44:56 +0000 Subject: [PATCH 01/14] feat(byteforms): add ByteForms integration plugin Implements the ByteForms plugin (5 ops, API-key auth, no webhooks): forms.create, forms.list, forms.get, forms.delete, forms.responses. - client: https://api.forms.bytesuite.io/api with raw API-key auth - zod-validated input/output schemas on every endpoint - error handlers incl. rate-limit (429) + auth (401) - unit tests for all endpoints (mocked client) Fixes #1374 --- packages/byteforms/client.ts | 102 ++++++++++++ packages/byteforms/endpoints/forms.ts | 89 +++++++++++ packages/byteforms/endpoints/index.ts | 11 ++ packages/byteforms/endpoints/types.ts | 182 +++++++++++++++++++++ packages/byteforms/error-handlers.ts | 31 ++++ packages/byteforms/forms.test.ts | 158 +++++++++++++++++++ packages/byteforms/index.ts | 218 ++++++++++++++++++++++++++ packages/byteforms/jest.config.cjs | 55 +++++++ packages/byteforms/package.json | 44 ++++++ packages/byteforms/schema.test.ts | 20 +++ packages/byteforms/schema/index.ts | 4 + packages/byteforms/tsconfig.json | 20 +++ packages/byteforms/tsup.config.ts | 15 ++ packages/corsair/core/constants.ts | 3 + pnpm-lock.yaml | 24 +++ 15 files changed, 976 insertions(+) create mode 100644 packages/byteforms/client.ts create mode 100644 packages/byteforms/endpoints/forms.ts create mode 100644 packages/byteforms/endpoints/index.ts create mode 100644 packages/byteforms/endpoints/types.ts create mode 100644 packages/byteforms/error-handlers.ts create mode 100644 packages/byteforms/forms.test.ts create mode 100644 packages/byteforms/index.ts create mode 100644 packages/byteforms/jest.config.cjs create mode 100644 packages/byteforms/package.json create mode 100644 packages/byteforms/schema.test.ts create mode 100644 packages/byteforms/schema/index.ts create mode 100644 packages/byteforms/tsconfig.json create mode 100644 packages/byteforms/tsup.config.ts diff --git a/packages/byteforms/client.ts b/packages/byteforms/client.ts new file mode 100644 index 000000000..5e7a6ef7d --- /dev/null +++ b/packages/byteforms/client.ts @@ -0,0 +1,102 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class ByteFormsAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + public readonly body?: unknown; + + constructor( + message: string, + public readonly code?: string, + options?: { cause?: Error }, + ) { + super(message, options); + this.name = 'ByteFormsAPIError'; + + if (options?.cause instanceof ApiError) { + this.status = options.cause.status; + this.statusText = options.cause.statusText; + this.body = options.cause.body; + } + } +} + +const BYTEFORMS_API_BASE = 'https://api.forms.bytesuite.io/api'; + +const BYTEFORMS_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 3, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +type QueryValue = + | string + | number + | boolean + | readonly string[] + | readonly number[] + | readonly boolean[] + | undefined; + +export async function makeByteFormsRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + } = {}, +): Promise { + const { method = 'GET', body, query } = options; + const isWriteMethod = + method === 'POST' || method === 'PUT' || method === 'PATCH'; + + const config: OpenAPIConfig = { + BASE: BYTEFORMS_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + HEADERS: { + // ByteForms uses "basic" auth: the raw API key is sent in the + // Authorization header with no "Bearer" prefix. + Authorization: apiKey, + 'Content-Type': 'application/json', + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: isWriteMethod ? body : undefined, + mediaType: 'application/json; charset=utf-8', + query: + method === 'GET' + ? (query as Record) + : undefined, + }; + + try { + return await request(config, requestOptions, { + rateLimitConfig: BYTEFORMS_RATE_LIMIT_CONFIG, + }); + } catch (error) { + if (error instanceof ApiError) { + throw new ByteFormsAPIError(error.message, String(error.status), { + cause: error, + }); + } + if (error instanceof Error) { + throw new ByteFormsAPIError(error.message, undefined, { cause: error }); + } + throw new ByteFormsAPIError('Unknown error'); + } +} diff --git a/packages/byteforms/endpoints/forms.ts b/packages/byteforms/endpoints/forms.ts new file mode 100644 index 000000000..7afbbf454 --- /dev/null +++ b/packages/byteforms/endpoints/forms.ts @@ -0,0 +1,89 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeByteFormsRequest } from '../client'; +import type { ByteFormsEndpoints } from '../index'; +import type { ByteFormsEndpointOutputs } from './types'; + +export const create: ByteFormsEndpoints['formsCreate'] = async (ctx, input) => { + const body = Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined), + ); + + const response = await makeByteFormsRequest< + ByteFormsEndpointOutputs['formsCreate'] + >('form', ctx.key, { method: 'POST', body }); + + await logEventFromContext( + ctx, + 'byteforms.forms.create', + { name: input.name }, + 'completed', + ); + return response; +}; + +export const deleteForm: ByteFormsEndpoints['formsDelete'] = async ( + ctx, + input, +) => { + const response = await makeByteFormsRequest< + ByteFormsEndpointOutputs['formsDelete'] + >(`form/${encodeURIComponent(input.formId)}`, ctx.key, { method: 'DELETE' }); + + await logEventFromContext( + ctx, + 'byteforms.forms.delete', + { formId: input.formId }, + 'completed', + ); + return response; +}; + +export const getById: ByteFormsEndpoints['formsGet'] = async (ctx, input) => { + const response = await makeByteFormsRequest< + ByteFormsEndpointOutputs['formsGet'] + >(`form/${encodeURIComponent(input.formId)}`, ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'byteforms.forms.get', + { formId: input.formId }, + 'completed', + ); + return response; +}; + +export const getAll: ByteFormsEndpoints['formsList'] = async (ctx, input) => { + const response = await makeByteFormsRequest< + ByteFormsEndpointOutputs['formsList'] + >('form', ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'byteforms.forms.list', + { count: response.data.length }, + 'completed', + ); + return response; +}; + +export const getResponses: ByteFormsEndpoints['formsResponses'] = async ( + ctx, + input, +) => { + const { formId, ...query } = input; + + const response = await makeByteFormsRequest< + ByteFormsEndpointOutputs['formsResponses'] + >(`form/responses/${encodeURIComponent(formId)}`, ctx.key, { + method: 'GET', + query: query as Record, + }); + + await logEventFromContext( + ctx, + 'byteforms.forms.responses', + { formId, count: response.count }, + 'completed', + ); + return response; +}; diff --git a/packages/byteforms/endpoints/index.ts b/packages/byteforms/endpoints/index.ts new file mode 100644 index 000000000..5a2d2fd1e --- /dev/null +++ b/packages/byteforms/endpoints/index.ts @@ -0,0 +1,11 @@ +import { create, deleteForm, getAll, getById, getResponses } from './forms'; + +export const Forms = { + create, + delete: deleteForm, + get: getById, + list: getAll, + responses: getResponses, +}; + +export * from './types'; diff --git a/packages/byteforms/endpoints/types.ts b/packages/byteforms/endpoints/types.ts new file mode 100644 index 000000000..f66ca3dce --- /dev/null +++ b/packages/byteforms/endpoints/types.ts @@ -0,0 +1,182 @@ +import { z } from 'zod'; + +// A single field/component inside a ByteForms form definition. Components vary +// widely (input, textarea, select, checkbox, …), so we capture the common +// discriminator fields and allow providers to attach extra attributes. +const FormFieldSchema = z + .object({ + component: z.string(), + type: z.string().optional(), + label: z.string().optional(), + id: z.string().optional(), + required: z.boolean().optional(), + placeholder: z.string().optional(), + }) + .loose(); + +export type FormField = z.infer; + +const FormOptionsSchema = z + .object({ + one_submission_per_email: z.boolean().optional(), + thank_you_message: z.string().optional(), + max_submissions: z.number().int().optional(), + stop_submissions_after: z.string().nullable().optional(), + submit_button_text: z.string().optional(), + form_width: z.string().optional(), + redirect_url: z.string().optional(), + password: z.string().optional(), + theme: z.string().optional(), + visibility: z.string().optional(), + page_behaviour: z.string().optional(), + custom_code: z.string().optional(), + draft_submissions: z.boolean().optional(), + remove_branding: z.boolean().optional(), + email_notifications: z.boolean().optional(), + }) + .loose(); + +export type FormOptions = z.infer; + +const FormItemSchema = z + .object({ + id: z.number(), + public_id: z.string(), + name: z.string(), + body: z.array(FormFieldSchema), + pages: z.nullable(z.unknown()).optional(), + is_custom: z.boolean(), + options: FormOptionsSchema, + user_id: z.number(), + created_at: z.string(), + updated_at: z.string(), + deleted_at: z.nullable(z.string()).optional(), + }) + .loose(); + +export type FormItem = z.infer; + +const FormResponseItemSchema = z + .object({ + id: z.number(), + form_id: z.number(), + response: z.record(z.string(), z.unknown()), + options: z.object({ ip: z.string().optional() }).loose().optional(), + created_at: z.string(), + updated_at: z.string(), + deleted_at: z.nullable(z.string()).optional(), + }) + .loose(); + +export type FormResponseItem = z.infer; + +const CreateFormInputSchema = z.object({ + name: z.string(), + body: z.array(FormFieldSchema).optional(), + options: FormOptionsSchema.optional(), +}); + +export type CreateFormInput = z.infer; + +const DeleteFormInputSchema = z.object({ + formId: z.string(), +}); + +export type DeleteFormInput = z.infer; + +const GetAllFormsInputSchema = z.object({}); + +export type GetAllFormsInput = z.infer; + +const GetFormByIdInputSchema = z.object({ + formId: z.string(), +}); + +export type GetFormByIdInput = z.infer; + +const GetFormResponsesInputSchema = z.object({ + formId: z.string(), + limit: z.coerce.number().int().optional(), + order: z.enum(['asc', 'desc']).optional(), + query: z.string().optional(), + after: z.string().optional(), + before: z.string().optional(), +}); + +export type GetFormResponsesInput = z.infer; + +const GetAllFormsResponseSchema = z.object({ + data: z.array(FormItemSchema), + status: z.string(), +}); + +const GetFormByIdResponseSchema = z.object({ + data: FormItemSchema, + status: z.string(), +}); + +const GetFormResponsesResponseSchema = z.object({ + count: z.number(), + cursor: z.object({ + after: z.nullable(z.string()), + before: z.nullable(z.string()), + }), + data: z.array(FormResponseItemSchema), + status: z.string(), +}); + +// The Create and Delete endpoints return a lightweight envelope. We keep them +// permissive because the provider's exact envelope shape can vary by account. +const CreateFormResponseSchema = z + .object({ + data: FormItemSchema.optional(), + status: z.string(), + }) + .loose(); + +const DeleteFormResponseSchema = z + .object({ + data: z.boolean().optional(), + status: z.string(), + }) + .loose(); + +export type CreateFormResponse = z.infer; +export type DeleteFormResponse = z.infer; +export type GetAllFormsResponse = z.infer; +export type GetFormByIdResponse = z.infer; +export type GetFormResponsesResponse = z.infer< + typeof GetFormResponsesResponseSchema +>; + +export type ByteFormsEndpointInputs = { + formsCreate: CreateFormInput; + formsDelete: DeleteFormInput; + formsGet: GetFormByIdInput; + formsList: GetAllFormsInput; + formsResponses: GetFormResponsesInput; +}; + +export type ByteFormsEndpointOutputs = { + formsCreate: CreateFormResponse; + formsDelete: DeleteFormResponse; + formsGet: GetFormByIdResponse; + formsList: GetAllFormsResponse; + formsResponses: GetFormResponsesResponse; +}; + +export const ByteFormsEndpointInputSchemas = { + formsCreate: CreateFormInputSchema, + formsDelete: DeleteFormInputSchema, + formsGet: GetFormByIdInputSchema, + formsList: GetAllFormsInputSchema, + formsResponses: GetFormResponsesInputSchema, +} as const; + +export const ByteFormsEndpointOutputSchemas = { + formsCreate: CreateFormResponseSchema, + formsDelete: DeleteFormResponseSchema, + formsGet: GetFormByIdResponseSchema, + formsList: GetAllFormsResponseSchema, + formsResponses: GetFormResponsesResponseSchema, +} as const; diff --git a/packages/byteforms/error-handlers.ts b/packages/byteforms/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /dev/null +++ b/packages/byteforms/error-handlers.ts @@ -0,0 +1,31 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/byteforms/forms.test.ts b/packages/byteforms/forms.test.ts new file mode 100644 index 000000000..bf0a5b700 --- /dev/null +++ b/packages/byteforms/forms.test.ts @@ -0,0 +1,158 @@ +import { + create, + deleteForm, + getAll, + getById, + getResponses, +} from './endpoints/forms'; + +jest.mock('./client', () => ({ + makeByteFormsRequest: jest.fn(), +})); + +jest.mock('corsair/core', () => ({ + logEventFromContext: jest.fn(), +})); + +import { logEventFromContext } from 'corsair/core'; +import { makeByteFormsRequest } from './client'; + +const mockRequest = makeByteFormsRequest as jest.Mock; +const mockLog = logEventFromContext as jest.Mock; + +const ctx = { key: 'test-api-key' } as any; + +beforeEach(() => { + mockRequest.mockReset(); + mockLog.mockReset(); +}); + +describe('ByteForms endpoints', () => { + it('create posts to /form with the API key and returns the envelope', async () => { + mockRequest.mockResolvedValue({ + data: { id: 1, public_id: 'abc', name: 'Demo' }, + status: 'success', + }); + + const res = await create(ctx, { + name: 'Demo', + body: [{ component: 'input', type: 'text', label: 'Name' }], + }); + + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith( + 'form', + 'test-api-key', + expect.objectContaining({ method: 'POST' }), + ); + expect(res.status).toBe('success'); + expect(mockLog).toHaveBeenCalledWith( + ctx, + 'byteforms.forms.create', + expect.any(Object), + 'completed', + ); + }); + + it('delete issues a DELETE to /form/:id', async () => { + mockRequest.mockResolvedValue({ status: 'success' }); + + const res = await deleteForm(ctx, { formId: '42' }); + + expect(mockRequest).toHaveBeenCalledWith( + 'form/42', + 'test-api-key', + expect.objectContaining({ method: 'DELETE' }), + ); + expect(res.status).toBe('success'); + }); + + it('getById fetches a single form by id', async () => { + mockRequest.mockResolvedValue({ + data: { + id: 7, + public_id: 'xyz', + name: 'Contact', + body: [], + is_custom: false, + options: {}, + user_id: 1, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + status: 'success', + }); + + const res = await getById(ctx, { formId: '7' }); + + expect(mockRequest).toHaveBeenCalledWith( + 'form/7', + 'test-api-key', + expect.objectContaining({ method: 'GET' }), + ); + expect(res.data.name).toBe('Contact'); + }); + + it('list returns the array of forms', async () => { + mockRequest.mockResolvedValue({ + data: [ + { + id: 1, + public_id: 'a', + name: 'A', + body: [], + is_custom: false, + options: {}, + user_id: 1, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + ], + status: 'success', + }); + + const res = await getAll(ctx, {}); + + expect(mockRequest).toHaveBeenCalledWith( + 'form', + 'test-api-key', + expect.objectContaining({ method: 'GET' }), + ); + expect(Array.isArray(res.data)).toBe(true); + expect(res.data).toHaveLength(1); + }); + + it('responses passes pagination query params and returns the cursor envelope', async () => { + mockRequest.mockResolvedValue({ + count: 2, + cursor: { after: null, before: null }, + data: [ + { + id: 1, + form_id: 9, + response: { email: 'a@b.com' }, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + ], + status: 'success', + }); + + const res = await getResponses(ctx, { + formId: '9', + limit: 10, + order: 'desc', + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'form/responses/9', + 'test-api-key', + expect.objectContaining({ + method: 'GET', + query: expect.objectContaining({ limit: 10, order: 'desc' }), + }), + ); + expect(res.count).toBe(2); + expect(res.cursor).toEqual({ after: null, before: null }); + }); +}); diff --git a/packages/byteforms/index.ts b/packages/byteforms/index.ts new file mode 100644 index 000000000..c0ae2ad7c --- /dev/null +++ b/packages/byteforms/index.ts @@ -0,0 +1,218 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { Forms } from './endpoints'; +import type { + ByteFormsEndpointInputs, + ByteFormsEndpointOutputs, +} from './endpoints/types'; +import { + ByteFormsEndpointInputSchemas, + ByteFormsEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { ByteFormsSchema } from './schema'; + +export type ByteFormsPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalByteFormsPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type ByteFormsContext = CorsairPluginContext< + typeof ByteFormsSchema, + ByteFormsPluginOptions +>; + +export type ByteFormsKeyBuilderContext = + KeyBuilderContext; + +export type ByteFormsBoundEndpoints = BindEndpoints< + typeof byteformsEndpointsNested +>; + +type ByteFormsEndpoint = + CorsairEndpoint< + ByteFormsContext, + ByteFormsEndpointInputs[K], + ByteFormsEndpointOutputs[K] + >; + +export type ByteFormsEndpoints = { + formsCreate: ByteFormsEndpoint<'formsCreate'>; + formsDelete: ByteFormsEndpoint<'formsDelete'>; + formsGet: ByteFormsEndpoint<'formsGet'>; + formsList: ByteFormsEndpoint<'formsList'>; + formsResponses: ByteFormsEndpoint<'formsResponses'>; +}; + +export type ByteFormsBoundWebhooks = BindWebhooks< + typeof byteformsWebhooksNested +>; + +const byteformsEndpointsNested = { + forms: { + create: Forms.create, + delete: Forms.delete, + get: Forms.get, + list: Forms.list, + responses: Forms.responses, + }, +} as const; + +const byteformsWebhooksNested = {} as const; + +export const byteformsEndpointSchemas = { + 'forms.create': { + input: ByteFormsEndpointInputSchemas.formsCreate, + output: ByteFormsEndpointOutputSchemas.formsCreate, + }, + 'forms.delete': { + input: ByteFormsEndpointInputSchemas.formsDelete, + output: ByteFormsEndpointOutputSchemas.formsDelete, + }, + 'forms.get': { + input: ByteFormsEndpointInputSchemas.formsGet, + output: ByteFormsEndpointOutputSchemas.formsGet, + }, + 'forms.list': { + input: ByteFormsEndpointInputSchemas.formsList, + output: ByteFormsEndpointOutputSchemas.formsList, + }, + 'forms.responses': { + input: ByteFormsEndpointInputSchemas.formsResponses, + output: ByteFormsEndpointOutputSchemas.formsResponses, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof byteformsEndpointsNested +>; + +const byteformsWebhookSchemas = + {} as const satisfies RequiredPluginWebhookSchemas< + typeof byteformsWebhooksNested + >; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const byteformsEndpointMeta = { + 'forms.create': { + riskLevel: 'write', + description: 'Create a new ByteForms form with custom fields and options', + }, + 'forms.delete': { + riskLevel: 'write', + description: 'Delete a ByteForms form by its numeric or public ID', + }, + 'forms.get': { + riskLevel: 'read', + description: 'Retrieve a single ByteForms form definition by ID', + }, + 'forms.list': { + riskLevel: 'read', + description: 'List all ByteForms forms created by the authenticated user', + }, + 'forms.responses': { + riskLevel: 'read', + description: 'Retrieve paginated responses submitted to a ByteForms form', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof byteformsEndpointsNested +>; + +export const byteformsAuthConfig = { + api_key: { + account: ['one'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseByteFormsPlugin = + CorsairPlugin< + 'byteforms', + typeof ByteFormsSchema, + typeof byteformsEndpointsNested, + typeof byteformsWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalByteFormsPlugin = + BaseByteFormsPlugin; + +export type ExternalByteFormsPlugin = + BaseByteFormsPlugin; + +export function byteforms( + incomingOptions: ByteFormsPluginOptions & T = {} as ByteFormsPluginOptions & + T, +): ExternalByteFormsPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'byteforms', + authConfig: byteformsAuthConfig, + schema: ByteFormsSchema, + options: options, + hooks: options.hooks, + webhooks: byteformsWebhooksNested, + endpoints: byteformsEndpointsNested, + endpointMeta: byteformsEndpointMeta, + endpointSchemas: byteformsEndpointSchemas, + webhookSchemas: byteformsWebhookSchemas, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: ByteFormsKeyBuilderContext, source) => { + 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('byteforms', 'api_key'); + } + return res; + } + + throw new AuthMissingError('byteforms', 'api_key'); + }, + } satisfies InternalByteFormsPlugin; +} + +export type { + ByteFormsEndpointInputs, + ByteFormsEndpointOutputs, + CreateFormInput, + CreateFormResponse, + DeleteFormInput, + DeleteFormResponse, + FormField, + FormItem, + FormOptions, + FormResponseItem, + GetAllFormsInput, + GetAllFormsResponse, + GetFormByIdInput, + GetFormByIdResponse, + GetFormResponsesInput, + GetFormResponsesResponse, +} from './endpoints/types'; diff --git a/packages/byteforms/jest.config.cjs b/packages/byteforms/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/byteforms/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/byteforms/package.json b/packages/byteforms/package.json new file mode 100644 index 000000000..2f4521b50 --- /dev/null +++ b/packages/byteforms/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/byteforms", + "version": "0.1.0", + "description": "ByteForms 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", + "byteforms", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/byteforms/schema.test.ts b/packages/byteforms/schema.test.ts new file mode 100644 index 000000000..a5a1b6e5a --- /dev/null +++ b/packages/byteforms/schema.test.ts @@ -0,0 +1,20 @@ +import { ByteFormsSchema } from './schema'; + +describe('ByteForms schema', () => { + it('declares a semver version', () => { + expect(ByteFormsSchema.version).toBeDefined(); + expect(ByteFormsSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof ByteFormsSchema.entities).toBe('object'); + expect(ByteFormsSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(ByteFormsSchema.entities))).toBe(true); + for (const entity of Object.values(ByteFormsSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/byteforms/schema/index.ts b/packages/byteforms/schema/index.ts new file mode 100644 index 000000000..12543ccec --- /dev/null +++ b/packages/byteforms/schema/index.ts @@ -0,0 +1,4 @@ +export const ByteFormsSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/byteforms/tsconfig.json b/packages/byteforms/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/byteforms/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/byteforms/tsup.config.ts b/packages/byteforms/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/byteforms/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/corsair/core/constants.ts b/packages/corsair/core/constants.ts index b0b147e8c..c024672b2 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -88,6 +88,7 @@ export const BaseProviders = [ 'brandfetch', 'browseai', 'bugsnag', + 'byteforms', 'cal', 'calendly', 'canva', @@ -307,6 +308,7 @@ export const ProviderDisplayNames = { brandfetch: 'Brandfetch', browseai: 'Browse AI', bugsnag: 'BugSnag', + byteforms: 'ByteForms', cal: 'Cal', calendly: 'Calendly', canva: 'Canva', @@ -533,6 +535,7 @@ export type AllProviders = | 'brandfetch' | 'browseai' | 'bugsnag' + | 'byteforms' | 'cal' | 'calendly' | 'canva' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e8b2ec84..e4a00f760 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2155,6 +2155,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/byteforms: + 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/cal: devDependencies: '@types/jest': From ef9f71a6cd41a86f656b05f55ee9e4c9a6e0ff52 Mon Sep 17 00:00:00 2001 From: RavaniRoshan Date: Sat, 29 Aug 2026 16:04:57 +0000 Subject: [PATCH 02/14] docs(byteforms): add plugin README --- packages/byteforms/README.md | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/byteforms/README.md diff --git a/packages/byteforms/README.md b/packages/byteforms/README.md new file mode 100644 index 000000000..6f4af1d46 --- /dev/null +++ b/packages/byteforms/README.md @@ -0,0 +1,124 @@ +# ByteForms for Corsair + +A Corsair integration plugin for **ByteForms** — a no-code form builder. It exposes +ByteForms' REST API as typed, agent-callable operations with API-key auth and no +webhooks. + +- Plugin id: `byteforms` +- Package: `@corsair-dev/byteforms` +- Provider docs: https://forms.bytesuite.io/docs/api + +## Installation + +```bash +pnpm add @corsair-dev/byteforms +``` + +Register the plugin with a Corsair instance: + +```ts +import { createCorsair } from 'corsair'; +import { byteforms } from '@corsair-dev/byteforms'; + +const corsair = createCorsair({ + // ...database, kek, permissions, hub... + plugins: [ + byteforms(), + ], +}); +``` + +## Authentication + +ByteForms uses API-key ("basic") auth. The raw API key is sent in the +`Authorization` header — **no `Bearer` prefix**. Provide the key when registering +the plugin or via stored credentials: + +```ts +byteforms({ key: process.env.BYTEFORMS_API_KEY }); +``` + +Base URL: `https://api.forms.bytesuite.io/api` + +## Operations + +All operations are validated with zod input/output schemas. + +### `forms.create` — Create a form + +Create a new form with a name, optional field definitions, and options. + +```ts +await corsair.byteforms.forms.create({ + name: 'Contact us', + body: [{ component: 'input', type: 'text', label: 'Name', id: 'name', required: true }], + options: { theme: 'light', thank_you_message: 'Thanks!' }, +}); +``` + +Risk level: `write`. + +### `forms.list` — List all forms + +List every form created by the authenticated user. + +```ts +const { data, status } = await corsair.byteforms.forms.list({}); +``` + +Risk level: `read`. + +### `forms.get` — Get a form by id + +```ts +const { data } = await corsair.byteforms.forms.get({ formId: '42' }); +``` + +`formId` may be the numeric id or the `public_id` string. + +Risk level: `read`. + +### `forms.delete` — Delete a form + +```ts +await corsair.byteforms.forms.delete({ formId: '42' }); +``` + +Risk level: `write`. + +### `forms.responses` — Get form responses (paginated) + +```ts +const { count, cursor, data } = await corsair.byteforms.forms.responses({ + formId: '42', + limit: 25, + order: 'desc', + query: 'john', +}); +``` + +Supported query params: `limit` (number), `order` (`asc` | `desc`), `query` +(string), `after` / `before` (cursor strings). + +Risk level: `read`. + +## Error handling + +Errors are routed through `error-handlers.ts`: + +- `429` → rate-limit handling with `Retry-After` backoff. +- `401` → auth error (no retries). + +API errors are wrapped as `ByteFormsAPIError` carrying `status`, `statusText`, and +the response `body` when available. + +## Development + +```bash +pnpm --filter @corsair-dev/byteforms typecheck +pnpm --filter @corsair-dev/byteforms test +pnpm --filter @corsair-dev/byteforms build +``` + +Tests mock `makeByteFormsRequest` and assert each endpoint builds the correct +method/path and returns the provider envelope. From 99affbe8ca0fe50e10cab68f34d434eb8c6fe124 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Tue, 1 Sep 2026 16:58:39 +0530 Subject: [PATCH 03/14] refactor(byteforms): remove unnecessary type assertions and test any --- packages/byteforms/client.ts | 5 +---- packages/byteforms/endpoints/forms.ts | 2 +- packages/byteforms/forms.test.ts | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/byteforms/client.ts b/packages/byteforms/client.ts index 5e7a6ef7d..882e1305a 100644 --- a/packages/byteforms/client.ts +++ b/packages/byteforms/client.ts @@ -78,10 +78,7 @@ export async function makeByteFormsRequest( url: endpoint, body: isWriteMethod ? body : undefined, mediaType: 'application/json; charset=utf-8', - query: - method === 'GET' - ? (query as Record) - : undefined, + query: method === 'GET' ? query : undefined, }; try { diff --git a/packages/byteforms/endpoints/forms.ts b/packages/byteforms/endpoints/forms.ts index 7afbbf454..b0c1cb420 100644 --- a/packages/byteforms/endpoints/forms.ts +++ b/packages/byteforms/endpoints/forms.ts @@ -76,7 +76,7 @@ export const getResponses: ByteFormsEndpoints['formsResponses'] = async ( ByteFormsEndpointOutputs['formsResponses'] >(`form/responses/${encodeURIComponent(formId)}`, ctx.key, { method: 'GET', - query: query as Record, + query, }); await logEventFromContext( diff --git a/packages/byteforms/forms.test.ts b/packages/byteforms/forms.test.ts index bf0a5b700..df6f9e933 100644 --- a/packages/byteforms/forms.test.ts +++ b/packages/byteforms/forms.test.ts @@ -20,7 +20,7 @@ import { makeByteFormsRequest } from './client'; const mockRequest = makeByteFormsRequest as jest.Mock; const mockLog = logEventFromContext as jest.Mock; -const ctx = { key: 'test-api-key' } as any; +const ctx = { key: 'test-api-key' } as never; beforeEach(() => { mockRequest.mockReset(); From 55a64121bbd930c0097d18b83a13f6b0a5437120 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Tue, 1 Sep 2026 17:05:21 +0530 Subject: [PATCH 04/14] fix(byteforms): match wrapped API errors by status in error handlers --- packages/byteforms/client.ts | 2 ++ packages/byteforms/error-handlers.test.ts | 44 +++++++++++++++++++++++ packages/byteforms/error-handlers.ts | 15 ++++++++ 3 files changed, 61 insertions(+) create mode 100644 packages/byteforms/error-handlers.test.ts diff --git a/packages/byteforms/client.ts b/packages/byteforms/client.ts index 882e1305a..2d6ab1e69 100644 --- a/packages/byteforms/client.ts +++ b/packages/byteforms/client.ts @@ -9,6 +9,7 @@ export class ByteFormsAPIError extends Error { public readonly status?: number; public readonly statusText?: string; public readonly body?: unknown; + public readonly retryAfter?: number; constructor( message: string, @@ -22,6 +23,7 @@ export class ByteFormsAPIError extends Error { this.status = options.cause.status; this.statusText = options.cause.statusText; this.body = options.cause.body; + this.retryAfter = options.cause.retryAfter; } } } diff --git a/packages/byteforms/error-handlers.test.ts b/packages/byteforms/error-handlers.test.ts new file mode 100644 index 000000000..05832b8fd --- /dev/null +++ b/packages/byteforms/error-handlers.test.ts @@ -0,0 +1,44 @@ +import { ApiError } from 'corsair/http'; +import { ByteFormsAPIError } from './client'; +import { errorHandlers } from './error-handlers'; + +const makeApiError = (status: number, retryAfter?: number) => + new ApiError( + { method: 'GET', url: 'https://example.test' }, + { + url: 'https://example.test', + ok: false, + status, + statusText: '', + body: undefined, + }, + status === 429 ? 'Too Many Requests' : 'Unauthorized', + retryAfter !== undefined ? { retryAfter } : undefined, + ); + +describe('ByteForms error handlers', () => { + it('matches wrapped rate-limit errors by status, not message', async () => { + const wrapped = new ByteFormsAPIError('Too Many Requests', '429', { + cause: makeApiError(429, 2000), + }); + + expect(errorHandlers.RATE_LIMIT_ERROR.match(wrapped)).toBe(true); + + const result = await errorHandlers.RATE_LIMIT_ERROR.handler(wrapped); + expect(result.maxRetries).toBeGreaterThan(0); + expect( + (result as { headersRetryAfterMs?: number }).headersRetryAfterMs, + ).toBe(2000); + }); + + it('matches wrapped auth errors and never retries them', async () => { + const wrapped = new ByteFormsAPIError('Unauthorized', '401', { + cause: makeApiError(401), + }); + + expect(errorHandlers.AUTH_ERROR.match(wrapped)).toBe(true); + + const result = await errorHandlers.AUTH_ERROR.handler(); + expect(result.maxRetries).toBe(0); + }); +}); diff --git a/packages/byteforms/error-handlers.ts b/packages/byteforms/error-handlers.ts index 5a4f4c19f..ddfa0ab01 100644 --- a/packages/byteforms/error-handlers.ts +++ b/packages/byteforms/error-handlers.ts @@ -1,10 +1,17 @@ import type { CorsairErrorHandler } from 'corsair/core'; import { ApiError } from 'corsair/http'; +import { ByteFormsAPIError } from './client'; export const errorHandlers = { RATE_LIMIT_ERROR: { match: (error: Error) => { if (error instanceof ApiError && error.status === 429) return true; + // makeByteFormsRequest wraps ApiError into ByteFormsAPIError, so + // match on the wrapped error's status too (its message is e.g. + // "Too Many Requests", which contains neither "429" nor "rate_limited"). + if (error instanceof ByteFormsAPIError && error.status === 429) { + return true; + } const msg = error.message.toLowerCase(); return msg.includes('rate_limited') || msg.includes('429'); }, @@ -12,6 +19,11 @@ export const errorHandlers = { let retryAfterMs: number | undefined; if (error instanceof ApiError && error.retryAfter !== undefined) { retryAfterMs = error.retryAfter; + } else if ( + error instanceof ByteFormsAPIError && + error.retryAfter !== undefined + ) { + retryAfterMs = error.retryAfter; } return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; }, @@ -19,6 +31,9 @@ export const errorHandlers = { AUTH_ERROR: { match: (error: Error) => { if (error instanceof ApiError && error.status === 401) return true; + if (error instanceof ByteFormsAPIError && error.status === 401) { + return true; + } const msg = error.message.toLowerCase(); return msg.includes('unauthorized') || msg.includes('invalid_auth'); }, From 4de5b1352851d7bda23e397e7432b511b5549acb Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Tue, 1 Sep 2026 17:21:56 +0530 Subject: [PATCH 05/14] test(byteforms): add live API tests for all ops; fix null responses schema --- packages/byteforms/api.test.ts | 163 ++++++++++++++++++++++++++ packages/byteforms/endpoints/types.ts | 7 +- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 packages/byteforms/api.test.ts diff --git a/packages/byteforms/api.test.ts b/packages/byteforms/api.test.ts new file mode 100644 index 000000000..fb69d6096 --- /dev/null +++ b/packages/byteforms/api.test.ts @@ -0,0 +1,163 @@ +// Live API tests for the ByteForms plugin. +// +// These tests call the real ByteForms API. CI ignores api.test.ts files (see +// pr-checks.yml testPathIgnorePatterns), so they only run when invoked +// explicitly with a key: +// +// BYTEFORMS_API_KEY= pnpm --filter @corsair-dev/byteforms test +// +// Pattern follows packages/slack/api.test.ts: call the plugin's own client, +// validate every live response through the plugin's zod output schemas, and +// clean up created resources in afterAll. +import { makeByteFormsRequest } from './client'; +import { ByteFormsEndpointOutputSchemas } from './endpoints/types'; + +describe('ByteForms live API', () => { + const key = process.env.BYTEFORMS_API_KEY ?? ''; + let createdFormId: number | undefined; + const uniqueName = `corsair-live-test-${Date.now()}`; + + afterAll(async () => { + // Cleanup: remove any form this suite created. + if (createdFormId !== undefined) { + try { + await makeByteFormsRequest<{ status: string }>( + `form/${createdFormId}`, + key, + { method: 'DELETE' }, + ); + } catch { + // Best effort — the suite already failed if we got here. + } + } + }); + + it('forms.list returns a valid envelope with real forms', async () => { + const response = await makeByteFormsRequest('form', key, { + method: 'GET', + }); + + const parsed = ByteFormsEndpointOutputSchemas.formsList.parse(response); + expect(parsed.status).toBe('success'); + expect(Array.isArray(parsed.data)).toBe(true); + }); + + it('forms.create creates a form and the output schema validates', async () => { + const response = await makeByteFormsRequest('form', key, { + method: 'POST', + body: { + name: uniqueName, + body: [ + { + component: 'input', + type: 'email', + label: 'Email', + id: 'email', + required: true, + }, + ], + options: { thank_you_message: 'Thanks from corsair tests!' }, + }, + }); + + const parsed = ByteFormsEndpointOutputSchemas.formsCreate.parse(response); + expect(parsed.status).toBe('success'); + expect(parsed.data).toBeDefined(); + + createdFormId = parsed.data?.id; + + expect(typeof createdFormId).toBe('number'); + expect(createdFormId).toBeGreaterThan(0); + }); + + it('forms.get fetches the created form by numeric id', async () => { + if (createdFormId === undefined) { + throw new Error('Create test did not produce a form id'); + } + + const response = await makeByteFormsRequest( + `form/${createdFormId}`, + key, + { method: 'GET' }, + ); + + const parsed = ByteFormsEndpointOutputSchemas.formsGet.parse(response); + expect(parsed.data.id).toBe(createdFormId); + expect(parsed.data.name).toBe(uniqueName); + expect(parsed.status).toBe('success'); + expect(Array.isArray(parsed.data.body)).toBe(true); + expect(parsed.data.body.length).toBeGreaterThan(0); + const firstField = parsed.data.body[0]; + if (!firstField) { + throw new Error('Created form has no fields'); + } + expect(firstField.component).toBe('input'); + expect(firstField.type).toBe('email'); + }); + + it('forms.responses returns a valid paginated envelope', async () => { + if (createdFormId === undefined) { + throw new Error('Create test did not produce a form id'); + } + + const response = await makeByteFormsRequest( + `form/responses/${createdFormId}`, + key, + { + method: 'GET', + query: { limit: 10, order: 'desc' }, + }, + ); + + const parsed = + ByteFormsEndpointOutputSchemas.formsResponses.parse(response); + expect(parsed.status).toBe('success'); + expect(typeof parsed.count).toBe('number'); + expect(parsed.count).toBeGreaterThanOrEqual(0); + expect(parsed.cursor).toHaveProperty('after'); + expect(parsed.cursor).toHaveProperty('before'); + expect(Array.isArray(parsed.data)).toBe(true); + }); + + it('forms.get on a nonexistent id surfaces a provider error', async () => { + await expect( + makeByteFormsRequest('form/999999999', key, { + method: 'GET', + }), + ).rejects.toThrow(); + }); + + it('an invalid API key is rejected by the provider', async () => { + await expect( + makeByteFormsRequest('form', 'definitely-not-a-valid-key', { + method: 'GET', + }), + ).rejects.toThrow(); + }); + + it('forms.delete removes the created form and it is no longer fetchable', async () => { + if (createdFormId === undefined) { + throw new Error('Create test did not produce a form id'); + } + + const response = await makeByteFormsRequest( + `form/${createdFormId}`, + key, + { method: 'DELETE' }, + ); + + const parsed = ByteFormsEndpointOutputSchemas.formsDelete.parse(response); + expect(parsed.status).toBe('success'); + + // Mark cleaned up before the negative check so afterAll does not retry. + const deletedId = createdFormId; + createdFormId = undefined; + + // The deleted form should no longer be retrievable. + await expect( + makeByteFormsRequest(`form/${deletedId}`, key, { + method: 'GET', + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/byteforms/endpoints/types.ts b/packages/byteforms/endpoints/types.ts index f66ca3dce..454d989b2 100644 --- a/packages/byteforms/endpoints/types.ts +++ b/packages/byteforms/endpoints/types.ts @@ -121,7 +121,12 @@ const GetFormResponsesResponseSchema = z.object({ after: z.nullable(z.string()), before: z.nullable(z.string()), }), - data: z.array(FormResponseItemSchema), + // The provider returns null (not []) when a form has no responses yet, + // so normalize to an empty array here. + data: z + .array(FormResponseItemSchema) + .nullable() + .transform((data) => data ?? []), status: z.string(), }); From a903b9b7e1dab01b44c7229edfe71ccc4fcb5f6c Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Tue, 1 Sep 2026 17:32:26 +0530 Subject: [PATCH 06/14] test(byteforms): add client unit tests covering auth, requests and error wrapping --- packages/byteforms/client.test.ts | 163 ++++++++++++++++++++++++++++++ packages/byteforms/client.ts | 2 +- 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/byteforms/client.test.ts diff --git a/packages/byteforms/client.test.ts b/packages/byteforms/client.test.ts new file mode 100644 index 000000000..c310a3b76 --- /dev/null +++ b/packages/byteforms/client.test.ts @@ -0,0 +1,163 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; +import { + BYTEFORMS_API_BASE, + ByteFormsAPIError, + makeByteFormsRequest, +} from './client'; + +jest.mock('corsair/http', () => { + const actual = jest.requireActual('corsair/http'); + return { ...actual, request: jest.fn() }; +}); + +const mockRequest = request as jest.MockedFunction; + +function lastCall(): [OpenAPIConfig, ApiRequestOptions] { + const call = mockRequest.mock.calls.at(-1); + if (!call) throw new Error('request() was never called'); + return call as unknown as [OpenAPIConfig, ApiRequestOptions]; +} + +function apiError(status: number, retryAfter?: number): ApiError { + return new ApiError( + { method: 'GET', url: 'form' }, + { + url: `${BYTEFORMS_API_BASE}/form`, + ok: false, + status, + statusText: 'Error', + body: { message: 'failed', status: 'fail' }, + }, + status === 429 ? 'Too Many Requests' : 'Unauthorized', + retryAfter !== undefined ? { retryAfter } : undefined, + ); +} + +beforeEach(() => { + mockRequest.mockReset(); +}); + +describe('makeByteFormsRequest', () => { + it('sends the raw API key in the Authorization header with no Bearer prefix', async () => { + mockRequest.mockResolvedValue({ data: {} }); + + await makeByteFormsRequest('form', 'secret-key'); + + const [config] = lastCall(); + expect(config.BASE).toBe(BYTEFORMS_API_BASE); + expect(config.HEADERS).toMatchObject({ + Authorization: 'secret-key', + 'Content-Type': 'application/json', + }); + expect(config.TOKEN).toBeUndefined(); + }); + + it('issues a GET with the endpoint path and passes query parameters', async () => { + mockRequest.mockResolvedValue({ data: [] }); + + await makeByteFormsRequest('form/responses/9', 'k', { + method: 'GET', + query: { limit: 10, order: 'desc' }, + }); + + const [, options] = lastCall(); + expect(options.method).toBe('GET'); + expect(options.url).toBe('form/responses/9'); + expect(options.query).toEqual({ limit: 10, order: 'desc' }); + }); + + it('returns the parsed body on success', async () => { + mockRequest.mockResolvedValue({ data: [], status: 'success' }); + + const result = await makeByteFormsRequest<{ data: unknown[] }>('form', 'k'); + + expect(result).toEqual({ data: [], status: 'success' }); + }); + + it('sends a JSON body on write methods and omits query', async () => { + mockRequest.mockResolvedValue({ status: 'success' }); + + await makeByteFormsRequest('form', 'k', { + method: 'POST', + body: { name: 'Demo', options: { theme: 'light' } }, + }); + + const [, options] = lastCall(); + expect(options.method).toBe('POST'); + expect(options.body).toEqual({ + name: 'Demo', + options: { theme: 'light' }, + }); + expect(options.query).toBeUndefined(); + expect(options.mediaType).toContain('application/json'); + }); + + it('does not send a body on GET or DELETE', async () => { + mockRequest.mockResolvedValue({ status: 'success' }); + + await makeByteFormsRequest('form/1', 'k', { method: 'DELETE' }); + + const [, options] = lastCall(); + expect(options.method).toBe('DELETE'); + expect(options.body).toBeUndefined(); + }); + + it('passes the rate-limit configuration to the http client', async () => { + mockRequest.mockResolvedValue({ data: {} }); + + await makeByteFormsRequest('form', 'k'); + + const [, options] = lastCall(); + expect(options).not.toHaveProperty('rateLimitConfig'); + const requestOptions = mockRequest.mock.calls[0]?.[2] as + | { rateLimitConfig?: { enabled: boolean; maxRetries: number } } + | undefined; + expect(requestOptions?.rateLimitConfig).toMatchObject({ + enabled: true, + maxRetries: 3, + }); + }); + + it('wraps an ApiError in ByteFormsAPIError, preserving status, retryAfter and cause', async () => { + const original = apiError(429, 1500); + mockRequest.mockRejectedValue(original); + + try { + await makeByteFormsRequest('form', 'k'); + throw new Error('expected makeByteFormsRequest to throw'); + } catch (error) { + const wrapped = error as ByteFormsAPIError; + expect(wrapped).toBeInstanceOf(ByteFormsAPIError); + expect(wrapped.status).toBe(429); + expect(wrapped.code).toBe('429'); + expect(wrapped.retryAfter).toBe(1500); + expect(wrapped.cause).toBe(original); + } + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('wraps a non-ApiError failure without inventing a status', async () => { + mockRequest.mockRejectedValue(new Error('socket hang up')); + + try { + await makeByteFormsRequest('form', 'k'); + throw new Error('expected makeByteFormsRequest to throw'); + } catch (error) { + const wrapped = error as ByteFormsAPIError; + expect(wrapped).toBeInstanceOf(ByteFormsAPIError); + expect(wrapped.message).toBe('socket hang up'); + expect(wrapped.status).toBeUndefined(); + expect(wrapped.retryAfter).toBeUndefined(); + } + }); + + it('wraps a thrown non-Error value as an unknown error', async () => { + mockRequest.mockRejectedValue('not an error'); + + await expect(makeByteFormsRequest('form', 'k')).rejects.toMatchObject({ + name: 'ByteFormsAPIError', + message: 'Unknown error', + }); + }); +}); diff --git a/packages/byteforms/client.ts b/packages/byteforms/client.ts index 2d6ab1e69..75e5ec12b 100644 --- a/packages/byteforms/client.ts +++ b/packages/byteforms/client.ts @@ -28,7 +28,7 @@ export class ByteFormsAPIError extends Error { } } -const BYTEFORMS_API_BASE = 'https://api.forms.bytesuite.io/api'; +export const BYTEFORMS_API_BASE = 'https://api.forms.bytesuite.io/api'; const BYTEFORMS_RATE_LIMIT_CONFIG: RateLimitConfig = { enabled: true, From 3785bc6762f6823ca005c28d7b9aca3e79cc0bfb Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Tue, 1 Sep 2026 17:42:30 +0530 Subject: [PATCH 07/14] Delete packages/byteforms/README.md --- packages/byteforms/README.md | 124 ----------------------------------- 1 file changed, 124 deletions(-) delete mode 100644 packages/byteforms/README.md diff --git a/packages/byteforms/README.md b/packages/byteforms/README.md deleted file mode 100644 index 6f4af1d46..000000000 --- a/packages/byteforms/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# ByteForms for Corsair - -A Corsair integration plugin for **ByteForms** — a no-code form builder. It exposes -ByteForms' REST API as typed, agent-callable operations with API-key auth and no -webhooks. - -- Plugin id: `byteforms` -- Package: `@corsair-dev/byteforms` -- Provider docs: https://forms.bytesuite.io/docs/api - -## Installation - -```bash -pnpm add @corsair-dev/byteforms -``` - -Register the plugin with a Corsair instance: - -```ts -import { createCorsair } from 'corsair'; -import { byteforms } from '@corsair-dev/byteforms'; - -const corsair = createCorsair({ - // ...database, kek, permissions, hub... - plugins: [ - byteforms(), - ], -}); -``` - -## Authentication - -ByteForms uses API-key ("basic") auth. The raw API key is sent in the -`Authorization` header — **no `Bearer` prefix**. Provide the key when registering -the plugin or via stored credentials: - -```ts -byteforms({ key: process.env.BYTEFORMS_API_KEY }); -``` - -Base URL: `https://api.forms.bytesuite.io/api` - -## Operations - -All operations are validated with zod input/output schemas. - -### `forms.create` — Create a form - -Create a new form with a name, optional field definitions, and options. - -```ts -await corsair.byteforms.forms.create({ - name: 'Contact us', - body: [{ component: 'input', type: 'text', label: 'Name', id: 'name', required: true }], - options: { theme: 'light', thank_you_message: 'Thanks!' }, -}); -``` - -Risk level: `write`. - -### `forms.list` — List all forms - -List every form created by the authenticated user. - -```ts -const { data, status } = await corsair.byteforms.forms.list({}); -``` - -Risk level: `read`. - -### `forms.get` — Get a form by id - -```ts -const { data } = await corsair.byteforms.forms.get({ formId: '42' }); -``` - -`formId` may be the numeric id or the `public_id` string. - -Risk level: `read`. - -### `forms.delete` — Delete a form - -```ts -await corsair.byteforms.forms.delete({ formId: '42' }); -``` - -Risk level: `write`. - -### `forms.responses` — Get form responses (paginated) - -```ts -const { count, cursor, data } = await corsair.byteforms.forms.responses({ - formId: '42', - limit: 25, - order: 'desc', - query: 'john', -}); -``` - -Supported query params: `limit` (number), `order` (`asc` | `desc`), `query` -(string), `after` / `before` (cursor strings). - -Risk level: `read`. - -## Error handling - -Errors are routed through `error-handlers.ts`: - -- `429` → rate-limit handling with `Retry-After` backoff. -- `401` → auth error (no retries). - -API errors are wrapped as `ByteFormsAPIError` carrying `status`, `statusText`, and -the response `body` when available. - -## Development - -```bash -pnpm --filter @corsair-dev/byteforms typecheck -pnpm --filter @corsair-dev/byteforms test -pnpm --filter @corsair-dev/byteforms build -``` - -Tests mock `makeByteFormsRequest` and assert each endpoint builds the correct -method/path and returns the provider envelope. From a6d442dfe04fa45f5532d49eaeac522c79ac07f1 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Tue, 1 Sep 2026 18:05:28 +0530 Subject: [PATCH 08/14] chore(byteforms): remove redundant comments --- packages/byteforms/api.test.ts | 11 ----------- packages/byteforms/client.ts | 2 -- packages/byteforms/endpoints/types.ts | 2 -- packages/byteforms/error-handlers.ts | 3 --- 4 files changed, 18 deletions(-) diff --git a/packages/byteforms/api.test.ts b/packages/byteforms/api.test.ts index fb69d6096..a97fe555f 100644 --- a/packages/byteforms/api.test.ts +++ b/packages/byteforms/api.test.ts @@ -1,14 +1,3 @@ -// Live API tests for the ByteForms plugin. -// -// These tests call the real ByteForms API. CI ignores api.test.ts files (see -// pr-checks.yml testPathIgnorePatterns), so they only run when invoked -// explicitly with a key: -// -// BYTEFORMS_API_KEY= pnpm --filter @corsair-dev/byteforms test -// -// Pattern follows packages/slack/api.test.ts: call the plugin's own client, -// validate every live response through the plugin's zod output schemas, and -// clean up created resources in afterAll. import { makeByteFormsRequest } from './client'; import { ByteFormsEndpointOutputSchemas } from './endpoints/types'; diff --git a/packages/byteforms/client.ts b/packages/byteforms/client.ts index 75e5ec12b..6a9cd851e 100644 --- a/packages/byteforms/client.ts +++ b/packages/byteforms/client.ts @@ -68,8 +68,6 @@ export async function makeByteFormsRequest( WITH_CREDENTIALS: false, CREDENTIALS: 'omit', HEADERS: { - // ByteForms uses "basic" auth: the raw API key is sent in the - // Authorization header with no "Bearer" prefix. Authorization: apiKey, 'Content-Type': 'application/json', }, diff --git a/packages/byteforms/endpoints/types.ts b/packages/byteforms/endpoints/types.ts index 454d989b2..b835bd8ab 100644 --- a/packages/byteforms/endpoints/types.ts +++ b/packages/byteforms/endpoints/types.ts @@ -121,8 +121,6 @@ const GetFormResponsesResponseSchema = z.object({ after: z.nullable(z.string()), before: z.nullable(z.string()), }), - // The provider returns null (not []) when a form has no responses yet, - // so normalize to an empty array here. data: z .array(FormResponseItemSchema) .nullable() diff --git a/packages/byteforms/error-handlers.ts b/packages/byteforms/error-handlers.ts index ddfa0ab01..0ade4404f 100644 --- a/packages/byteforms/error-handlers.ts +++ b/packages/byteforms/error-handlers.ts @@ -6,9 +6,6 @@ export const errorHandlers = { RATE_LIMIT_ERROR: { match: (error: Error) => { if (error instanceof ApiError && error.status === 429) return true; - // makeByteFormsRequest wraps ApiError into ByteFormsAPIError, so - // match on the wrapped error's status too (its message is e.g. - // "Too Many Requests", which contains neither "429" nor "rate_limited"). if (error instanceof ByteFormsAPIError && error.status === 429) { return true; } From 7c5e909d725a6a984b675b3b1d404faf67dd5958 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Tue, 1 Sep 2026 18:14:44 +0530 Subject: [PATCH 09/14] fix(byteforms): avoid nested 429 retries on non-idempotent writes --- packages/byteforms/error-handlers.test.ts | 2 +- packages/byteforms/error-handlers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/byteforms/error-handlers.test.ts b/packages/byteforms/error-handlers.test.ts index 05832b8fd..7ac423b07 100644 --- a/packages/byteforms/error-handlers.test.ts +++ b/packages/byteforms/error-handlers.test.ts @@ -25,7 +25,7 @@ describe('ByteForms error handlers', () => { expect(errorHandlers.RATE_LIMIT_ERROR.match(wrapped)).toBe(true); const result = await errorHandlers.RATE_LIMIT_ERROR.handler(wrapped); - expect(result.maxRetries).toBeGreaterThan(0); + expect(result.maxRetries).toBe(0); expect( (result as { headersRetryAfterMs?: number }).headersRetryAfterMs, ).toBe(2000); diff --git a/packages/byteforms/error-handlers.ts b/packages/byteforms/error-handlers.ts index 0ade4404f..9236101df 100644 --- a/packages/byteforms/error-handlers.ts +++ b/packages/byteforms/error-handlers.ts @@ -22,7 +22,7 @@ export const errorHandlers = { ) { retryAfterMs = error.retryAfter; } - return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + return { maxRetries: 0, headersRetryAfterMs: retryAfterMs }; }, }, AUTH_ERROR: { From a8a15200a9ba7cb88c00cbecd709a2580f926dfc Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Tue, 1 Sep 2026 18:23:00 +0530 Subject: [PATCH 10/14] fix(byteforms): disable transport-level 429 retries to prevent write replay --- packages/byteforms/client.test.ts | 2 +- packages/byteforms/client.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/byteforms/client.test.ts b/packages/byteforms/client.test.ts index c310a3b76..31ff67bfb 100644 --- a/packages/byteforms/client.test.ts +++ b/packages/byteforms/client.test.ts @@ -115,7 +115,7 @@ describe('makeByteFormsRequest', () => { | undefined; expect(requestOptions?.rateLimitConfig).toMatchObject({ enabled: true, - maxRetries: 3, + maxRetries: 0, }); }); diff --git a/packages/byteforms/client.ts b/packages/byteforms/client.ts index 6a9cd851e..f6a1b3b4d 100644 --- a/packages/byteforms/client.ts +++ b/packages/byteforms/client.ts @@ -30,9 +30,12 @@ export class ByteFormsAPIError extends Error { export const BYTEFORMS_API_BASE = 'https://api.forms.bytesuite.io/api'; +// Transport-level retries are disabled entirely (matching the abuseipdb +// convention): a 429 after the provider has processed a write would replay +// non-idempotent POSTs. Rate-limit errors are classified by error-handlers.ts. const BYTEFORMS_RATE_LIMIT_CONFIG: RateLimitConfig = { enabled: true, - maxRetries: 3, + maxRetries: 0, initialRetryDelay: 1000, backoffMultiplier: 2, headerNames: { From 769a9f3b53911c88249e8f8698caf173313c0497 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Tue, 1 Sep 2026 18:56:07 +0530 Subject: [PATCH 11/14] fix(byteforms): classify errors by HTTP status first --- packages/byteforms/error-handlers.test.ts | 14 ++++++++++++++ packages/byteforms/error-handlers.ts | 18 ++++++++++-------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/byteforms/error-handlers.test.ts b/packages/byteforms/error-handlers.test.ts index 7ac423b07..2deb246ac 100644 --- a/packages/byteforms/error-handlers.test.ts +++ b/packages/byteforms/error-handlers.test.ts @@ -41,4 +41,18 @@ describe('ByteForms error handlers', () => { const result = await errorHandlers.AUTH_ERROR.handler(); expect(result.maxRetries).toBe(0); }); + + it('does not treat a 500 as a rate limit just because the message mentions 429', () => { + const wrapped = new ByteFormsAPIError('upstream 429', '500', { + cause: makeApiError(500), + }); + expect(errorHandlers.RATE_LIMIT_ERROR.match(wrapped)).toBe(false); + }); + + it('does not treat a 500 as auth failure just because the message mentions unauthorized', () => { + const wrapped = new ByteFormsAPIError('unauthorized backend', '500', { + cause: makeApiError(500), + }); + expect(errorHandlers.AUTH_ERROR.match(wrapped)).toBe(false); + }); }); diff --git a/packages/byteforms/error-handlers.ts b/packages/byteforms/error-handlers.ts index 9236101df..ed6889af0 100644 --- a/packages/byteforms/error-handlers.ts +++ b/packages/byteforms/error-handlers.ts @@ -2,13 +2,17 @@ import type { CorsairErrorHandler } from 'corsair/core'; import { ApiError } from 'corsair/http'; import { ByteFormsAPIError } from './client'; +function getStatus(error: Error): number | undefined { + if (error instanceof ApiError) return error.status; + if (error instanceof ByteFormsAPIError) return error.status; + return undefined; +} + export const errorHandlers = { RATE_LIMIT_ERROR: { match: (error: Error) => { - if (error instanceof ApiError && error.status === 429) return true; - if (error instanceof ByteFormsAPIError && error.status === 429) { - return true; - } + const status = getStatus(error); + if (status !== undefined) return status === 429; const msg = error.message.toLowerCase(); return msg.includes('rate_limited') || msg.includes('429'); }, @@ -27,10 +31,8 @@ export const errorHandlers = { }, AUTH_ERROR: { match: (error: Error) => { - if (error instanceof ApiError && error.status === 401) return true; - if (error instanceof ByteFormsAPIError && error.status === 401) { - return true; - } + const status = getStatus(error); + if (status !== undefined) return status === 401; const msg = error.message.toLowerCase(); return msg.includes('unauthorized') || msg.includes('invalid_auth'); }, From f4c3faabad8a5f08dcde2b7a466c9195fcbec154 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Tue, 1 Sep 2026 18:56:07 +0530 Subject: [PATCH 12/14] fix(byteforms): parse handler inputs and outputs --- packages/byteforms/endpoints/forms.ts | 48 ++++++++++++++++------- packages/byteforms/forms.test.ts | 56 ++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/packages/byteforms/endpoints/forms.ts b/packages/byteforms/endpoints/forms.ts index b0c1cb420..f4bf4f5e7 100644 --- a/packages/byteforms/endpoints/forms.ts +++ b/packages/byteforms/endpoints/forms.ts @@ -2,20 +2,26 @@ import { logEventFromContext } from 'corsair/core'; import { makeByteFormsRequest } from '../client'; import type { ByteFormsEndpoints } from '../index'; import type { ByteFormsEndpointOutputs } from './types'; +import { + ByteFormsEndpointInputSchemas, + ByteFormsEndpointOutputSchemas, +} from './types'; export const create: ByteFormsEndpoints['formsCreate'] = async (ctx, input) => { + const parsed = ByteFormsEndpointInputSchemas.formsCreate.parse(input); const body = Object.fromEntries( - Object.entries(input).filter(([, value]) => value !== undefined), + Object.entries(parsed).filter(([, value]) => value !== undefined), ); - const response = await makeByteFormsRequest< + const raw = await makeByteFormsRequest< ByteFormsEndpointOutputs['formsCreate'] >('form', ctx.key, { method: 'POST', body }); + const response = ByteFormsEndpointOutputSchemas.formsCreate.parse(raw); await logEventFromContext( ctx, 'byteforms.forms.create', - { name: input.name }, + { name: parsed.name }, 'completed', ); return response; @@ -25,37 +31,47 @@ export const deleteForm: ByteFormsEndpoints['formsDelete'] = async ( ctx, input, ) => { - const response = await makeByteFormsRequest< + const parsed = ByteFormsEndpointInputSchemas.formsDelete.parse(input); + const raw = await makeByteFormsRequest< ByteFormsEndpointOutputs['formsDelete'] - >(`form/${encodeURIComponent(input.formId)}`, ctx.key, { method: 'DELETE' }); + >(`form/${encodeURIComponent(parsed.formId)}`, ctx.key, { method: 'DELETE' }); + const response = ByteFormsEndpointOutputSchemas.formsDelete.parse(raw); await logEventFromContext( ctx, 'byteforms.forms.delete', - { formId: input.formId }, + { formId: parsed.formId }, 'completed', ); return response; }; export const getById: ByteFormsEndpoints['formsGet'] = async (ctx, input) => { - const response = await makeByteFormsRequest< - ByteFormsEndpointOutputs['formsGet'] - >(`form/${encodeURIComponent(input.formId)}`, ctx.key, { method: 'GET' }); + const parsed = ByteFormsEndpointInputSchemas.formsGet.parse(input); + const raw = await makeByteFormsRequest( + `form/${encodeURIComponent(parsed.formId)}`, + ctx.key, + { method: 'GET' }, + ); + const response = ByteFormsEndpointOutputSchemas.formsGet.parse(raw); await logEventFromContext( ctx, 'byteforms.forms.get', - { formId: input.formId }, + { formId: parsed.formId }, 'completed', ); return response; }; export const getAll: ByteFormsEndpoints['formsList'] = async (ctx, input) => { - const response = await makeByteFormsRequest< - ByteFormsEndpointOutputs['formsList'] - >('form', ctx.key, { method: 'GET' }); + ByteFormsEndpointInputSchemas.formsList.parse(input); + const raw = await makeByteFormsRequest( + 'form', + ctx.key, + { method: 'GET' }, + ); + const response = ByteFormsEndpointOutputSchemas.formsList.parse(raw); await logEventFromContext( ctx, @@ -70,14 +86,16 @@ export const getResponses: ByteFormsEndpoints['formsResponses'] = async ( ctx, input, ) => { - const { formId, ...query } = input; + const parsed = ByteFormsEndpointInputSchemas.formsResponses.parse(input); + const { formId, ...query } = parsed; - const response = await makeByteFormsRequest< + const raw = await makeByteFormsRequest< ByteFormsEndpointOutputs['formsResponses'] >(`form/responses/${encodeURIComponent(formId)}`, ctx.key, { method: 'GET', query, }); + const response = ByteFormsEndpointOutputSchemas.formsResponses.parse(raw); await logEventFromContext( ctx, diff --git a/packages/byteforms/forms.test.ts b/packages/byteforms/forms.test.ts index df6f9e933..49caba4d5 100644 --- a/packages/byteforms/forms.test.ts +++ b/packages/byteforms/forms.test.ts @@ -30,7 +30,17 @@ beforeEach(() => { describe('ByteForms endpoints', () => { it('create posts to /form with the API key and returns the envelope', async () => { mockRequest.mockResolvedValue({ - data: { id: 1, public_id: 'abc', name: 'Demo' }, + data: { + id: 1, + public_id: 'abc', + name: 'Demo', + body: [], + is_custom: false, + options: {}, + user_id: 1, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, status: 'success', }); @@ -155,4 +165,48 @@ describe('ByteForms endpoints', () => { expect(res.count).toBe(2); expect(res.cursor).toEqual({ after: null, before: null }); }); + + it('strips unknown fields before creating a form', async () => { + mockRequest.mockResolvedValue({ status: 'success' }); + + await create(ctx, { + name: 'Demo', + unexpected: 'nope', + } as never); + + expect(mockRequest).toHaveBeenCalledWith( + 'form', + 'test-api-key', + expect.objectContaining({ + method: 'POST', + body: { name: 'Demo' }, + }), + ); + }); + + it('does not fetch a form when formId is missing', async () => { + await expect(getById(ctx, {} as never)).rejects.toThrow(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('does not request responses with an invalid order', async () => { + await expect( + getResponses(ctx, { formId: '9', order: 'sideways' } as never), + ).rejects.toThrow(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('normalizes null responses data to an empty array', async () => { + mockRequest.mockResolvedValue({ + count: 0, + cursor: { after: null, before: null }, + data: null, + status: 'success', + }); + + const res = await getResponses(ctx, { formId: '9' }); + + expect(Array.isArray(res.data)).toBe(true); + expect(res.data).toEqual([]); + }); }); From fbff20c6ee62fad32bc5cbe751552cde60ab4b25 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Tue, 1 Sep 2026 18:56:07 +0530 Subject: [PATCH 13/14] fix(byteforms): mark forms.delete as destructive --- packages/byteforms/index.ts | 2 +- packages/byteforms/schema.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/byteforms/index.ts b/packages/byteforms/index.ts index c0ae2ad7c..e432a5b4c 100644 --- a/packages/byteforms/index.ts +++ b/packages/byteforms/index.ts @@ -116,7 +116,7 @@ const byteformsEndpointMeta = { description: 'Create a new ByteForms form with custom fields and options', }, 'forms.delete': { - riskLevel: 'write', + riskLevel: 'destructive', description: 'Delete a ByteForms form by its numeric or public ID', }, 'forms.get': { diff --git a/packages/byteforms/schema.test.ts b/packages/byteforms/schema.test.ts index a5a1b6e5a..5f9f4742f 100644 --- a/packages/byteforms/schema.test.ts +++ b/packages/byteforms/schema.test.ts @@ -1,3 +1,4 @@ +import { byteforms } from './index'; import { ByteFormsSchema } from './schema'; describe('ByteForms schema', () => { @@ -14,6 +15,12 @@ describe('ByteForms schema', () => { expect(entity).toBeDefined(); } }); + + it('marks form delete as destructive', () => { + const plugin = byteforms({ key: 'test' }); + const meta = plugin.endpointMeta as Record; + expect(meta['forms.delete']?.riskLevel).toBe('destructive'); + }); }); // Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint From 462eca3c749d115644c902940dcbb7a365b04e00 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Tue, 1 Sep 2026 18:56:07 +0530 Subject: [PATCH 14/14] test(byteforms): skip live API tests without a key --- packages/byteforms/api.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/byteforms/api.test.ts b/packages/byteforms/api.test.ts index a97fe555f..ab3e3844b 100644 --- a/packages/byteforms/api.test.ts +++ b/packages/byteforms/api.test.ts @@ -1,8 +1,11 @@ import { makeByteFormsRequest } from './client'; import { ByteFormsEndpointOutputSchemas } from './endpoints/types'; -describe('ByteForms live API', () => { - const key = process.env.BYTEFORMS_API_KEY ?? ''; +const liveApiKey = process.env.BYTEFORMS_API_KEY ?? ''; +const describeLive = liveApiKey ? describe : describe.skip; + +describeLive('ByteForms live API', () => { + const key = liveApiKey; let createdFormId: number | undefined; const uniqueName = `corsair-live-test-${Date.now()}`;