@openclaw/voice-call 2026.2.25 → 2026.3.1
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/http-headers.test.ts +16 -0
- package/src/http-headers.ts +12 -0
- package/src/providers/base.ts +2 -1
- package/src/providers/mock.ts +5 -1
- package/src/providers/plivo.test.ts +22 -0
- package/src/providers/plivo.ts +23 -27
- package/src/providers/shared/guarded-json-api.ts +42 -0
- package/src/providers/telnyx.test.ts +27 -0
- package/src/providers/telnyx.ts +23 -17
- package/src/providers/twilio/webhook.ts +1 -0
- package/src/providers/twilio.test.ts +23 -2
- package/src/providers/twilio.ts +17 -18
- package/src/types.ts +7 -0
- package/src/webhook/stale-call-reaper.ts +33 -0
- package/src/webhook-security.test.ts +102 -0
- package/src/webhook-security.ts +68 -42
- package/src/webhook.test.ts +87 -2
- package/src/webhook.ts +18 -36
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getHeader } from "./http-headers.js";
|
|
3
|
+
|
|
4
|
+
describe("getHeader", () => {
|
|
5
|
+
it("returns first value when header is an array", () => {
|
|
6
|
+
expect(getHeader({ "x-test": ["first", "second"] }, "x-test")).toBe("first");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("matches headers case-insensitively", () => {
|
|
10
|
+
expect(getHeader({ "X-Twilio-Signature": "sig-1" }, "x-twilio-signature")).toBe("sig-1");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns undefined for missing header", () => {
|
|
14
|
+
expect(getHeader({ host: "example.com" }, "x-missing")).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type HttpHeaderMap = Record<string, string | string[] | undefined>;
|
|
2
|
+
|
|
3
|
+
export function getHeader(headers: HttpHeaderMap, name: string): string | undefined {
|
|
4
|
+
const target = name.toLowerCase();
|
|
5
|
+
const direct = headers[target];
|
|
6
|
+
const value =
|
|
7
|
+
direct ?? Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return value[0];
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
package/src/providers/base.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
InitiateCallResult,
|
|
5
5
|
PlayTtsInput,
|
|
6
6
|
ProviderName,
|
|
7
|
+
WebhookParseOptions,
|
|
7
8
|
ProviderWebhookParseResult,
|
|
8
9
|
StartListeningInput,
|
|
9
10
|
StopListeningInput,
|
|
@@ -36,7 +37,7 @@ export interface VoiceCallProvider {
|
|
|
36
37
|
* Parse provider-specific webhook payload into normalized events.
|
|
37
38
|
* Returns events and optional response to send back to provider.
|
|
38
39
|
*/
|
|
39
|
-
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult;
|
|
40
|
+
parseWebhookEvent(ctx: WebhookContext, options?: WebhookParseOptions): ProviderWebhookParseResult;
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Initiate an outbound call.
|
package/src/providers/mock.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
InitiateCallResult,
|
|
7
7
|
NormalizedEvent,
|
|
8
8
|
PlayTtsInput,
|
|
9
|
+
WebhookParseOptions,
|
|
9
10
|
ProviderWebhookParseResult,
|
|
10
11
|
StartListeningInput,
|
|
11
12
|
StopListeningInput,
|
|
@@ -28,7 +29,10 @@ export class MockProvider implements VoiceCallProvider {
|
|
|
28
29
|
return { ok: true };
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
parseWebhookEvent(
|
|
32
|
+
parseWebhookEvent(
|
|
33
|
+
ctx: WebhookContext,
|
|
34
|
+
_options?: WebhookParseOptions,
|
|
35
|
+
): ProviderWebhookParseResult {
|
|
32
36
|
try {
|
|
33
37
|
const payload = JSON.parse(ctx.rawBody);
|
|
34
38
|
const events: NormalizedEvent[] = [];
|
|
@@ -24,4 +24,26 @@ describe("PlivoProvider", () => {
|
|
|
24
24
|
expect(result.providerResponseBody).toContain("<Wait");
|
|
25
25
|
expect(result.providerResponseBody).toContain('length="300"');
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
it("uses verified request key when provided", () => {
|
|
29
|
+
const provider = new PlivoProvider({
|
|
30
|
+
authId: "MA000000000000000000",
|
|
31
|
+
authToken: "test-token",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const result = provider.parseWebhookEvent(
|
|
35
|
+
{
|
|
36
|
+
headers: { host: "example.com", "x-plivo-signature-v3-nonce": "nonce-1" },
|
|
37
|
+
rawBody:
|
|
38
|
+
"CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp",
|
|
39
|
+
url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id",
|
|
40
|
+
method: "POST",
|
|
41
|
+
query: { provider: "plivo", flow: "answer", callId: "internal-call-id" },
|
|
42
|
+
},
|
|
43
|
+
{ verifiedRequestKey: "plivo:v3:verified" },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(result.events).toHaveLength(1);
|
|
47
|
+
expect(result.events[0]?.dedupeKey).toBe("plivo:v3:verified");
|
|
48
|
+
});
|
|
27
49
|
});
|
package/src/providers/plivo.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
|
|
3
|
+
import { getHeader } from "../http-headers.js";
|
|
3
4
|
import type {
|
|
4
5
|
HangupCallInput,
|
|
5
6
|
InitiateCallInput,
|
|
@@ -10,11 +11,13 @@ import type {
|
|
|
10
11
|
StartListeningInput,
|
|
11
12
|
StopListeningInput,
|
|
12
13
|
WebhookContext,
|
|
14
|
+
WebhookParseOptions,
|
|
13
15
|
WebhookVerificationResult,
|
|
14
16
|
} from "../types.js";
|
|
15
17
|
import { escapeXml } from "../voice-mapping.js";
|
|
16
18
|
import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
|
|
17
19
|
import type { VoiceCallProvider } from "./base.js";
|
|
20
|
+
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
|
|
18
21
|
|
|
19
22
|
export interface PlivoProviderOptions {
|
|
20
23
|
/** Override public URL origin for signature verification */
|
|
@@ -30,17 +33,6 @@ export interface PlivoProviderOptions {
|
|
|
30
33
|
type PendingSpeak = { text: string; locale?: string };
|
|
31
34
|
type PendingListen = { language?: string };
|
|
32
35
|
|
|
33
|
-
function getHeader(
|
|
34
|
-
headers: Record<string, string | string[] | undefined>,
|
|
35
|
-
name: string,
|
|
36
|
-
): string | undefined {
|
|
37
|
-
const value = headers[name.toLowerCase()];
|
|
38
|
-
if (Array.isArray(value)) {
|
|
39
|
-
return value[0];
|
|
40
|
-
}
|
|
41
|
-
return value;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
36
|
function createPlivoRequestDedupeKey(ctx: WebhookContext): string {
|
|
45
37
|
const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
|
|
46
38
|
if (nonceV3) {
|
|
@@ -60,6 +52,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
60
52
|
private readonly authToken: string;
|
|
61
53
|
private readonly baseUrl: string;
|
|
62
54
|
private readonly options: PlivoProviderOptions;
|
|
55
|
+
private readonly apiHost: string;
|
|
63
56
|
|
|
64
57
|
// Best-effort mapping between create-call request UUID and call UUID.
|
|
65
58
|
private requestUuidToCallUuid = new Map<string, string>();
|
|
@@ -82,6 +75,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
82
75
|
this.authId = config.authId;
|
|
83
76
|
this.authToken = config.authToken;
|
|
84
77
|
this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`;
|
|
78
|
+
this.apiHost = new URL(this.baseUrl).hostname;
|
|
85
79
|
this.options = options;
|
|
86
80
|
}
|
|
87
81
|
|
|
@@ -92,25 +86,19 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
92
86
|
allowNotFound?: boolean;
|
|
93
87
|
}): Promise<T> {
|
|
94
88
|
const { method, endpoint, body, allowNotFound } = params;
|
|
95
|
-
|
|
89
|
+
return await guardedJsonApiRequest<T>({
|
|
90
|
+
url: `${this.baseUrl}${endpoint}`,
|
|
96
91
|
method,
|
|
97
92
|
headers: {
|
|
98
93
|
Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
|
|
99
94
|
"Content-Type": "application/json",
|
|
100
95
|
},
|
|
101
|
-
body
|
|
96
|
+
body,
|
|
97
|
+
allowNotFound,
|
|
98
|
+
allowedHostnames: [this.apiHost],
|
|
99
|
+
auditContext: "voice-call.plivo.api",
|
|
100
|
+
errorPrefix: "Plivo API error",
|
|
102
101
|
});
|
|
103
|
-
|
|
104
|
-
if (!response.ok) {
|
|
105
|
-
if (allowNotFound && response.status === 404) {
|
|
106
|
-
return undefined as T;
|
|
107
|
-
}
|
|
108
|
-
const errorText = await response.text();
|
|
109
|
-
throw new Error(`Plivo API error: ${response.status} ${errorText}`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const text = await response.text();
|
|
113
|
-
return text ? (JSON.parse(text) as T) : (undefined as T);
|
|
114
102
|
}
|
|
115
103
|
|
|
116
104
|
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
|
@@ -127,10 +115,18 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
127
115
|
console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
|
|
128
116
|
}
|
|
129
117
|
|
|
130
|
-
return {
|
|
118
|
+
return {
|
|
119
|
+
ok: result.ok,
|
|
120
|
+
reason: result.reason,
|
|
121
|
+
isReplay: result.isReplay,
|
|
122
|
+
verifiedRequestKey: result.verifiedRequestKey,
|
|
123
|
+
};
|
|
131
124
|
}
|
|
132
125
|
|
|
133
|
-
parseWebhookEvent(
|
|
126
|
+
parseWebhookEvent(
|
|
127
|
+
ctx: WebhookContext,
|
|
128
|
+
options?: WebhookParseOptions,
|
|
129
|
+
): ProviderWebhookParseResult {
|
|
134
130
|
const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
|
|
135
131
|
|
|
136
132
|
const parsed = this.parseBody(ctx.rawBody);
|
|
@@ -196,7 +192,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
196
192
|
|
|
197
193
|
// Normal events.
|
|
198
194
|
const callIdFromQuery = this.getCallIdFromQuery(ctx);
|
|
199
|
-
const dedupeKey = createPlivoRequestDedupeKey(ctx);
|
|
195
|
+
const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx);
|
|
200
196
|
const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey);
|
|
201
197
|
|
|
202
198
|
return {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
type GuardedJsonApiRequestParams = {
|
|
4
|
+
url: string;
|
|
5
|
+
method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH";
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
body?: Record<string, unknown>;
|
|
8
|
+
allowNotFound?: boolean;
|
|
9
|
+
allowedHostnames: string[];
|
|
10
|
+
auditContext: string;
|
|
11
|
+
errorPrefix: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function guardedJsonApiRequest<T = unknown>(
|
|
15
|
+
params: GuardedJsonApiRequestParams,
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
18
|
+
url: params.url,
|
|
19
|
+
init: {
|
|
20
|
+
method: params.method,
|
|
21
|
+
headers: params.headers,
|
|
22
|
+
body: params.body ? JSON.stringify(params.body) : undefined,
|
|
23
|
+
},
|
|
24
|
+
policy: { allowedHostnames: params.allowedHostnames },
|
|
25
|
+
auditContext: params.auditContext,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
if (params.allowNotFound && response.status === 404) {
|
|
31
|
+
return undefined as T;
|
|
32
|
+
}
|
|
33
|
+
const errorText = await response.text();
|
|
34
|
+
throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const text = await response.text();
|
|
38
|
+
return text ? (JSON.parse(text) as T) : (undefined as T);
|
|
39
|
+
} finally {
|
|
40
|
+
await release();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -133,7 +133,34 @@ describe("TelnyxProvider.verifyWebhook", () => {
|
|
|
133
133
|
|
|
134
134
|
expect(first.ok).toBe(true);
|
|
135
135
|
expect(first.isReplay).toBeFalsy();
|
|
136
|
+
expect(first.verifiedRequestKey).toBeTruthy();
|
|
136
137
|
expect(second.ok).toBe(true);
|
|
137
138
|
expect(second.isReplay).toBe(true);
|
|
139
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("TelnyxProvider.parseWebhookEvent", () => {
|
|
144
|
+
it("uses verified request key for manager dedupe", () => {
|
|
145
|
+
const provider = new TelnyxProvider({
|
|
146
|
+
apiKey: "KEY123",
|
|
147
|
+
connectionId: "CONN456",
|
|
148
|
+
publicKey: undefined,
|
|
149
|
+
});
|
|
150
|
+
const result = provider.parseWebhookEvent(
|
|
151
|
+
createCtx({
|
|
152
|
+
rawBody: JSON.stringify({
|
|
153
|
+
data: {
|
|
154
|
+
id: "evt-123",
|
|
155
|
+
event_type: "call.initiated",
|
|
156
|
+
payload: { call_control_id: "call-1" },
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
}),
|
|
160
|
+
{ verifiedRequestKey: "telnyx:req:abc" },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(result.events).toHaveLength(1);
|
|
164
|
+
expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc");
|
|
138
165
|
});
|
|
139
166
|
});
|
package/src/providers/telnyx.ts
CHANGED
|
@@ -11,10 +11,12 @@ import type {
|
|
|
11
11
|
StartListeningInput,
|
|
12
12
|
StopListeningInput,
|
|
13
13
|
WebhookContext,
|
|
14
|
+
WebhookParseOptions,
|
|
14
15
|
WebhookVerificationResult,
|
|
15
16
|
} from "../types.js";
|
|
16
17
|
import { verifyTelnyxWebhook } from "../webhook-security.js";
|
|
17
18
|
import type { VoiceCallProvider } from "./base.js";
|
|
19
|
+
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Telnyx Voice API provider implementation.
|
|
@@ -35,6 +37,7 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
35
37
|
private readonly publicKey: string | undefined;
|
|
36
38
|
private readonly options: TelnyxProviderOptions;
|
|
37
39
|
private readonly baseUrl = "https://api.telnyx.com/v2";
|
|
40
|
+
private readonly apiHost = "api.telnyx.com";
|
|
38
41
|
|
|
39
42
|
constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) {
|
|
40
43
|
if (!config.apiKey) {
|
|
@@ -58,25 +61,19 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
58
61
|
body: Record<string, unknown>,
|
|
59
62
|
options?: { allowNotFound?: boolean },
|
|
60
63
|
): Promise<T> {
|
|
61
|
-
|
|
64
|
+
return await guardedJsonApiRequest<T>({
|
|
65
|
+
url: `${this.baseUrl}${endpoint}`,
|
|
62
66
|
method: "POST",
|
|
63
67
|
headers: {
|
|
64
68
|
Authorization: `Bearer ${this.apiKey}`,
|
|
65
69
|
"Content-Type": "application/json",
|
|
66
70
|
},
|
|
67
|
-
body
|
|
71
|
+
body,
|
|
72
|
+
allowNotFound: options?.allowNotFound,
|
|
73
|
+
allowedHostnames: [this.apiHost],
|
|
74
|
+
auditContext: "voice-call.telnyx.api",
|
|
75
|
+
errorPrefix: "Telnyx API error",
|
|
68
76
|
});
|
|
69
|
-
|
|
70
|
-
if (!response.ok) {
|
|
71
|
-
if (options?.allowNotFound && response.status === 404) {
|
|
72
|
-
return undefined as T;
|
|
73
|
-
}
|
|
74
|
-
const errorText = await response.text();
|
|
75
|
-
throw new Error(`Telnyx API error: ${response.status} ${errorText}`);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const text = await response.text();
|
|
79
|
-
return text ? (JSON.parse(text) as T) : (undefined as T);
|
|
80
77
|
}
|
|
81
78
|
|
|
82
79
|
/**
|
|
@@ -87,13 +84,21 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
87
84
|
skipVerification: this.options.skipVerification,
|
|
88
85
|
});
|
|
89
86
|
|
|
90
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
ok: result.ok,
|
|
89
|
+
reason: result.reason,
|
|
90
|
+
isReplay: result.isReplay,
|
|
91
|
+
verifiedRequestKey: result.verifiedRequestKey,
|
|
92
|
+
};
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/**
|
|
94
96
|
* Parse Telnyx webhook event into normalized format.
|
|
95
97
|
*/
|
|
96
|
-
parseWebhookEvent(
|
|
98
|
+
parseWebhookEvent(
|
|
99
|
+
ctx: WebhookContext,
|
|
100
|
+
options?: WebhookParseOptions,
|
|
101
|
+
): ProviderWebhookParseResult {
|
|
97
102
|
try {
|
|
98
103
|
const payload = JSON.parse(ctx.rawBody);
|
|
99
104
|
const data = payload.data;
|
|
@@ -102,7 +107,7 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
102
107
|
return { events: [], statusCode: 200 };
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
const event = this.normalizeEvent(data);
|
|
110
|
+
const event = this.normalizeEvent(data, options?.verifiedRequestKey);
|
|
106
111
|
return {
|
|
107
112
|
events: event ? [event] : [],
|
|
108
113
|
statusCode: 200,
|
|
@@ -115,7 +120,7 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
115
120
|
/**
|
|
116
121
|
* Convert Telnyx event to normalized event format.
|
|
117
122
|
*/
|
|
118
|
-
private normalizeEvent(data: TelnyxEvent): NormalizedEvent | null {
|
|
123
|
+
private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null {
|
|
119
124
|
// Decode client_state from Base64 (we encode it in initiateCall)
|
|
120
125
|
let callId = "";
|
|
121
126
|
if (data.payload?.client_state) {
|
|
@@ -132,6 +137,7 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
132
137
|
|
|
133
138
|
const baseEvent = {
|
|
134
139
|
id: data.id || crypto.randomUUID(),
|
|
140
|
+
dedupeKey,
|
|
135
141
|
callId,
|
|
136
142
|
providerCallId: data.payload?.call_control_id,
|
|
137
143
|
timestamp: Date.now(),
|
|
@@ -60,7 +60,7 @@ describe("TwilioProvider", () => {
|
|
|
60
60
|
expect(result.providerResponseBody).toContain("<Connect>");
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it("uses a stable dedupeKey for identical request payloads", () => {
|
|
63
|
+
it("uses a stable fallback dedupeKey for identical request payloads", () => {
|
|
64
64
|
const provider = createProvider();
|
|
65
65
|
const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
|
|
66
66
|
const ctxA = {
|
|
@@ -78,10 +78,31 @@ describe("TwilioProvider", () => {
|
|
|
78
78
|
expect(eventA).toBeDefined();
|
|
79
79
|
expect(eventB).toBeDefined();
|
|
80
80
|
expect(eventA?.id).not.toBe(eventB?.id);
|
|
81
|
-
expect(eventA?.dedupeKey).
|
|
81
|
+
expect(eventA?.dedupeKey).toContain("twilio:fallback:");
|
|
82
82
|
expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it("uses verified request key for dedupe and ignores idempotency header changes", () => {
|
|
86
|
+
const provider = createProvider();
|
|
87
|
+
const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello";
|
|
88
|
+
const ctxA = {
|
|
89
|
+
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
|
|
90
|
+
headers: { "i-twilio-idempotency-token": "idem-a" },
|
|
91
|
+
};
|
|
92
|
+
const ctxB = {
|
|
93
|
+
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
|
|
94
|
+
headers: { "i-twilio-idempotency-token": "idem-b" },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" })
|
|
98
|
+
.events[0];
|
|
99
|
+
const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" })
|
|
100
|
+
.events[0];
|
|
101
|
+
|
|
102
|
+
expect(eventA?.dedupeKey).toBe("twilio:req:abc");
|
|
103
|
+
expect(eventB?.dedupeKey).toBe("twilio:req:abc");
|
|
104
|
+
});
|
|
105
|
+
|
|
85
106
|
it("keeps turnToken from query on speech events", () => {
|
|
86
107
|
const provider = createProvider();
|
|
87
108
|
const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {
|
package/src/providers/twilio.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
|
|
3
|
+
import { getHeader } from "../http-headers.js";
|
|
3
4
|
import type { MediaStreamHandler } from "../media-stream.js";
|
|
4
5
|
import { chunkAudio } from "../telephony-audio.js";
|
|
5
6
|
import type { TelephonyTtsProvider } from "../telephony-tts.js";
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
StartListeningInput,
|
|
14
15
|
StopListeningInput,
|
|
15
16
|
WebhookContext,
|
|
17
|
+
WebhookParseOptions,
|
|
16
18
|
WebhookVerificationResult,
|
|
17
19
|
} from "../types.js";
|
|
18
20
|
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
|
|
@@ -20,30 +22,24 @@ import type { VoiceCallProvider } from "./base.js";
|
|
|
20
22
|
import { twilioApiRequest } from "./twilio/api.js";
|
|
21
23
|
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
|
|
22
24
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
): string | undefined {
|
|
27
|
-
const value = headers[name.toLowerCase()];
|
|
28
|
-
if (Array.isArray(value)) {
|
|
29
|
-
return value[0];
|
|
30
|
-
}
|
|
31
|
-
return value;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function createTwilioRequestDedupeKey(ctx: WebhookContext): string {
|
|
35
|
-
const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token");
|
|
36
|
-
if (idempotencyToken) {
|
|
37
|
-
return `twilio:idempotency:${idempotencyToken}`;
|
|
25
|
+
function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
|
|
26
|
+
if (verifiedRequestKey) {
|
|
27
|
+
return verifiedRequestKey;
|
|
38
28
|
}
|
|
39
29
|
|
|
40
30
|
const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
|
|
31
|
+
const params = new URLSearchParams(ctx.rawBody);
|
|
32
|
+
const callSid = params.get("CallSid") ?? "";
|
|
33
|
+
const callStatus = params.get("CallStatus") ?? "";
|
|
34
|
+
const direction = params.get("Direction") ?? "";
|
|
41
35
|
const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : "";
|
|
42
36
|
const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
|
|
43
37
|
const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : "";
|
|
44
38
|
return `twilio:fallback:${crypto
|
|
45
39
|
.createHash("sha256")
|
|
46
|
-
.update(
|
|
40
|
+
.update(
|
|
41
|
+
`${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`,
|
|
42
|
+
)
|
|
47
43
|
.digest("hex")}`;
|
|
48
44
|
}
|
|
49
45
|
|
|
@@ -232,7 +228,10 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
232
228
|
/**
|
|
233
229
|
* Parse Twilio webhook event into normalized format.
|
|
234
230
|
*/
|
|
235
|
-
parseWebhookEvent(
|
|
231
|
+
parseWebhookEvent(
|
|
232
|
+
ctx: WebhookContext,
|
|
233
|
+
options?: WebhookParseOptions,
|
|
234
|
+
): ProviderWebhookParseResult {
|
|
236
235
|
try {
|
|
237
236
|
const params = new URLSearchParams(ctx.rawBody);
|
|
238
237
|
const callIdFromQuery =
|
|
@@ -243,7 +242,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
243
242
|
typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim()
|
|
244
243
|
? ctx.query.turnToken.trim()
|
|
245
244
|
: undefined;
|
|
246
|
-
const dedupeKey = createTwilioRequestDedupeKey(ctx);
|
|
245
|
+
const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey);
|
|
247
246
|
const event = this.normalizeEvent(params, {
|
|
248
247
|
callIdOverride: callIdFromQuery,
|
|
249
248
|
dedupeKey,
|
package/src/types.ts
CHANGED
|
@@ -177,6 +177,13 @@ export type WebhookVerificationResult = {
|
|
|
177
177
|
reason?: string;
|
|
178
178
|
/** Signature is valid, but request was seen before within replay window. */
|
|
179
179
|
isReplay?: boolean;
|
|
180
|
+
/** Stable key derived from authenticated request material. */
|
|
181
|
+
verifiedRequestKey?: string;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export type WebhookParseOptions = {
|
|
185
|
+
/** Stable request key from verifyWebhook. */
|
|
186
|
+
verifiedRequestKey?: string;
|
|
180
187
|
};
|
|
181
188
|
|
|
182
189
|
export type WebhookContext = {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CallManager } from "../manager.js";
|
|
2
|
+
|
|
3
|
+
const CHECK_INTERVAL_MS = 30_000;
|
|
4
|
+
|
|
5
|
+
export function startStaleCallReaper(params: {
|
|
6
|
+
manager: CallManager;
|
|
7
|
+
staleCallReaperSeconds?: number;
|
|
8
|
+
}): (() => void) | null {
|
|
9
|
+
const maxAgeSeconds = params.staleCallReaperSeconds;
|
|
10
|
+
if (!maxAgeSeconds || maxAgeSeconds <= 0) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const maxAgeMs = maxAgeSeconds * 1000;
|
|
15
|
+
const interval = setInterval(() => {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
for (const call of params.manager.getActiveCalls()) {
|
|
18
|
+
const age = now - call.startedAt;
|
|
19
|
+
if (age > maxAgeMs) {
|
|
20
|
+
console.log(
|
|
21
|
+
`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
|
|
22
|
+
);
|
|
23
|
+
void params.manager.endCall(call.callId).catch((err) => {
|
|
24
|
+
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}, CHECK_INTERVAL_MS);
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
clearInterval(interval);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -198,8 +198,26 @@ describe("verifyPlivoWebhook", () => {
|
|
|
198
198
|
|
|
199
199
|
expect(first.ok).toBe(true);
|
|
200
200
|
expect(first.isReplay).toBeFalsy();
|
|
201
|
+
expect(first.verifiedRequestKey).toBeTruthy();
|
|
201
202
|
expect(second.ok).toBe(true);
|
|
202
203
|
expect(second.isReplay).toBe(true);
|
|
204
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("returns a stable request key when verification is skipped", () => {
|
|
208
|
+
const ctx = {
|
|
209
|
+
headers: {},
|
|
210
|
+
rawBody: "CallUUID=uuid&CallStatus=in-progress",
|
|
211
|
+
url: "https://example.com/voice/webhook",
|
|
212
|
+
method: "POST" as const,
|
|
213
|
+
};
|
|
214
|
+
const first = verifyPlivoWebhook(ctx, "token", { skipVerification: true });
|
|
215
|
+
const second = verifyPlivoWebhook(ctx, "token", { skipVerification: true });
|
|
216
|
+
|
|
217
|
+
expect(first.ok).toBe(true);
|
|
218
|
+
expect(first.verifiedRequestKey).toMatch(/^plivo:skip:/);
|
|
219
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
220
|
+
expect(second.isReplay).toBe(true);
|
|
203
221
|
});
|
|
204
222
|
});
|
|
205
223
|
|
|
@@ -229,8 +247,26 @@ describe("verifyTelnyxWebhook", () => {
|
|
|
229
247
|
|
|
230
248
|
expect(first.ok).toBe(true);
|
|
231
249
|
expect(first.isReplay).toBeFalsy();
|
|
250
|
+
expect(first.verifiedRequestKey).toBeTruthy();
|
|
232
251
|
expect(second.ok).toBe(true);
|
|
233
252
|
expect(second.isReplay).toBe(true);
|
|
253
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("returns a stable request key when verification is skipped", () => {
|
|
257
|
+
const ctx = {
|
|
258
|
+
headers: {},
|
|
259
|
+
rawBody: JSON.stringify({ data: { event_type: "call.initiated" } }),
|
|
260
|
+
url: "https://example.com/voice/webhook",
|
|
261
|
+
method: "POST" as const,
|
|
262
|
+
};
|
|
263
|
+
const first = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true });
|
|
264
|
+
const second = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true });
|
|
265
|
+
|
|
266
|
+
expect(first.ok).toBe(true);
|
|
267
|
+
expect(first.verifiedRequestKey).toMatch(/^telnyx:skip:/);
|
|
268
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
269
|
+
expect(second.isReplay).toBe(true);
|
|
234
270
|
});
|
|
235
271
|
});
|
|
236
272
|
|
|
@@ -304,8 +340,58 @@ describe("verifyTwilioWebhook", () => {
|
|
|
304
340
|
|
|
305
341
|
expect(first.ok).toBe(true);
|
|
306
342
|
expect(first.isReplay).toBeFalsy();
|
|
343
|
+
expect(first.verifiedRequestKey).toBeTruthy();
|
|
344
|
+
expect(second.ok).toBe(true);
|
|
345
|
+
expect(second.isReplay).toBe(true);
|
|
346
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("treats changed idempotency header as replay for identical signed requests", () => {
|
|
350
|
+
const authToken = "test-auth-token";
|
|
351
|
+
const publicUrl = "https://example.com/voice/webhook";
|
|
352
|
+
const urlWithQuery = `${publicUrl}?callId=abc`;
|
|
353
|
+
const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000";
|
|
354
|
+
const signature = twilioSignature({ authToken, url: urlWithQuery, postBody });
|
|
355
|
+
|
|
356
|
+
const first = verifyTwilioWebhook(
|
|
357
|
+
{
|
|
358
|
+
headers: {
|
|
359
|
+
host: "example.com",
|
|
360
|
+
"x-forwarded-proto": "https",
|
|
361
|
+
"x-twilio-signature": signature,
|
|
362
|
+
"i-twilio-idempotency-token": "idem-replay-a",
|
|
363
|
+
},
|
|
364
|
+
rawBody: postBody,
|
|
365
|
+
url: "http://local/voice/webhook?callId=abc",
|
|
366
|
+
method: "POST",
|
|
367
|
+
query: { callId: "abc" },
|
|
368
|
+
},
|
|
369
|
+
authToken,
|
|
370
|
+
{ publicUrl },
|
|
371
|
+
);
|
|
372
|
+
const second = verifyTwilioWebhook(
|
|
373
|
+
{
|
|
374
|
+
headers: {
|
|
375
|
+
host: "example.com",
|
|
376
|
+
"x-forwarded-proto": "https",
|
|
377
|
+
"x-twilio-signature": signature,
|
|
378
|
+
"i-twilio-idempotency-token": "idem-replay-b",
|
|
379
|
+
},
|
|
380
|
+
rawBody: postBody,
|
|
381
|
+
url: "http://local/voice/webhook?callId=abc",
|
|
382
|
+
method: "POST",
|
|
383
|
+
query: { callId: "abc" },
|
|
384
|
+
},
|
|
385
|
+
authToken,
|
|
386
|
+
{ publicUrl },
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
expect(first.ok).toBe(true);
|
|
390
|
+
expect(first.isReplay).toBe(false);
|
|
391
|
+
expect(first.verifiedRequestKey).toBeTruthy();
|
|
307
392
|
expect(second.ok).toBe(true);
|
|
308
393
|
expect(second.isReplay).toBe(true);
|
|
394
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
309
395
|
});
|
|
310
396
|
|
|
311
397
|
it("rejects invalid signatures even when attacker injects forwarded host", () => {
|
|
@@ -517,4 +603,20 @@ describe("verifyTwilioWebhook", () => {
|
|
|
517
603
|
expect(result.ok).toBe(false);
|
|
518
604
|
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
|
|
519
605
|
});
|
|
606
|
+
|
|
607
|
+
it("returns a stable request key when verification is skipped", () => {
|
|
608
|
+
const ctx = {
|
|
609
|
+
headers: {},
|
|
610
|
+
rawBody: "CallSid=CS123&CallStatus=completed",
|
|
611
|
+
url: "https://example.com/voice/webhook",
|
|
612
|
+
method: "POST" as const,
|
|
613
|
+
};
|
|
614
|
+
const first = verifyTwilioWebhook(ctx, "token", { skipVerification: true });
|
|
615
|
+
const second = verifyTwilioWebhook(ctx, "token", { skipVerification: true });
|
|
616
|
+
|
|
617
|
+
expect(first.ok).toBe(true);
|
|
618
|
+
expect(first.verifiedRequestKey).toMatch(/^twilio:skip:/);
|
|
619
|
+
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
|
620
|
+
expect(second.isReplay).toBe(true);
|
|
621
|
+
});
|
|
520
622
|
});
|
package/src/webhook-security.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { getHeader } from "./http-headers.js";
|
|
2
3
|
import type { WebhookContext } from "./types.js";
|
|
3
4
|
|
|
4
5
|
const REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
|
@@ -29,6 +30,10 @@ function sha256Hex(input: string): string {
|
|
|
29
30
|
return crypto.createHash("sha256").update(input).digest("hex");
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string {
|
|
34
|
+
return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
function pruneReplayCache(cache: ReplayCache, now: number): void {
|
|
33
38
|
for (const [key, expiresAt] of cache.seenUntil) {
|
|
34
39
|
if (expiresAt <= now) {
|
|
@@ -81,17 +86,7 @@ export function validateTwilioSignature(
|
|
|
81
86
|
return false;
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
|
|
85
|
-
let dataToSign = url;
|
|
86
|
-
|
|
87
|
-
// Sort params alphabetically and append key+value
|
|
88
|
-
const sortedParams = Array.from(params.entries()).toSorted((a, b) =>
|
|
89
|
-
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
for (const [key, value] of sortedParams) {
|
|
93
|
-
dataToSign += key + value;
|
|
94
|
-
}
|
|
89
|
+
const dataToSign = buildTwilioDataToSign(url, params);
|
|
95
90
|
|
|
96
91
|
// HMAC-SHA1 with auth token, then base64 encode
|
|
97
92
|
const expectedSignature = crypto
|
|
@@ -103,6 +98,24 @@ export function validateTwilioSignature(
|
|
|
103
98
|
return timingSafeEqual(signature, expectedSignature);
|
|
104
99
|
}
|
|
105
100
|
|
|
101
|
+
function buildTwilioDataToSign(url: string, params: URLSearchParams): string {
|
|
102
|
+
let dataToSign = url;
|
|
103
|
+
const sortedParams = Array.from(params.entries()).toSorted((a, b) =>
|
|
104
|
+
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
|
105
|
+
);
|
|
106
|
+
for (const [key, value] of sortedParams) {
|
|
107
|
+
dataToSign += key + value;
|
|
108
|
+
}
|
|
109
|
+
return dataToSign;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildCanonicalTwilioParamString(params: URLSearchParams): string {
|
|
113
|
+
return Array.from(params.entries())
|
|
114
|
+
.toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
115
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
116
|
+
.join("&");
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
/**
|
|
107
120
|
* Timing-safe string comparison to prevent timing attacks.
|
|
108
121
|
*/
|
|
@@ -353,20 +366,6 @@ function buildTwilioVerificationUrl(
|
|
|
353
366
|
}
|
|
354
367
|
}
|
|
355
368
|
|
|
356
|
-
/**
|
|
357
|
-
* Get a header value, handling both string and string[] types.
|
|
358
|
-
*/
|
|
359
|
-
function getHeader(
|
|
360
|
-
headers: Record<string, string | string[] | undefined>,
|
|
361
|
-
name: string,
|
|
362
|
-
): string | undefined {
|
|
363
|
-
const value = headers[name.toLowerCase()];
|
|
364
|
-
if (Array.isArray(value)) {
|
|
365
|
-
return value[0];
|
|
366
|
-
}
|
|
367
|
-
return value;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
369
|
function isLoopbackAddress(address?: string): boolean {
|
|
371
370
|
if (!address) {
|
|
372
371
|
return false;
|
|
@@ -392,6 +391,8 @@ export interface TwilioVerificationResult {
|
|
|
392
391
|
isNgrokFreeTier?: boolean;
|
|
393
392
|
/** Request is cryptographically valid but was already processed recently. */
|
|
394
393
|
isReplay?: boolean;
|
|
394
|
+
/** Stable request identity derived from signed Twilio material. */
|
|
395
|
+
verifiedRequestKey?: string;
|
|
395
396
|
}
|
|
396
397
|
|
|
397
398
|
export interface TelnyxVerificationResult {
|
|
@@ -399,19 +400,18 @@ export interface TelnyxVerificationResult {
|
|
|
399
400
|
reason?: string;
|
|
400
401
|
/** Request is cryptographically valid but was already processed recently. */
|
|
401
402
|
isReplay?: boolean;
|
|
403
|
+
/** Stable request identity derived from signed Telnyx material. */
|
|
404
|
+
verifiedRequestKey?: string;
|
|
402
405
|
}
|
|
403
406
|
|
|
404
407
|
function createTwilioReplayKey(params: {
|
|
405
|
-
ctx: WebhookContext;
|
|
406
|
-
signature: string;
|
|
407
408
|
verificationUrl: string;
|
|
409
|
+
signature: string;
|
|
410
|
+
requestParams: URLSearchParams;
|
|
408
411
|
}): string {
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
return `twilio:fallback:${sha256Hex(
|
|
414
|
-
`${params.verificationUrl}\n${params.signature}\n${params.ctx.rawBody}`,
|
|
412
|
+
const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
|
|
413
|
+
return `twilio:req:${sha256Hex(
|
|
414
|
+
`${params.verificationUrl}\n${canonicalParams}\n${params.signature}`,
|
|
415
415
|
)}`;
|
|
416
416
|
}
|
|
417
417
|
|
|
@@ -470,7 +470,14 @@ export function verifyTelnyxWebhook(
|
|
|
470
470
|
},
|
|
471
471
|
): TelnyxVerificationResult {
|
|
472
472
|
if (options?.skipVerification) {
|
|
473
|
-
|
|
473
|
+
const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
|
|
474
|
+
const isReplay = markReplay(telnyxReplayCache, replayKey);
|
|
475
|
+
return {
|
|
476
|
+
ok: true,
|
|
477
|
+
reason: "verification skipped (dev mode)",
|
|
478
|
+
isReplay,
|
|
479
|
+
verifiedRequestKey: replayKey,
|
|
480
|
+
};
|
|
474
481
|
}
|
|
475
482
|
|
|
476
483
|
if (!publicKey) {
|
|
@@ -508,7 +515,7 @@ export function verifyTelnyxWebhook(
|
|
|
508
515
|
|
|
509
516
|
const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`;
|
|
510
517
|
const isReplay = markReplay(telnyxReplayCache, replayKey);
|
|
511
|
-
return { ok: true, isReplay };
|
|
518
|
+
return { ok: true, isReplay, verifiedRequestKey: replayKey };
|
|
512
519
|
} catch (err) {
|
|
513
520
|
return {
|
|
514
521
|
ok: false,
|
|
@@ -560,7 +567,14 @@ export function verifyTwilioWebhook(
|
|
|
560
567
|
): TwilioVerificationResult {
|
|
561
568
|
// Allow skipping verification for development/testing
|
|
562
569
|
if (options?.skipVerification) {
|
|
563
|
-
|
|
570
|
+
const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
|
|
571
|
+
const isReplay = markReplay(twilioReplayCache, replayKey);
|
|
572
|
+
return {
|
|
573
|
+
ok: true,
|
|
574
|
+
reason: "verification skipped (dev mode)",
|
|
575
|
+
isReplay,
|
|
576
|
+
verifiedRequestKey: replayKey,
|
|
577
|
+
};
|
|
564
578
|
}
|
|
565
579
|
|
|
566
580
|
const signature = getHeader(ctx.headers, "x-twilio-signature");
|
|
@@ -583,13 +597,16 @@ export function verifyTwilioWebhook(
|
|
|
583
597
|
// Parse the body as URL-encoded params
|
|
584
598
|
const params = new URLSearchParams(ctx.rawBody);
|
|
585
599
|
|
|
586
|
-
// Validate signature
|
|
587
600
|
const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params);
|
|
588
601
|
|
|
589
602
|
if (isValid) {
|
|
590
|
-
const replayKey = createTwilioReplayKey({
|
|
603
|
+
const replayKey = createTwilioReplayKey({
|
|
604
|
+
verificationUrl,
|
|
605
|
+
signature,
|
|
606
|
+
requestParams: params,
|
|
607
|
+
});
|
|
591
608
|
const isReplay = markReplay(twilioReplayCache, replayKey);
|
|
592
|
-
return { ok: true, verificationUrl, isReplay };
|
|
609
|
+
return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey };
|
|
593
610
|
}
|
|
594
611
|
|
|
595
612
|
// Check if this is ngrok free tier - the URL might have different format
|
|
@@ -619,6 +636,8 @@ export interface PlivoVerificationResult {
|
|
|
619
636
|
version?: "v3" | "v2";
|
|
620
637
|
/** Request is cryptographically valid but was already processed recently. */
|
|
621
638
|
isReplay?: boolean;
|
|
639
|
+
/** Stable request identity derived from signed Plivo material. */
|
|
640
|
+
verifiedRequestKey?: string;
|
|
622
641
|
}
|
|
623
642
|
|
|
624
643
|
function normalizeSignatureBase64(input: string): string {
|
|
@@ -791,7 +810,14 @@ export function verifyPlivoWebhook(
|
|
|
791
810
|
},
|
|
792
811
|
): PlivoVerificationResult {
|
|
793
812
|
if (options?.skipVerification) {
|
|
794
|
-
|
|
813
|
+
const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
|
|
814
|
+
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
815
|
+
return {
|
|
816
|
+
ok: true,
|
|
817
|
+
reason: "verification skipped (dev mode)",
|
|
818
|
+
isReplay,
|
|
819
|
+
verifiedRequestKey: replayKey,
|
|
820
|
+
};
|
|
795
821
|
}
|
|
796
822
|
|
|
797
823
|
const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
|
|
@@ -849,7 +875,7 @@ export function verifyPlivoWebhook(
|
|
|
849
875
|
}
|
|
850
876
|
const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`;
|
|
851
877
|
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
852
|
-
return { ok: true, version: "v3", verificationUrl, isReplay };
|
|
878
|
+
return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey };
|
|
853
879
|
}
|
|
854
880
|
|
|
855
881
|
if (signatureV2 && nonceV2) {
|
|
@@ -869,7 +895,7 @@ export function verifyPlivoWebhook(
|
|
|
869
895
|
}
|
|
870
896
|
const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`;
|
|
871
897
|
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
872
|
-
return { ok: true, version: "v2", verificationUrl, isReplay };
|
|
898
|
+
return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey };
|
|
873
899
|
}
|
|
874
900
|
|
|
875
901
|
return {
|
package/src/webhook.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { VoiceCallWebhookServer } from "./webhook.js";
|
|
|
7
7
|
|
|
8
8
|
const provider: VoiceCallProvider = {
|
|
9
9
|
name: "mock",
|
|
10
|
-
verifyWebhook: () => ({ ok: true }),
|
|
10
|
+
verifyWebhook: () => ({ ok: true, verifiedRequestKey: "mock:req:base" }),
|
|
11
11
|
parseWebhookEvent: () => ({ events: [] }),
|
|
12
12
|
initiateCall: async () => ({ providerCallId: "provider-call", status: "initiated" }),
|
|
13
13
|
hangupCall: async () => {},
|
|
@@ -123,7 +123,7 @@ describe("VoiceCallWebhookServer replay handling", () => {
|
|
|
123
123
|
it("acknowledges replayed webhook requests and skips event side effects", async () => {
|
|
124
124
|
const replayProvider: VoiceCallProvider = {
|
|
125
125
|
...provider,
|
|
126
|
-
verifyWebhook: () => ({ ok: true, isReplay: true }),
|
|
126
|
+
verifyWebhook: () => ({ ok: true, isReplay: true, verifiedRequestKey: "mock:req:replay" }),
|
|
127
127
|
parseWebhookEvent: () => ({
|
|
128
128
|
events: [
|
|
129
129
|
{
|
|
@@ -165,4 +165,89 @@ describe("VoiceCallWebhookServer replay handling", () => {
|
|
|
165
165
|
await server.stop();
|
|
166
166
|
}
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
it("passes verified request key from verifyWebhook into parseWebhookEvent", async () => {
|
|
170
|
+
const parseWebhookEvent = vi.fn((_ctx: unknown, options?: { verifiedRequestKey?: string }) => ({
|
|
171
|
+
events: [
|
|
172
|
+
{
|
|
173
|
+
id: "evt-verified",
|
|
174
|
+
dedupeKey: options?.verifiedRequestKey,
|
|
175
|
+
type: "call.speech" as const,
|
|
176
|
+
callId: "call-1",
|
|
177
|
+
providerCallId: "provider-call-1",
|
|
178
|
+
timestamp: Date.now(),
|
|
179
|
+
transcript: "hello",
|
|
180
|
+
isFinal: true,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
statusCode: 200,
|
|
184
|
+
}));
|
|
185
|
+
const verifiedProvider: VoiceCallProvider = {
|
|
186
|
+
...provider,
|
|
187
|
+
verifyWebhook: () => ({ ok: true, verifiedRequestKey: "verified:req:123" }),
|
|
188
|
+
parseWebhookEvent,
|
|
189
|
+
};
|
|
190
|
+
const { manager, processEvent } = createManager([]);
|
|
191
|
+
const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
|
|
192
|
+
const server = new VoiceCallWebhookServer(config, manager, verifiedProvider);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const baseUrl = await server.start();
|
|
196
|
+
const address = (
|
|
197
|
+
server as unknown as { server?: { address?: () => unknown } }
|
|
198
|
+
).server?.address?.();
|
|
199
|
+
const requestUrl = new URL(baseUrl);
|
|
200
|
+
if (address && typeof address === "object" && "port" in address && address.port) {
|
|
201
|
+
requestUrl.port = String(address.port);
|
|
202
|
+
}
|
|
203
|
+
const response = await fetch(requestUrl.toString(), {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
206
|
+
body: "CallSid=CA123&SpeechResult=hello",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(response.status).toBe(200);
|
|
210
|
+
expect(parseWebhookEvent).toHaveBeenCalledTimes(1);
|
|
211
|
+
expect(parseWebhookEvent.mock.calls[0]?.[1]).toEqual({
|
|
212
|
+
verifiedRequestKey: "verified:req:123",
|
|
213
|
+
});
|
|
214
|
+
expect(processEvent).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(processEvent.mock.calls[0]?.[0]?.dedupeKey).toBe("verified:req:123");
|
|
216
|
+
} finally {
|
|
217
|
+
await server.stop();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("rejects requests when verification succeeds without a request key", async () => {
|
|
222
|
+
const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
|
|
223
|
+
const badProvider: VoiceCallProvider = {
|
|
224
|
+
...provider,
|
|
225
|
+
verifyWebhook: () => ({ ok: true }),
|
|
226
|
+
parseWebhookEvent,
|
|
227
|
+
};
|
|
228
|
+
const { manager } = createManager([]);
|
|
229
|
+
const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
|
|
230
|
+
const server = new VoiceCallWebhookServer(config, manager, badProvider);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const baseUrl = await server.start();
|
|
234
|
+
const address = (
|
|
235
|
+
server as unknown as { server?: { address?: () => unknown } }
|
|
236
|
+
).server?.address?.();
|
|
237
|
+
const requestUrl = new URL(baseUrl);
|
|
238
|
+
if (address && typeof address === "object" && "port" in address && address.port) {
|
|
239
|
+
requestUrl.port = String(address.port);
|
|
240
|
+
}
|
|
241
|
+
const response = await fetch(requestUrl.toString(), {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
244
|
+
body: "CallSid=CA123&SpeechResult=hello",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(response.status).toBe(401);
|
|
248
|
+
expect(parseWebhookEvent).not.toHaveBeenCalled();
|
|
249
|
+
} finally {
|
|
250
|
+
await server.stop();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
168
253
|
});
|
package/src/webhook.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { VoiceCallProvider } from "./providers/base.js";
|
|
|
15
15
|
import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
|
|
16
16
|
import type { TwilioProvider } from "./providers/twilio.js";
|
|
17
17
|
import type { NormalizedEvent, WebhookContext } from "./types.js";
|
|
18
|
+
import { startStaleCallReaper } from "./webhook/stale-call-reaper.js";
|
|
18
19
|
|
|
19
20
|
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
|
|
20
21
|
|
|
@@ -28,7 +29,7 @@ export class VoiceCallWebhookServer {
|
|
|
28
29
|
private manager: CallManager;
|
|
29
30
|
private provider: VoiceCallProvider;
|
|
30
31
|
private coreConfig: CoreConfig | null;
|
|
31
|
-
private
|
|
32
|
+
private stopStaleCallReaper: (() => void) | null = null;
|
|
32
33
|
|
|
33
34
|
/** Media stream handler for bidirectional audio (when streaming enabled) */
|
|
34
35
|
private mediaStreamHandler: MediaStreamHandler | null = null;
|
|
@@ -217,48 +218,21 @@ export class VoiceCallWebhookServer {
|
|
|
217
218
|
resolve(url);
|
|
218
219
|
|
|
219
220
|
// Start the stale call reaper if configured
|
|
220
|
-
this.startStaleCallReaper(
|
|
221
|
+
this.stopStaleCallReaper = startStaleCallReaper({
|
|
222
|
+
manager: this.manager,
|
|
223
|
+
staleCallReaperSeconds: this.config.staleCallReaperSeconds,
|
|
224
|
+
});
|
|
221
225
|
});
|
|
222
226
|
});
|
|
223
227
|
}
|
|
224
228
|
|
|
225
|
-
/**
|
|
226
|
-
* Start a periodic reaper that ends calls older than the configured threshold.
|
|
227
|
-
* Catches calls stuck in unexpected states (e.g., notify-mode calls that never
|
|
228
|
-
* receive a terminal webhook from the provider).
|
|
229
|
-
*/
|
|
230
|
-
private startStaleCallReaper(): void {
|
|
231
|
-
const maxAgeSeconds = this.config.staleCallReaperSeconds;
|
|
232
|
-
if (!maxAgeSeconds || maxAgeSeconds <= 0) {
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const CHECK_INTERVAL_MS = 30_000; // Check every 30 seconds
|
|
237
|
-
const maxAgeMs = maxAgeSeconds * 1000;
|
|
238
|
-
|
|
239
|
-
this.staleCallReaperInterval = setInterval(() => {
|
|
240
|
-
const now = Date.now();
|
|
241
|
-
for (const call of this.manager.getActiveCalls()) {
|
|
242
|
-
const age = now - call.startedAt;
|
|
243
|
-
if (age > maxAgeMs) {
|
|
244
|
-
console.log(
|
|
245
|
-
`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
|
|
246
|
-
);
|
|
247
|
-
void this.manager.endCall(call.callId).catch((err) => {
|
|
248
|
-
console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}, CHECK_INTERVAL_MS);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
229
|
/**
|
|
256
230
|
* Stop the webhook server.
|
|
257
231
|
*/
|
|
258
232
|
async stop(): Promise<void> {
|
|
259
|
-
if (this.
|
|
260
|
-
|
|
261
|
-
this.
|
|
233
|
+
if (this.stopStaleCallReaper) {
|
|
234
|
+
this.stopStaleCallReaper();
|
|
235
|
+
this.stopStaleCallReaper = null;
|
|
262
236
|
}
|
|
263
237
|
return new Promise((resolve) => {
|
|
264
238
|
if (this.server) {
|
|
@@ -341,9 +315,17 @@ export class VoiceCallWebhookServer {
|
|
|
341
315
|
res.end("Unauthorized");
|
|
342
316
|
return;
|
|
343
317
|
}
|
|
318
|
+
if (!verification.verifiedRequestKey) {
|
|
319
|
+
console.warn("[voice-call] Webhook verification succeeded without request identity key");
|
|
320
|
+
res.statusCode = 401;
|
|
321
|
+
res.end("Unauthorized");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
344
324
|
|
|
345
325
|
// Parse events
|
|
346
|
-
const result = this.provider.parseWebhookEvent(ctx
|
|
326
|
+
const result = this.provider.parseWebhookEvent(ctx, {
|
|
327
|
+
verifiedRequestKey: verification.verifiedRequestKey,
|
|
328
|
+
});
|
|
347
329
|
|
|
348
330
|
// Process each event
|
|
349
331
|
if (verification.isReplay) {
|