Skip to content

Commit 4171703

Browse files
authored
Merge pull request #195 from ShipSecAI/betterclever/smart-webhooks
feat: Custom Smart Webhooks with Transformation Scripts
2 parents 1eaf789 + d5483aa commit 4171703

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+6361
-764
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
CREATE TABLE IF NOT EXISTS "webhook_configurations" (
2+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3+
"workflow_id" uuid NOT NULL,
4+
"workflow_version_id" uuid,
5+
"workflow_version" integer,
6+
"name" text NOT NULL,
7+
"description" text,
8+
"webhook_path" varchar(255) UNIQUE NOT NULL,
9+
"parsing_script" text NOT NULL,
10+
"expected_inputs" jsonb NOT NULL,
11+
"status" text NOT NULL DEFAULT 'active',
12+
"organization_id" varchar(191),
13+
"created_by" varchar(191),
14+
"created_at" timestamptz NOT NULL DEFAULT now(),
15+
"updated_at" timestamptz NOT NULL DEFAULT now()
16+
);
17+
18+
CREATE TABLE IF NOT EXISTS "webhook_deliveries" (
19+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
20+
"webhook_id" uuid NOT NULL REFERENCES "webhook_configurations"("id") ON DELETE CASCADE,
21+
"workflow_run_id" text,
22+
"status" text NOT NULL DEFAULT 'processing',
23+
"payload" jsonb NOT NULL,
24+
"headers" jsonb,
25+
"parsed_data" jsonb,
26+
"error_message" text,
27+
"created_at" timestamptz NOT NULL DEFAULT now(),
28+
"completed_at" timestamptz
29+
);
30+
31+
CREATE INDEX IF NOT EXISTS "webhook_configurations_workflow_idx" ON "webhook_configurations" ("workflow_id", "status");
32+
CREATE INDEX IF NOT EXISTS "webhook_configurations_path_idx" ON "webhook_configurations" ("webhook_path");
33+
CREATE INDEX IF NOT EXISTS "webhook_deliveries_webhook_idx" ON "webhook_deliveries" ("webhook_id", "created_at" DESC);
34+
CREATE INDEX IF NOT EXISTS "webhook_deliveries_run_id_idx" ON "webhook_deliveries" ("workflow_run_id");

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"bcryptjs": "^3.0.3",
3737
"class-transformer": "^0.5.1",
3838
"class-validator": "^0.14.1",
39+
"date-fns": "^4.1.0",
3940
"dotenv": "^17.2.3",
4041
"drizzle-orm": "^0.44.6",
4142
"ioredis": "^5.4.1",

backend/src/auth/__tests__/auth.guard.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'bun:test';
22
import type { ExecutionContext } from '@nestjs/common';
33
import { UnauthorizedException } from '@nestjs/common';
4+
import { Reflector } from '@nestjs/core';
45
import type { Request } from 'express';
56

67
import { AuthGuard, type RequestWithAuthContext } from '../auth.guard';
@@ -18,6 +19,9 @@ describe('AuthGuard', () => {
1819
let mockApiKeysService: {
1920
validateKey: ReturnType<typeof vi.fn>;
2021
};
22+
let mockReflector: {
23+
getAllAndOverride: ReturnType<typeof vi.fn>;
24+
};
2125
let mockExecutionContext: ExecutionContext;
2226
let mockRequest: RequestWithAuthContext;
2327

@@ -30,11 +34,15 @@ describe('AuthGuard', () => {
3034
mockApiKeysService = {
3135
validateKey: vi.fn(),
3236
};
37+
mockReflector = {
38+
getAllAndOverride: vi.fn(),
39+
};
3340

3441
// Create guard with mocked dependencies
3542
guard = new AuthGuard(
3643
mockAuthService as unknown as AuthService,
3744
mockApiKeysService as unknown as ApiKeysService,
45+
mockReflector as unknown as Reflector,
3846
);
3947

4048
// Setup mock request
@@ -50,6 +58,8 @@ describe('AuthGuard', () => {
5058
switchToHttp: vi.fn(() => ({
5159
getRequest: vi.fn(() => mockRequest),
5260
})),
61+
getHandler: vi.fn(),
62+
getClass: vi.fn(),
5363
} as unknown as ExecutionContext;
5464
});
5565

@@ -59,6 +69,8 @@ describe('AuthGuard', () => {
5969
switchToHttp: vi.fn(() => ({
6070
getRequest: vi.fn(() => null),
6171
})),
72+
getHandler: vi.fn(),
73+
getClass: vi.fn(),
6274
} as unknown as ExecutionContext;
6375

6476
const result = await guard.canActivate(contextWithoutRequest);
@@ -565,5 +577,35 @@ describe('AuthGuard', () => {
565577
expect(mockRequest.auth?.provider).toBe('api-key');
566578
});
567579
});
580+
581+
describe('Public decorator', () => {
582+
it('should allow access to endpoints marked as public', async () => {
583+
mockReflector.getAllAndOverride.mockReturnValue(true);
584+
585+
const result = await guard.canActivate(mockExecutionContext);
586+
587+
expect(result).toBe(true);
588+
expect(mockAuthService.authenticate).not.toHaveBeenCalled();
589+
expect(mockApiKeysService.validateKey).not.toHaveBeenCalled();
590+
expect(mockRequest.auth).toBeUndefined();
591+
});
592+
593+
it('should continue authentication for endpoints not marked as public', async () => {
594+
mockReflector.getAllAndOverride.mockReturnValue(false);
595+
mockAuthService.authenticate.mockResolvedValue({
596+
userId: 'user-1',
597+
organizationId: 'org-1',
598+
roles: ['MEMBER'],
599+
isAuthenticated: true,
600+
provider: 'clerk',
601+
});
602+
603+
const result = await guard.canActivate(mockExecutionContext);
604+
605+
expect(result).toBe(true);
606+
expect(mockAuthService.authenticate).toHaveBeenCalled();
607+
expect(mockRequest.auth?.userId).toBe('user-1');
608+
});
609+
});
568610
});
569611

