@auxiora/webhooks 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts ADDED
@@ -0,0 +1,78 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { getLogger } from '@auxiora/logger';
4
+ import type { WebhookDefinition } from './types.js';
5
+
6
+ const logger = getLogger('webhooks:store');
7
+
8
+ export class WebhookStore {
9
+ private filePath: string;
10
+
11
+ constructor(filePath: string) {
12
+ this.filePath = filePath;
13
+ }
14
+
15
+ async save(webhook: WebhookDefinition): Promise<void> {
16
+ const webhooks = await this.readFile();
17
+ const existing = webhooks.find((w) => w.name === webhook.name && w.id !== webhook.id);
18
+ if (existing) {
19
+ throw new Error(`Webhook with name '${webhook.name}' already exists`);
20
+ }
21
+
22
+ const index = webhooks.findIndex((w) => w.id === webhook.id);
23
+ if (index >= 0) {
24
+ webhooks[index] = webhook;
25
+ } else {
26
+ webhooks.push(webhook);
27
+ }
28
+
29
+ await this.writeFile(webhooks);
30
+ logger.debug('Saved webhook', { id: webhook.id, name: webhook.name });
31
+ }
32
+
33
+ async get(id: string): Promise<WebhookDefinition | undefined> {
34
+ const webhooks = await this.readFile();
35
+ return webhooks.find((w) => w.id === id);
36
+ }
37
+
38
+ async getByName(name: string): Promise<WebhookDefinition | undefined> {
39
+ const webhooks = await this.readFile();
40
+ return webhooks.find((w) => w.name === name);
41
+ }
42
+
43
+ async getAll(): Promise<WebhookDefinition[]> {
44
+ return this.readFile();
45
+ }
46
+
47
+ async listEnabled(): Promise<WebhookDefinition[]> {
48
+ const webhooks = await this.readFile();
49
+ return webhooks.filter((w) => w.enabled);
50
+ }
51
+
52
+ async remove(id: string): Promise<boolean> {
53
+ const webhooks = await this.readFile();
54
+ const filtered = webhooks.filter((w) => w.id !== id);
55
+ if (filtered.length === webhooks.length) return false;
56
+ await this.writeFile(filtered);
57
+ logger.debug('Removed webhook', { id });
58
+ return true;
59
+ }
60
+
61
+ private async readFile(): Promise<WebhookDefinition[]> {
62
+ try {
63
+ const content = await fs.readFile(this.filePath, 'utf-8');
64
+ return JSON.parse(content) as WebhookDefinition[];
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
67
+ return [];
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ private async writeFile(webhooks: WebhookDefinition[]): Promise<void> {
74
+ const dir = path.dirname(this.filePath);
75
+ await fs.mkdir(dir, { recursive: true });
76
+ await fs.writeFile(this.filePath, JSON.stringify(webhooks, null, 2), 'utf-8');
77
+ }
78
+ }
package/src/types.ts ADDED
@@ -0,0 +1,25 @@
1
+ export interface WebhookDefinition {
2
+ id: string;
3
+ name: string;
4
+ type: 'channel' | 'generic';
5
+ channelType?: string;
6
+ secret: string;
7
+ behaviorId?: string;
8
+ transform?: string;
9
+ enabled: boolean;
10
+ createdAt: string;
11
+ }
12
+
13
+ export interface WebhookConfig {
14
+ enabled: boolean;
15
+ basePath: string;
16
+ signatureHeader: string;
17
+ maxPayloadSize: number;
18
+ }
19
+
20
+ export const DEFAULT_WEBHOOK_CONFIG: WebhookConfig = {
21
+ enabled: false,
22
+ basePath: '/api/v1/webhooks',
23
+ signatureHeader: 'x-webhook-signature',
24
+ maxPayloadSize: 65536,
25
+ };
package/src/verify.ts ADDED
@@ -0,0 +1,47 @@
1
+ import * as crypto from 'node:crypto';
2
+
3
+ /**
4
+ * Verify HMAC-SHA256 signature using timing-safe comparison.
5
+ * Handles optional "sha256=" prefix (GitHub style).
6
+ */
7
+ export function verifyHmacSha256(body: Buffer, secret: string, signature: string): boolean {
8
+ if (!signature) return false;
9
+
10
+ const sig = signature.startsWith('sha256=') ? signature.slice(7) : signature;
11
+
12
+ let sigBuffer: Buffer;
13
+ try {
14
+ sigBuffer = Buffer.from(sig, 'hex');
15
+ } catch {
16
+ return false;
17
+ }
18
+
19
+ const expected = crypto.createHmac('sha256', secret).update(body).digest();
20
+
21
+ if (sigBuffer.length !== expected.length) return false;
22
+
23
+ return crypto.timingSafeEqual(sigBuffer, expected);
24
+ }
25
+
26
+ /**
27
+ * Verify Twilio webhook signature (HMAC-SHA1, base64).
28
+ * Twilio signs: URL + sorted(key+value pairs), HMAC-SHA1, base64.
29
+ */
30
+ export function verifyTwilioSignature(
31
+ url: string,
32
+ params: Record<string, string>,
33
+ authToken: string,
34
+ signature: string
35
+ ): boolean {
36
+ if (!signature) return false;
37
+
38
+ const data = url + Object.keys(params).sort().reduce((acc, key) => acc + key + params[key], '');
39
+ const expected = crypto.createHmac('sha1', authToken).update(data).digest('base64');
40
+
41
+ const sigBuffer = Buffer.from(signature);
42
+ const expectedBuffer = Buffer.from(expected);
43
+
44
+ if (sigBuffer.length !== expectedBuffer.length) return false;
45
+
46
+ return crypto.timingSafeEqual(sigBuffer, expectedBuffer);
47
+ }
@@ -0,0 +1,147 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { getLogger } from '@auxiora/logger';
3
+ import { audit } from '@auxiora/audit';
4
+ import { WebhookStore } from './store.js';
5
+ import type { WebhookDefinition, WebhookConfig } from './types.js';
6
+ import { DEFAULT_WEBHOOK_CONFIG } from './types.js';
7
+ import { verifyHmacSha256 } from './verify.js';
8
+
9
+ const logger = getLogger('webhooks:manager');
10
+
11
+ export interface WebhookManagerOptions {
12
+ storePath: string;
13
+ config?: WebhookConfig;
14
+ onBehaviorTrigger?: (behaviorId: string, payload: string) => Promise<{ success: boolean; error?: string }>;
15
+ }
16
+
17
+ export interface CreateWebhookOptions {
18
+ name: string;
19
+ type: 'channel' | 'generic';
20
+ secret: string;
21
+ channelType?: string;
22
+ behaviorId?: string;
23
+ enabled?: boolean;
24
+ }
25
+
26
+ export interface WebhookResult {
27
+ accepted: boolean;
28
+ status: number;
29
+ error?: string;
30
+ }
31
+
32
+ export class WebhookManager {
33
+ private store: WebhookStore;
34
+ private config: WebhookConfig;
35
+ private behaviorTrigger?: (behaviorId: string, payload: string) => Promise<{ success: boolean; error?: string }>;
36
+
37
+ constructor(options: WebhookManagerOptions) {
38
+ this.store = new WebhookStore(options.storePath);
39
+ this.config = options.config ?? DEFAULT_WEBHOOK_CONFIG;
40
+ this.behaviorTrigger = options.onBehaviorTrigger;
41
+ }
42
+
43
+ async create(options: CreateWebhookOptions): Promise<WebhookDefinition> {
44
+ const webhook: WebhookDefinition = {
45
+ id: crypto.randomUUID(),
46
+ name: options.name,
47
+ type: options.type,
48
+ secret: options.secret,
49
+ channelType: options.channelType,
50
+ behaviorId: options.behaviorId,
51
+ enabled: options.enabled ?? true,
52
+ createdAt: new Date().toISOString(),
53
+ };
54
+
55
+ await this.store.save(webhook);
56
+ void audit('webhook.created', { name: webhook.name, type: webhook.type });
57
+ logger.info('Webhook created', { id: webhook.id, name: webhook.name });
58
+ return webhook;
59
+ }
60
+
61
+ async list(): Promise<WebhookDefinition[]> {
62
+ return this.store.getAll();
63
+ }
64
+
65
+ async update(
66
+ id: string,
67
+ updates: Partial<Pick<WebhookDefinition, 'name' | 'enabled' | 'secret' | 'behaviorId'>>,
68
+ ): Promise<WebhookDefinition | null> {
69
+ const webhook = await this.store.get(id);
70
+ if (!webhook) return null;
71
+
72
+ const updated: WebhookDefinition = { ...webhook, ...updates };
73
+ await this.store.save(updated);
74
+ void audit('webhook.updated', { id, name: updated.name });
75
+ logger.info('Webhook updated', { id, name: updated.name });
76
+ return updated;
77
+ }
78
+
79
+ async delete(id: string): Promise<boolean> {
80
+ const webhook = await this.store.get(id);
81
+ const removed = await this.store.remove(id);
82
+ if (removed && webhook) {
83
+ void audit('webhook.deleted', { name: webhook.name });
84
+ logger.info('Webhook deleted', { id, name: webhook.name });
85
+ }
86
+ return removed;
87
+ }
88
+
89
+ async handleGenericWebhook(
90
+ name: string,
91
+ body: Buffer,
92
+ headers: Record<string, string>
93
+ ): Promise<WebhookResult> {
94
+ // Check payload size
95
+ if (body.length > this.config.maxPayloadSize) {
96
+ logger.warn('Webhook payload too large', { name, size: body.length });
97
+ return { accepted: false, status: 413, error: 'Payload too large' };
98
+ }
99
+
100
+ // Find webhook
101
+ const webhook = await this.store.getByName(name);
102
+ if (!webhook || !webhook.enabled) {
103
+ return { accepted: false, status: 404, error: 'Not found' };
104
+ }
105
+
106
+ // Verify signature
107
+ const signature = headers[this.config.signatureHeader] ?? '';
108
+ if (!verifyHmacSha256(body, webhook.secret, signature)) {
109
+ void audit('webhook.signature_failed', { name });
110
+ logger.warn('Webhook signature verification failed', { name });
111
+ return { accepted: false, status: 401, error: 'Unauthorized' };
112
+ }
113
+
114
+ void audit('webhook.received', {
115
+ name,
116
+ type: webhook.type,
117
+ payloadSize: body.length,
118
+ });
119
+
120
+ // Trigger behavior asynchronously
121
+ if (webhook.behaviorId && this.behaviorTrigger) {
122
+ const payload = body.toString('utf-8');
123
+ this.triggerBehavior(webhook.name, webhook.behaviorId, payload);
124
+ }
125
+
126
+ return { accepted: true, status: 202 };
127
+ }
128
+
129
+ private triggerBehavior(webhookName: string, behaviorId: string, payload: string): void {
130
+ if (!this.behaviorTrigger) return;
131
+
132
+ this.behaviorTrigger(behaviorId, payload)
133
+ .then(() => {
134
+ void audit('webhook.triggered', { name: webhookName, behaviorId });
135
+ logger.info('Webhook triggered behavior', { webhookName, behaviorId });
136
+ })
137
+ .catch((error) => {
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ void audit('webhook.error', { name: webhookName, error: message });
140
+ logger.error('Webhook behavior trigger failed', { error: new Error(message), webhookName, behaviorId });
141
+ });
142
+ }
143
+
144
+ setBehaviorTrigger(trigger: (behaviorId: string, payload: string) => Promise<{ success: boolean; error?: string }>): void {
145
+ this.behaviorTrigger = trigger;
146
+ }
147
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import * as crypto from 'node:crypto';
6
+ import { WebhookManager } from '../src/webhook-manager.js';
7
+ import { DEFAULT_WEBHOOK_CONFIG } from '../src/types.js';
8
+
9
+ describe('Webhook integration', () => {
10
+ let manager: WebhookManager;
11
+ let tmpDir: string;
12
+ let mockBehaviorTrigger: ReturnType<typeof vi.fn>;
13
+
14
+ beforeEach(async () => {
15
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webhook-int-'));
16
+ mockBehaviorTrigger = vi.fn().mockResolvedValue({ success: true });
17
+
18
+ manager = new WebhookManager({
19
+ storePath: path.join(tmpDir, 'webhooks.json'),
20
+ config: { ...DEFAULT_WEBHOOK_CONFIG, enabled: true },
21
+ onBehaviorTrigger: mockBehaviorTrigger,
22
+ });
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await fs.rm(tmpDir, { recursive: true, force: true });
27
+ });
28
+
29
+ it('should handle full create → receive → trigger flow', async () => {
30
+ // 1. Create webhook
31
+ const secret = 'integration-test-secret';
32
+ const webhook = await manager.create({
33
+ name: 'github-push',
34
+ type: 'generic',
35
+ secret,
36
+ behaviorId: 'summarize-commits',
37
+ });
38
+ expect(webhook.name).toBe('github-push');
39
+
40
+ // 2. Simulate incoming webhook
41
+ const payload = JSON.stringify({ ref: 'refs/heads/main', commits: [{ message: 'fix bug' }] });
42
+ const body = Buffer.from(payload);
43
+ const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
44
+
45
+ const result = await manager.handleGenericWebhook('github-push', body, {
46
+ 'x-webhook-signature': signature,
47
+ });
48
+
49
+ expect(result.accepted).toBe(true);
50
+ expect(result.status).toBe(202);
51
+
52
+ // 3. Wait for async behavior trigger
53
+ await new Promise((resolve) => setTimeout(resolve, 10));
54
+ expect(mockBehaviorTrigger).toHaveBeenCalledWith('summarize-commits', payload);
55
+ });
56
+
57
+ it('should reject webhook after deletion', async () => {
58
+ const secret = 'temp-secret';
59
+ const webhook = await manager.create({ name: 'temp', type: 'generic', secret });
60
+ await manager.delete(webhook.id);
61
+
62
+ const body = Buffer.from('{}');
63
+ const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
64
+
65
+ const result = await manager.handleGenericWebhook('temp', body, {
66
+ 'x-webhook-signature': signature,
67
+ });
68
+
69
+ expect(result.accepted).toBe(false);
70
+ expect(result.status).toBe(404);
71
+ });
72
+
73
+ it('should handle signature failure with audit trail', async () => {
74
+ await manager.create({ name: 'secure', type: 'generic', secret: 'real-secret' });
75
+
76
+ const result = await manager.handleGenericWebhook('secure', Buffer.from('{}'), {
77
+ 'x-webhook-signature': 'forged-signature',
78
+ });
79
+
80
+ expect(result.accepted).toBe(false);
81
+ expect(result.status).toBe(401);
82
+ });
83
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { WebhookStore } from '../src/store.js';
6
+ import type { WebhookDefinition } from '../src/types.js';
7
+
8
+ function makeWebhook(overrides: Partial<WebhookDefinition> = {}): WebhookDefinition {
9
+ return {
10
+ id: 'wh-1',
11
+ name: 'test-webhook',
12
+ type: 'generic',
13
+ secret: 'test-secret',
14
+ enabled: true,
15
+ createdAt: new Date().toISOString(),
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('WebhookStore', () => {
21
+ let store: WebhookStore;
22
+ let tmpDir: string;
23
+ let filePath: string;
24
+
25
+ beforeEach(async () => {
26
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webhook-store-'));
27
+ filePath = path.join(tmpDir, 'webhooks.json');
28
+ store = new WebhookStore(filePath);
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await fs.rm(tmpDir, { recursive: true, force: true });
33
+ });
34
+
35
+ it('should save and retrieve a webhook', async () => {
36
+ const webhook = makeWebhook();
37
+ await store.save(webhook);
38
+ const result = await store.get('wh-1');
39
+ expect(result).toEqual(webhook);
40
+ });
41
+
42
+ it('should retrieve by name', async () => {
43
+ await store.save(makeWebhook());
44
+ const result = await store.getByName('test-webhook');
45
+ expect(result?.id).toBe('wh-1');
46
+ });
47
+
48
+ it('should reject duplicate names', async () => {
49
+ await store.save(makeWebhook());
50
+ await expect(
51
+ store.save(makeWebhook({ id: 'wh-2', name: 'test-webhook' }))
52
+ ).rejects.toThrow('already exists');
53
+ });
54
+
55
+ it('should remove a webhook', async () => {
56
+ await store.save(makeWebhook());
57
+ const removed = await store.remove('wh-1');
58
+ expect(removed).toBe(true);
59
+ expect(await store.get('wh-1')).toBeUndefined();
60
+ });
61
+
62
+ it('should list only enabled webhooks', async () => {
63
+ await store.save(makeWebhook({ id: 'wh-1', name: 'enabled', enabled: true }));
64
+ await store.save(makeWebhook({ id: 'wh-2', name: 'disabled', enabled: false }));
65
+ const enabled = await store.listEnabled();
66
+ expect(enabled).toHaveLength(1);
67
+ expect(enabled[0].name).toBe('enabled');
68
+ });
69
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as crypto from 'node:crypto';
3
+ import { verifyHmacSha256, verifyTwilioSignature } from '../src/verify.js';
4
+
5
+ describe('verifyHmacSha256', () => {
6
+ const secret = 'my-webhook-secret';
7
+
8
+ it('should accept valid HMAC-SHA256 signature', () => {
9
+ const body = Buffer.from('{"event":"push"}');
10
+ const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
11
+ expect(verifyHmacSha256(body, secret, signature)).toBe(true);
12
+ });
13
+
14
+ it('should reject invalid signature', () => {
15
+ const body = Buffer.from('{"event":"push"}');
16
+ expect(verifyHmacSha256(body, secret, 'invalid-signature')).toBe(false);
17
+ });
18
+
19
+ it('should reject tampered body', () => {
20
+ const body = Buffer.from('{"event":"push"}');
21
+ const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
22
+ const tampered = Buffer.from('{"event":"hack"}');
23
+ expect(verifyHmacSha256(tampered, secret, signature)).toBe(false);
24
+ });
25
+
26
+ it('should handle sha256= prefix in signature', () => {
27
+ const body = Buffer.from('test');
28
+ const hash = crypto.createHmac('sha256', secret).update(body).digest('hex');
29
+ expect(verifyHmacSha256(body, secret, `sha256=${hash}`)).toBe(true);
30
+ });
31
+
32
+ it('should reject empty signature', () => {
33
+ const body = Buffer.from('test');
34
+ expect(verifyHmacSha256(body, secret, '')).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe('verifyTwilioSignature', () => {
39
+ const authToken = 'twilio-auth-token';
40
+
41
+ it('should accept valid Twilio signature', () => {
42
+ const url = 'https://example.com/api/v1/webhooks/twilio';
43
+ const params: Record<string, string> = {
44
+ Body: 'Hello',
45
+ From: '+1234567890',
46
+ To: '+0987654321',
47
+ };
48
+
49
+ // Build expected signature the Twilio way:
50
+ // Sort params by key, concatenate key+value, append to URL, HMAC-SHA1, base64
51
+ const data = url + Object.keys(params).sort().reduce((acc, key) => acc + key + params[key], '');
52
+ const expected = crypto.createHmac('sha1', authToken).update(data).digest('base64');
53
+
54
+ expect(verifyTwilioSignature(url, params, authToken, expected)).toBe(true);
55
+ });
56
+
57
+ it('should reject invalid Twilio signature', () => {
58
+ expect(verifyTwilioSignature('https://example.com', {}, authToken, 'bad-sig')).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import * as crypto from 'node:crypto';
6
+ import { WebhookManager } from '../src/webhook-manager.js';
7
+ import type { WebhookDefinition } from '../src/types.js';
8
+ import { DEFAULT_WEBHOOK_CONFIG } from '../src/types.js';
9
+
10
+ function makeWebhook(overrides: Partial<WebhookDefinition> = {}): WebhookDefinition {
11
+ return {
12
+ id: 'wh-1',
13
+ name: 'test-hook',
14
+ type: 'generic',
15
+ secret: 'test-secret-key',
16
+ behaviorId: 'beh-1',
17
+ enabled: true,
18
+ createdAt: new Date().toISOString(),
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe('WebhookManager', () => {
24
+ let manager: WebhookManager;
25
+ let tmpDir: string;
26
+ let filePath: string;
27
+ let mockBehaviorTrigger: ReturnType<typeof vi.fn>;
28
+
29
+ beforeEach(async () => {
30
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webhook-mgr-'));
31
+ filePath = path.join(tmpDir, 'webhooks.json');
32
+ mockBehaviorTrigger = vi.fn().mockResolvedValue({ success: true });
33
+
34
+ manager = new WebhookManager({
35
+ storePath: filePath,
36
+ config: { ...DEFAULT_WEBHOOK_CONFIG, enabled: true },
37
+ onBehaviorTrigger: mockBehaviorTrigger,
38
+ });
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await fs.rm(tmpDir, { recursive: true, force: true });
43
+ });
44
+
45
+ describe('webhook CRUD', () => {
46
+ it('should create a webhook', async () => {
47
+ const webhook = await manager.create({
48
+ name: 'github-push',
49
+ type: 'generic',
50
+ secret: 'my-secret',
51
+ behaviorId: 'beh-1',
52
+ });
53
+ expect(webhook.id).toBeDefined();
54
+ expect(webhook.name).toBe('github-push');
55
+ expect(webhook.enabled).toBe(true);
56
+ });
57
+
58
+ it('should list all webhooks', async () => {
59
+ await manager.create({ name: 'hook-1', type: 'generic', secret: 's1' });
60
+ await manager.create({ name: 'hook-2', type: 'generic', secret: 's2' });
61
+ const all = await manager.list();
62
+ expect(all).toHaveLength(2);
63
+ });
64
+
65
+ it('should update a webhook', async () => {
66
+ const wh = await manager.create({ name: 'original', type: 'generic', secret: 's' });
67
+ const updated = await manager.update(wh.id, { name: 'renamed', enabled: false });
68
+ expect(updated).not.toBeNull();
69
+ expect(updated!.name).toBe('renamed');
70
+ expect(updated!.enabled).toBe(false);
71
+
72
+ const all = await manager.list();
73
+ expect(all[0].name).toBe('renamed');
74
+ });
75
+
76
+ it('should return null when updating non-existent webhook', async () => {
77
+ const result = await manager.update('nonexistent', { name: 'test' });
78
+ expect(result).toBeNull();
79
+ });
80
+
81
+ it('should delete a webhook', async () => {
82
+ const wh = await manager.create({ name: 'to-delete', type: 'generic', secret: 's' });
83
+ const deleted = await manager.delete(wh.id);
84
+ expect(deleted).toBe(true);
85
+ const all = await manager.list();
86
+ expect(all).toHaveLength(0);
87
+ });
88
+ });
89
+
90
+ describe('generic webhook handling', () => {
91
+ it('should verify signature and trigger behavior', async () => {
92
+ const secret = 'my-secret';
93
+ await manager.create({ name: 'test', type: 'generic', secret, behaviorId: 'beh-1' });
94
+
95
+ const body = Buffer.from('{"event":"push"}');
96
+ const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
97
+
98
+ const result = await manager.handleGenericWebhook('test', body, {
99
+ 'x-webhook-signature': signature,
100
+ });
101
+
102
+ expect(result.accepted).toBe(true);
103
+ expect(mockBehaviorTrigger).toHaveBeenCalledWith('beh-1', expect.stringContaining('push'));
104
+ });
105
+
106
+ it('should reject invalid signature', async () => {
107
+ await manager.create({ name: 'test', type: 'generic', secret: 'real-secret' });
108
+
109
+ const body = Buffer.from('{}');
110
+ const result = await manager.handleGenericWebhook('test', body, {
111
+ 'x-webhook-signature': 'bad-sig',
112
+ });
113
+
114
+ expect(result.accepted).toBe(false);
115
+ expect(result.status).toBe(401);
116
+ });
117
+
118
+ it('should reject unknown webhook name', async () => {
119
+ const result = await manager.handleGenericWebhook('nonexistent', Buffer.from('{}'), {});
120
+ expect(result.accepted).toBe(false);
121
+ expect(result.status).toBe(404);
122
+ });
123
+
124
+ it('should reject disabled webhook', async () => {
125
+ const wh = await manager.create({ name: 'disabled', type: 'generic', secret: 's', enabled: false });
126
+
127
+ const result = await manager.handleGenericWebhook('disabled', Buffer.from('{}'), {});
128
+ expect(result.accepted).toBe(false);
129
+ expect(result.status).toBe(404);
130
+ });
131
+
132
+ it('should handle missing behavior gracefully', async () => {
133
+ mockBehaviorTrigger.mockRejectedValueOnce(new Error('Behavior not found'));
134
+ const secret = 'my-secret';
135
+ await manager.create({ name: 'orphan', type: 'generic', secret, behaviorId: 'missing' });
136
+
137
+ const body = Buffer.from('{}');
138
+ const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
139
+
140
+ const result = await manager.handleGenericWebhook('orphan', body, {
141
+ 'x-webhook-signature': signature,
142
+ });
143
+
144
+ // Still accepted (202) — don't leak internal state
145
+ expect(result.accepted).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe('payload size', () => {
150
+ it('should reject oversized payloads', async () => {
151
+ await manager.create({ name: 'test', type: 'generic', secret: 's' });
152
+ const oversized = Buffer.alloc(DEFAULT_WEBHOOK_CONFIG.maxPayloadSize + 1);
153
+
154
+ const result = await manager.handleGenericWebhook('test', oversized, {});
155
+ expect(result.accepted).toBe(false);
156
+ expect(result.status).toBe(413);
157
+ });
158
+ });
159
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [
9
+ { "path": "../logger" },
10
+ { "path": "../audit" }
11
+ ]
12
+ }