@sinch/functions-runtime 0.4.0 → 0.4.2

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/dist/index.d.ts CHANGED
@@ -1006,6 +1006,35 @@ export interface FunctionContext {
1006
1006
  /** Read a file from the assets/ directory (private, not served over HTTP) */
1007
1007
  assets(filename: string): Promise<string>;
1008
1008
  }
1009
+ /**
1010
+ * WebSocket handler callback
1011
+ */
1012
+ export type WebSocketHandler = (ws: import("ws").WebSocket, req: import("http").IncomingMessage) => void;
1013
+ /**
1014
+ * Runtime configuration object passed to the optional `setup()` export.
1015
+ *
1016
+ * Provides hooks for startup initialization and WebSocket endpoints
1017
+ * without exposing the raw Express app or HTTP server.
1018
+ *
1019
+ * @example
1020
+ * ```typescript
1021
+ * export function setup(runtime: SinchRuntime) {
1022
+ * runtime.onStartup(async (context) => {
1023
+ * // Initialize database, warm caches, etc.
1024
+ * });
1025
+ *
1026
+ * runtime.onWebSocket('/stream', (ws, req) => {
1027
+ * // Handle Sinch connectStream binary audio
1028
+ * });
1029
+ * }
1030
+ * ```
1031
+ */
1032
+ export interface SinchRuntime {
1033
+ /** Register a callback to run once at startup, before the server accepts requests. */
1034
+ onStartup(handler: (context: FunctionContext) => Promise<void> | void): void;
1035
+ /** Register a WebSocket upgrade handler at the given path. */
1036
+ onWebSocket(path: string, handler: WebSocketHandler): void;
1037
+ }
1009
1038
  /**
1010
1039
  * Application credentials structure
1011
1040
  */
