Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0f08df3
feat(webhooks): add smart webhooks with parsing scripts
betterclever Jan 1, 2026
eaa1405
feat(frontend): add Smart Webhook Manager to sidebar navigation
betterclever Jan 1, 2026
f8d768c
refactor: add dedicated runtime-inputs endpoint for cleaner API design
betterclever Jan 1, 2026
ade98ef
feat: use Monaco editor for webhook script and clean up API usage
betterclever Jan 1, 2026
ae84e97
feat(webhooks): valid implementation of webhook editor and delivery h…
betterclever Jan 1, 2026
b581ac9
feat(webhooks): fix save button state, input mapping, and auto-popula…
betterclever Jan 2, 2026
a9ccdb9
feat(webhooks): make webhook editor URL-driven with tab routes
betterclever Jan 2, 2026
be88039
feat(webhooks): improve webhooks list page UX
betterclever Jan 2, 2026
e161262
feat(webhooks): add webhooks sidebar to workflow editor
betterclever Jan 2, 2026
3bfbe42
feat(webhooks): open webhooks sidebar from entrypoint node button
betterclever Jan 2, 2026
04b45fd
feat(webhooks): add stateful back navigation from webhook editor
betterclever Jan 2, 2026
4ddc6fb
feat(webhooks): add view code button to webhooks sidebar entries
betterclever Jan 2, 2026
b5cbc51
feat(webhooks): allow custom webhooks without API key auth in snippets
betterclever Jan 2, 2026
e127da7
feat(webhooks): add comment to custom webhook snippets about payload …
betterclever Jan 2, 2026
28f7bc9
feat(webhooks): enable public inbound webhooks and fix triggering con…
betterclever Jan 2, 2026
a0af100
fix(webhooks): correct delivery status badge logic in frontend
betterclever Jan 2, 2026
977baaf
fix(webhooks): use correct property name for workflow run id in frontend
betterclever Jan 2, 2026
87cdf01
refactor(webhooks): add proper typing to webhook API and frontend com…
betterclever Jan 2, 2026
d5483aa
fix(auth): fix AuthGuard unit tests after Public decorator implementa…
betterclever Jan 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions backend/drizzle/0018_create-webhooks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS "webhook_configurations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"workflow_id" uuid NOT NULL,
"workflow_version_id" uuid,
"workflow_version" integer,
"name" text NOT NULL,
"description" text,
"webhook_path" varchar(255) UNIQUE NOT NULL,
"parsing_script" text NOT NULL,
"expected_inputs" jsonb NOT NULL,
"status" text NOT NULL DEFAULT 'active',
"organization_id" varchar(191),
"created_by" varchar(191),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS "webhook_deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"webhook_id" uuid NOT NULL REFERENCES "webhook_configurations"("id") ON DELETE CASCADE,
"workflow_run_id" text,
"status" text NOT NULL DEFAULT 'processing',
"payload" jsonb NOT NULL,
"headers" jsonb,
"parsed_data" jsonb,
"error_message" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"completed_at" timestamptz
);