backend/src/auth/auth.guard.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { CanActivate, ExecutionContext } from '@nestjs/common';
22
import { Injectable, Logger, UnauthorizedException, Inject, forwardRef } from '@nestjs/common';
3+
import { Reflector } from '@nestjs/core';
34
import type { Request } from 'express';
45

56
import { AuthService } from './auth.service';
67
import { ApiKeysService } from '../api-keys/api-keys.service';
8+
import { IS_PUBLIC_KEY } from './public.decorator';
79
import type { AuthContext } from './types';
810
import { DEFAULT_ROLES } from './types';
911
import { DEFAULT_ORGANIZATION_ID } from './constants';
@@ -20,9 +22,19 @@ export class AuthGuard implements CanActivate {
2022
private readonly authService: AuthService,
2123
@Inject(forwardRef(() => ApiKeysService))
2224
private readonly apiKeysService: ApiKeysService,
25+
private readonly reflector: Reflector,
2326
) {}
2427

2528
async canActivate(context: ExecutionContext): Promise<boolean> {
29+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
30+
context.getHandler(),
31+
context.getClass(),
32+
]);
33+
34+
if (isPublic) {
35+
return true;
36+
}
37+
2638
const http = context.switchToHttp();
2739
const request = http.getRequest<RequestWithAuthContext>();
2840
if (!request) {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
3+
export const IS_PUBLIC_KEY = 'isPublic';
4+
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

backend/src/database/schema/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './workflow-roles';
1212
export * from './integrations';
1313
export * from './workflow-schedules';
1414
export * from './human-input-requests';
15+
export * from './webhooks';
1516

1617
export * from './terminal-records';
1718
export * from './agent-trace-events';
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { integer, jsonb, pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
2+
3+
export const webhookConfigurationsTable = pgTable('webhook_configurations', {
4+
id: uuid('id').primaryKey().defaultRandom(),
5+
workflowId: uuid('workflow_id').notNull(),
6+
workflowVersionId: uuid('workflow_version_id'),
7+
workflowVersion: integer('workflow_version'),
8+
name: text('name').notNull(),
9+
description: text('description'),
10+
webhookPath: varchar('webhook_path', { length: 255 }).notNull().unique(),
11+
parsingScript: text('parsing_script').notNull(),
12+
expectedInputs: jsonb('expected_inputs').notNull().$type<Array<{
13+
id: string;
14+
label: string;
15+
type: 'text' | 'number' | 'json' | 'array' | 'file';
16+
required: boolean;
17+
description?: string;
18+
}>>(),
19+
status: text('status').notNull().default('active').$type<'active' | 'inactive'>(),
20+
organizationId: varchar('organization_id', { length: 191 }),
21+
createdBy: varchar('created_by', { length: 191 }),
22+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
23+
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
24+
});
25+
26+
export const webhookDeliveriesTable = pgTable('webhook_deliveries', {
27+
id: uuid('id').primaryKey().defaultRandom(),
28+
webhookId: uuid('webhook_id').notNull().references(() => webhookConfigurationsTable.id, { onDelete: 'cascade' }),
29+
workflowRunId: text('workflow_run_id'),
30+
status: text('status').notNull().default('processing').$type<'processing' | 'delivered' | 'failed'>(),
31+
payload: jsonb('payload').notNull().$type<Record<string, unknown>>(),
32+
headers: jsonb('headers').$type<Record<string, string> | undefined>(),
33+
parsedData: jsonb('parsed_data').$type<Record<string, unknown>>(),
34+
errorMessage: text('error_message'),
35+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
36+
completedAt: timestamp('completed_at', { withTimezone: true }),
37+
});
38+
39+
export type WebhookConfigurationRecord = typeof webhookConfigurationsTable.$inferSelect;
40+
export type WebhookConfigurationInsert = typeof webhookConfigurationsTable.$inferInsert;
41+
export type WebhookDeliveryRecord = typeof webhookDeliveriesTable.$inferSelect;
42+
export type WebhookDeliveryInsert = typeof webhookDeliveriesTable.$inferInsert;

0 commit comments

Comments
 (0)