@@ -1292,6 +1321,12 @@ export declare function createLenientJsonParser(options?: JsonParsingOptions): (
1292
1321
  * @internal
1293
1322
  */
1294
1323
  export declare function setupJsonParsing(app: Express, options?: JsonParsingOptions): Express;
1324
+ /**
1325
+ * Declarative auth configuration exported by user functions.
1326
+ * - string[] — protect specific handler names
1327
+ * - '*' — protect all handlers
1328
+ */
1329
+ export type AuthConfig = string[] | "*";
1295
1330
  /**
1296
1331
  * Get the landing page HTML content
1297
1332
  * Exported so production runtime can also use it
@@ -1329,6 +1364,12 @@ export interface RequestHandlerOptions {
1329
1364
  logger?: (...args: unknown[]) => void;
1330
1365
  /** Enable landing page for browser requests at root (default: true) */
1331
1366
  landingPageEnabled?: boolean;
1367
+ /** Declarative auth config from user module (string[] or '*') */
1368
+ authConfig?: AuthConfig;
1369
+ /** API key for Basic Auth validation */
1370
+ authKey?: string;
1371
+ /** API secret for Basic Auth validation */
1372
+ authSecret?: string;
1332
1373
  /** Called when request starts */
1333
1374
  onRequestStart?: (data: {
1334
1375
  functionName: string;
@@ -1443,6 +1484,7 @@ export interface SinchClients {
1443
1484
  sms?: SmsService;
1444
1485
  numbers?: NumbersService;
1445
1486
  validateWebhookSignature?: (requestData: WebhookRequestData) => boolean;
1487
+ validateConversationWebhook?: (headers: Record<string, string | string[] | undefined>, body: unknown) => boolean;
1446
1488
  }
1447
1489
  export interface WebhookRequestData {
1448
1490
  method: string;
@@ -2408,6 +2450,12 @@ export declare class TunnelClient {
2408
2450
  private generateTunnelId;
2409
2451
  connect(): Promise<void>;
2410
2452
  private handleMessage;
2453
+ /**
2454
+ * Build a full tunnel URL with optional sub-path and tunnel query param.
2455
+ * e.g. buildTunnelUrl('/webhook/conversation') →
2456
+ * https://tunnel.fn.sinch.com/ingress/webhook/conversation?tunnel=01KKT...
2457
+ */
2458
+ private buildTunnelUrl;
2411
2459
  private handleWelcomeMessage;
2412
2460
  private handleRequest;
2413
2461
  private sendPong;
package/dist/index.js CHANGED
@@ -934,6 +934,147 @@ function setupJsonParsing(app, options = {}) {
934
934
  return app;
935
935
  }
936
936
 
937
+ // ../runtime-shared/dist/auth/basic-auth.js
938
+ import { timingSafeEqual } from "crypto";
939
+ function validateBasicAuth(authHeader, expectedKey, expectedSecret) {
940
+ if (!authHeader) {
941
+ return false;
942
+ }
943
+ if (!authHeader.toLowerCase().startsWith("basic ")) {
944
+ return false;
945
+ }
946
+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf-8");
947
+ const colonIndex = decoded.indexOf(":");
948
+ if (colonIndex === -1) {
949
+ return false;
950
+ }
951
+ const providedKey = decoded.slice(0, colonIndex);
952
+ const providedSecret = decoded.slice(colonIndex + 1);
953
+ const expectedKeyBuf = Buffer.from(expectedKey);
954
+ const providedKeyBuf = Buffer.from(providedKey);
955
+ const expectedSecretBuf = Buffer.from(expectedSecret);
956
+ const providedSecretBuf = Buffer.from(providedSecret);
957
+ const keyMatch = expectedKeyBuf.length === providedKeyBuf.length && timingSafeEqual(expectedKeyBuf, providedKeyBuf);
958
+ const secretMatch = expectedSecretBuf.length === providedSecretBuf.length && timingSafeEqual(expectedSecretBuf, providedSecretBuf);
959
+ return keyMatch && secretMatch;
960
+ }
961
+
962
+ // ../runtime-shared/dist/security/index.js
963
+ function shouldValidateWebhook(mode, isDevelopment) {
964
+ const normalizedMode = (mode || "deploy").toLowerCase();
965
+ switch (normalizedMode) {
966
+ case "never":
967
+ return false;
968
+ case "always":
969
+ return true;
970
+ case "deploy":
971
+ default:
972
+ return !isDevelopment;
973
+ }
974
+ }
975
+ var VALID_MODES = ["never", "deploy", "always"];
976
+ function getProtectionMode(config) {
977
+ const envValue = process.env.WEBHOOK_PROTECTION ?? process.env.PROTECT_VOICE_CALLBACKS;
978
+ if (envValue) {
979
+ const normalized = envValue.toLowerCase();
980
+ if (VALID_MODES.includes(normalized)) {
981
+ return normalized;
982
+ }
983
+ console.warn(`[SECURITY] Unknown WEBHOOK_PROTECTION value "${envValue}", defaulting to "deploy"`);
984
+ return "deploy";
985
+ }
986
+ const configValue = config?.WebhookProtection ?? config?.webhookProtection ?? config?.ProtectVoiceCallbacks ?? config?.protectVoiceCallbacks;
987
+ if (typeof configValue === "string") {
988
+ const normalized = configValue.toLowerCase();
989
+ if (VALID_MODES.includes(normalized)) {
990
+ return normalized;
991
+ }
992
+ console.warn(`[SECURITY] Unknown webhook protection value "${configValue}", defaulting to "deploy"`);
993
+ return "deploy";
994
+ }
995
+ return "deploy";
996
+ }
997
+
998
+ // ../runtime-shared/dist/sinch/index.js
999
+ import { SinchClient, validateAuthenticationHeader, ConversationCallbackWebhooks } from "@sinch/sdk-core";
1000
+ function createSinchClients() {
1001
+ const clients = {};
1002
+ const hasCredentials = process.env.PROJECT_ID && process.env.PROJECT_ID_API_KEY && process.env.PROJECT_ID_API_SECRET;
1003
+ if (process.env.CONVERSATION_WEBHOOK_SECRET) {
1004
+ const callbackProcessor = new ConversationCallbackWebhooks(process.env.CONVERSATION_WEBHOOK_SECRET);
1005
+ clients.validateConversationWebhook = (headers, body) => {
1006
+ try {
1007
+ const result = callbackProcessor.validateAuthenticationHeader(headers, body);
1008
+ console.log("[SINCH] Conversation webhook validation:", result ? "VALID" : "INVALID");
1009
+ return result;
1010
+ } catch (error) {
1011
+ console.error("[SINCH] Conversation validation error:", error instanceof Error ? error.message : error);
1012
+ return false;
1013
+ }
1014
+ };
1015
+ }
1016
+ if (!hasCredentials) {
1017
+ return clients;
1018
+ }
1019
+ try {
1020
+ const sinchClient = new SinchClient({
1021
+ projectId: process.env.PROJECT_ID,
1022
+ keyId: process.env.PROJECT_ID_API_KEY,
1023
+ keySecret: process.env.PROJECT_ID_API_SECRET
1024
+ });
1025
+ if (process.env.CONVERSATION_APP_ID) {
1026
+ clients.conversation = sinchClient.conversation;
1027
+ console.log("[SINCH] Conversation API initialized");
1028
+ }
1029
+ if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
1030
+ const voiceClient = new SinchClient({
1031
+ projectId: process.env.PROJECT_ID,
1032
+ keyId: process.env.PROJECT_ID_API_KEY,
1033
+ keySecret: process.env.PROJECT_ID_API_SECRET,
1034
+ applicationKey: process.env.VOICE_APPLICATION_KEY,
1035
+ applicationSecret: process.env.VOICE_APPLICATION_SECRET
1036
+ });
1037
+ clients.voice = voiceClient.voice;
1038
+ console.log("[SINCH] Voice API initialized with application credentials");
1039
+ }
1040
+ if (process.env.SMS_SERVICE_PLAN_ID) {
1041
+ clients.sms = sinchClient.sms;
1042
+ console.log("[SINCH] SMS API initialized");
1043
+ }
1044
+ if (process.env.ENABLE_NUMBERS_API === "true") {
1045
+ clients.numbers = sinchClient.numbers;
1046
+ console.log("[SINCH] Numbers API initialized");
1047
+ }
1048
+ } catch (error) {
1049
+ console.error("[SINCH] Failed to initialize Sinch clients:", error.message);
1050
+ return {};
1051
+ }
1052
+ if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
1053
+ clients.validateWebhookSignature = (requestData) => {
1054
+ console.log("[SINCH] Validating Voice webhook signature");
1055
+ try {
1056
+ const result = validateAuthenticationHeader(process.env.VOICE_APPLICATION_KEY, process.env.VOICE_APPLICATION_SECRET, requestData.headers, requestData.body, requestData.path, requestData.method);
1057
+ console.log("[SINCH] Validation result:", result ? "VALID" : "INVALID");
1058
+ return result;
1059
+ } catch (error) {
1060
+ console.error("[SINCH] Validation error:", error.message);
1061
+ return false;
1062
+ }
1063
+ };
1064
+ }
1065
+ return clients;
1066
+ }
1067
+ var cachedClients = null;
1068
+ function getSinchClients() {
1069
+ if (!cachedClients) {
1070
+ cachedClients = createSinchClients();
1071
+ }
1072
+ return cachedClients;
1073
+ }
1074
+ function resetSinchClients() {
1075
+ cachedClients = null;
1076
+ }
1077
+
937
1078
  // ../runtime-shared/dist/host/app.js
938
1079
  import { createRequire as createRequire2 } from "module";
939
1080
  import { pathToFileURL } from "url";
@@ -1155,7 +1296,13 @@ function buildBaseContext(req, config = {}) {
1155
1296
  async function handleVoiceCallback(functionName, userFunction, context, callbackData, logger) {
1156
1297
  const handler = userFunction[functionName];
1157
1298
  if (!handler || typeof handler !== "function") {
1158
- throw new Error(`Function '${functionName}' not found in function.js`);
1299
+ if (functionName === "ice") {
1300
+ throw new Error(`Voice callback 'ice' not found \u2014 export an ice() function in function.ts`);
1301
+ }
1302
+ if (logger) {
1303
+ logger(`${functionName.toUpperCase()} callback not implemented \u2014 returning 200`);
1304
+ }
1305
+ return { statusCode: 200, body: {}, headers: {} };
1159
1306
  }
1160
1307
  let result;
1161
1308
  switch (functionName) {
@@ -1198,7 +1345,9 @@ async function handleCustomEndpoint(functionName, userFunction, context, request
1198
1345
  handler = userFunction["home"];
1199
1346
  }
1200
1347
  if (!handler || typeof handler !== "function") {
1201
- throw new Error(`Function '${functionName}' not found in function.js`);
1348
+ const available = Object.keys(userFunction).filter((k) => typeof userFunction[k] === "function");
1349
+ const pathHint = functionName.endsWith("Webhook") ? `/webhook/${functionName.replace("Webhook", "")}` : `/${functionName}`;
1350
+ throw new Error(`No export '${functionName}' found for path ${pathHint}. Available exports: [${available.join(", ")}]. Custom endpoints require a named export matching the last path segment.`);
1202
1351
  }
1203
1352
  const result = await handler(context, request);
1204
1353
  if (logger) {
@@ -1236,7 +1385,7 @@ function setupRequestHandler(app, options = {}) {
1236
1385
  const functionUrl = pathToFileURL(functionPath).href;
1237
1386
  const module = await import(functionUrl);
1238
1387
  return module.default || module;
1239
- }, buildContext = buildBaseContext, logger = console.log, landingPageEnabled = true, onRequestStart = () => {
1388
+ }, buildContext = buildBaseContext, logger = console.log, landingPageEnabled = true, authConfig, authKey, authSecret, onRequestStart = () => {
1240
1389
  }, onRequestEnd = () => {
1241
1390
  } } = options;
1242
1391
  app.use("/{*splat}", async (req, res) => {
@@ -1264,6 +1413,44 @@ function setupRequestHandler(app, options = {}) {
1264
1413
  try {
1265
1414
  const functionName = extractFunctionName(req.originalUrl, req.body);
1266
1415
  logger(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${req.method} ${req.path} -> ${functionName}`);
1416
+ if (authConfig && authKey && authSecret) {
1417
+ const needsAuth = authConfig === "*" || Array.isArray(authConfig) && authConfig.includes(functionName);
1418
+ if (needsAuth) {
1419
+ const isValid = validateBasicAuth(req.headers.authorization, authKey, authSecret);
1420
+ if (!isValid) {
1421
+ logger(`[AUTH] Rejected unauthorized request to ${functionName}`);
1422
+ res.status(401).set("WWW-Authenticate", 'Basic realm="sinch-function"').json({ error: "Unauthorized" });
1423
+ return;
1424
+ }
1425
+ }
1426
+ }
1427
+ const protectionMode = getProtectionMode();
1428
+ const isDev = process.env.NODE_ENV !== "production" && process.env.ASPNETCORE_ENVIRONMENT !== "Production";
1429
+ if (shouldValidateWebhook(protectionMode, isDev)) {
1430
+ const sinchClients = getSinchClients();
1431
+ if (isVoiceCallback(functionName) && sinchClients.validateWebhookSignature) {
1432
+ const rawBody = req.rawBody ?? JSON.stringify(req.body);
1433
+ const isValid = sinchClients.validateWebhookSignature({
1434
+ method: req.method,
1435
+ path: req.path,
1436
+ headers: req.headers,
1437
+ body: rawBody
1438
+ });
1439
+ if (!isValid) {
1440
+ logger("[SECURITY] Voice webhook signature validation failed");
1441
+ res.status(401).json({ error: "Invalid webhook signature" });
1442
+ return;
1443
+ }
1444
+ }
1445
+ if (functionName === "conversationWebhook" && sinchClients.validateConversationWebhook) {
1446
+ const isValid = sinchClients.validateConversationWebhook(req.headers, req.body);
1447
+ if (!isValid) {
1448
+ logger("[SECURITY] Conversation webhook signature validation failed");
1449
+ res.status(401).json({ error: "Invalid webhook signature" });
1450
+ return;
1451
+ }
1452
+ }
1453
+ }
1267
1454
  onRequestStart({ functionName, req });
1268
1455
  const context = buildContext(req);
1269
1456
  const userFunction = await Promise.resolve(loadUserFunction());
@@ -1339,73 +1526,6 @@ function setupRequestHandler(app, options = {}) {
1339
1526
  });
1340
1527
  }
1341
1528
 
1342
- // ../runtime-shared/dist/sinch/index.js
1343
- import { SinchClient, validateAuthenticationHeader } from "@sinch/sdk-core";
1344
- function createSinchClients() {
1345
- const clients = {};
1346
- const hasCredentials = process.env.PROJECT_ID && process.env.PROJECT_ID_API_KEY && process.env.PROJECT_ID_API_SECRET;
1347
- if (!hasCredentials) {
1348
- return clients;
1349
- }
1350
- try {
1351
- const sinchClient = new SinchClient({
1352
- projectId: process.env.PROJECT_ID,
1353
- keyId: process.env.PROJECT_ID_API_KEY,
1354
- keySecret: process.env.PROJECT_ID_API_SECRET
1355
- });
1356
- if (process.env.CONVERSATION_APP_ID) {
1357
- clients.conversation = sinchClient.conversation;
1358
- console.log("[SINCH] Conversation API initialized");
1359
- }
1360
- if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
1361
- const voiceClient = new SinchClient({
1362
- projectId: process.env.PROJECT_ID,
1363
- keyId: process.env.PROJECT_ID_API_KEY,
1364
- keySecret: process.env.PROJECT_ID_API_SECRET,
1365
- applicationKey: process.env.VOICE_APPLICATION_KEY,
1366
- applicationSecret: process.env.VOICE_APPLICATION_SECRET
1367
- });
1368
- clients.voice = voiceClient.voice;
1369
- console.log("[SINCH] Voice API initialized with application credentials");
1370
- }
1371
- if (process.env.SMS_SERVICE_PLAN_ID) {
1372
- clients.sms = sinchClient.sms;
1373
- console.log("[SINCH] SMS API initialized");
1374
- }
1375
- if (process.env.ENABLE_NUMBERS_API === "true") {
1376
- clients.numbers = sinchClient.numbers;
1377
- console.log("[SINCH] Numbers API initialized");
1378
- }
1379
- } catch (error) {
1380
- console.error("[SINCH] Failed to initialize Sinch clients:", error.message);
1381
- return {};
1382
- }
1383
- if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
1384
- clients.validateWebhookSignature = (requestData) => {
1385
- console.log("[SINCH] Validating Voice webhook signature");
1386
- try {
1387
- const result = validateAuthenticationHeader(process.env.VOICE_APPLICATION_KEY, process.env.VOICE_APPLICATION_SECRET, requestData.headers, requestData.body, requestData.path, requestData.method);
1388
- console.log("[SINCH] Validation result:", result ? "VALID" : "INVALID");
1389
- return result;
1390
- } catch (error) {
1391
- console.error("[SINCH] Validation error:", error.message);
1392
- return false;
1393
- }
1394
- };
1395
- }
1396
- return clients;
1397
- }
1398
- var cachedClients = null;
1399
- function getSinchClients() {
1400
- if (!cachedClients) {
1401
- cachedClients = createSinchClients();
1402
- }
1403
- return cachedClients;
1404
- }
1405
- function resetSinchClients() {
1406
- cachedClients = null;
1407
- }
1408
-
1409
1529
  // ../runtime-shared/dist/ai/elevenlabs/state.js
1410
1530
  var ElevenLabsStateManager = class {
1411
1531
  state = {
@@ -2457,7 +2577,7 @@ import * as path4 from "path";
2457
2577
  var LocalStorage = class {
2458
2578
  baseDir;
2459
2579
  constructor(baseDir) {
2460
- this.baseDir = baseDir ?? path4.join(process.cwd(), "storage");
2580
+ this.baseDir = baseDir ?? path4.join(process.cwd(), ".sinch", "storage");
2461
2581
  }
2462
2582
  resolvePath(key) {
2463
2583
  const sanitized = key.replace(/^\/+/, "").replace(/\.\./g, "_");
@@ -2520,7 +2640,15 @@ import axios from "axios";
2520
2640
 
2521
2641
  // src/tunnel/webhook-config.ts
2522
2642
  import { SinchClient as SinchClient2 } from "@sinch/sdk-core";
2523
- async function configureConversationWebhooks(tunnelUrl, config) {
2643
+ import { randomBytes } from "crypto";
2644
+ var SINCH_FN_URL_PATTERN = /\.fn(-\w+)?\.sinch\.com/;
2645
+ function isOurWebhook(target) {
2646
+ return !!target && SINCH_FN_URL_PATTERN.test(target);
2647
+ }
2648
+ function isTunnelUrl(target) {
2649
+ return !!target && target.includes("tunnel.fn");
2650
+ }
2651
+ async function configureConversationWebhooks(webhookUrl, config) {
2524
2652
  try {
2525
2653
  const conversationAppId = process.env.CONVERSATION_APP_ID;
2526
2654
  const projectId = process.env.PROJECT_ID;
@@ -2530,7 +2658,6 @@ async function configureConversationWebhooks(tunnelUrl, config) {
2530
2658
  console.log("\u{1F4A1} Conversation API not fully configured - skipping webhook setup");
2531
2659
  return;
2532
2660
  }
2533
- const webhookUrl = `${tunnelUrl}/webhook/conversation`;
2534
2661
  console.log(`\u{1F4AC} Conversation webhook URL: ${webhookUrl}`);
2535
2662
  const sinchClient = new SinchClient2({
2536
2663
  projectId,
@@ -2541,27 +2668,31 @@ async function configureConversationWebhooks(tunnelUrl, config) {
2541
2668
  app_id: conversationAppId
2542
2669
  });
2543
2670
  const existingWebhooks = webhooksResult.webhooks || [];
2544
- const tunnelWebhooks = existingWebhooks.filter((w) => w.target?.includes("/api/ingress/"));
2545
- for (const staleWebhook of tunnelWebhooks) {
2546
- try {
2547
- await sinchClient.conversation.webhooks.delete({ webhook_id: staleWebhook.id });
2548
- console.log(`\u{1F9F9} Cleaned up stale tunnel webhook: ${staleWebhook.id}`);
2549
- } catch (err) {
2550
- }
2671
+ const deployedWebhook = existingWebhooks.find(
2672
+ (w) => isOurWebhook(w.target)
2673
+ );
2674
+ if (!deployedWebhook || !deployedWebhook.id) {
2675
+ console.log("\u26A0\uFE0F No deployed webhook found \u2014 deploy first");
2676
+ return;
2551
2677
  }
2552
- const createResult = await sinchClient.conversation.webhooks.create({
2553
- webhookCreateRequestBody: {
2554
- app_id: conversationAppId,
2678
+ config.conversationWebhookId = deployedWebhook.id;
2679
+ config.originalTarget = deployedWebhook.target;
2680
+ const hmacSecret = randomBytes(32).toString("hex");
2681
+ process.env.CONVERSATION_WEBHOOK_SECRET = hmacSecret;
2682
+ resetSinchClients();
2683
+ await sinchClient.conversation.webhooks.update({
2684
+ webhook_id: deployedWebhook.id,
2685
+ webhookUpdateRequestBody: {
2555
2686
  target: webhookUrl,
2556
- target_type: "HTTP",
2557
- triggers: ["MESSAGE_INBOUND"]
2558
- }
2687
+ secret: hmacSecret
2688
+ },
2689
+ update_mask: ["target", "secret"]
2559
2690
  });
2560
- config.conversationWebhookId = createResult.id;
2561
- console.log(`\u2705 Created Conversation webhook: ${webhookUrl}`);
2691
+ console.log(`\u2705 Updated Conversation webhook to tunnel: ${webhookUrl}`);
2692
+ console.log("\u{1F512} HMAC secret configured for webhook signature validation");
2562
2693
  console.log("\u{1F4AC} Send a message to your Conversation app to test!");
2563
2694
  } catch (error) {
2564
- console.log("\u26A0\uFE0F Could not configure Conversation webhooks:", error.message);
2695
+ console.log("\u26A0\uFE0F Could not configure Conversation webhooks:", error instanceof Error ? error.message : error);
2565
2696
  }
2566
2697
  }
2567
2698
  async function cleanupConversationWebhook(config) {
@@ -2577,10 +2708,31 @@ async function cleanupConversationWebhook(config) {
2577
2708
  keyId,
2578
2709
  keySecret
2579
2710
  });
2580
- await sinchClient.conversation.webhooks.delete({ webhook_id: config.conversationWebhookId });
2581
- console.log("\u{1F9F9} Cleaned up tunnel webhook");
2711
+ let restoreTarget = config.originalTarget;
2712
+ if (!restoreTarget || isTunnelUrl(restoreTarget)) {
2713
+ const functionName = process.env.FUNCTION_NAME || process.env.FUNCTION_ID;
2714
+ if (functionName) {
2715
+ restoreTarget = `https://${functionName}.fn-dev.sinch.com/webhook/conversation`;
2716
+ console.log(`\u{1F527} Derived restore target from env: ${restoreTarget}`);
2717
+ } else {
2718
+ console.log("\u26A0\uFE0F Cannot restore webhook \u2014 no FUNCTION_NAME or FUNCTION_ID available");
2719
+ return;
2720
+ }
2721
+ }
2722
+ await sinchClient.conversation.webhooks.update({
2723
+ webhook_id: config.conversationWebhookId,
2724
+ webhookUpdateRequestBody: {
2725
+ target: restoreTarget
2726
+ },
2727
+ update_mask: ["target"]
2728
+ });
2729
+ console.log(`\u{1F504} Restored webhook target to: ${restoreTarget}`);
2582
2730
  config.conversationWebhookId = void 0;
2731
+ config.originalTarget = void 0;
2732
+ delete process.env.CONVERSATION_WEBHOOK_SECRET;
2733
+ resetSinchClients();
2583
2734
  } catch (error) {
2735
+ console.log("\u26A0\uFE0F Could not restore Conversation webhook:", error instanceof Error ? error.message : error);
2584
2736
  }
2585
2737
  }
2586
2738
  async function configureElevenLabs() {
@@ -2595,7 +2747,7 @@ async function configureElevenLabs() {
2595
2747
  console.log("\u{1F916} ElevenLabs auto-configuration enabled");
2596
2748
  console.log(` Agent ID: ${agentId}`);
2597
2749
  } catch (error) {
2598
- console.log("\u26A0\uFE0F Could not configure ElevenLabs:", error.message);
2750
+ console.log("\u26A0\uFE0F Could not configure ElevenLabs:", error instanceof Error ? error.message : error);
2599
2751
  }
2600
2752
  }
2601
2753
 
@@ -2632,14 +2784,14 @@ var TunnelClient = class {
2632
2784
  timestampPart = ENCODING[t % 32] + timestampPart;
2633
2785
  t = Math.floor(t / 32);
2634
2786
  }
2635
- const randomBytes = new Uint8Array(10);
2636
- crypto.getRandomValues(randomBytes);
2787
+ const randomBytes2 = new Uint8Array(10);
2788
+ crypto.getRandomValues(randomBytes2);
2637
2789
  let randomPart = "";
2638
2790
  for (let i = 0; i < 10; i++) {
2639
- const byte = randomBytes[i];
2791
+ const byte = randomBytes2[i];
2640
2792
  randomPart += ENCODING[byte >> 3];
2641
2793
  if (randomPart.length < 16) {
2642
- randomPart += ENCODING[(byte & 7) << 2 | (i + 1 < 10 ? randomBytes[i + 1] >> 6 : 0)];
2794
+ randomPart += ENCODING[(byte & 7) << 2 | (i + 1 < 10 ? randomBytes2[i + 1] >> 6 : 0)];
2643
2795
  }
2644
2796
  }
2645
2797
  randomPart = randomPart.substring(0, 16);
@@ -2712,6 +2864,15 @@ var TunnelClient = class {
2712
2864
  break;
2713
2865
  }
2714
2866
  }
2867
+ /**
2868
+ * Build a full tunnel URL with optional sub-path and tunnel query param.
2869
+ * e.g. buildTunnelUrl('/webhook/conversation') →
2870
+ * https://tunnel.fn.sinch.com/ingress/webhook/conversation?tunnel=01KKT...
2871
+ */
2872
+ buildTunnelUrl(path6) {
2873
+ const base = this.tunnelUrl.replace(/\/$/, "");
2874
+ return `${base}${path6 || ""}?tunnel=${this.tunnelId}`;
2875
+ }
2715
2876
  handleWelcomeMessage(message) {
2716
2877
  this.tunnelId = message.tunnelId || null;
2717
2878
  this.tunnelUrl = message.publicUrl || null;
@@ -2722,7 +2883,11 @@ var TunnelClient = class {
2722
2883
  }
2723
2884
  }
2724
2885
  async handleRequest(message) {
2886
+ const verbose = process.env.VERBOSE === "true";
2725
2887
  console.log(`Forwarding ${message.method} request to ${message.path}`);
2888
+ if (verbose && message.body) {
2889
+ console.log(` \u2190 Request body: ${message.body.substring(0, 2e3)}`);
2890
+ }
2726
2891
  try {
2727
2892
  const localUrl = `http://localhost:${this.localPort}${message.path}${message.query || ""}`;
2728
2893
  const axiosConfig = {
@@ -2755,9 +2920,15 @@ var TunnelClient = class {
2755
2920
  headers,
2756
2921
  body
2757
2922
  };
2923
+ if (verbose) {
2924
+ console.log(` \u2192 Response ${response.status}: ${body.substring(0, 2e3)}`);
2925
+ }
2758
2926
  this.ws?.send(JSON.stringify(responseMessage));
2759
2927
  } catch (error) {
2760
- console.error("Error forwarding request:", error.message);
2928
+ console.error(`Error forwarding request: ${error.message} (${error.response?.status || "no response"})`);
2929
+ if (verbose && error.response?.data) {
2930
+ console.error(` \u2192 Error body: ${typeof error.response.data === "string" ? error.response.data : JSON.stringify(error.response.data)}`.substring(0, 2e3));
2931
+ }
2761
2932
  const errorResponse = {
2762
2933
  type: "response",
2763
2934
  id: message.id,
@@ -2801,7 +2972,7 @@ var TunnelClient = class {
2801
2972
  await this.configureVoiceWebhooks();
2802
2973
  }
2803
2974
  if (autoConfigConversation && process.env.CONVERSATION_APP_ID) {
2804
- await configureConversationWebhooks(this.tunnelUrl, this.webhookConfig);
2975
+ await configureConversationWebhooks(this.buildTunnelUrl("/webhook/conversation"), this.webhookConfig);
2805
2976
  }
2806
2977
  if (process.env.ELEVENLABS_AUTO_CONFIGURE === "true") {
2807
2978
  await configureElevenLabs();
@@ -2828,7 +2999,7 @@ var TunnelClient = class {
2828
2999
  updateUrl,
2829
3000
  {
2830
3001
  url: {
2831
- primary: this.tunnelUrl,
3002
+ primary: this.buildTunnelUrl(),
2832
3003
  fallback: null
2833
3004
  }
2834
3005
  },
@@ -2890,7 +3061,8 @@ var TunnelClient = class {
2890
3061
  this.isConnected = false;
2891
3062
  }
2892
3063
  getTunnelUrl() {
2893
- return this.tunnelUrl;
3064
+ if (!this.tunnelUrl || !this.tunnelId) return null;
3065
+ return this.buildTunnelUrl();
2894
3066
  }
2895
3067
  getIsConnected() {
2896
3068
  return this.isConnected;