CREATE INDEX IF NOT EXISTS "webhook_configurations_workflow_idx" ON "webhook_configurations" ("workflow_id", "status");
CREATE INDEX IF NOT EXISTS "webhook_configurations_path_idx" ON "webhook_configurations" ("webhook_path");
CREATE INDEX IF NOT EXISTS "webhook_deliveries_webhook_idx" ON "webhook_deliveries" ("webhook_id", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "webhook_deliveries_run_id_idx" ON "webhook_deliveries" ("workflow_run_id");
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.6",
"ioredis": "^5.4.1",
Expand Down
42 changes: 42 additions & 0 deletions backend/src/auth/__tests__/auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'bun:test';
import type { ExecutionContext } from '@nestjs/common';
import { UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';

import { AuthGuard, type RequestWithAuthContext } from '../auth.guard';
Expand All @@ -18,6 +19,9 @@ describe('AuthGuard', () => {
let mockApiKeysService: {
validateKey: ReturnType<typeof vi.fn>;
};
let mockReflector: {
getAllAndOverride: ReturnType<typeof vi.fn>;
};
let mockExecutionContext: ExecutionContext;
let mockRequest: RequestWithAuthContext;

Expand All @@ -30,11 +34,15 @@ describe('AuthGuard', () => {
mockApiKeysService = {
validateKey: vi.fn(),
};
mockReflector = {
getAllAndOverride: vi.fn(),
};

// Create guard with mocked dependencies
guard = new AuthGuard(
mockAuthService as unknown as AuthService,
mockApiKeysService as unknown as ApiKeysService,
mockReflector as unknown as Reflector,
);

// Setup mock request
Expand All @@ -50,6 +58,8 @@ describe('AuthGuard', () => {
switchToHttp: vi.fn(() => ({
getRequest: vi.fn(() => mockRequest),
})),
getHandler: vi.fn(),
getClass: vi.fn(),
} as unknown as ExecutionContext;
});

Expand All @@ -59,6 +69,8 @@ describe('AuthGuard', () => {
switchToHttp: vi.fn(() => ({
getRequest: vi.fn(() => null),
})),
getHandler: vi.fn(),
getClass: vi.fn(),
} as unknown as ExecutionContext;

const result = await guard.canActivate(contextWithoutRequest);
Expand Down Expand Up @@ -565,5 +577,35 @@ describe('AuthGuard', () => {
expect(mockRequest.auth?.provider).toBe('api-key');
});
});

describe('Public decorator', () => {
it('should allow access to endpoints marked as public', async () => {
mockReflector.getAllAndOverride.mockReturnValue(true);

const result = await guard.canActivate(mockExecutionContext);

expect(result).toBe(true);
expect(mockAuthService.authenticate).not.toHaveBeenCalled();
expect(mockApiKeysService.validateKey).not.toHaveBeenCalled();
expect(mockRequest.auth).toBeUndefined();
});

it('should continue authentication for endpoints not marked as public', async () => {
mockReflector.getAllAndOverride.mockReturnValue(false);
mockAuthService.authenticate.mockResolvedValue({
userId: 'user-1',
organizationId: 'org-1',
roles: ['MEMBER'],
isAuthenticated: true,
provider: 'clerk',
});

const result = await guard.canActivate(mockExecutionContext);

expect(result).toBe(true);
expect(mockAuthService.authenticate).toHaveBeenCalled();
expect(mockRequest.auth?.userId).toBe('user-1');
});
});
});

12 changes: 12 additions & 0 deletions backend/src/auth/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException, Inject, forwardRef } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';

import { AuthService } from './auth.service';
import { ApiKeysService } from '../api-keys/api-keys.service';
import { IS_PUBLIC_KEY } from './public.decorator';
import type { AuthContext } from './types';
import { DEFAULT_ROLES } from './types';
import { DEFAULT_ORGANIZATION_ID } from './constants';
Expand All @@ -20,9 +22,19 @@ export class AuthGuard implements CanActivate {
private readonly authService: AuthService,
@Inject(forwardRef(() => ApiKeysService))
private readonly apiKeysService: ApiKeysService,
private readonly reflector: Reflector,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);

if (isPublic) {
return true;
}

const http = context.switchToHttp();
const request = http.getRequest<RequestWithAuthContext>();
if (!request) {
Expand Down
4 changes: 4 additions & 0 deletions backend/src/auth/public.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
1 change: 1 addition & 0 deletions backend/src/database/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './workflow-roles';
export * from './integrations';
export * from './workflow-schedules';
export * from './human-input-requests';
export * from './webhooks';

export * from './terminal-records';
export * from './agent-trace-events';
Expand Down
42 changes: 42 additions & 0 deletions backend/src/database/schema/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { integer, jsonb, pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';

export const webhookConfigurationsTable = pgTable('webhook_configurations', {
id: uuid('id').primaryKey().defaultRandom(),
workflowId: uuid('workflow_id').notNull(),
workflowVersionId: uuid('workflow_version_id'),
workflowVersion: integer('workflow_version'),
name: text('name').notNull(),
description: text('description'),
webhookPath: varchar('webhook_path', { length: 255 }).notNull().unique(),
parsingScript: text('parsing_script').notNull(),
expectedInputs: jsonb('expected_inputs').notNull().$type<Array<{
id: string;
label: string;
type: 'text' | 'number' | 'json' | 'array' | 'file';
required: boolean;
description?: string;
}>>(),
status: text('status').notNull().default('active').$type<'active' | 'inactive'>(),
organizationId: varchar('organization_id', { length: 191 }),
createdBy: varchar('created_by', { length: 191 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

export const webhookDeliveriesTable = pgTable('webhook_deliveries', {
id: uuid('id').primaryKey().defaultRandom(),
webhookId: uuid('webhook_id').notNull().references(() => webhookConfigurationsTable.id, { onDelete: 'cascade' }),
workflowRunId: text('workflow_run_id'),
status: text('status').notNull().default('processing').$type<'processing' | 'delivered' | 'failed'>(),
payload: jsonb('payload').notNull().$type<Record<string, unknown>>(),
headers: jsonb('headers').$type<Record<string, string> | undefined>(),
parsedData: jsonb('parsed_data').$type<Record<string, unknown>>(),
errorMessage: text('error_message'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
});

export type WebhookConfigurationRecord = typeof webhookConfigurationsTable.$inferSelect;
export type WebhookConfigurationInsert = typeof webhookConfigurationsTable.$inferInsert;
export type WebhookDeliveryRecord = typeof webhookDeliveriesTable.$inferSelect;
export type WebhookDeliveryInsert = typeof webhookDeliveriesTable.$inferInsert;
Loading