diff --git a/packages/abyssale/cache-banner.ts b/packages/abyssale/cache-banner.ts new file mode 100644 index 000000000..3c058a901 --- /dev/null +++ b/packages/abyssale/cache-banner.ts @@ -0,0 +1,32 @@ +import type { AbyssaleBanner } from './schema/database'; + +type CacheCtx = { + db: { + banners?: { + upsertByEntityId: ( + entityId: string, + data: AbyssaleBanner, + ) => Promise<{ id?: string } | null | undefined>; + }; + }; +}; + +/** + * Mirrors a generated visual into the plugin cache. Best-effort: a caching + * failure must never fail an otherwise successful generation or webhook. + * Returns the corsair entity id (empty string when caching is unavailable). + */ +export async function cacheBanner( + ctx: CacheCtx, + banner: AbyssaleBanner, +): Promise { + if (!ctx.db.banners) return ''; + + try { + const entity = await ctx.db.banners.upsertByEntityId(banner.id, banner); + return entity?.id || ''; + } catch (error) { + console.warn(`[abyssale] failed to cache banner ${banner.id}:`, error); + return ''; + } +} diff --git a/packages/abyssale/endpoints.test.ts b/packages/abyssale/endpoints.test.ts index eb8311d54..7dcc3c3bf 100644 --- a/packages/abyssale/endpoints.test.ts +++ b/packages/abyssale/endpoints.test.ts @@ -18,10 +18,16 @@ const mockRequest = request as jest.MockedFunction; describe('Abyssale Plugin API', () => { const apiKey = 'test-api-key'; const plugin = abyssale({ key: apiKey }) as any; - const ctx = { key: apiKey } as any; + const upsert = jest.fn(); + const ctx = { + key: apiKey, + db: { banners: { upsertByEntityId: upsert } }, + } as any; beforeEach(() => { mockRequest.mockReset(); + upsert.mockReset(); + upsert.mockResolvedValue({ id: 'corsair-entity-1' }); }); describe('createProject', () => { @@ -144,6 +150,199 @@ describe('Abyssale Plugin API', () => { }); }); + describe('generation.image', () => { + const designId = '5978e8d9-ab34-4735-a2cb-fe95c2c56251'; + + it('sends POST /banner-builder/{designId}/generate without the path param in the body', async () => { + const mockResponse = { + id: 'b3f1a6ea-0d47-4e29-9c02-8f7f5f4e6a01', + version: 1, + file: { + type: 'jpeg', + url: 'https://cdn.abyssale.com/banner.jpeg', + filename: 'banner.jpeg', + }, + format: { id: 'facebook-feed', width: 1200, height: 628 }, + template: { id: designId, name: 'Summer campaign' }, + }; + mockRequest.mockResolvedValueOnce(mockResponse); + + const result = await plugin.endpoints.generation.image(ctx, { + designId, + template_format_name: 'facebook-feed', + image_file_type: 'png', + file_compression_level: 90, + elements: { + text_title: { payload: 'Hello', color: '#FF0000' }, + }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://api.abyssale.com', + HEADERS: expect.objectContaining({ + 'x-api-key': apiKey, + }), + }), + expect.objectContaining({ + method: 'POST', + url: `banner-builder/${designId}/generate`, + body: { + template_format_name: 'facebook-feed', + image_file_type: 'png', + file_compression_level: 90, + elements: { + text_title: { payload: 'Hello', color: '#FF0000' }, + }, + }, + }), + ); + expect(result).toEqual(mockResponse); + expect(upsert).toHaveBeenCalledWith( + mockResponse.id, + expect.objectContaining({ id: mockResponse.id }), + ); + }); + + it('rejects a non-uuid design id before calling the API', async () => { + await expect( + plugin.endpoints.generation.image(ctx, { designId: 'not-a-uuid' }), + ).rejects.toThrow(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('rejects an unsupported image_file_type before calling the API', async () => { + await expect( + plugin.endpoints.generation.image(ctx, { + designId, + image_file_type: 'mp4', + }), + ).rejects.toThrow(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + }); + + describe('generation.batch', () => { + const designId = '46d22c62-d134-44d3-a040-138e4ea9ea08'; + + it('sends POST /async/banner-builder/{designId}/generate', async () => { + const requestId = 'df75afa8-5a77-4e03-aeef-6d1b6dd0580a'; + mockRequest.mockResolvedValueOnce({ + generation_request_id: requestId, + }); + + const result = await plugin.endpoints.generation.batch(ctx, { + designId, + template_format_names: ['facebook-feed', 'instagram-post'], + callback_url: 'https://webhook.example.com/abyssale', + gif: { max_fps: 9 }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: `async/banner-builder/${designId}/generate`, + body: { + template_format_names: ['facebook-feed', 'instagram-post'], + callback_url: 'https://webhook.example.com/abyssale', + gif: { max_fps: 9 }, + }, + }), + ); + expect(result).toEqual({ generation_request_id: requestId }); + }); + + it('rejects a gif fps outside the documented 2-9 range', async () => { + await expect( + plugin.endpoints.generation.batch(ctx, { + designId, + gif: { max_fps: 30 as never }, + }), + ).rejects.toThrow(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + }); + + describe('generation.status', () => { + const requestId = '497f6eca-6276-4993-bfeb-53cbbbba6f08'; + + it('polls GET /generation-request/{id} while not finalized', async () => { + mockRequest.mockResolvedValueOnce({ + is_finalized: false, + id: requestId, + banners: [], + errors: [], + }); + + const result = await plugin.endpoints.generation.status(ctx, { + generationRequestId: requestId, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: `generation-request/${requestId}`, + }), + ); + expect(result.is_finalized).toBe(false); + expect(upsert).not.toHaveBeenCalled(); + }); + + it('returns per-format errors alongside finished banners', async () => { + const banner = { + id: 'ec3a9fcd-f209-4077-b5ea-037d4bdfa9f2', + file: { type: 'jpeg', url: 'https://cdn.abyssale.com/a.jpeg' }, + format: { id: 'facebook', width: 1200, height: 628 }, + }; + mockRequest.mockResolvedValueOnce({ + is_finalized: true, + id: requestId, + banners: [banner], + errors: [ + { + template_format_name: 'instagram-story', + reason: 'The text cannot fit within the defined space.', + }, + ], + }); + + const result = await plugin.endpoints.generation.status(ctx, { + generationRequestId: requestId, + }); + + expect(result.banners).toEqual([banner]); + expect(result.errors).toHaveLength(1); + expect(upsert).toHaveBeenCalledWith( + banner.id, + expect.objectContaining({ id: banner.id }), + ); + }); + + it('accepts a finalized payload that omits errors and caches banners', async () => { + const banner = { + id: 'ec3a9fcd-f209-4077-b5ea-037d4bdfa9f2', + file: { type: 'jpeg', url: 'https://cdn.abyssale.com/a.jpeg' }, + }; + mockRequest.mockResolvedValueOnce({ + is_finalized: true, + id: requestId, + banners: [banner], + }); + + const result = await plugin.endpoints.generation.status(ctx, { + generationRequestId: requestId, + }); + + expect(result.errors).toEqual([]); + expect(upsert).toHaveBeenCalledWith( + banner.id, + expect.objectContaining({ id: banner.id }), + ); + }); + }); + describe('error handling', () => { it('maps ApiError to AbyssaleAPIError with cause', async () => { const apiError = new ApiError( diff --git a/packages/abyssale/endpoints/generation.ts b/packages/abyssale/endpoints/generation.ts new file mode 100644 index 000000000..f0b35528e --- /dev/null +++ b/packages/abyssale/endpoints/generation.ts @@ -0,0 +1,111 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AbyssaleEndpoints } from '..'; +import { makeAbyssaleRequest } from '../client'; +import { cacheEntities, parseInput, parseOutput } from './shared'; +import type { + AbyssaleEndpointOutputs, + GenerateBatchInput, + GenerateImageInput, +} from './types'; + +/** + * Synchronous generation — one image per call, static designs only. The render + * is hard-capped at 10 s provider-side; heavier output (video, GIF, HTML5, + * print PDF) belongs to `generateBatch`. + */ +export const generateImage: AbyssaleEndpoints['generateImage'] = async ( + ctx, + input, +) => { + const args = parseInput('generateImage', input); + const { designId, ...body } = args as GenerateImageInput & { + designId: string; + }; + + const response = await makeAbyssaleRequest< + AbyssaleEndpointOutputs['generateImage'] + >(`banner-builder/${designId}/generate`, ctx.key, { + method: 'POST', + body: body as Record, + }); + + const result = parseOutput('generateImage', response); + + await cacheEntities(ctx, 'banners', [result]); + + await logEventFromContext( + ctx, + 'abyssale.generation.image', + { design_id: designId }, + 'completed', + ); + return result; +}; + +/** + * Asynchronous generation — many formats in one call. Returns a + * `generation_request_id` immediately; results arrive via the `NEW_BANNER_BATCH` + * webhook or by polling `getGenerationRequest`. + */ +export const generateBatch: AbyssaleEndpoints['generateBatch'] = async ( + ctx, + input, +) => { + const args = parseInput('generateBatch', input); + const { designId, ...body } = args as GenerateBatchInput & { + designId: string; + }; + + const response = await makeAbyssaleRequest< + AbyssaleEndpointOutputs['generateBatch'] + >(`async/banner-builder/${designId}/generate`, ctx.key, { + method: 'POST', + body: body as Record, + }); + + const result = parseOutput('generateBatch', response); + + await logEventFromContext( + ctx, + 'abyssale.generation.batch', + { + design_id: designId, + generation_request_id: result.generation_request_id, + }, + 'completed', + ); + return result; +}; + +/** + * Polls an async generation request (`202` + `is_finalized: false` while it + * runs, `200` + `is_finalized: true` when complete). Banners are cached only + * once finalized so partial results never masquerade as final ones. + */ +export const getGenerationRequest: AbyssaleEndpoints['getGenerationRequest'] = + async (ctx, input) => { + const args = parseInput('getGenerationRequest', input); + + const response = await makeAbyssaleRequest< + AbyssaleEndpointOutputs['getGenerationRequest'] + >(`generation-request/${args.generationRequestId}`, ctx.key, { + method: 'GET', + }); + + const result = parseOutput('getGenerationRequest', response); + + if (result.is_finalized && result.banners.length > 0) { + await cacheEntities(ctx, 'banners', result.banners); + } + + await logEventFromContext( + ctx, + 'abyssale.generation.status', + { + generation_request_id: args.generationRequestId, + is_finalized: result.is_finalized, + }, + 'completed', + ); + return result; + }; diff --git a/packages/abyssale/endpoints/index.ts b/packages/abyssale/endpoints/index.ts index d3cd7cacd..81705dfff 100644 --- a/packages/abyssale/endpoints/index.ts +++ b/packages/abyssale/endpoints/index.ts @@ -1,5 +1,6 @@ export * as Auth from './auth'; export * as Designs from './designs'; export * as Fonts from './fonts'; +export * as Generation from './generation'; export * as Projects from './projects'; export * from './types'; diff --git a/packages/abyssale/endpoints/shared.ts b/packages/abyssale/endpoints/shared.ts index ca6744bf5..6caa6918b 100644 --- a/packages/abyssale/endpoints/shared.ts +++ b/packages/abyssale/endpoints/shared.ts @@ -43,7 +43,7 @@ type EntityClient = { */ export async function cacheEntities( ctx: AbyssaleContext, - entity: 'projects' | 'designs' | 'fonts', + entity: 'projects' | 'designs' | 'fonts' | 'banners', items: ReadonlyArray<{ id?: string }>, ): Promise { const db = ctx.db as unknown as diff --git a/packages/abyssale/endpoints/types.ts b/packages/abyssale/endpoints/types.ts index 36ff47d06..3c0f23e0e 100644 --- a/packages/abyssale/endpoints/types.ts +++ b/packages/abyssale/endpoints/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { AbyssaleBanner } from '../schema/database'; // Create Project const CreateProjectInputSchema = z.object({ @@ -87,11 +88,121 @@ const TestAuthResponseSchema = z .loose(); export type TestAuthResponse = z.infer; +// Generation +/** + * Element overrides keyed by layer name. Every value must be an object on the + * synchronous endpoint (a bare string answers `400 invalid_payload`); the + * overridable properties per layer type are provider-side, so the shape stays + * open here and is never narrowed away. + */ +const ElementsSchema = z.record(z.string(), z.object({}).loose()); + +/** Generate Single Image — synchronous, static designs only. */ +const GenerateImageInputSchema = z.object({ + designId: z.string().uuid(), + elements: ElementsSchema.optional(), + template_format_name: z.string().min(1).optional(), + image_file_type: z + .enum(['png', 'jpeg', 'webp', 'avif', 'pdf', 'auto']) + .optional(), + file_compression_level: z.number().int().min(1).max(100).optional(), + original_visual_id: z.string().uuid().optional(), +}); +export type GenerateImageInput = z.infer; + +const GenerateImageResponseSchema = AbyssaleBanner; +export type GenerateImageResponse = z.infer; + +/** Asynchronous multi-format generation (images, videos, GIFs, HTML5, PDFs). */ +const GenerateBatchInputSchema = z.object({ + designId: z.string().uuid(), + elements: ElementsSchema.optional(), + template_format_names: z.array(z.string().min(1)).optional(), + callback_url: z.string().url().optional(), + image_file_type: z + .enum(['png', 'jpeg', 'webp', 'avif', 'gif', 'pdf', 'html5', 'mp4', 'auto']) + .optional(), + file_compression_level: z.number().int().min(1).max(100).optional(), + html5: z + .object({ + page_title: z.string().optional(), + click_tag: z.string().optional(), + ad_network: z.string().optional(), + include_backup_image: z.boolean().optional(), + repeat: z.boolean().optional(), + }) + .loose() + .optional(), + gif: z + .object({ + max_fps: z.number().int().min(2).max(9).optional(), + repeat: z.boolean().optional(), + }) + .loose() + .optional(), + video: z + .object({ + fps: z.number().int().min(2).max(30).optional(), + }) + .loose() + .optional(), + print: z + .object({ + color_profile: z.string().uuid().optional(), + display_crop_marks: z.boolean().optional(), + }) + .loose() + .optional(), + original_visual_id: z.string().uuid().optional(), + pages: z.record(z.string(), z.object({}).loose()).optional(), +}); +export type GenerateBatchInput = z.infer; + +const GenerateBatchResponseSchema = z + .object({ + generation_request_id: z.string().uuid(), + }) + .loose(); +export type GenerateBatchResponse = z.infer; + +/** Poll an asynchronous generation request; branch on `is_finalized`. */ +const GetGenerationRequestInputSchema = z.object({ + generationRequestId: z.string().uuid(), +}); +export type GetGenerationRequestInput = z.infer< + typeof GetGenerationRequestInputSchema +>; + +const GetGenerationRequestResponseSchema = z + .object({ + is_finalized: z.boolean(), + id: z.string().uuid(), + banners: z.array(AbyssaleBanner), + errors: z + .array( + z + .object({ + template_format_name: z.string(), + reason: z.string(), + }) + .loose(), + ) + .optional() + .default([]), + }) + .loose(); +export type GetGenerationRequestResponse = z.infer< + typeof GetGenerationRequestResponseSchema +>; + export type AbyssaleEndpointInputs = { createProject: CreateProjectInput; getDesigns: GetDesignsInput; getFonts: GetFontsInput; testAuth: TestAuthInput; + generateImage: GenerateImageInput; + generateBatch: GenerateBatchInput; + getGenerationRequest: GetGenerationRequestInput; }; export type AbyssaleEndpointOutputs = { @@ -99,6 +210,9 @@ export type AbyssaleEndpointOutputs = { getDesigns: GetDesignsResponse; getFonts: GetFontsResponse; testAuth: TestAuthResponse; + generateImage: GenerateImageResponse; + generateBatch: GenerateBatchResponse; + getGenerationRequest: GetGenerationRequestResponse; }; export const AbyssaleEndpointInputSchemas = { @@ -106,6 +220,9 @@ export const AbyssaleEndpointInputSchemas = { getDesigns: GetDesignsInputSchema, getFonts: GetFontsInputSchema, testAuth: TestAuthInputSchema, + generateImage: GenerateImageInputSchema, + generateBatch: GenerateBatchInputSchema, + getGenerationRequest: GetGenerationRequestInputSchema, } as const; export const AbyssaleEndpointOutputSchemas = { @@ -113,4 +230,7 @@ export const AbyssaleEndpointOutputSchemas = { getDesigns: GetDesignsResponseSchema, getFonts: GetFontsResponseSchema, testAuth: TestAuthResponseSchema, + generateImage: GenerateImageResponseSchema, + generateBatch: GenerateBatchResponseSchema, + getGenerationRequest: GetGenerationRequestResponseSchema, } as const; diff --git a/packages/abyssale/index.ts b/packages/abyssale/index.ts index 18542b1e4..a67cdad40 100644 --- a/packages/abyssale/index.ts +++ b/packages/abyssale/index.ts @@ -6,6 +6,7 @@ import type { CorsairErrorHandler, CorsairPlugin, CorsairPluginContext, + CorsairWebhook, KeyBuilderContext, PickAuth, PluginAuthConfig, @@ -15,7 +16,7 @@ import type { RequiredPluginWebhookSchemas, } from 'corsair/core'; import { AuthMissingError } from 'corsair/core'; -import { Auth, Designs, Fonts, Projects } from './endpoints'; +import { Auth, Designs, Fonts, Generation, Projects } from './endpoints'; import type { AbyssaleEndpointInputs, AbyssaleEndpointOutputs, @@ -26,11 +27,30 @@ import { } from './endpoints/types'; import { errorHandlers } from './error-handlers'; import { AbyssaleSchema } from './schema'; +import type { + AbyssaleWebhookOutputs, + NewBannerBatchEvent, + NewBannerEvent, + NewExportEvent, + TemplateStatusEvent, +} from './webhooks'; +import { + BannerWebhooks, + DesignWebhooks, + ExportWebhooks, + matchAbyssalePluginWebhook, + NewBannerBatchEventSchema, + NewBannerEventSchema, + NewExportEventSchema, + TemplateStatusEventSchema, +} from './webhooks'; export type AbyssalePluginOptions = { authType?: PickAuth<'api_key'>; key?: string; + webhookSecret?: string; hooks?: InternalAbyssalePlugin['hooks']; + webhookHooks?: InternalAbyssalePlugin['webhookHooks']; errorHandlers?: CorsairErrorHandler; permissions?: PluginPermissionsConfig; }; @@ -59,11 +79,22 @@ export type AbyssaleEndpoints = { getDesigns: AbyssaleEndpoint<'getDesigns'>; getFonts: AbyssaleEndpoint<'getFonts'>; testAuth: AbyssaleEndpoint<'testAuth'>; + generateImage: AbyssaleEndpoint<'generateImage'>; + generateBatch: AbyssaleEndpoint<'generateBatch'>; + getGenerationRequest: AbyssaleEndpoint<'getGenerationRequest'>; }; -export type AbyssaleWebhooks = Record; +type AbyssaleWebhook< + K extends keyof AbyssaleWebhookOutputs, + TEvent, +> = CorsairWebhook; -export type AbyssaleBoundWebhooks = BindWebhooks; +export type AbyssaleWebhooks = { + newBanner: AbyssaleWebhook<'newBanner', NewBannerEvent>; + newBannerBatch: AbyssaleWebhook<'newBannerBatch', NewBannerBatchEvent>; + newExport: AbyssaleWebhook<'newExport', NewExportEvent>; + templateStatus: AbyssaleWebhook<'templateStatus', TemplateStatusEvent>; +}; const abyssaleEndpointsNested = { projects: { @@ -78,9 +109,25 @@ const abyssaleEndpointsNested = { auth: { test: Auth.testAuth, }, + generation: { + image: Generation.generateImage, + batch: Generation.generateBatch, + status: Generation.getGenerationRequest, + }, } as const; -const abyssaleWebhooksNested = {} as const; +const abyssaleWebhooksNested = { + banners: { + created: BannerWebhooks.created, + batchCompleted: BannerWebhooks.batchCompleted, + }, + exports: { + completed: ExportWebhooks.completed, + }, + designs: { + statusChanged: DesignWebhooks.statusChanged, + }, +} as const; export const abyssaleEndpointSchemas = { 'projects.create': { @@ -99,14 +146,52 @@ export const abyssaleEndpointSchemas = { input: AbyssaleEndpointInputSchemas.testAuth, output: AbyssaleEndpointOutputSchemas.testAuth, }, + 'generation.image': { + input: AbyssaleEndpointInputSchemas.generateImage, + output: AbyssaleEndpointOutputSchemas.generateImage, + }, + 'generation.batch': { + input: AbyssaleEndpointInputSchemas.generateBatch, + output: AbyssaleEndpointOutputSchemas.generateBatch, + }, + 'generation.status': { + input: AbyssaleEndpointInputSchemas.getGenerationRequest, + output: AbyssaleEndpointOutputSchemas.getGenerationRequest, + }, } as const satisfies RequiredPluginEndpointSchemas< typeof abyssaleEndpointsNested >; -const abyssaleWebhookSchemas = - {} as const satisfies RequiredPluginWebhookSchemas< - typeof abyssaleWebhooksNested - >; +export type AbyssaleBoundWebhooks = BindWebhooks; + +const abyssaleWebhookSchemas = { + 'banners.created': { + description: + 'A visual was generated or saved in Abyssale (NEW_BANNER event)', + payload: NewBannerEventSchema, + response: NewBannerEventSchema, + }, + 'banners.batchCompleted': { + description: + 'An asynchronous batch generation request completed (NEW_BANNER_BATCH event)', + payload: NewBannerBatchEventSchema, + response: NewBannerBatchEventSchema, + }, + 'exports.completed': { + description: + 'A workspace export archive finished processing (NEW_EXPORT event)', + payload: NewExportEventSchema, + response: NewExportEventSchema, + }, + 'designs.statusChanged': { + description: + "A design's workflow status was updated (TEMPLATE_STATUS event)", + payload: TemplateStatusEventSchema, + response: TemplateStatusEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas< + typeof abyssaleWebhooksNested +>; const defaultAuthType: AuthTypes = 'api_key' as const; @@ -127,6 +212,20 @@ const abyssaleEndpointMeta = { riskLevel: 'read', description: 'Test Abyssale API key authentication validity', }, + 'generation.image': { + riskLevel: 'write', + description: + 'Synchronously generate a single image from an Abyssale design', + }, + 'generation.batch': { + riskLevel: 'write', + description: + 'Start an asynchronous multi-format generation from an Abyssale design', + }, + 'generation.status': { + riskLevel: 'read', + description: 'Poll the status of an asynchronous generation request', + }, } as const satisfies RequiredPluginEndpointMeta; export const abyssaleAuthConfig = { @@ -160,13 +259,13 @@ export function abyssale( schema: AbyssaleSchema, options: options, hooks: options.hooks, - webhookHooks: undefined, + webhookHooks: options.webhookHooks, endpoints: abyssaleEndpointsNested, webhooks: abyssaleWebhooksNested, endpointMeta: abyssaleEndpointMeta, endpointSchemas: abyssaleEndpointSchemas, webhookSchemas: abyssaleWebhookSchemas, - pluginWebhookMatcher: undefined, + pluginWebhookMatcher: matchAbyssalePluginWebhook, pluginTenantWebhookMatcher: undefined, oauthWebhookTenantLinkResolver: undefined, errorHandlers: { @@ -174,6 +273,15 @@ export function abyssale( ...options.errorHandlers, }, keyBuilder: async (ctx: AbyssaleKeyBuilderContext, 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; } @@ -196,10 +304,23 @@ export type { AbyssaleEndpointOutputs, CreateProjectInput, CreateProjectResponse, + GenerateBatchInput, + GenerateBatchResponse, + GenerateImageInput, + GenerateImageResponse, GetDesignsInput, GetDesignsResponse, GetFontsInput, GetFontsResponse, + GetGenerationRequestInput, + GetGenerationRequestResponse, TestAuthInput, TestAuthResponse, } from './endpoints/types'; +export type { + AbyssaleWebhookOutputs, + NewBannerBatchEvent, + NewBannerEvent, + NewExportEvent, + TemplateStatusEvent, +} from './webhooks/types'; diff --git a/packages/abyssale/schema.test.ts b/packages/abyssale/schema.test.ts index 2e99bc1d7..4524c02ba 100644 --- a/packages/abyssale/schema.test.ts +++ b/packages/abyssale/schema.test.ts @@ -1,5 +1,6 @@ import { AbyssaleSchema } from './schema'; import { + AbyssaleBanner, AbyssaleDesign, AbyssaleFont, AbyssaleProject, @@ -10,8 +11,9 @@ describe('Abyssale database schema', () => { expect(AbyssaleSchema.version).toMatch(/^\d+\.\d+\.\d+$/); }); - it('registers the three cacheable resources', () => { + it('registers the cacheable resources', () => { expect(Object.keys(AbyssaleSchema.entities).sort()).toEqual([ + 'banners', 'designs', 'fonts', 'projects', @@ -70,4 +72,26 @@ describe('Abyssale database schema', () => { }); expect(parsed.brand_new_field).toBe(1); }); + + it('parses a generated banner, including HTML5 output without a cdn_url', () => { + const parsed = AbyssaleBanner.parse({ + id: '64238d01-d402-474b-8c2d-fbc957e9d290', + version: 3, + sharing_id: '5fcec999-2bfb-4dd7-ba38-2d9e16c49149', + file: { + type: 'zip', + url: 'https://cdn.abyssale.com/banner.zip', + fallback_image_url: 'https://cdn.abyssale.com/banner.jpeg', + }, + format: { width: 1200, height: 628 }, + template: { + id: '46d22c62-d134-44d3-a040-138e4ea9ea08', + name: 'Summer campaign', + }, + }); + expect(parsed.file?.fallback_image_url).toBe( + 'https://cdn.abyssale.com/banner.jpeg', + ); + expect(parsed.format?.id).toBeUndefined(); + }); }); diff --git a/packages/abyssale/schema/database.ts b/packages/abyssale/schema/database.ts index 5e362a2a8..c2a514db4 100644 --- a/packages/abyssale/schema/database.ts +++ b/packages/abyssale/schema/database.ts @@ -48,6 +48,61 @@ export const AbyssaleFont = z }) .loose(); +/** + * A generated visual ("banner"), returned by synchronous generation, async + * batch generation and the `NEW_BANNER` / `NEW_BANNER_BATCH` webhook events. + * + * Sub-objects mirror the wire format verbatim; most fields are optional + * because batch items omit `version` / `sharing_id`, HTML5 (`zip`) output has + * no `cdn_url` and `printer_multipage` visuals have no `format.id`. + */ +export const AbyssaleBannerFile = z + .object({ + type: z.string().optional(), + url: z.string().optional(), + cdn_url: z.string().optional(), + fallback_image_url: z.string().optional(), + filename: z.string().optional(), + }) + .loose(); + +export const AbyssaleBannerFormat = z + .object({ + id: z.string().optional(), + width: z.number().optional(), + height: z.number().optional(), + unit: z.string().optional(), + }) + .loose(); + +export const AbyssaleBannerTemplate = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + created_at: z.number().optional(), + updated_at: z.number().optional(), + }) + .loose(); + +export const AbyssaleBanner = z + .object({ + id: z.string(), + version: z.number().optional(), + sharing_id: z.string().optional(), + file: AbyssaleBannerFile.optional(), + format: AbyssaleBannerFormat.optional(), + template: AbyssaleBannerTemplate.optional(), + project: z + .object({ + id: z.string().optional(), + name: z.string().optional(), + }) + .loose() + .optional(), + }) + .loose(); + export type AbyssaleProject = z.infer; export type AbyssaleDesign = z.infer; export type AbyssaleFont = z.infer; +export type AbyssaleBanner = z.infer; diff --git a/packages/abyssale/schema/index.ts b/packages/abyssale/schema/index.ts index 508f23fca..3f9a696c6 100644 --- a/packages/abyssale/schema/index.ts +++ b/packages/abyssale/schema/index.ts @@ -1,10 +1,16 @@ -import { AbyssaleDesign, AbyssaleFont, AbyssaleProject } from './database'; +import { + AbyssaleBanner, + AbyssaleDesign, + AbyssaleFont, + AbyssaleProject, +} from './database'; export const AbyssaleSchema = { - version: '1.0.0', + version: '1.1.0', entities: { projects: AbyssaleProject, designs: AbyssaleDesign, fonts: AbyssaleFont, + banners: AbyssaleBanner, }, } as const; diff --git a/packages/abyssale/webhooks.test.ts b/packages/abyssale/webhooks.test.ts new file mode 100644 index 000000000..72bb006e6 --- /dev/null +++ b/packages/abyssale/webhooks.test.ts @@ -0,0 +1,493 @@ +import type { WebhookRequest } from 'corsair/core'; +import crypto from 'crypto'; +import { abyssale } from './index'; +import { + matchAbyssalePluginWebhook, + NewBannerBatchEventSchema, + NewBannerEventSchema, + NewExportEventSchema, + TemplateStatusEventSchema, + verifyAbyssaleWebhookSignature, +} from './webhooks'; + +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn(async () => undefined), +})); + +const SECRET = crypto.randomBytes(32).toString('hex'); +const WRONG_SECRET = crypto.randomBytes(32).toString('hex'); +const ROTATED_SECRET = crypto.randomBytes(32).toString('hex'); +const OTHER_SECRET = crypto.randomBytes(32).toString('hex'); +const OPTIONS_SECRET = crypto.randomBytes(32).toString('hex'); +const STORED_SECRET = crypto.randomBytes(32).toString('hex'); + +function sign( + body: string, + secret: string, + timestamp = Math.floor(Date.now() / 1000), +): string { + const digest = crypto + .createHmac('sha256', secret) + .update(`v1:webhook:${timestamp}.${body}`) + .digest('hex'); + return `t=${timestamp},v1=${digest}`; +} + +function signedRequest( + eventType: string, + extra: Record, + options: { + secret?: string; + timestamp?: number; + header?: string | null; + } = {}, +): WebhookRequest { + const rawBody = JSON.stringify({ event_type: eventType, ...extra }); + const headers: Record = {}; + const header = + options.header === null + ? undefined + : (options.header ?? + sign(rawBody, options.secret ?? SECRET, options.timestamp)); + if (header) headers['x-abyssale-signature'] = header; + + return { + payload: JSON.parse(rawBody), + headers, + rawBody, + }; +} + +const BANNER_ID = 'ec3a9fcd-f209-4077-b5ea-037d4bdfa9f2'; +const DESIGN_ID = '873608a1-e498-47dd-a36d-bd065e3e2b8e'; +const REQUEST_ID = 'c18c3cec-14c2-4539-99d4-92623b6a4aef'; +const EXPORT_ID = '54e62358-2656-455c-afd7-66d5ed3dd581'; + +describe('verifyAbyssaleWebhookSignature', () => { + it('accepts a correctly signed delivery', () => { + const request = signedRequest('NEW_BANNER', { id: BANNER_ID }); + expect(verifyAbyssaleWebhookSignature(request, SECRET)).toEqual({ + valid: true, + }); + }); + + it('rejects a tampered body', () => { + const request = signedRequest('NEW_BANNER', { id: BANNER_ID }); + request.rawBody = `${request.rawBody} `; + expect(verifyAbyssaleWebhookSignature(request, SECRET)).toEqual({ + valid: false, + error: 'Invalid signature', + }); + }); + + it('rejects a signature made with a different secret', () => { + const request = signedRequest( + 'NEW_BANNER', + { id: BANNER_ID }, + { + secret: WRONG_SECRET, + }, + ); + expect(verifyAbyssaleWebhookSignature(request, SECRET)).toEqual({ + valid: false, + error: 'Invalid signature', + }); + }); + + it('rejects a delivery older than the tolerance window', () => { + const stale = Math.floor(Date.now() / 1000) - 301; + const request = signedRequest( + 'NEW_BANNER', + { id: BANNER_ID }, + { timestamp: stale }, + ); + expect(verifyAbyssaleWebhookSignature(request, SECRET)).toEqual({ + valid: false, + error: 'Signature timestamp outside tolerance', + }); + }); + + it('checks every v1 during a rotation', () => { + const rawBody = JSON.stringify({ event_type: 'NEW_BANNER', id: BANNER_ID }); + const rotatedSecret = ROTATED_SECRET; + // Read the clock once so both v1 hashes sign the same timestamp even if + // the wall clock crosses a second boundary mid-test. + const timestamp = Math.floor(Date.now() / 1000); + const header = + `${sign(rawBody, SECRET, timestamp)},v1=` + + crypto + .createHmac('sha256', rotatedSecret) + .update(`v1:webhook:${timestamp}.${rawBody}`) + .digest('hex'); + + // The first v1 was minted with the old secret and must verify too. + const request: WebhookRequest = { + payload: JSON.parse(rawBody), + headers: { 'x-abyssale-signature': header }, + rawBody, + }; + expect(verifyAbyssaleWebhookSignature(request, SECRET)).toEqual({ + valid: true, + }); + expect(verifyAbyssaleWebhookSignature(request, rotatedSecret)).toEqual({ + valid: true, + }); + }); + + it.each([ + ['garbage', 'Malformed signature timestamp'], + ['t=not-a-number,v1=abc', 'Malformed signature timestamp'], + [ + `t=${Math.floor(Date.now() / 1000)}`, + 'Malformed signature header: no v1 value', + ], + ])('returns invalid instead of throwing on header %j', (header, error) => { + const request: WebhookRequest = { + payload: {}, + headers: { 'x-abyssale-signature': header }, + rawBody: '{}', + }; + expect(verifyAbyssaleWebhookSignature(request, SECRET)).toEqual({ + valid: false, + error, + }); + }); + + it('rejects a signed delivery whose raw body is unavailable', () => { + const request = signedRequest('NEW_BANNER', { id: BANNER_ID }); + request.rawBody = undefined; + expect(verifyAbyssaleWebhookSignature(request, SECRET)).toEqual({ + valid: false, + error: 'Missing raw body for signature verification', + }); + }); + + it('skips verification when the Hub already verified the delivery', () => { + const request: WebhookRequest = { + payload: {}, + headers: {}, + rawBody: '{}', + hubVerified: true, + }; + expect(verifyAbyssaleWebhookSignature(request, undefined)).toEqual({ + valid: true, + }); + }); + + it('rejects an unsigned delivery whether or not a secret is configured', () => { + const request = signedRequest( + 'NEW_BANNER', + { id: BANNER_ID }, + { header: null }, + ); + const unconfigured = verifyAbyssaleWebhookSignature(request, undefined); + expect(unconfigured.valid).toBe(false); + expect(unconfigured.error).toContain('no webhook secret is configured'); + expect(verifyAbyssaleWebhookSignature(request, SECRET).valid).toBe(false); + }); + + it('rejects a signed delivery when no secret is configured', () => { + const request = signedRequest('NEW_BANNER', { id: BANNER_ID }); + const result = verifyAbyssaleWebhookSignature(request, undefined); + expect(result.valid).toBe(false); + expect(result.error).toContain('no webhook secret is configured'); + }); +}); + +describe('event schemas', () => { + it('parses a documented NEW_BANNER payload', () => { + const parsed = NewBannerEventSchema.safeParse({ + event_type: 'NEW_BANNER', + id: BANNER_ID, + version: 1, + sharing_id: '5fcec999-2bfb-4dd7-ba38-2d9e16c49149', + file: { + type: 'jpeg', + url: 'url/name.jpeg', + cdn_url: 'cdn/name.jpeg', + filename: 'name.jpeg', + }, + format: { id: '300x250-medium-rectangle', width: 300, height: 250 }, + template: { + id: DESIGN_ID, + name: 'Template name', + created_at: 1623229458, + updated_at: 1649942114, + }, + }); + expect(parsed.success).toBe(true); + }); + + it('parses NEW_BANNER items that omit version, sharing_id and format.id', () => { + const parsed = NewBannerBatchEventSchema.safeParse({ + event_type: 'NEW_BANNER_BATCH', + generation_request_id: REQUEST_ID, + banners: [ + { + id: BANNER_ID, + file: { type: 'zip', url: 'url/name.zip' }, + format: { width: 1200, height: 628 }, + }, + ], + errors: [ + { + template_format_name: 'some-format', + reason: 'The text cannot fit within the defined space.', + }, + ], + }); + expect(parsed.success).toBe(true); + }); + + it('parses a successful NEW_BANNER_BATCH that omits errors', () => { + const parsed = NewBannerBatchEventSchema.safeParse({ + event_type: 'NEW_BANNER_BATCH', + generation_request_id: REQUEST_ID, + banners: [{ id: BANNER_ID }], + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.errors).toEqual([]); + } + }); + + it('parses NEW_EXPORT and TEMPLATE_STATUS payloads', () => { + expect( + NewExportEventSchema.safeParse({ + event_type: 'NEW_EXPORT', + export_id: EXPORT_ID, + archive_url: 'https://example.com/export.zip', + requested_at: 1649838051, + generated_at: 1649838135, + }).success, + ).toBe(true); + expect( + TemplateStatusEventSchema.safeParse({ + event_type: 'TEMPLATE_STATUS', + id: DESIGN_ID, + name: 'Template name', + status: 'APPROVED', + created_at: 1623229458, + updated_at: 1649837900, + status_updated_at: 1649837939, + }).success, + ).toBe(true); + }); + + it('rejects a banner payload with an unknown event_type', () => { + expect( + NewBannerEventSchema.safeParse({ + event_type: 'SOMETHING_ELSE', + id: BANNER_ID, + }).success, + ).toBe(false); + }); +}); + +describe('matchers', () => { + it.each([ + ['NEW_BANNER', 'banners.created'], + ['NEW_BANNER_BATCH', 'banners.batchCompleted'], + ['NEW_EXPORT', 'exports.completed'], + ['TEMPLATE_STATUS', 'designs.statusChanged'], + ] as const)('%s routes to %s and only to it', (eventType, webhookPath) => { + const plugin = abyssale({ key: 'k' }) as any; + for (const [path, webhook] of Object.entries(flatten(plugin.webhooks))) { + const raw = { + headers: {}, + body: JSON.stringify({ event_type: eventType }), + }; + expect(webhook.match(raw)).toBe(path === webhookPath); + } + }); + + it('ignores unknown Abyssale events', () => { + const plugin = abyssale({ key: 'k' }) as any; + const raw = { + headers: {}, + body: JSON.stringify({ event_type: 'NEW_FUTURE_EVENT' }), + }; + for (const webhook of Object.values(flatten(plugin.webhooks))) { + expect(webhook.match(raw)).toBe(false); + } + }); + + it('plugin matcher accepts handled events regardless of signing state', () => { + expect( + matchAbyssalePluginWebhook({ + headers: {}, + body: JSON.stringify({ event_type: 'NEW_BANNER' }), + }), + ).toBe(true); + expect(matchAbyssalePluginWebhook({ headers: {}, body: 'not json' })).toBe( + false, + ); + }); +}); + +function flatten(tree: unknown, prefix = ''): Record { + const flat: Record = {}; + for (const [key, value] of Object.entries(tree as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value?.match === 'function') flat[path] = value; + else Object.assign(flat, flatten(value, path)); + } + return flat; +} + +describe('webhook handlers', () => { + const upsert = jest.fn(); + const makeCtx = (key?: string) => + ({ + key, + options: {}, + db: { banners: { upsertByEntityId: upsert } }, + }) as any; + + beforeEach(() => { + upsert.mockReset(); + upsert.mockResolvedValue({ id: 'corsair-entity-1' }); + }); + + it('newBanner caches the visual and returns its entity id', async () => { + const plugin = abyssale({ key: 'k', webhookSecret: SECRET }) as any; + const handler = plugin.webhooks.banners.created.handler; + const response = await handler( + makeCtx(SECRET), + signedRequest('NEW_BANNER', { id: BANNER_ID }), + ); + + expect(response.success).toBe(true); + expect(response.corsairEntityId).toBe('corsair-entity-1'); + expect(upsert).toHaveBeenCalledWith( + BANNER_ID, + expect.objectContaining({ id: BANNER_ID }), + ); + }); + + it('newBannerBatch caches every banner in the batch', async () => { + const plugin = abyssale({ key: 'k', webhookSecret: SECRET }) as any; + const handler = plugin.webhooks.banners.batchCompleted.handler; + const secondId = 'a14e1d26-ff41-47cb-bbf9-8f2d777a5bd7'; + const response = await handler( + makeCtx(SECRET), + signedRequest('NEW_BANNER_BATCH', { + generation_request_id: REQUEST_ID, + banners: [{ id: BANNER_ID }, { id: secondId }], + errors: [], + }), + ); + + expect(response.success).toBe(true); + expect(upsert).toHaveBeenCalledTimes(2); + expect(response.corsairEntityId).toBe('corsair-entity-1'); + }); + + it('newBannerBatch caches banners when the payload omits errors', async () => { + const plugin = abyssale({ key: 'k', webhookSecret: SECRET }) as any; + const handler = plugin.webhooks.banners.batchCompleted.handler; + const response = await handler( + makeCtx(SECRET), + signedRequest('NEW_BANNER_BATCH', { + generation_request_id: REQUEST_ID, + banners: [{ id: BANNER_ID }], + }), + ); + + expect(response.success).toBe(true); + expect(upsert).toHaveBeenCalledWith( + BANNER_ID, + expect.objectContaining({ id: BANNER_ID }), + ); + }); + + it('newBanner omits corsairEntityId when caching is unavailable', async () => { + upsert.mockResolvedValueOnce(null); + const plugin = abyssale({ key: 'k', webhookSecret: SECRET }) as any; + const handler = plugin.webhooks.banners.created.handler; + const response = await handler( + makeCtx(SECRET), + signedRequest('NEW_BANNER', { id: BANNER_ID }), + ); + + expect(response.success).toBe(true); + expect(response.corsairEntityId).toBeUndefined(); + }); + + it('newBannerBatch omits corsairEntityId when no banner was cached', async () => { + const plugin = abyssale({ key: 'k', webhookSecret: SECRET }) as any; + const handler = plugin.webhooks.banners.batchCompleted.handler; + const response = await handler( + makeCtx(SECRET), + signedRequest('NEW_BANNER_BATCH', { + generation_request_id: REQUEST_ID, + banners: [], + errors: [], + }), + ); + + expect(response.success).toBe(true); + expect(response.corsairEntityId).toBeUndefined(); + }); + + it('returns 401 when the signature is invalid', async () => { + const plugin = abyssale({ key: 'k', webhookSecret: SECRET }) as any; + const handler = plugin.webhooks.banners.created.handler; + const response = await handler( + makeCtx(SECRET), + signedRequest('NEW_BANNER', { id: BANNER_ID }, { secret: OTHER_SECRET }), + ); + + expect(response.success).toBe(false); + expect(response.statusCode).toBe(401); + expect(upsert).not.toHaveBeenCalled(); + }); + + it('returns 400 on a payload that breaks the schema', async () => { + const plugin = abyssale({ key: 'k', webhookSecret: SECRET }) as any; + const handler = plugin.webhooks.designs.statusChanged.handler; + const response = await handler( + makeCtx(SECRET), + signedRequest('TEMPLATE_STATUS', { id: 'nope' }), + ); + + expect(response.success).toBe(false); + expect(response.statusCode).toBe(400); + }); + + it('exports.completed succeeds without caching anything', async () => { + const plugin = abyssale({ key: 'k', webhookSecret: SECRET }) as any; + const handler = plugin.webhooks.exports.completed.handler; + const response = await handler( + makeCtx(SECRET), + signedRequest('NEW_EXPORT', { + export_id: EXPORT_ID, + archive_url: 'https://example.com/export.zip', + }), + ); + + expect(response.success).toBe(true); + expect(upsert).not.toHaveBeenCalled(); + }); + + it('resolves the webhook secret through keyBuilder', async () => { + const plugin = abyssale({ webhookSecret: OPTIONS_SECRET }) as any; + const key = await plugin.keyBuilder( + { authType: 'api_key', keys: { get_webhook_signature: jest.fn() } }, + 'webhook', + ); + expect(key).toBe(OPTIONS_SECRET); + + const dynamicPlugin = abyssale({}) as any; + const dynamicCtx = { + authType: 'api_key', + keys: { + get_webhook_signature: jest.fn().mockResolvedValue(STORED_SECRET), + }, + }; + await expect(dynamicPlugin.keyBuilder(dynamicCtx, 'webhook')).resolves.toBe( + STORED_SECRET, + ); + }); +}); diff --git a/packages/abyssale/webhooks/banners.ts b/packages/abyssale/webhooks/banners.ts new file mode 100644 index 000000000..501ae6af4 --- /dev/null +++ b/packages/abyssale/webhooks/banners.ts @@ -0,0 +1,99 @@ +import { logEventFromContext } from 'corsair/core'; +import { cacheBanner } from '../cache-banner'; +import type { AbyssaleWebhooks } from '../index'; +import { + createAbyssaleMatch, + NewBannerBatchEventSchema, + NewBannerEventSchema, + verifyAndParseEvent, +} from './types'; + +export const created: AbyssaleWebhooks['newBanner'] = { + match: createAbyssaleMatch('NEW_BANNER'), + + handler: async (ctx, request) => { + const guard = verifyAndParseEvent( + request, + ctx.key, + NewBannerEventSchema, + 'NEW_BANNER', + ); + if (!guard.ok) { + return { + success: false, + statusCode: guard.statusCode, + error: guard.error, + }; + } + + const event = guard.event; + const corsairEntityId = await cacheBanner(ctx, { + id: event.id, + version: event.version, + sharing_id: event.sharing_id, + file: event.file, + format: event.format, + template: event.template, + }); + + await logEventFromContext( + ctx, + 'abyssale.webhook.newBanner', + { + banner_id: event.id, + template_id: event.template?.id, + format_id: event.format?.id, + }, + 'completed', + ); + + return corsairEntityId + ? { success: true, corsairEntityId, data: event } + : { success: true, data: event }; + }, +}; + +export const batchCompleted: AbyssaleWebhooks['newBannerBatch'] = { + match: createAbyssaleMatch('NEW_BANNER_BATCH'), + + handler: async (ctx, request) => { + const guard = verifyAndParseEvent( + request, + ctx.key, + NewBannerBatchEventSchema, + 'NEW_BANNER_BATCH', + ); + if (!guard.ok) { + return { + success: false, + statusCode: guard.statusCode, + error: guard.error, + }; + } + + const event = guard.event; + const entityIds: string[] = []; + for (let i = 0; i < event.banners.length; i += 8) { + const chunk = event.banners.slice(i, i + 8); + entityIds.push( + ...(await Promise.all(chunk.map((banner) => cacheBanner(ctx, banner)))), + ); + } + const firstEntityId = entityIds.find(Boolean); + + await logEventFromContext( + ctx, + 'abyssale.webhook.newBannerBatch', + { + generation_request_id: event.generation_request_id, + banner_count: event.banners.length, + error_count: event.errors.length, + }, + 'completed', + ); + + return firstEntityId + ? { success: true, corsairEntityId: firstEntityId, data: event } + : { success: true, data: event }; + }, +}; diff --git a/packages/abyssale/webhooks/designs.ts b/packages/abyssale/webhooks/designs.ts new file mode 100644 index 000000000..81e10e319 --- /dev/null +++ b/packages/abyssale/webhooks/designs.ts @@ -0,0 +1,44 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AbyssaleWebhooks } from '../index'; +import { + createAbyssaleMatch, + TemplateStatusEventSchema, + verifyAndParseEvent, +} from './types'; + +export const statusChanged: AbyssaleWebhooks['templateStatus'] = { + match: createAbyssaleMatch('TEMPLATE_STATUS'), + + handler: async (ctx, request) => { + const guard = verifyAndParseEvent( + request, + ctx.key, + TemplateStatusEventSchema, + 'TEMPLATE_STATUS', + ); + if (!guard.ok) { + return { + success: false, + statusCode: guard.statusCode, + error: guard.error, + }; + } + + const event = guard.event; + + await logEventFromContext( + ctx, + 'abyssale.webhook.templateStatus', + { + design_id: event.id, + status: event.status, + }, + 'completed', + ); + + return { + success: true, + data: event, + }; + }, +}; diff --git a/packages/abyssale/webhooks/exports.ts b/packages/abyssale/webhooks/exports.ts new file mode 100644 index 000000000..d0a469490 --- /dev/null +++ b/packages/abyssale/webhooks/exports.ts @@ -0,0 +1,44 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AbyssaleWebhooks } from '../index'; +import { + createAbyssaleMatch, + NewExportEventSchema, + verifyAndParseEvent, +} from './types'; + +export const completed: AbyssaleWebhooks['newExport'] = { + match: createAbyssaleMatch('NEW_EXPORT'), + + handler: async (ctx, request) => { + const guard = verifyAndParseEvent( + request, + ctx.key, + NewExportEventSchema, + 'NEW_EXPORT', + ); + if (!guard.ok) { + return { + success: false, + statusCode: guard.statusCode, + error: guard.error, + }; + } + + const event = guard.event; + + await logEventFromContext( + ctx, + 'abyssale.webhook.newExport', + { + export_id: event.export_id, + archive_url: event.archive_url, + }, + 'completed', + ); + + return { + success: true, + data: event, + }; + }, +}; diff --git a/packages/abyssale/webhooks/index.ts b/packages/abyssale/webhooks/index.ts new file mode 100644 index 000000000..de19748ed --- /dev/null +++ b/packages/abyssale/webhooks/index.ts @@ -0,0 +1,21 @@ +import { + batchCompleted as bannerBatchCompleted, + created as bannerCreated, +} from './banners'; +import { statusChanged as designStatusChanged } from './designs'; +import { completed as exportCompleted } from './exports'; + +export const BannerWebhooks = { + created: bannerCreated, + batchCompleted: bannerBatchCompleted, +}; + +export const DesignWebhooks = { + statusChanged: designStatusChanged, +}; + +export const ExportWebhooks = { + completed: exportCompleted, +}; + +export * from './types'; diff --git a/packages/abyssale/webhooks/types.ts b/packages/abyssale/webhooks/types.ts new file mode 100644 index 000000000..75ebcfb5e --- /dev/null +++ b/packages/abyssale/webhooks/types.ts @@ -0,0 +1,301 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import crypto from 'crypto'; +import { z } from 'zod'; +import { + AbyssaleBanner, + AbyssaleBannerFile, + AbyssaleBannerFormat, + AbyssaleBannerTemplate, +} from '../schema/database'; + +/** + * Abyssale webhook payloads all carry a top-level `event_type`; dispatch on it + * and ignore unknown values rather than failing, so a new provider event never + * breaks the receiver. + */ +const AbyssaleWebhookPayloadSchema = z + .object({ + event_type: z.string(), + }) + .loose(); + +export type AbyssaleWebhookPayload = z.infer< + typeof AbyssaleWebhookPayloadSchema +>; + +const BannerEventFields = { + id: z.uuid(), + version: z.number().optional(), + sharing_id: z.uuid().optional(), + file: AbyssaleBannerFile.optional(), + format: AbyssaleBannerFormat.optional(), + template: AbyssaleBannerTemplate.optional(), +}; + +/** `NEW_BANNER` — a single visual was generated or saved (never for sync API renders). */ +export const NewBannerEventSchema = AbyssaleWebhookPayloadSchema.extend({ + event_type: z.literal('NEW_BANNER'), + ...BannerEventFields, +}); +export type NewBannerEvent = z.infer; + +/** `NEW_BANNER_BATCH` — an asynchronous batch generation request completed. */ +export const NewBannerBatchEventSchema = AbyssaleWebhookPayloadSchema.extend({ + event_type: z.literal('NEW_BANNER_BATCH'), + generation_request_id: z.uuid(), + banners: z.array(AbyssaleBanner), + errors: z + .array( + z + .object({ + template_format_name: z.string(), + reason: z.string(), + }) + .loose(), + ) + .optional() + .default([]), +}); +export type NewBannerBatchEvent = z.infer; + +/** `NEW_EXPORT` — a workspace-wide export (ZIP) finished processing. */ +export const NewExportEventSchema = AbyssaleWebhookPayloadSchema.extend({ + event_type: z.literal('NEW_EXPORT'), + export_id: z.uuid(), + archive_url: z.url(), + requested_at: z.number().optional(), + generated_at: z.number().optional(), +}); +export type NewExportEvent = z.infer; + +/** + * `TEMPLATE_STATUS` — a design moved through its review workflow. The status + * stays an open string: Abyssale may introduce workflow states beyond the six + * documented values, and rejecting those deliveries would drop real updates. + */ +export const TemplateStatusEventSchema = AbyssaleWebhookPayloadSchema.extend({ + event_type: z.literal('TEMPLATE_STATUS'), + id: z.uuid(), + name: z.string().optional(), + status: z.string(), + created_at: z.number().optional(), + updated_at: z.number().optional(), + status_updated_at: z.number().optional(), +}); +export type TemplateStatusEvent = z.infer; + +export type AbyssaleWebhookOutputs = { + newBanner: NewBannerEvent; + newBannerBatch: NewBannerBatchEvent; + newExport: NewExportEvent; + templateStatus: TemplateStatusEvent; +}; + +/** Event types this plugin handles; anything else is ignored by the matcher. */ +export const HANDLED_EVENT_TYPES = [ + 'NEW_BANNER', + 'NEW_BANNER_BATCH', + 'NEW_EXPORT', + 'TEMPLATE_STATUS', +] as const; + +// `body` is unknown because the transport may deliver a raw JSON string or an +// already-parsed object; neither shape is knowable at the type boundary. +export function parseBody(body: unknown): Record | null { + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body); + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + return null; + } + return parsed as Record; + } catch { + return null; + } + } + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return null; + } + return body as Record; +} + +export function createAbyssaleMatch(eventType: string): CorsairWebhookMatcher { + const handled = HANDLED_EVENT_TYPES.includes( + eventType as (typeof HANDLED_EVENT_TYPES)[number], + ); + return (request: RawWebhookRequest) => { + if (!handled) return false; + const parsedBody = parseBody(request.body); + return parsedBody !== null && parsedBody.event_type === eventType; + }; +} + +/** Plugin-level matcher: an Abyssale event payload with a handled `event_type`. */ +export function matchAbyssalePluginWebhook( + request: RawWebhookRequest, +): boolean { + const payload = parseBody(request.body); + return ( + payload !== null && + HANDLED_EVENT_TYPES.some((type) => payload.event_type === type) + ); +} + +function getHeader( + headers: WebhookRequest['headers'], + name: string, +): string | undefined { + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== lower) continue; + return Array.isArray(value) ? value[0] : value; + } + return undefined; +} + +/** Deliveries older than this are rejected as replays. */ +const TIMESTAMP_TOLERANCE_SECONDS = 300; + +function parseSignatureHeader(header: string): { + timestamp?: string; + signatures: string[]; +} { + let timestamp: string | undefined; + const signatures: string[] = []; + + for (const part of header.split(',')) { + const trimmed = part.trim(); + const separator = trimmed.indexOf('='); + if (separator <= 0) continue; + const tag = trimmed.slice(0, separator); + const value = trimmed.slice(separator + 1); + if (!tag || !value) continue; + if (tag === 't') { + timestamp = value; + } else if (tag === 'v1') { + signatures.push(value); + } + } + + return { timestamp, signatures }; +} + +export function verifyAbyssaleWebhookSignature( + request: WebhookRequest, + secret?: string, +): { valid: boolean; error?: string } { + // The Hub already verified the provider signature on this delivery. + if (request.hubVerified === true) { + return { valid: true }; + } + + const signatureHeader = getHeader(request.headers, 'x-abyssale-signature'); + + // Fail closed: an unauthenticated delivery must never reach the handlers, + // so a delivery without a signature header is only accepted when the Hub + // already verified it. Abyssale signing is opt-in provider-side, which only + // means operators must create a signing secret and configure it here before + // webhooks can be received. + if (!signatureHeader) { + return { + valid: false, + error: secret + ? 'Missing x-abyssale-signature header but a webhook secret is configured' + : 'Unsigned delivery received but no webhook secret is configured (set options.webhookSecret or the webhook_signature key)', + }; + } + if (!secret) { + return { + valid: false, + error: + 'Signed delivery received but no webhook secret is configured (set options.webhookSecret or the webhook_signature key)', + }; + } + + const rawBody = request.rawBody; + if (!rawBody) { + return { + valid: false, + error: 'Missing raw body for signature verification', + }; + } + + const { timestamp, signatures } = parseSignatureHeader(signatureHeader); + if (!timestamp || !/^\d+$/.test(timestamp)) { + return { valid: false, error: 'Malformed signature timestamp' }; + } + + const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); + if (age > TIMESTAMP_TOLERANCE_SECONDS) { + return { valid: false, error: 'Signature timestamp outside tolerance' }; + } + + if (signatures.length === 0) { + return { valid: false, error: 'Malformed signature header: no v1 value' }; + } + + // During a rotation the header carries two v1 hashes, one per secret, so + // every candidate must be checked — never just the first. + const signedContent = `v1:webhook:${timestamp}.${rawBody}`; + const expected = crypto + .createHmac('sha256', secret) + .update(signedContent) + .digest('hex'); + + for (const signature of signatures) { + // timingSafeEqual throws on length mismatch, so guard before comparing; + // a forged header must yield `valid: false`, never a thrown error. + const received = Buffer.from(signature, 'utf8'); + const expectedBuffer = Buffer.from(expected, 'utf8'); + if ( + received.length === expectedBuffer.length && + crypto.timingSafeEqual(received, expectedBuffer) + ) { + return { valid: true }; + } + } + + return { valid: false, error: 'Invalid signature' }; +} + +/** + * Shared webhook guard: verifies the delivery signature, then validates the + * payload against the event schema. Keeps the 401/400 semantics identical + * across every handler. + */ +export function verifyAndParseEvent( + request: WebhookRequest, + secret: string | undefined, + schema: S, + eventName: string, +): + | { ok: true; event: z.output } + | { ok: false; statusCode: number; error: string } { + const verification = verifyAbyssaleWebhookSignature(request, secret); + if (!verification.valid) { + return { + ok: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const parsed = schema.safeParse(request.payload); + if (!parsed.success) { + return { + ok: false, + statusCode: 400, + error: `Invalid ${eventName} payload`, + }; + } + + return { ok: true, event: parsed.data }; +}