@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.
@@ -160,6 +160,147 @@ function setupJsonParsing(app, options = {}) {
160
160
  return app;
161
161
  }
162
162
 
163
+ // ../runtime-shared/dist/auth/basic-auth.js
164
+ import { timingSafeEqual } from "crypto";
165
+ function validateBasicAuth(authHeader, expectedKey, expectedSecret) {
166
+ if (!authHeader) {
167
+ return false;
168
+ }
169
+ if (!authHeader.toLowerCase().startsWith("basic ")) {
170
+ return false;
171
+ }
172
+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf-8");
173
+ const colonIndex = decoded.indexOf(":");
174
+ if (colonIndex === -1) {
175
+ return false;
176
+ }
177
+ const providedKey = decoded.slice(0, colonIndex);
178
+ const providedSecret = decoded.slice(colonIndex + 1);
179
+ const expectedKeyBuf = Buffer.from(expectedKey);
180
+ const providedKeyBuf = Buffer.from(providedKey);
181
+ const expectedSecretBuf = Buffer.from(expectedSecret);
182
+ const providedSecretBuf = Buffer.from(providedSecret);
183
+ const keyMatch = expectedKeyBuf.length === providedKeyBuf.length && timingSafeEqual(expectedKeyBuf, providedKeyBuf);
184
+ const secretMatch = expectedSecretBuf.length === providedSecretBuf.length && timingSafeEqual(expectedSecretBuf, providedSecretBuf);
185
+ return keyMatch && secretMatch;
186
+ }
187
+
188
+ // ../runtime-shared/dist/security/index.js
189
+ function shouldValidateWebhook(mode, isDevelopment) {
190
+ const normalizedMode = (mode || "deploy").toLowerCase();
191
+ switch (normalizedMode) {
192
+ case "never":
193
+ return false;
194
+ case "always":
195
+ return true;
196
+ case "deploy":
197
+ default:
198
+ return !isDevelopment;
199
+ }
200
+ }
201
+ var VALID_MODES = ["never", "deploy", "always"];
202
+ function getProtectionMode(config) {
203
+ const envValue = process.env.WEBHOOK_PROTECTION ?? process.env.PROTECT_VOICE_CALLBACKS;
204
+ if (envValue) {
205
+ const normalized = envValue.toLowerCase();
206
+ if (VALID_MODES.includes(normalized)) {
207
+ return normalized;
208
+ }
209
+ console.warn(`[SECURITY] Unknown WEBHOOK_PROTECTION value "${envValue}", defaulting to "deploy"`);
210
+ return "deploy";
211
+ }
212
+ const configValue = config?.WebhookProtection ?? config?.webhookProtection ?? config?.ProtectVoiceCallbacks ?? config?.protectVoiceCallbacks;
213
+ if (typeof configValue === "string") {
214
+ const normalized = configValue.toLowerCase();
215
+ if (VALID_MODES.includes(normalized)) {
216
+ return normalized;
217
+ }
218
+ console.warn(`[SECURITY] Unknown webhook protection value "${configValue}", defaulting to "deploy"`);
219
+ return "deploy";
220
+ }
221
+ return "deploy";
222
+ }
223
+
224
+ // ../runtime-shared/dist/sinch/index.js
225
+ import { SinchClient, validateAuthenticationHeader, ConversationCallbackWebhooks } from "@sinch/sdk-core";
226
+ function createSinchClients() {
227
+ const clients = {};
228
+ const hasCredentials = process.env.PROJECT_ID && process.env.PROJECT_ID_API_KEY && process.env.PROJECT_ID_API_SECRET;
229
+ if (process.env.CONVERSATION_WEBHOOK_SECRET) {
230
+ const callbackProcessor = new ConversationCallbackWebhooks(process.env.CONVERSATION_WEBHOOK_SECRET);
231
+ clients.validateConversationWebhook = (headers, body) => {
232
+ try {
233
+ const result = callbackProcessor.validateAuthenticationHeader(headers, body);
234
+ console.log("[SINCH] Conversation webhook validation:", result ? "VALID" : "INVALID");
235
+ return result;
236
+ } catch (error) {
237
+ console.error("[SINCH] Conversation validation error:", error instanceof Error ? error.message : error);
238
+ return false;
239
+ }
240
+ };
241
+ }
242
+ if (!hasCredentials) {
243
+ return clients;
244
+ }
245
+ try {
246
+ const sinchClient = new SinchClient({
247
+ projectId: process.env.PROJECT_ID,
248
+ keyId: process.env.PROJECT_ID_API_KEY,
249
+ keySecret: process.env.PROJECT_ID_API_SECRET
250
+ });
251
+ if (process.env.CONVERSATION_APP_ID) {
252
+ clients.conversation = sinchClient.conversation;
253
+ console.log("[SINCH] Conversation API initialized");
254
+ }
255
+ if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
256
+ const voiceClient = new SinchClient({
257
+ projectId: process.env.PROJECT_ID,
258
+ keyId: process.env.PROJECT_ID_API_KEY,
259
+ keySecret: process.env.PROJECT_ID_API_SECRET,
260
+ applicationKey: process.env.VOICE_APPLICATION_KEY,
261
+ applicationSecret: process.env.VOICE_APPLICATION_SECRET
262
+ });
263
+ clients.voice = voiceClient.voice;
264
+ console.log("[SINCH] Voice API initialized with application credentials");
265
+ }
266
+ if (process.env.SMS_SERVICE_PLAN_ID) {
267
+ clients.sms = sinchClient.sms;
268
+ console.log("[SINCH] SMS API initialized");
269
+ }
270
+ if (process.env.ENABLE_NUMBERS_API === "true") {
271
+ clients.numbers = sinchClient.numbers;
272
+ console.log("[SINCH] Numbers API initialized");
273
+ }
274
+ } catch (error) {
275
+ console.error("[SINCH] Failed to initialize Sinch clients:", error.message);
276
+ return {};
277
+ }
278
+ if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
279
+ clients.validateWebhookSignature = (requestData) => {
280
+ console.log("[SINCH] Validating Voice webhook signature");
281
+ try {
282
+ const result = validateAuthenticationHeader(process.env.VOICE_APPLICATION_KEY, process.env.VOICE_APPLICATION_SECRET, requestData.headers, requestData.body, requestData.path, requestData.method);
283
+ console.log("[SINCH] Validation result:", result ? "VALID" : "INVALID");
284
+ return result;
285
+ } catch (error) {
286
+ console.error("[SINCH] Validation error:", error.message);
287
+ return false;
288
+ }
289
+ };
290
+ }
291
+ return clients;
292
+ }
293
+ var cachedClients = null;
294
+ function getSinchClients() {
295
+ if (!cachedClients) {
296
+ cachedClients = createSinchClients();
297
+ }
298
+ return cachedClients;
299
+ }
300
+ function resetSinchClients() {
301
+ cachedClients = null;
302
+ }
303
+
163
304
  // ../runtime-shared/dist/host/app.js
