@vobase/core 0.10.0 → 0.11.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/package.json +7 -9
- package/src/__tests__/drizzle-introspection.test.ts +77 -0
- package/src/__tests__/e2e.test.ts +225 -0
- package/src/__tests__/permissions.test.ts +157 -0
- package/src/__tests__/rpc-types.test.ts +92 -0
- package/src/app.test.ts +99 -0
- package/src/app.ts +178 -0
- package/src/audit.test.ts +126 -0
- package/src/auth.test.ts +74 -0
- package/src/contracts/auth.ts +37 -0
- package/{dist/contracts/module.d.ts → src/contracts/module.ts} +6 -6
- package/src/contracts/notify.ts +47 -0
- package/src/contracts/permissions.ts +10 -0
- package/src/contracts/storage.ts +61 -0
- package/src/ctx.test.ts +162 -0
- package/src/ctx.ts +64 -0
- package/src/db/client.test.ts +75 -0
- package/src/db/client.ts +15 -0
- package/src/db/helpers.test.ts +147 -0
- package/src/db/helpers.ts +51 -0
- package/src/db/index.ts +8 -0
- package/{dist/index.d.ts → src/index.ts} +103 -6
- package/src/infra/circuit-breaker.test.ts +74 -0
- package/src/infra/circuit-breaker.ts +57 -0
- package/src/infra/errors.test.ts +175 -0
- package/src/infra/errors.ts +64 -0
- package/src/infra/http-client.test.ts +482 -0
- package/src/infra/http-client.ts +221 -0
- package/src/infra/index.ts +35 -0
- package/src/infra/job.test.ts +85 -0
- package/src/infra/job.ts +94 -0
- package/src/infra/logger.test.ts +65 -0
- package/src/infra/logger.ts +18 -0
- package/src/infra/queue.test.ts +46 -0
- package/src/infra/queue.ts +147 -0
- package/src/infra/throw-proxy.test.ts +34 -0
- package/src/infra/throw-proxy.ts +17 -0
- package/src/infra/webhooks-schema.ts +17 -0
- package/src/infra/webhooks.test.ts +364 -0
- package/src/infra/webhooks.ts +146 -0
- package/src/mcp/auth.test.ts +129 -0
- package/src/mcp/crud.test.ts +128 -0
- package/src/mcp/crud.ts +171 -0
- package/{dist/mcp/index.d.ts → src/mcp/index.ts} +0 -1
- package/src/mcp/server.test.ts +153 -0
- package/src/mcp/server.ts +178 -0
- package/src/middleware/audit.test.ts +169 -0
- package/src/module-registry.ts +18 -0
- package/src/module.test.ts +168 -0
- package/src/module.ts +111 -0
- package/src/modules/audit/index.ts +18 -0
- package/src/modules/audit/middleware.ts +33 -0
- package/src/modules/audit/schema.ts +35 -0
- package/src/modules/audit/track-changes.ts +70 -0
- package/src/modules/auth/audit-hooks.ts +74 -0
- package/src/modules/auth/index.ts +101 -0
- package/src/modules/auth/middleware.ts +51 -0
- package/src/modules/auth/permissions.ts +46 -0
- package/src/modules/auth/schema.ts +184 -0
- package/src/modules/credentials/encrypt.ts +95 -0
- package/src/modules/credentials/index.ts +15 -0
- package/src/modules/credentials/schema.ts +10 -0
- package/src/modules/notify/index.ts +90 -0
- package/src/modules/notify/notify.test.ts +145 -0
- package/src/modules/notify/providers/resend.ts +47 -0
- package/src/modules/notify/providers/smtp.ts +117 -0
- package/src/modules/notify/providers/waba.ts +82 -0
- package/src/modules/notify/schema.ts +27 -0
- package/src/modules/notify/service.ts +93 -0
- package/src/modules/sequences/index.ts +15 -0
- package/src/modules/sequences/next-sequence.ts +48 -0
- package/src/modules/sequences/schema.ts +12 -0
- package/src/modules/storage/index.ts +44 -0
- package/src/modules/storage/providers/local.ts +124 -0
- package/src/modules/storage/providers/s3.ts +83 -0
- package/src/modules/storage/routes.ts +76 -0
- package/src/modules/storage/schema.ts +26 -0
- package/src/modules/storage/service.ts +202 -0
- package/src/modules/storage/storage.test.ts +225 -0
- package/src/schemas.test.ts +44 -0
- package/src/schemas.ts +63 -0
- package/src/sequence.test.ts +56 -0
- package/dist/app.d.ts +0 -37
- package/dist/app.d.ts.map +0 -1
- package/dist/contracts/auth.d.ts +0 -35
- package/dist/contracts/auth.d.ts.map +0 -1
- package/dist/contracts/module.d.ts.map +0 -1
- package/dist/contracts/notify.d.ts +0 -46
- package/dist/contracts/notify.d.ts.map +0 -1
- package/dist/contracts/permissions.d.ts +0 -10
- package/dist/contracts/permissions.d.ts.map +0 -1
- package/dist/contracts/storage.d.ts +0 -54
- package/dist/contracts/storage.d.ts.map +0 -1
- package/dist/ctx.d.ts +0 -40
- package/dist/ctx.d.ts.map +0 -1
- package/dist/db/client.d.ts +0 -4
- package/dist/db/client.d.ts.map +0 -1
- package/dist/db/helpers.d.ts +0 -26
- package/dist/db/helpers.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -3
- package/dist/db/index.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -98611
- package/dist/infra/circuit-breaker.d.ts +0 -17
- package/dist/infra/circuit-breaker.d.ts.map +0 -1
- package/dist/infra/errors.d.ts +0 -26
- package/dist/infra/errors.d.ts.map +0 -1
- package/dist/infra/http-client.d.ts +0 -31
- package/dist/infra/http-client.d.ts.map +0 -1
- package/dist/infra/index.d.ts +0 -11
- package/dist/infra/index.d.ts.map +0 -1
- package/dist/infra/job.d.ts +0 -14
- package/dist/infra/job.d.ts.map +0 -1
- package/dist/infra/logger.d.ts +0 -7
- package/dist/infra/logger.d.ts.map +0 -1
- package/dist/infra/queue.d.ts +0 -18
- package/dist/infra/queue.d.ts.map +0 -1
- package/dist/infra/throw-proxy.d.ts +0 -7
- package/dist/infra/throw-proxy.d.ts.map +0 -1
- package/dist/infra/webhooks-schema.d.ts +0 -60
- package/dist/infra/webhooks-schema.d.ts.map +0 -1
- package/dist/infra/webhooks.d.ts +0 -46
- package/dist/infra/webhooks.d.ts.map +0 -1
- package/dist/mcp/crud.d.ts +0 -12
- package/dist/mcp/crud.d.ts.map +0 -1
- package/dist/mcp/index.d.ts.map +0 -1
- package/dist/mcp/server.d.ts +0 -16
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/module-registry.d.ts +0 -3
- package/dist/module-registry.d.ts.map +0 -1
- package/dist/module.d.ts +0 -29
- package/dist/module.d.ts.map +0 -1
- package/dist/modules/audit/index.d.ts +0 -5
- package/dist/modules/audit/index.d.ts.map +0 -1
- package/dist/modules/audit/middleware.d.ts +0 -3
- package/dist/modules/audit/middleware.d.ts.map +0 -1
- package/dist/modules/audit/schema.d.ts +0 -247
- package/dist/modules/audit/schema.d.ts.map +0 -1
- package/dist/modules/audit/track-changes.d.ts +0 -3
- package/dist/modules/audit/track-changes.d.ts.map +0 -1
- package/dist/modules/auth/audit-hooks.d.ts +0 -6
- package/dist/modules/auth/audit-hooks.d.ts.map +0 -1
- package/dist/modules/auth/index.d.ts +0 -25
- package/dist/modules/auth/index.d.ts.map +0 -1
- package/dist/modules/auth/middleware.d.ts +0 -15
- package/dist/modules/auth/middleware.d.ts.map +0 -1
- package/dist/modules/auth/permissions.d.ts +0 -5
- package/dist/modules/auth/permissions.d.ts.map +0 -1
- package/dist/modules/auth/schema.d.ts +0 -2519
- package/dist/modules/auth/schema.d.ts.map +0 -1
- package/dist/modules/credentials/encrypt.d.ts +0 -12
- package/dist/modules/credentials/encrypt.d.ts.map +0 -1
- package/dist/modules/credentials/index.d.ts +0 -4
- package/dist/modules/credentials/index.d.ts.map +0 -1
- package/dist/modules/credentials/schema.d.ts +0 -56
- package/dist/modules/credentials/schema.d.ts.map +0 -1
- package/dist/modules/notify/index.d.ts +0 -36
- package/dist/modules/notify/index.d.ts.map +0 -1
- package/dist/modules/notify/providers/resend.d.ts +0 -7
- package/dist/modules/notify/providers/resend.d.ts.map +0 -1
- package/dist/modules/notify/providers/smtp.d.ts +0 -18
- package/dist/modules/notify/providers/smtp.d.ts.map +0 -1
- package/dist/modules/notify/providers/waba.d.ts +0 -12
- package/dist/modules/notify/providers/waba.d.ts.map +0 -1
- package/dist/modules/notify/schema.d.ts +0 -337
- package/dist/modules/notify/schema.d.ts.map +0 -1
- package/dist/modules/notify/service.d.ts +0 -22
- package/dist/modules/notify/service.d.ts.map +0 -1
- package/dist/modules/sequences/index.d.ts +0 -4
- package/dist/modules/sequences/index.d.ts.map +0 -1
- package/dist/modules/sequences/next-sequence.d.ts +0 -8
- package/dist/modules/sequences/next-sequence.d.ts.map +0 -1
- package/dist/modules/sequences/schema.d.ts +0 -72
- package/dist/modules/sequences/schema.d.ts.map +0 -1
- package/dist/modules/storage/index.d.ts +0 -24
- package/dist/modules/storage/index.d.ts.map +0 -1
- package/dist/modules/storage/providers/local.d.ts +0 -3
- package/dist/modules/storage/providers/local.d.ts.map +0 -1
- package/dist/modules/storage/providers/s3.d.ts +0 -3
- package/dist/modules/storage/providers/s3.d.ts.map +0 -1
- package/dist/modules/storage/routes.d.ts +0 -4
- package/dist/modules/storage/routes.d.ts.map +0 -1
- package/dist/modules/storage/schema.d.ts +0 -273
- package/dist/modules/storage/schema.d.ts.map +0 -1
- package/dist/modules/storage/service.d.ts +0 -35
- package/dist/modules/storage/service.d.ts.map +0 -1
- package/dist/schemas.d.ts +0 -19
- package/dist/schemas.d.ts.map +0 -1
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
5
|
+
import type { VobaseDb } from '../db/client';
|
|
6
|
+
import type { Scheduler } from './queue';
|
|
7
|
+
import {
|
|
8
|
+
checkAndRecordWebhook,
|
|
9
|
+
createWebhookRoutes,
|
|
10
|
+
verifyHmacSignature,
|
|
11
|
+
type WebhookConfig,
|
|
12
|
+
} from './webhooks';
|
|
13
|
+
|
|
14
|
+
/** Helper: compute a valid HMAC-SHA256 hex signature for a given payload + secret */
|
|
15
|
+
function computeSignature(payload: string, secret: string): string {
|
|
16
|
+
return createHmac('sha256', secret).update(payload).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Create a Drizzle db with the webhook dedup table */
|
|
20
|
+
function createTestDb(): VobaseDb {
|
|
21
|
+
const sqlite = new Database(':memory:');
|
|
22
|
+
sqlite.run(`CREATE TABLE _webhook_dedup (
|
|
23
|
+
id TEXT NOT NULL,
|
|
24
|
+
source TEXT NOT NULL,
|
|
25
|
+
received_at INTEGER NOT NULL,
|
|
26
|
+
PRIMARY KEY (id, source)
|
|
27
|
+
)`);
|
|
28
|
+
return drizzle({ client: sqlite }) as VobaseDb;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('WebhookConfig', () => {
|
|
32
|
+
test('type is correctly shaped with required and optional fields', () => {
|
|
33
|
+
const config: WebhookConfig = {
|
|
34
|
+
path: '/webhooks/stripe',
|
|
35
|
+
secret: 'whsec_test123',
|
|
36
|
+
handler: 'system:processWebhook',
|
|
37
|
+
};
|
|
38
|
+
expect(config.path).toBe('/webhooks/stripe');
|
|
39
|
+
expect(config.secret).toBe('whsec_test123');
|
|
40
|
+
expect(config.handler).toBe('system:processWebhook');
|
|
41
|
+
expect(config.signatureHeader).toBeUndefined();
|
|
42
|
+
expect(config.dedup).toBeUndefined();
|
|
43
|
+
expect(config.idHeader).toBeUndefined();
|
|
44
|
+
|
|
45
|
+
const fullConfig: WebhookConfig = {
|
|
46
|
+
path: '/webhooks/github',
|
|
47
|
+
secret: 'ghsec_abc',
|
|
48
|
+
handler: 'system:processGithub',
|
|
49
|
+
signatureHeader: 'x-hub-signature-256',
|
|
50
|
+
dedup: false,
|
|
51
|
+
idHeader: 'x-github-delivery',
|
|
52
|
+
};
|
|
53
|
+
expect(fullConfig.signatureHeader).toBe('x-hub-signature-256');
|
|
54
|
+
expect(fullConfig.dedup).toBe(false);
|
|
55
|
+
expect(fullConfig.idHeader).toBe('x-github-delivery');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('verifyHmacSignature', () => {
|
|
60
|
+
const secret = 'test-webhook-secret';
|
|
61
|
+
const payload = '{"event":"payment.completed","id":"evt_123"}';
|
|
62
|
+
|
|
63
|
+
test('verifies valid HMAC-SHA256 signature', () => {
|
|
64
|
+
const signature = computeSignature(payload, secret);
|
|
65
|
+
expect(verifyHmacSignature(payload, signature, secret)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('rejects invalid signature', () => {
|
|
69
|
+
const signature = computeSignature(payload, secret);
|
|
70
|
+
// Flip last character
|
|
71
|
+
const tampered = signature.slice(0, -1) + (signature.endsWith('0') ? '1' : '0');
|
|
72
|
+
expect(verifyHmacSignature(payload, tampered, secret)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('rejects empty signature', () => {
|
|
76
|
+
expect(verifyHmacSignature(payload, '', secret)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('rejects signature with wrong length', () => {
|
|
80
|
+
expect(verifyHmacSignature(payload, 'abcdef', secret)).toBe(false);
|
|
81
|
+
expect(verifyHmacSignature(payload, 'a'.repeat(128), secret)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('uses timing-safe comparison (not ===)', () => {
|
|
85
|
+
const valid = computeSignature(payload, secret);
|
|
86
|
+
expect(verifyHmacSignature(payload, valid, secret)).toBe(true);
|
|
87
|
+
|
|
88
|
+
// Same length but different content — must still reject
|
|
89
|
+
const sameLength = 'f'.repeat(valid.length);
|
|
90
|
+
expect(verifyHmacSignature(payload, sameLength, secret)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('handles different payloads correctly', () => {
|
|
94
|
+
const payload2 = '{"different":"data"}';
|
|
95
|
+
const sig1 = computeSignature(payload, secret);
|
|
96
|
+
const sig2 = computeSignature(payload2, secret);
|
|
97
|
+
|
|
98
|
+
expect(verifyHmacSignature(payload, sig1, secret)).toBe(true);
|
|
99
|
+
expect(verifyHmacSignature(payload2, sig2, secret)).toBe(true);
|
|
100
|
+
// Cross-check: sig for payload1 should not verify payload2
|
|
101
|
+
expect(verifyHmacSignature(payload2, sig1, secret)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('never throws on malformed input', () => {
|
|
105
|
+
expect(verifyHmacSignature(payload, 'not-hex-at-all!!!', secret)).toBe(false);
|
|
106
|
+
expect(verifyHmacSignature(payload, 'zzzz'.repeat(16), secret)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('webhook deduplication', () => {
|
|
111
|
+
let db: VobaseDb;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
db = createTestDb();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('first webhook ID is not a duplicate', () => {
|
|
118
|
+
const isDuplicate = checkAndRecordWebhook(db, 'wh_001', 'stripe');
|
|
119
|
+
expect(isDuplicate).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('same webhook ID is a duplicate', () => {
|
|
123
|
+
checkAndRecordWebhook(db, 'wh_002', 'stripe');
|
|
124
|
+
const isDuplicate = checkAndRecordWebhook(db, 'wh_002', 'stripe');
|
|
125
|
+
expect(isDuplicate).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('different webhook IDs are not duplicates', () => {
|
|
129
|
+
expect(checkAndRecordWebhook(db, 'wh_aaa', 'stripe')).toBe(false);
|
|
130
|
+
expect(checkAndRecordWebhook(db, 'wh_bbb', 'stripe')).toBe(false);
|
|
131
|
+
expect(checkAndRecordWebhook(db, 'wh_ccc', 'stripe')).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('same ID with different source is not a duplicate (composite key)', () => {
|
|
135
|
+
expect(checkAndRecordWebhook(db, 'wh_shared', 'stripe')).toBe(false);
|
|
136
|
+
expect(checkAndRecordWebhook(db, 'wh_shared', 'github')).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// --- Route handler tests ---
|
|
141
|
+
|
|
142
|
+
function createMockScheduler(): Scheduler & { calls: Array<{ jobName: string; data: unknown }> } {
|
|
143
|
+
const calls: Array<{ jobName: string; data: unknown }> = [];
|
|
144
|
+
return {
|
|
145
|
+
calls,
|
|
146
|
+
async add(jobName: string, data: unknown) {
|
|
147
|
+
calls.push({ jobName, data });
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createWebhookTestApp(
|
|
153
|
+
configs: Record<string, WebhookConfig>,
|
|
154
|
+
mockScheduler: Scheduler,
|
|
155
|
+
) {
|
|
156
|
+
const db = createTestDb();
|
|
157
|
+
const router = createWebhookRoutes(configs, { db, scheduler: mockScheduler });
|
|
158
|
+
return { app: router, db };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function computeHmac(payload: string, secret: string): string {
|
|
162
|
+
return createHmac('sha256', secret).update(payload).digest('hex');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe('createWebhookRoutes', () => {
|
|
166
|
+
const secret = 'test-secret';
|
|
167
|
+
const payload = JSON.stringify({ event: 'test', id: 'evt_1' });
|
|
168
|
+
|
|
169
|
+
test('receives webhook with valid signature and enqueues job', async () => {
|
|
170
|
+
const scheduler = createMockScheduler();
|
|
171
|
+
const { app } = createWebhookTestApp(
|
|
172
|
+
{ stripe: { path: '/webhooks/stripe', secret, handler: 'billing:processWebhook' } },
|
|
173
|
+
scheduler,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const sig = computeHmac(payload, secret);
|
|
177
|
+
const res = await app.request('/webhooks/stripe', {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
body: payload,
|
|
180
|
+
headers: {
|
|
181
|
+
'x-webhook-signature': sig,
|
|
182
|
+
'x-webhook-id': 'wh_001',
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(res.status).toBe(200);
|
|
187
|
+
expect(await res.json()).toEqual({ received: true });
|
|
188
|
+
expect(scheduler.calls).toHaveLength(1);
|
|
189
|
+
expect(scheduler.calls[0].jobName).toBe('billing:processWebhook');
|
|
190
|
+
expect(scheduler.calls[0].data).toEqual({
|
|
191
|
+
source: 'stripe',
|
|
192
|
+
webhookId: 'wh_001',
|
|
193
|
+
payload: JSON.parse(payload),
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('rejects invalid signature with 401', async () => {
|
|
198
|
+
const scheduler = createMockScheduler();
|
|
199
|
+
const { app } = createWebhookTestApp(
|
|
200
|
+
{ stripe: { path: '/webhooks/stripe', secret, handler: 'billing:processWebhook' } },
|
|
201
|
+
scheduler,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const res = await app.request('/webhooks/stripe', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
body: payload,
|
|
207
|
+
headers: {
|
|
208
|
+
'x-webhook-signature': 'invalidsignature',
|
|
209
|
+
'x-webhook-id': 'wh_002',
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(res.status).toBe(401);
|
|
214
|
+
expect(await res.json()).toEqual({ error: 'Invalid signature' });
|
|
215
|
+
expect(scheduler.calls).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('deduplicates same webhook ID', async () => {
|
|
219
|
+
const scheduler = createMockScheduler();
|
|
220
|
+
const { app } = createWebhookTestApp(
|
|
221
|
+
{ stripe: { path: '/webhooks/stripe', secret, handler: 'billing:processWebhook' } },
|
|
222
|
+
scheduler,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const sig = computeHmac(payload, secret);
|
|
226
|
+
const headers = {
|
|
227
|
+
'x-webhook-signature': sig,
|
|
228
|
+
'x-webhook-id': 'wh_dup',
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const res1 = await app.request('/webhooks/stripe', { method: 'POST', body: payload, headers });
|
|
232
|
+
expect(res1.status).toBe(200);
|
|
233
|
+
expect(await res1.json()).toEqual({ received: true });
|
|
234
|
+
|
|
235
|
+
const res2 = await app.request('/webhooks/stripe', { method: 'POST', body: payload, headers });
|
|
236
|
+
expect(res2.status).toBe(200);
|
|
237
|
+
expect(await res2.json()).toEqual({ received: true, deduplicated: true });
|
|
238
|
+
|
|
239
|
+
expect(scheduler.calls).toHaveLength(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('skips dedup when dedup: false in config', async () => {
|
|
243
|
+
const scheduler = createMockScheduler();
|
|
244
|
+
const { app } = createWebhookTestApp(
|
|
245
|
+
{ stripe: { path: '/webhooks/stripe', secret, handler: 'billing:processWebhook', dedup: false } },
|
|
246
|
+
scheduler,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const sig = computeHmac(payload, secret);
|
|
250
|
+
const headers = {
|
|
251
|
+
'x-webhook-signature': sig,
|
|
252
|
+
'x-webhook-id': 'wh_nodup',
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
await app.request('/webhooks/stripe', { method: 'POST', body: payload, headers });
|
|
256
|
+
await app.request('/webhooks/stripe', { method: 'POST', body: payload, headers });
|
|
257
|
+
|
|
258
|
+
expect(scheduler.calls).toHaveLength(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('uses custom signatureHeader when configured', async () => {
|
|
262
|
+
const scheduler = createMockScheduler();
|
|
263
|
+
const { app } = createWebhookTestApp(
|
|
264
|
+
{
|
|
265
|
+
github: {
|
|
266
|
+
path: '/webhooks/github',
|
|
267
|
+
secret,
|
|
268
|
+
handler: 'gh:process',
|
|
269
|
+
signatureHeader: 'x-hub-signature-256',
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
scheduler,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const sig = computeHmac(payload, secret);
|
|
276
|
+
const res = await app.request('/webhooks/github', {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
body: payload,
|
|
279
|
+
headers: {
|
|
280
|
+
'x-hub-signature-256': sig,
|
|
281
|
+
'x-webhook-id': 'gh_001',
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(res.status).toBe(200);
|
|
286
|
+
expect(scheduler.calls).toHaveLength(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('uses custom idHeader when configured', async () => {
|
|
290
|
+
const scheduler = createMockScheduler();
|
|
291
|
+
const { app } = createWebhookTestApp(
|
|
292
|
+
{
|
|
293
|
+
github: {
|
|
294
|
+
path: '/webhooks/github',
|
|
295
|
+
secret,
|
|
296
|
+
handler: 'gh:process',
|
|
297
|
+
idHeader: 'x-github-delivery',
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
scheduler,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const sig = computeHmac(payload, secret);
|
|
304
|
+
const headers = {
|
|
305
|
+
'x-webhook-signature': sig,
|
|
306
|
+
'x-github-delivery': 'gh_dup',
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
await app.request('/webhooks/github', { method: 'POST', body: payload, headers });
|
|
310
|
+
const res2 = await app.request('/webhooks/github', { method: 'POST', body: payload, headers });
|
|
311
|
+
|
|
312
|
+
expect(res2.status).toBe(200);
|
|
313
|
+
expect(await res2.json()).toEqual({ received: true, deduplicated: true });
|
|
314
|
+
expect(scheduler.calls).toHaveLength(1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('enqueues raw string payload when body is not valid JSON', async () => {
|
|
318
|
+
const scheduler = createMockScheduler();
|
|
319
|
+
const { app } = createWebhookTestApp(
|
|
320
|
+
{ stripe: { path: '/webhooks/stripe', secret, handler: 'billing:processWebhook' } },
|
|
321
|
+
scheduler,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const rawBody = 'plain text payload';
|
|
325
|
+
const sig = computeHmac(rawBody, secret);
|
|
326
|
+
const res = await app.request('/webhooks/stripe', {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
body: rawBody,
|
|
329
|
+
headers: {
|
|
330
|
+
'x-webhook-signature': sig,
|
|
331
|
+
'x-webhook-id': 'wh_raw',
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(res.status).toBe(200);
|
|
336
|
+
expect(await res.json()).toEqual({ received: true });
|
|
337
|
+
expect(scheduler.calls).toHaveLength(1);
|
|
338
|
+
expect(scheduler.calls[0].data).toEqual({
|
|
339
|
+
source: 'stripe',
|
|
340
|
+
webhookId: 'wh_raw',
|
|
341
|
+
payload: 'plain text payload',
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('returns 401 when signature header is missing', async () => {
|
|
346
|
+
const scheduler = createMockScheduler();
|
|
347
|
+
const { app } = createWebhookTestApp(
|
|
348
|
+
{ stripe: { path: '/webhooks/stripe', secret, handler: 'billing:processWebhook' } },
|
|
349
|
+
scheduler,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const res = await app.request('/webhooks/stripe', {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
body: payload,
|
|
355
|
+
headers: {
|
|
356
|
+
'x-webhook-id': 'wh_nosig',
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(res.status).toBe(401);
|
|
361
|
+
expect(await res.json()).toEqual({ error: 'Invalid signature' });
|
|
362
|
+
expect(scheduler.calls).toHaveLength(0);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { and, eq } from 'drizzle-orm';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import type { VobaseDb } from '../db/client';
|
|
5
|
+
import type { Scheduler } from './queue';
|
|
6
|
+
import { webhookDedup } from './webhooks-schema';
|
|
7
|
+
|
|
8
|
+
export { webhookDedup } from './webhooks-schema';
|
|
9
|
+
|
|
10
|
+
export interface WebhookConfig {
|
|
11
|
+
/** Route path, e.g. '/webhooks/stripe' */
|
|
12
|
+
path: string;
|
|
13
|
+
/** HMAC secret for signature verification */
|
|
14
|
+
secret: string;
|
|
15
|
+
/** Job name to enqueue, e.g. 'system:processWebhook' */
|
|
16
|
+
handler: string;
|
|
17
|
+
/** Header containing the signature (default: 'x-webhook-signature') */
|
|
18
|
+
signatureHeader?: string;
|
|
19
|
+
/** Whether to deduplicate webhooks (default: true) */
|
|
20
|
+
dedup?: boolean;
|
|
21
|
+
/** Header containing the webhook delivery ID (default: 'x-webhook-id') */
|
|
22
|
+
idHeader?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Verify an HMAC-SHA256 signature against a payload and secret.
|
|
27
|
+
*
|
|
28
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
29
|
+
* Returns false for any malformed or invalid signature (never throws).
|
|
30
|
+
*/
|
|
31
|
+
export function verifyHmacSignature(
|
|
32
|
+
payload: string,
|
|
33
|
+
signature: string,
|
|
34
|
+
secret: string,
|
|
35
|
+
): boolean {
|
|
36
|
+
try {
|
|
37
|
+
const expected = createHmac('sha256', secret).update(payload).digest('hex');
|
|
38
|
+
|
|
39
|
+
if (signature.length !== expected.length) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sigBuffer = Buffer.from(signature, 'hex');
|
|
44
|
+
const expectedBuffer = Buffer.from(expected, 'hex');
|
|
45
|
+
|
|
46
|
+
// If the hex decode produced different lengths (malformed hex), reject
|
|
47
|
+
if (sigBuffer.length !== expectedBuffer.length) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return timingSafeEqual(sigBuffer, expectedBuffer);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check whether a webhook has already been processed and record it if not.
|
|
59
|
+
*
|
|
60
|
+
* Checks for existing record first, then inserts if not found.
|
|
61
|
+
*
|
|
62
|
+
* @returns `true` if the webhook is a duplicate, `false` if it's new.
|
|
63
|
+
*/
|
|
64
|
+
export function checkAndRecordWebhook(
|
|
65
|
+
db: VobaseDb,
|
|
66
|
+
webhookId: string,
|
|
67
|
+
source: string,
|
|
68
|
+
): boolean {
|
|
69
|
+
const existing = db
|
|
70
|
+
.select({ id: webhookDedup.id })
|
|
71
|
+
.from(webhookDedup)
|
|
72
|
+
.where(and(eq(webhookDedup.id, webhookId), eq(webhookDedup.source, source)))
|
|
73
|
+
.get();
|
|
74
|
+
|
|
75
|
+
if (existing) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
db.insert(webhookDedup)
|
|
80
|
+
.values({
|
|
81
|
+
id: webhookId,
|
|
82
|
+
source,
|
|
83
|
+
receivedAt: new Date(),
|
|
84
|
+
})
|
|
85
|
+
.run();
|
|
86
|
+
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a Hono router that handles incoming webhook POST requests.
|
|
92
|
+
*
|
|
93
|
+
* For each webhook config, registers a POST handler that:
|
|
94
|
+
* 1. Verifies HMAC signature
|
|
95
|
+
* 2. Optionally deduplicates by webhook ID
|
|
96
|
+
* 3. Enqueues the payload to the configured job
|
|
97
|
+
*/
|
|
98
|
+
export function createWebhookRoutes(
|
|
99
|
+
configs: Record<string, WebhookConfig>,
|
|
100
|
+
deps: { db: VobaseDb; scheduler: Scheduler },
|
|
101
|
+
): Hono {
|
|
102
|
+
const { db, scheduler } = deps;
|
|
103
|
+
|
|
104
|
+
const router = new Hono();
|
|
105
|
+
|
|
106
|
+
for (const [source, config] of Object.entries(configs)) {
|
|
107
|
+
router.post(config.path, async (c) => {
|
|
108
|
+
const body = await c.req.text();
|
|
109
|
+
|
|
110
|
+
const sigHeader = config.signatureHeader ?? 'x-webhook-signature';
|
|
111
|
+
const signature = c.req.header(sigHeader) ?? '';
|
|
112
|
+
|
|
113
|
+
if (!verifyHmacSignature(body, signature, config.secret)) {
|
|
114
|
+
return c.json({ error: 'Invalid signature' }, 401);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const dedupEnabled = config.dedup !== false;
|
|
118
|
+
|
|
119
|
+
if (dedupEnabled) {
|
|
120
|
+
const idHeader = config.idHeader ?? 'x-webhook-id';
|
|
121
|
+
const webhookId = c.req.header(idHeader) ?? '';
|
|
122
|
+
|
|
123
|
+
if (webhookId && checkAndRecordWebhook(db, webhookId, source)) {
|
|
124
|
+
return c.json({ received: true, deduplicated: true }, 200);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let payload: unknown;
|
|
129
|
+
try {
|
|
130
|
+
payload = JSON.parse(body);
|
|
131
|
+
} catch {
|
|
132
|
+
payload = body;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await scheduler.add(config.handler, {
|
|
136
|
+
source,
|
|
137
|
+
webhookId: c.req.header(config.idHeader ?? 'x-webhook-id') ?? '',
|
|
138
|
+
payload,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return c.json({ received: true }, 200);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return router;
|
|
146
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
|
|
4
|
+
import { createDatabase } from '../db/client';
|
|
5
|
+
import { createMcpHandler } from './server';
|
|
6
|
+
import { auditLog } from '../modules/audit/schema';
|
|
7
|
+
import type { VobaseModule } from '../module';
|
|
8
|
+
|
|
9
|
+
const MODULES_WITH_SCHEMA: VobaseModule[] = [
|
|
10
|
+
{
|
|
11
|
+
name: 'audit',
|
|
12
|
+
schema: { auditLog },
|
|
13
|
+
routes: new Hono(),
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function createTestDb() {
|
|
18
|
+
const db = createDatabase(':memory:');
|
|
19
|
+
db.$client.run(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS _audit_log (
|
|
21
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
22
|
+
event TEXT NOT NULL,
|
|
23
|
+
actor_id TEXT,
|
|
24
|
+
actor_email TEXT,
|
|
25
|
+
ip TEXT,
|
|
26
|
+
details TEXT,
|
|
27
|
+
created_at INTEGER NOT NULL
|
|
28
|
+
)
|
|
29
|
+
`);
|
|
30
|
+
return db;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function postMcp(
|
|
34
|
+
handler: (req: Request) => Promise<Response>,
|
|
35
|
+
payload: Record<string, unknown>,
|
|
36
|
+
headers?: Record<string, string>,
|
|
37
|
+
): Promise<{ response: Response; body: Record<string, unknown> }> {
|
|
38
|
+
const request = new Request('http://localhost/mcp', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'content-type': 'application/json',
|
|
42
|
+
accept: 'application/json, text/event-stream',
|
|
43
|
+
...headers,
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify(payload),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const response = await handler(request);
|
|
49
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
50
|
+
return { response, body };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('MCP API key auth', () => {
|
|
54
|
+
it('allows unauthenticated access to discovery tools', async () => {
|
|
55
|
+
const handler = createMcpHandler({
|
|
56
|
+
db: createTestDb(),
|
|
57
|
+
modules: MODULES_WITH_SCHEMA,
|
|
58
|
+
verifyApiKey: async () => null,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const { response, body } = await postMcp(handler, {
|
|
62
|
+
jsonrpc: '2.0',
|
|
63
|
+
id: 1,
|
|
64
|
+
method: 'tools/list',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const tools = (
|
|
68
|
+
(body.result as { tools?: Array<{ name: string }> }).tools ?? []
|
|
69
|
+
).map((t) => t.name);
|
|
70
|
+
|
|
71
|
+
expect(response.status).toBe(200);
|
|
72
|
+
expect(tools).toContain('list_modules');
|
|
73
|
+
expect(tools).toContain('view_logs');
|
|
74
|
+
// CRUD tools should NOT be present without auth
|
|
75
|
+
expect(tools).not.toContain('list_audit_log');
|
|
76
|
+
expect(tools).not.toContain('create_audit_log');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('exposes CRUD tools when valid API key is provided', async () => {
|
|
80
|
+
const handler = createMcpHandler({
|
|
81
|
+
db: createTestDb(),
|
|
82
|
+
modules: MODULES_WITH_SCHEMA,
|
|
83
|
+
verifyApiKey: async (key) =>
|
|
84
|
+
key === 'valid-key' ? { userId: 'u1' } : null,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { response, body } = await postMcp(
|
|
88
|
+
handler,
|
|
89
|
+
{ jsonrpc: '2.0', id: 1, method: 'tools/list' },
|
|
90
|
+
{ authorization: 'Bearer valid-key' },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const tools = (
|
|
94
|
+
(body.result as { tools?: Array<{ name: string }> }).tools ?? []
|
|
95
|
+
).map((t) => t.name);
|
|
96
|
+
|
|
97
|
+
expect(response.status).toBe(200);
|
|
98
|
+
// Discovery tools present
|
|
99
|
+
expect(tools).toContain('list_modules');
|
|
100
|
+
// CRUD tools present when authenticated
|
|
101
|
+
expect(tools).toContain('list_audit_log');
|
|
102
|
+
expect(tools).toContain('get_audit_log');
|
|
103
|
+
expect(tools).toContain('create_audit_log');
|
|
104
|
+
expect(tools).toContain('update_audit_log');
|
|
105
|
+
expect(tools).toContain('delete_audit_log');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not expose CRUD tools with invalid API key', async () => {
|
|
109
|
+
const handler = createMcpHandler({
|
|
110
|
+
db: createTestDb(),
|
|
111
|
+
modules: MODULES_WITH_SCHEMA,
|
|
112
|
+
verifyApiKey: async () => null,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const { response, body } = await postMcp(
|
|
116
|
+
handler,
|
|
117
|
+
{ jsonrpc: '2.0', id: 1, method: 'tools/list' },
|
|
118
|
+
{ authorization: 'Bearer bad-key' },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const tools = (
|
|
122
|
+
(body.result as { tools?: Array<{ name: string }> }).tools ?? []
|
|
123
|
+
).map((t) => t.name);
|
|
124
|
+
|
|
125
|
+
expect(response.status).toBe(200);
|
|
126
|
+
expect(tools).toContain('list_modules');
|
|
127
|
+
expect(tools).not.toContain('list_audit_log');
|
|
128
|
+
});
|
|
129
|
+
});
|