@openclaw/voice-call 2026.2.23 → 2026.2.24

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.24
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.2.22
4
10
 
5
11
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.2.23",
3
+ "version": "2026.2.24",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -8,9 +8,6 @@
8
8
  "ws": "^8.19.0",
9
9
  "zod": "^4.3.6"
10
10
  },
11
- "devDependencies": {
12
- "openclaw": "workspace:*"
13
- },
14
11
  "openclaw": {
15
12
  "extensions": [
16
13
  "./index.ts"
@@ -103,4 +103,37 @@ describe("TelnyxProvider.verifyWebhook", () => {
103
103
  const spkiDerBase64 = spkiDer.toString("base64");
104
104
  expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey });
105
105
  });
106
+
107
+ it("returns replay status when the same signed request is seen twice", () => {
108
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
109
+ const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
110
+ const provider = new TelnyxProvider(
111
+ { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDer.toString("base64") },
112
+ { skipVerification: false },
113
+ );
114
+
115
+ const rawBody = JSON.stringify({
116
+ event_type: "call.initiated",
117
+ payload: { call_control_id: "call-replay-test" },
118
+ nonce: crypto.randomUUID(),
119
+ });
120
+ const timestamp = String(Math.floor(Date.now() / 1000));
121
+ const signedPayload = `${timestamp}|${rawBody}`;
122
+ const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
123
+ const ctx = createCtx({
124
+ rawBody,
125
+ headers: {
126
+ "telnyx-signature-ed25519": signature,
127
+ "telnyx-timestamp": timestamp,
128
+ },
129
+ });
130
+
131
+ const first = provider.verifyWebhook(ctx);
132
+ const second = provider.verifyWebhook(ctx);
133
+
134
+ expect(first.ok).toBe(true);
135
+ expect(first.isReplay).toBeFalsy();
136
+ expect(second.ok).toBe(true);
137
+ expect(second.isReplay).toBe(true);
138
+ });
106
139
  });
@@ -87,7 +87,7 @@ export class TelnyxProvider implements VoiceCallProvider {
87
87
  skipVerification: this.options.skipVerification,
88
88
  });
89
89
 
90
- return { ok: result.ok, reason: result.reason };
90
+ return { ok: result.ok, reason: result.reason, isReplay: result.isReplay };
91
91
  }
92
92
 
93
93
  /**
@@ -1,6 +1,10 @@
1
1
  import crypto from "node:crypto";
2
2
  import { describe, expect, it } from "vitest";
3
- import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js";
3
+ import {
4
+ verifyPlivoWebhook,
5
+ verifyTelnyxWebhook,
6
+ verifyTwilioWebhook,
7
+ } from "./webhook-security.js";
4
8
 
5
9
  function canonicalizeBase64(input: string): string {
6
10
  return Buffer.from(input, "base64").toString("base64");
@@ -199,6 +203,37 @@ describe("verifyPlivoWebhook", () => {
199
203
  });
200
204
  });
201
205
 
206
+ describe("verifyTelnyxWebhook", () => {
207
+ it("marks replayed valid requests as replay without failing auth", () => {
208
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
209
+ const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }).toString();
210
+ const timestamp = String(Math.floor(Date.now() / 1000));
211
+ const rawBody = JSON.stringify({
212
+ data: { event_type: "call.initiated", payload: { call_control_id: "call-1" } },
213
+ nonce: crypto.randomUUID(),
214
+ });
215
+ const signedPayload = `${timestamp}|${rawBody}`;
216
+ const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
217
+ const ctx = {
218
+ headers: {
219
+ "telnyx-signature-ed25519": signature,
220
+ "telnyx-timestamp": timestamp,
221
+ },
222
+ rawBody,
223
+ url: "https://example.com/voice/webhook",
224
+ method: "POST" as const,
225
+ };
226
+
227
+ const first = verifyTelnyxWebhook(ctx, pemPublicKey);
228
+ const second = verifyTelnyxWebhook(ctx, pemPublicKey);
229
+
230
+ expect(first.ok).toBe(true);
231
+ expect(first.isReplay).toBeFalsy();
232
+ expect(second.ok).toBe(true);
233
+ expect(second.isReplay).toBe(true);
234
+ });
235
+ });
236
+
202
237
  describe("verifyTwilioWebhook", () => {
203
238
  it("uses request query when publicUrl omits it", () => {
204
239
  const authToken = "test-auth-token";
@@ -20,6 +20,11 @@ const plivoReplayCache: ReplayCache = {
20
20
  calls: 0,
21
21
  };
22
22
 
23
+ const telnyxReplayCache: ReplayCache = {
24
+ seenUntil: new Map<string, number>(),
25
+ calls: 0,
26
+ };
27
+
23
28
  function sha256Hex(input: string): string {
24
29
  return crypto.createHash("sha256").update(input).digest("hex");
25
30
  }
@@ -392,6 +397,8 @@ export interface TwilioVerificationResult {
392
397
  export interface TelnyxVerificationResult {
393
398
  ok: boolean;
394
399
  reason?: string;
400
+ /** Request is cryptographically valid but was already processed recently. */
401
+ isReplay?: boolean;
395
402
  }
396
403
 
397
404
  function createTwilioReplayKey(params: {
@@ -499,7 +506,9 @@ export function verifyTelnyxWebhook(
499
506
  return { ok: false, reason: "Timestamp too old" };
500
507
  }
501
508
 
502
- return { ok: true };
509
+ const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`;
510
+ const isReplay = markReplay(telnyxReplayCache, replayKey);
511
+ return { ok: true, isReplay };
503
512
  } catch (err) {
504
513
  return {
505
514
  ok: false,