164
305
  import { createRequire as createRequire2 } from "module";
165
306
  import { pathToFileURL } from "url";
@@ -381,7 +522,13 @@ function buildBaseContext(req, config = {}) {
381
522
  async function handleVoiceCallback(functionName, userFunction, context, callbackData, logger) {
382
523
  const handler = userFunction[functionName];
383
524
  if (!handler || typeof handler !== "function") {
384
- throw new Error(`Function '${functionName}' not found in function.js`);
525
+ if (functionName === "ice") {
526
+ throw new Error(`Voice callback 'ice' not found \u2014 export an ice() function in function.ts`);
527
+ }
528
+ if (logger) {
529
+ logger(`${functionName.toUpperCase()} callback not implemented \u2014 returning 200`);
530
+ }
531
+ return { statusCode: 200, body: {}, headers: {} };
385
532
  }
386
533
  let result;
387
534
  switch (functionName) {
@@ -424,7 +571,9 @@ async function handleCustomEndpoint(functionName, userFunction, context, request
424
571
  handler = userFunction["home"];
425
572
  }
426
573
  if (!handler || typeof handler !== "function") {
427
- throw new Error(`Function '${functionName}' not found in function.js`);
574
+ const available = Object.keys(userFunction).filter((k) => typeof userFunction[k] === "function");
575
+ const pathHint = functionName.endsWith("Webhook") ? `/webhook/${functionName.replace("Webhook", "")}` : `/${functionName}`;
576
+ 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.`);
428
577
  }
429
578
  const result = await handler(context, request);
430
579
  if (logger) {
@@ -456,13 +605,49 @@ function createApp(options = {}) {
456
605
  app.use(express.urlencoded({ extended: true, limit: "10mb" }));
457
606
  return app;
458
607
  }
608
+ function createSinchRuntime() {
609
+ const result = {
610
+ startupHandlers: [],
611
+ wsHandlers: /* @__PURE__ */ new Map()
612
+ };
613
+ const runtime = {
614
+ onStartup(handler) {
615
+ result.startupHandlers.push(handler);
616
+ },
617
+ onWebSocket(path7, handler) {
618
+ const normalizedPath = path7.startsWith("/") ? path7 : `/${path7}`;
619
+ result.wsHandlers.set(normalizedPath, handler);
620
+ }
621
+ };
622
+ return { runtime, result };
623
+ }
624
+ function installWebSocketHandlers(server, wsHandlers, logger = console.log) {
625
+ if (wsHandlers.size === 0)
626
+ return;
627
+ const { WebSocketServer } = requireCjs2("ws");
628
+ const wss = new WebSocketServer({ noServer: true });
629
+ server.on("upgrade", (req, socket, head) => {
630
+ const pathname = new URL(req.url || "/", `http://${req.headers.host}`).pathname;
631
+ const handler = wsHandlers.get(pathname);
632
+ if (handler) {
633
+ logger(`[WS] Upgrade request for ${pathname}`);
634
+ wss.handleUpgrade(req, socket, head, (ws) => {
635
+ handler(ws, req);
636
+ });
637
+ } else {
638
+ logger(`[WS] No handler for ${pathname} \u2014 rejecting upgrade`);
639
+ socket.destroy();
640
+ }
641
+ });
642
+ logger(`[WS] WebSocket endpoints registered: ${[...wsHandlers.keys()].join(", ")}`);
643
+ }
459
644
  function setupRequestHandler(app, options = {}) {
460
645
  const { loadUserFunction = async () => {
461
646
  const functionPath = findFunctionPath();
462
647
  const functionUrl = pathToFileURL(functionPath).href;
463
648
  const module = await import(functionUrl);
464
649
  return module.default || module;
465
- }, buildContext = buildBaseContext, logger = console.log, landingPageEnabled = true, onRequestStart = () => {
650
+ }, buildContext = buildBaseContext, logger = console.log, landingPageEnabled = true, authConfig, authKey, authSecret, onRequestStart = () => {
466
651
  }, onRequestEnd = () => {
467
652
  } } = options;
468
653
  app.use("/{*splat}", async (req, res) => {
@@ -490,6 +675,44 @@ function setupRequestHandler(app, options = {}) {
490
675
  try {
491
676
  const functionName = extractFunctionName(req.originalUrl, req.body);
492
677
  logger(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${req.method} ${req.path} -> ${functionName}`);
678
+ if (authConfig && authKey && authSecret) {
679
+ const needsAuth = authConfig === "*" || Array.isArray(authConfig) && authConfig.includes(functionName);
680
+ if (needsAuth) {
681
+ const isValid = validateBasicAuth(req.headers.authorization, authKey, authSecret);
682
+ if (!isValid) {
683
+ logger(`[AUTH] Rejected unauthorized request to ${functionName}`);
684
+ res.status(401).set("WWW-Authenticate", 'Basic realm="sinch-function"').json({ error: "Unauthorized" });
685
+ return;
686
+ }
687
+ }
688
+ }
689
+ const protectionMode = getProtectionMode();
690
+ const isDev = process.env.NODE_ENV !== "production" && process.env.ASPNETCORE_ENVIRONMENT !== "Production";
691
+ if (shouldValidateWebhook(protectionMode, isDev)) {
692
+ const sinchClients = getSinchClients();
693
+ if (isVoiceCallback(functionName) && sinchClients.validateWebhookSignature) {
694
+ const rawBody = req.rawBody ?? JSON.stringify(req.body);
695
+ const isValid = sinchClients.validateWebhookSignature({
696
+ method: req.method,
697
+ path: req.path,
698
+ headers: req.headers,
699
+ body: rawBody
700
+ });
701
+ if (!isValid) {
702
+ logger("[SECURITY] Voice webhook signature validation failed");
703
+ res.status(401).json({ error: "Invalid webhook signature" });
704
+ return;
705
+ }
706
+ }
707
+ if (functionName === "conversationWebhook" && sinchClients.validateConversationWebhook) {
708
+ const isValid = sinchClients.validateConversationWebhook(req.headers, req.body);
709
+ if (!isValid) {
710
+ logger("[SECURITY] Conversation webhook signature validation failed");
711
+ res.status(401).json({ error: "Invalid webhook signature" });
712
+ return;
713
+ }
714
+ }
715
+ }
493
716
  onRequestStart({ functionName, req });
494
717
  const context = buildContext(req);
495
718
  const userFunction = await Promise.resolve(loadUserFunction());
@@ -565,70 +788,6 @@ function setupRequestHandler(app, options = {}) {
565
788
  });
566
789
  }
567
790
 
568
- // ../runtime-shared/dist/sinch/index.js
569
- import { SinchClient, validateAuthenticationHeader } from "@sinch/sdk-core";
570
- function createSinchClients() {
571
- const clients = {};
572
- const hasCredentials = process.env.PROJECT_ID && process.env.PROJECT_ID_API_KEY && process.env.PROJECT_ID_API_SECRET;
573
- if (!hasCredentials) {
574
- return clients;
575
- }
576
- try {
577
- const sinchClient = new SinchClient({
578
- projectId: process.env.PROJECT_ID,
579
- keyId: process.env.PROJECT_ID_API_KEY,
580
- keySecret: process.env.PROJECT_ID_API_SECRET
581
- });
582
- if (process.env.CONVERSATION_APP_ID) {
583
- clients.conversation = sinchClient.conversation;
584
- console.log("[SINCH] Conversation API initialized");
585
- }
586
- if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
587
- const voiceClient = new SinchClient({
588
- projectId: process.env.PROJECT_ID,
589
- keyId: process.env.PROJECT_ID_API_KEY,
590
- keySecret: process.env.PROJECT_ID_API_SECRET,
591
- applicationKey: process.env.VOICE_APPLICATION_KEY,
592
- applicationSecret: process.env.VOICE_APPLICATION_SECRET
593
- });
594
- clients.voice = voiceClient.voice;
595
- console.log("[SINCH] Voice API initialized with application credentials");
596
- }
597
- if (process.env.SMS_SERVICE_PLAN_ID) {
598
- clients.sms = sinchClient.sms;
599
- console.log("[SINCH] SMS API initialized");
600
- }
601
- if (process.env.ENABLE_NUMBERS_API === "true") {
602
- clients.numbers = sinchClient.numbers;
603
- console.log("[SINCH] Numbers API initialized");
604
- }
605
- } catch (error) {
606
- console.error("[SINCH] Failed to initialize Sinch clients:", error.message);
607
- return {};
608
- }
609
- if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
610
- clients.validateWebhookSignature = (requestData) => {
611
- console.log("[SINCH] Validating Voice webhook signature");
612
- try {
613
- const result = validateAuthenticationHeader(process.env.VOICE_APPLICATION_KEY, process.env.VOICE_APPLICATION_SECRET, requestData.headers, requestData.body, requestData.path, requestData.method);
614
- console.log("[SINCH] Validation result:", result ? "VALID" : "INVALID");
615
- return result;
616
- } catch (error) {
617
- console.error("[SINCH] Validation error:", error.message);
618
- return false;
619
- }
620
- };
621
- }
622
- return clients;
623
- }
624
- var cachedClients = null;
625
- function getSinchClients() {
626
- if (!cachedClients) {
627
- cachedClients = createSinchClients();
628
- }
629
- return cachedClients;
630
- }
631
-
632
791
  // ../runtime-shared/dist/ai/elevenlabs/state.js
633
792
  var ElevenLabsStateManager = class {
634
793
  state = {
@@ -780,7 +939,7 @@ import * as path4 from "path";
780
939
  var LocalStorage = class {
781
940
  baseDir;
782
941
  constructor(baseDir) {
783
- this.baseDir = baseDir ?? path4.join(process.cwd(), "storage");
942
+ this.baseDir = baseDir ?? path4.join(process.cwd(), ".sinch", "storage");
784
943
  }
785
944
  resolvePath(key) {
786
945
  const sanitized = key.replace(/^\/+/, "").replace(/\.\./g, "_");
@@ -1232,7 +1391,15 @@ import axios from "axios";
1232
1391
 
1233
1392
  // src/tunnel/webhook-config.ts
1234
1393
  import { SinchClient as SinchClient2 } from "@sinch/sdk-core";
1235
- async function configureConversationWebhooks(tunnelUrl, config) {
1394
+ import { randomBytes } from "crypto";
1395
+ var SINCH_FN_URL_PATTERN = /\.fn(-\w+)?\.sinch\.com/;
1396
+ function isOurWebhook(target) {
1397
+ return !!target && SINCH_FN_URL_PATTERN.test(target);
1398
+ }
1399
+ function isTunnelUrl(target) {
1400
+ return !!target && target.includes("tunnel.fn");
1401
+ }
1402
+ async function configureConversationWebhooks(webhookUrl, config) {
1236
1403
  try {
1237
1404
  const conversationAppId = process.env.CONVERSATION_APP_ID;
1238
1405
  const projectId = process.env.PROJECT_ID;
@@ -1242,7 +1409,6 @@ async function configureConversationWebhooks(tunnelUrl, config) {
1242
1409
  console.log("\u{1F4A1} Conversation API not fully configured - skipping webhook setup");
1243
1410
  return;
1244
1411
  }
1245
- const webhookUrl = `${tunnelUrl}/webhook/conversation`;
1246
1412
  console.log(`\u{1F4AC} Conversation webhook URL: ${webhookUrl}`);
1247
1413
  const sinchClient = new SinchClient2({
1248
1414
  projectId,
@@ -1253,27 +1419,31 @@ async function configureConversationWebhooks(tunnelUrl, config) {
1253
1419
  app_id: conversationAppId
1254
1420
  });
1255
1421
  const existingWebhooks = webhooksResult.webhooks || [];
1256
- const tunnelWebhooks = existingWebhooks.filter((w) => w.target?.includes("/api/ingress/"));
1257
- for (const staleWebhook of tunnelWebhooks) {
1258
- try {
1259
- await sinchClient.conversation.webhooks.delete({ webhook_id: staleWebhook.id });
1260
- console.log(`\u{1F9F9} Cleaned up stale tunnel webhook: ${staleWebhook.id}`);
1261
- } catch (err) {
1262
- }
1422
+ const deployedWebhook = existingWebhooks.find(
1423
+ (w) => isOurWebhook(w.target)
1424
+ );
1425
+ if (!deployedWebhook || !deployedWebhook.id) {
1426
+ console.log("\u26A0\uFE0F No deployed webhook found \u2014 deploy first");
1427
+ return;
1263
1428
  }
1264
- const createResult = await sinchClient.conversation.webhooks.create({
1265
- webhookCreateRequestBody: {
1266
- app_id: conversationAppId,
1429
+ config.conversationWebhookId = deployedWebhook.id;
1430
+ config.originalTarget = deployedWebhook.target;
1431
+ const hmacSecret = randomBytes(32).toString("hex");
1432
+ process.env.CONVERSATION_WEBHOOK_SECRET = hmacSecret;
1433
+ resetSinchClients();
1434
+ await sinchClient.conversation.webhooks.update({
1435
+ webhook_id: deployedWebhook.id,
1436
+ webhookUpdateRequestBody: {
1267
1437
  target: webhookUrl,
1268
- target_type: "HTTP",
1269
- triggers: ["MESSAGE_INBOUND"]
1270
- }
1438
+ secret: hmacSecret
1439
+ },
1440
+ update_mask: ["target", "secret"]
1271
1441
  });
1272
- config.conversationWebhookId = createResult.id;
1273
- console.log(`\u2705 Created Conversation webhook: ${webhookUrl}`);
1442
+ console.log(`\u2705 Updated Conversation webhook to tunnel: ${webhookUrl}`);
1443
+ console.log("\u{1F512} HMAC secret configured for webhook signature validation");
1274
1444
  console.log("\u{1F4AC} Send a message to your Conversation app to test!");
1275
1445
  } catch (error) {
1276
- console.log("\u26A0\uFE0F Could not configure Conversation webhooks:", error.message);
1446
+ console.log("\u26A0\uFE0F Could not configure Conversation webhooks:", error instanceof Error ? error.message : error);
1277
1447
  }
1278
1448
  }
1279
1449
  async function cleanupConversationWebhook(config) {
@@ -1289,10 +1459,31 @@ async function cleanupConversationWebhook(config) {
1289
1459
  keyId,
1290
1460
  keySecret
1291
1461
  });
1292
- await sinchClient.conversation.webhooks.delete({ webhook_id: config.conversationWebhookId });
1293
- console.log("\u{1F9F9} Cleaned up tunnel webhook");
1462
+ let restoreTarget = config.originalTarget;
1463
+ if (!restoreTarget || isTunnelUrl(restoreTarget)) {
1464
+ const functionName = process.env.FUNCTION_NAME || process.env.FUNCTION_ID;
1465
+ if (functionName) {
1466
+ restoreTarget = `https://${functionName}.fn-dev.sinch.com/webhook/conversation`;
1467
+ console.log(`\u{1F527} Derived restore target from env: ${restoreTarget}`);
1468
+ } else {
1469
+ console.log("\u26A0\uFE0F Cannot restore webhook \u2014 no FUNCTION_NAME or FUNCTION_ID available");
1470
+ return;
1471
+ }
1472
+ }
1473
+ await sinchClient.conversation.webhooks.update({
1474
+ webhook_id: config.conversationWebhookId,
1475
+ webhookUpdateRequestBody: {
1476
+ target: restoreTarget
1477
+ },
1478
+ update_mask: ["target"]
1479
+ });
1480
+ console.log(`\u{1F504} Restored webhook target to: ${restoreTarget}`);
1294
1481
  config.conversationWebhookId = void 0;
1482
+ config.originalTarget = void 0;
1483
+ delete process.env.CONVERSATION_WEBHOOK_SECRET;
1484
+ resetSinchClients();
1295
1485
  } catch (error) {
1486
+ console.log("\u26A0\uFE0F Could not restore Conversation webhook:", error instanceof Error ? error.message : error);
1296
1487
  }
1297
1488
  }
1298
1489
  async function configureElevenLabs() {
@@ -1307,7 +1498,7 @@ async function configureElevenLabs() {
1307
1498
  console.log("\u{1F916} ElevenLabs auto-configuration enabled");
1308
1499
  console.log(` Agent ID: ${agentId}`);
1309
1500
  } catch (error) {
1310
- console.log("\u26A0\uFE0F Could not configure ElevenLabs:", error.message);
1501
+ console.log("\u26A0\uFE0F Could not configure ElevenLabs:", error instanceof Error ? error.message : error);
1311
1502
  }
1312
1503
  }
1313
1504
 
@@ -1344,14 +1535,14 @@ var TunnelClient = class {
1344
1535
  timestampPart = ENCODING[t % 32] + timestampPart;
1345
1536
  t = Math.floor(t / 32);
1346
1537
  }
1347
- const randomBytes = new Uint8Array(10);
1348
- crypto.getRandomValues(randomBytes);
1538
+ const randomBytes2 = new Uint8Array(10);
1539
+ crypto.getRandomValues(randomBytes2);
1349
1540
  let randomPart = "";
1350
1541
  for (let i = 0; i < 10; i++) {
1351
- const byte = randomBytes[i];
1542
+ const byte = randomBytes2[i];
1352
1543
  randomPart += ENCODING[byte >> 3];
1353
1544
  if (randomPart.length < 16) {
1354
- randomPart += ENCODING[(byte & 7) << 2 | (i + 1 < 10 ? randomBytes[i + 1] >> 6 : 0)];
1545
+ randomPart += ENCODING[(byte & 7) << 2 | (i + 1 < 10 ? randomBytes2[i + 1] >> 6 : 0)];
1355
1546
  }
1356
1547
  }
1357
1548
  randomPart = randomPart.substring(0, 16);
@@ -1424,6 +1615,15 @@ var TunnelClient = class {
1424
1615
  break;
1425
1616
  }
1426
1617
  }
1618
+ /**
1619
+ * Build a full tunnel URL with optional sub-path and tunnel query param.
1620
+ * e.g. buildTunnelUrl('/webhook/conversation') →
1621
+ * https://tunnel.fn.sinch.com/ingress/webhook/conversation?tunnel=01KKT...
1622
+ */
1623
+ buildTunnelUrl(path7) {
1624
+ const base = this.tunnelUrl.replace(/\/$/, "");
1625
+ return `${base}${path7 || ""}?tunnel=${this.tunnelId}`;
1626
+ }
1427
1627
  handleWelcomeMessage(message) {
1428
1628
  this.tunnelId = message.tunnelId || null;
1429
1629
  this.tunnelUrl = message.publicUrl || null;
@@ -1434,7 +1634,11 @@ var TunnelClient = class {
1434
1634
  }
1435
1635
  }
1436
1636
  async handleRequest(message) {
1637
+ const verbose = process.env.VERBOSE === "true";
1437
1638
  console.log(`Forwarding ${message.method} request to ${message.path}`);
1639
+ if (verbose && message.body) {
1640
+ console.log(` \u2190 Request body: ${message.body.substring(0, 2e3)}`);
1641
+ }
1438
1642
  try {
1439
1643
  const localUrl = `http://localhost:${this.localPort}${message.path}${message.query || ""}`;
1440
1644
  const axiosConfig = {
@@ -1467,9 +1671,15 @@ var TunnelClient = class {
1467
1671
  headers,
1468
1672
  body
1469
1673
  };
1674
+ if (verbose) {
1675
+ console.log(` \u2192 Response ${response.status}: ${body.substring(0, 2e3)}`);
1676
+ }
1470
1677
  this.ws?.send(JSON.stringify(responseMessage));
1471
1678
  } catch (error) {
1472
- console.error("Error forwarding request:", error.message);
1679
+ console.error(`Error forwarding request: ${error.message} (${error.response?.status || "no response"})`);
1680
+ if (verbose && error.response?.data) {
1681
+ console.error(` \u2192 Error body: ${typeof error.response.data === "string" ? error.response.data : JSON.stringify(error.response.data)}`.substring(0, 2e3));
1682
+ }
1473
1683
  const errorResponse = {
1474
1684
  type: "response",
1475
1685
  id: message.id,
@@ -1513,7 +1723,7 @@ var TunnelClient = class {
1513
1723
  await this.configureVoiceWebhooks();
1514
1724
  }
1515
1725
  if (autoConfigConversation && process.env.CONVERSATION_APP_ID) {
1516
- await configureConversationWebhooks(this.tunnelUrl, this.webhookConfig);
1726
+ await configureConversationWebhooks(this.buildTunnelUrl("/webhook/conversation"), this.webhookConfig);
1517
1727
  }
1518
1728
  if (process.env.ELEVENLABS_AUTO_CONFIGURE === "true") {
1519
1729
  await configureElevenLabs();
@@ -1540,7 +1750,7 @@ var TunnelClient = class {
1540
1750
  updateUrl,
1541
1751
  {
1542
1752
  url: {
1543
- primary: this.tunnelUrl,
1753
+ primary: this.buildTunnelUrl(),
1544
1754
  fallback: null
1545
1755
  }
1546
1756
  },
@@ -1602,7 +1812,8 @@ var TunnelClient = class {
1602
1812
  this.isConnected = false;
1603
1813
  }
1604
1814
  getTunnelUrl() {
1605
- return this.tunnelUrl;
1815
+ if (!this.tunnelUrl || !this.tunnelId) return null;
1816
+ return this.buildTunnelUrl();
1606
1817
  }
1607
1818
  getIsConnected() {
1608
1819
  return this.isConnected;
@@ -1627,7 +1838,7 @@ function loadRuntimeConfig() {
1627
1838
  };
1628
1839
  }
1629
1840
  var storage = createStorageClient();
1630
- var databasePath = path6.join(process.cwd(), "data", "app.db");
1841
+ var databasePath = path6.join(process.cwd(), ".sinch", "data", "app.db");
1631
1842
  function buildLocalContext(req, runtimeConfig) {
1632
1843
  const baseContext = buildBaseContext(req);
1633
1844
  const cache = createCacheClient();
@@ -1725,19 +1936,19 @@ function displayApplicationCredentials() {
1725
1936
  console.log(' Tip: Add VOICE_APPLICATION_KEY to .env and run "sinch auth login"');
1726
1937
  }
1727
1938
  }
1728
- async function displayDetectedFunctions() {
1939
+ async function displayDetectedFunctions(port) {
1729
1940
  try {
1730
1941
  const functionPath = findFunctionPath3();
1731
1942
  if (!fs5.existsSync(functionPath)) return;
1732
1943
  const functionUrl = pathToFileURL2(functionPath).href;
1733
1944
  const module = await import(functionUrl);
1734
1945
  const userFunction = module.default || module;
1735
- const functions = Object.keys(userFunction);
1946
+ const functions = Object.keys(userFunction).filter((k) => typeof userFunction[k] === "function");
1736
1947
  if (functions.length > 0) {
1737
- console.log("\nDetected functions in function.js:");
1948
+ console.log("\nDetected functions:");
1738
1949
  for (const fn of functions) {
1739
1950
  const type = isVoiceCallback(fn) ? "voice" : "custom";
1740
- console.log(` - ${fn} (${type})`);
1951
+ console.log(` ${fn} (${type}): POST http://localhost:${port}/${fn}`);
1741
1952
  }
1742
1953
  }
1743
1954
  } catch {
@@ -1755,20 +1966,45 @@ async function main() {
1755
1966
  } catch {
1756
1967
  }
1757
1968
  await secretsLoader.loadFromKeychain();
1758
- fs5.mkdirSync(path6.join(process.cwd(), "storage"), { recursive: true });
1759
- fs5.mkdirSync(path6.join(process.cwd(), "data"), { recursive: true });
1969
+ fs5.mkdirSync(path6.join(process.cwd(), ".sinch", "storage"), { recursive: true });
1970
+ fs5.mkdirSync(path6.join(process.cwd(), ".sinch", "data"), { recursive: true });
1760
1971
  const config = loadRuntimeConfig();
1761
1972
  const staticDir = process.env.STATIC_DIR;
1762
1973
  const landingPageEnabled = process.env.LANDING_PAGE_ENABLED !== "false";
1763
1974
  const app = createApp({ staticDir, landingPageEnabled });
1975
+ const authKey = process.env.PROJECT_ID_API_KEY;
1976
+ const authSecret = process.env.PROJECT_ID_API_SECRET;
1977
+ let userAuthConfig;
1978
+ const loadUserFunction = async () => {
1979
+ const functionPath = findFunctionPath3();
1980
+ const functionUrl = pathToFileURL2(functionPath).href;
1981
+ const module = await import(functionUrl);
1982
+ if (userAuthConfig === void 0) {
1983
+ userAuthConfig = module.auth || module.default?.auth;
1984
+ if (userAuthConfig && verbose) {
1985
+ console.log(`[AUTH] Auth config loaded: ${JSON.stringify(userAuthConfig)}`);
1986
+ }
1987
+ }
1988
+ return module.default || module;
1989
+ };
1990
+ await loadUserFunction();
1991
+ let setupResult;
1992
+ const rawModule = await import(pathToFileURL2(findFunctionPath3()).href);
1993
+ const setupFn = rawModule.setup || rawModule.default?.setup;
1994
+ if (typeof setupFn === "function") {
1995
+ const { runtime, result } = createSinchRuntime();
1996
+ await Promise.resolve(setupFn(runtime));
1997
+ setupResult = result;
1998
+ if (verbose) {
1999
+ console.log(`[SETUP] Registered ${result.startupHandlers.length} startup handler(s), ${result.wsHandlers.size} WebSocket endpoint(s)`);
2000
+ }
2001
+ }
1764
2002
  setupRequestHandler(app, {
1765
2003
  landingPageEnabled,
1766
- loadUserFunction: async () => {
1767
- const functionPath = findFunctionPath3();
1768
- const functionUrl = pathToFileURL2(functionPath).href;
1769
- const module = await import(functionUrl);
1770
- return module.default || module;
1771
- },
2004
+ authConfig: userAuthConfig,
2005
+ authKey,
2006
+ authSecret,
2007
+ loadUserFunction,
1772
2008
  buildContext: (req) => buildLocalContext(req, config),
1773
2009
  logger: console.log,
1774
2010
  onRequestStart: ({ req }) => {
@@ -1791,17 +2027,22 @@ async function main() {
1791
2027
  });
1792
2028
  });
1793
2029
  displayStartupInfo(config, verbose, port);
1794
- app.listen(port, async () => {
2030
+ if (setupResult?.startupHandlers.length) {
2031
+ const context = buildLocalContext({}, config);
2032
+ for (const handler of setupResult.startupHandlers) {
2033
+ await handler(context);
2034
+ }
2035
+ console.log(`[SETUP] Startup hooks completed`);
2036
+ }
2037
+ const server = app.listen(port, async () => {
1795
2038
  console.log(`Function server running on http://localhost:${port}`);
1796
- console.log("\nTest endpoints:");
1797
- console.log(` ICE: POST http://localhost:${port}/ice`);
1798
- console.log(` PIE: POST http://localhost:${port}/pie`);
1799
- console.log(` ACE: POST http://localhost:${port}/ace`);
1800
- console.log(` DICE: POST http://localhost:${port}/dice`);
1801
- await displayDetectedFunctions();
2039
+ await displayDetectedFunctions(port);
1802
2040
  if (!verbose) {
1803
2041
  console.log("\nTip: Set VERBOSE=true or use --verbose for detailed output");
1804
2042
  }
2043
+ if (setupResult?.wsHandlers.size) {
2044
+ installWebSocketHandlers(server, setupResult.wsHandlers, console.log);
2045
+ }
1805
2046
  if (process.env.SINCH_TUNNEL === "true") {
1806
2047
  console.log("\nStarting tunnel...");
1807
2048
  const tunnelClient = new TunnelClient(port);