buzz-me-in 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ [Unit]
2
+ Description=HomeKit Intercom Server
3
+ Documentation=https://github.com/atdr/buzz-me-in
4
+ # Start after the network is up AND after the tunnel service
5
+ # so the server is ready by the time Twilio can reach it.
6
+ After=network-online.target cloudflared.service
7
+ Wants=network-online.target
8
+
9
+ [Service]
10
+ Type=simple
11
+ User=pi
12
+ WorkingDirectory=/home/pi/intercom
13
+
14
+ # Load environment variables from .env file
15
+ EnvironmentFile=/home/pi/intercom/.env
16
+
17
+ # Resolved from PATH rather than hardcoded: the npm global prefix varies by
18
+ # install method (`npm prefix -g`) — /usr/local for Debian's apt nodejs, /usr
19
+ # for NodeSource — and systemd's default PATH covers both. If npm's prefix is
20
+ # elsewhere, such as under nvm, use an absolute ExecStart or prepend the
21
+ # prefix with Environment=PATH=...
22
+ ExecStart=/usr/bin/env buzz-me-in
23
+
24
+ # Sandboxing — the service only needs its working directory, the network,
25
+ # and a private /tmp (SDP files with SRTP keys live there).
26
+ # ProtectHome is intentionally NOT set: WorkingDirectory and the HAP
27
+ # pairing persist data live under /home/pi/intercom.
28
+ NoNewPrivileges=yes
29
+ PrivateTmp=yes
30
+ ProtectSystem=full
31
+ ProtectKernelTunables=yes
32
+ ProtectControlGroups=yes
33
+ RestrictSUIDSGID=yes
34
+
35
+ # Restart on any non-zero exit (crash, OOM, etc.)
36
+ # Do NOT restart on clean exit (code 0) to allow intentional shutdowns.
37
+ Restart=on-failure
38
+ RestartSec=5
39
+
40
+ # Give HAP-NodeJS time to unpublish the mDNS record gracefully on stop.
41
+ TimeoutStopSec=10
42
+
43
+ StandardOutput=journal
44
+ StandardError=journal
45
+ SyslogIdentifier=intercom
46
+
47
+ [Install]
48
+ WantedBy=multi-user.target
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "buzz-me-in",
3
+ "version": "2.0.0",
4
+ "description": "HomeKit-compatible apartment intercom via Twilio media streams",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "buzz-me-in": "./server.js"
8
+ },
9
+ "files": [
10
+ "server.js",
11
+ "homekit.js",
12
+ "twilio-api.js",
13
+ "src/",
14
+ "intercom.service",
15
+ ".env.example"
16
+ ],
17
+ "scripts": {
18
+ "start": "node server.js",
19
+ "lint": "eslint .",
20
+ "format": "prettier --write .",
21
+ "format:check": "prettier --check .",
22
+ "check": "node --check server.js homekit.js twilio-api.js src/core/*.js",
23
+ "test": "DOTENV_CONFIG_PATH=/dev/null node --test",
24
+ "typecheck": "tsc --noEmit",
25
+ "prepare": "husky || true"
26
+ },
27
+ "license": "MIT",
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/atdr/buzz-me-in.git"
35
+ },
36
+ "homepage": "https://github.com/atdr/buzz-me-in",
37
+ "bugs": {
38
+ "url": "https://github.com/atdr/buzz-me-in/issues"
39
+ },
40
+ "engines": {
41
+ "node": ">=20"
42
+ },
43
+ "dependencies": {
44
+ "dotenv": "^17.4.2",
45
+ "hap-nodejs": "^0.14.2",
46
+ "qrcode-terminal": "^0.12.0",
47
+ "twilio": "^6.1.0",
48
+ "websocket": "^1.0.35",
49
+ "zod": "^4.5.4"
50
+ },
51
+ "devDependencies": {
52
+ "@commitlint/cli": "^21.2.2",
53
+ "@commitlint/config-conventional": "^21.0.2",
54
+ "@eslint/js": "^10.0.1",
55
+ "@types/websocket": "^1.0.10",
56
+ "eslint": "^10.9.1",
57
+ "eslint-config-prettier": "^10.1.8",
58
+ "globals": "^17.11.0",
59
+ "husky": "^9.1.7",
60
+ "lint-staged": "^17.4.1",
61
+ "prettier": "^3.9.6",
62
+ "typescript": "^7.0.2"
63
+ },
64
+ "lint-staged": {
65
+ "*.{js,cjs}": [
66
+ "eslint --fix",
67
+ "prettier --write"
68
+ ],
69
+ "*.{md,yml,yaml,json}": [
70
+ "prettier --write"
71
+ ]
72
+ }
73
+ }
package/server.js ADDED
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env node
2
+ // based on https://github.com/twilio/media-streams/blob/master/node/basic/README.md
3
+ 'use strict';
4
+
5
+ const http = require('http');
6
+ const twilio = require('twilio');
7
+ const WebSocketServer = require('websocket').server;
8
+
9
+ const config = require('./src/core/config');
10
+ const state = require('./src/core/state');
11
+ const { createRingbackWav } = require('./src/core/mulaw-audio');
12
+ const {
13
+ buildConnectStreamTwiml,
14
+ STREAM_TOKEN_PARAMETER_NAME,
15
+ verifyAndConsumeStreamToken,
16
+ } = require('./src/core/stream-auth');
17
+ const { normalizeCallSid } = require('./src/core/twilio-ids');
18
+ const homekit = require('./homekit');
19
+ const { createLogger } = require('./src/core/log');
20
+ const { safeEqualString } = require('./src/core/safe-equal');
21
+ const { MediaStream } = require('./src/core/media-stream');
22
+ /** @import { request as WebSocketRequest } from 'websocket' */
23
+
24
+ const HTTP_SERVER_PORT = config.port;
25
+ const STREAM_PATH = '/media';
26
+ const STREAM_START_TIMEOUT_MS = config.streamStartTimeoutMs;
27
+ const STATUS_BEARER_PREFIX = 'Bearer ';
28
+ const MAX_WS_UTF8_BYTES = config.wsMaxMessageBytes;
29
+ const SHUTDOWN_GRACE_MS = config.shutdownGraceMs;
30
+ let shuttingDown = false;
31
+ const activeWsConnections = new Set();
32
+ const logger = createLogger({ component: 'server' });
33
+ const mediaWsLogger = logger.child({ component: 'media-ws' });
34
+ const twimlLogger = logger.child({ component: 'twiml' });
35
+ const activeMediaStreamsByCallSid = new Map();
36
+
37
+ homekit.setOnHapSessionStarted((callSid) => {
38
+ const mediaStream = activeMediaStreamsByCallSid.get(callSid);
39
+ if (mediaStream) mediaStream.markHomekitSessionStarted();
40
+ });
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Ringtone — one UK-style ringback cycle (400Hz+450Hz dual tone), generated
44
+ // in-process as a WAV buffer. Served at GET /ringtone for debug/manual
45
+ // checks. Runtime ringback is sent over the bidirectional media stream so
46
+ // Twilio keeps the same WebSocket connected.
47
+ // ---------------------------------------------------------------------------
48
+ const RINGTONE_WAV = createRingbackWav();
49
+
50
+ const wsserver = http.createServer(handleRequest);
51
+
52
+ // Enforce message bounds in the websocket library itself so oversized
53
+ // frames are rejected before assembly, instead of relying only on the
54
+ // per-message check in MediaStream (library defaults allow 1 MiB).
55
+ const WS_LIBRARY_MAX_BYTES = MAX_WS_UTF8_BYTES * 4;
56
+ const MAX_CONCURRENT_WS_CONNECTIONS = config.wsMaxConnections;
57
+
58
+ const mediaws = new WebSocketServer({
59
+ httpServer: wsserver,
60
+ autoAcceptConnections: false,
61
+ maxReceivedFrameSize: WS_LIBRARY_MAX_BYTES,
62
+ maxReceivedMessageSize: WS_LIBRARY_MAX_BYTES,
63
+ });
64
+
65
+ state.setOnSessionStale((session) => {
66
+ logger.warn('Call session stale; ending session', {
67
+ callSid: session.callSid,
68
+ event: 'session-stale',
69
+ reason: session.clearedReason,
70
+ });
71
+ if (session.wsConnection && typeof session.wsConnection.close === 'function') {
72
+ try {
73
+ session.wsConnection.close();
74
+ } catch (err) {
75
+ logger.error('Failed to close stale call websocket', {
76
+ callSid: session.callSid,
77
+ event: 'stale-close-failed',
78
+ reason: 'close-threw',
79
+ error: err,
80
+ });
81
+ }
82
+ }
83
+ homekit.endHapSession();
84
+ });
85
+
86
+ function handleRequest(request, response) {
87
+ try {
88
+ if (shuttingDown) {
89
+ response.writeHead(503);
90
+ response.end('Server shutting down');
91
+ return;
92
+ }
93
+ const path = request.url ? request.url.split('?')[0] : '';
94
+ if (request.method === 'POST' && path === '/twiml') {
95
+ handleTwimlRequest(request, response).catch((err) => {
96
+ twimlLogger.error('Unhandled TwiML handler failure', {
97
+ event: 'twiml-unhandled-error',
98
+ reason: 'handler-threw',
99
+ error: err,
100
+ });
101
+ if (!response.headersSent) response.writeHead(500);
102
+ response.end('Internal Server Error');
103
+ });
104
+ return;
105
+ }
106
+ const handler = request.method === 'GET' ? GET_ROUTES.get(path) : undefined;
107
+ if (handler) {
108
+ handler(request, response);
109
+ return;
110
+ }
111
+ response.writeHead(404);
112
+ response.end('Not Found');
113
+ } catch (err) {
114
+ logger.error('HTTP request handling failed', {
115
+ event: 'request-handler-error',
116
+ reason: 'handler-threw',
117
+ error: err,
118
+ });
119
+ if (!response.headersSent) response.writeHead(500);
120
+ response.end('Internal Server Error');
121
+ }
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // HTTP routes
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function buildTwiml(callSid) {
129
+ return `<?xml version="1.0" encoding="UTF-8"?>
130
+ <Response>
131
+ ${buildConnectStreamTwiml(callSid)}
132
+ </Response>`;
133
+ }
134
+
135
+ async function handleTwimlRequest(req, res) {
136
+ twimlLogger.info('Incoming TwiML request', {
137
+ event: 'twiml-request',
138
+ method: req.method,
139
+ path: req.url ? req.url.split('?')[0] : '',
140
+ });
141
+
142
+ // eslint-disable-next-line no-useless-assignment -- assigned before first use inside try for readable error path
143
+ let rawBody = '';
144
+ try {
145
+ rawBody = await readRequestBody(req, 32 * 1024);
146
+ } catch (err) {
147
+ twimlLogger.warn('TwiML body read failed', {
148
+ event: 'twiml-body-read-failed',
149
+ reason: 'invalid-request-body',
150
+ error: err,
151
+ });
152
+ res.writeHead(400);
153
+ res.end('Invalid request body');
154
+ return;
155
+ }
156
+
157
+ if (!isValidTwilioRequest(req, rawBody)) {
158
+ twimlLogger.warn('TwiML request rejected', {
159
+ event: 'twiml-rejected',
160
+ reason: 'invalid-twilio-signature',
161
+ });
162
+ res.writeHead(403);
163
+ res.end('Forbidden');
164
+ return;
165
+ }
166
+
167
+ const formData = parseFormUrlEncoded(rawBody);
168
+ const callSid = normalizeCallSid(formData.CallSid);
169
+ const body = buildTwiml(callSid);
170
+ twimlLogger.info('TwiML response generated', {
171
+ event: 'twiml-response',
172
+ callSid: callSid || undefined,
173
+ });
174
+ res.writeHead(200, {
175
+ 'Content-Type': 'text/xml',
176
+ 'Content-Length': Buffer.byteLength(body),
177
+ });
178
+ res.end(body);
179
+ }
180
+
181
+ /** @type {Map<string, (req: import('http').IncomingMessage, res: import('http').ServerResponse) => void>} */
182
+ const GET_ROUTES = new Map();
183
+
184
+ /**
185
+ * GET /ringtone
186
+ * UK-style ring tone WAV for debug/manual checks.
187
+ */
188
+ GET_ROUTES.set('/ringtone', function (_req, res) {
189
+ res.writeHead(200, {
190
+ 'Content-Type': 'audio/wav',
191
+ 'Content-Length': RINGTONE_WAV.length,
192
+ 'Cache-Control': 'no-store',
193
+ });
194
+ res.end(RINGTONE_WAV);
195
+ });
196
+
197
+ /**
198
+ * GET /status — quick health/debug endpoint
199
+ * Returns the current active call info (callSid only, no credentials).
200
+ */
201
+ GET_ROUTES.set('/status', function (req, res) {
202
+ if (!isAuthorizedForStatus(req)) {
203
+ res.writeHead(401);
204
+ res.end('Unauthorized');
205
+ return;
206
+ }
207
+ const body = JSON.stringify(state.getStatus());
208
+ res.writeHead(200, { 'Content-Type': 'application/json' });
209
+ res.end(body);
210
+ });
211
+
212
+ GET_ROUTES.set('/healthz', function (_req, res) {
213
+ const body = JSON.stringify({ ok: true });
214
+ res.writeHead(200, { 'Content-Type': 'application/json' });
215
+ res.end(body);
216
+ });
217
+
218
+ GET_ROUTES.set('/readyz', function (_req, res) {
219
+ const body = JSON.stringify({ ok: true });
220
+ res.writeHead(200, { 'Content-Type': 'application/json' });
221
+ res.end(body);
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // WebSocket media stream
226
+ // ---------------------------------------------------------------------------
227
+
228
+ mediaws.on('request', function (request) {
229
+ /** @type {WebSocketRequest} */
230
+ const wsRequest = request;
231
+ if (shuttingDown) {
232
+ wsRequest.reject(503, 'Server shutting down');
233
+ return;
234
+ }
235
+ const path = wsRequest.resourceURL && wsRequest.resourceURL.pathname;
236
+ if (path !== STREAM_PATH) {
237
+ wsRequest.reject(404, 'Not found');
238
+ return;
239
+ }
240
+ // Memory backstop only: cap total sockets so a connection flood can't grow
241
+ // unbounded. Only one Twilio call is ever authenticated at a time, and each
242
+ // un-started socket is dropped after config.streamStartTimeoutMs, so this is
243
+ // deliberately generous. Volumetric / per-source rate-limiting is delegated
244
+ // to the Cloudflare edge — the tunnel makes every socket look like localhost,
245
+ // so in-process source accounting is both unreliable and trivially bypassed
246
+ // by IP rotation.
247
+ if (activeWsConnections.size >= MAX_CONCURRENT_WS_CONNECTIONS) {
248
+ mediaWsLogger.warn('Media websocket connection rejected', {
249
+ event: 'media-ws-rejected',
250
+ reason: 'too-many-connections',
251
+ });
252
+ wsRequest.reject(503, 'Too many connections');
253
+ return;
254
+ }
255
+
256
+ const connection = wsRequest.accept(null, wsRequest.origin);
257
+ activeWsConnections.add(connection);
258
+ connection.on('close', () => activeWsConnections.delete(connection));
259
+ mediaWsLogger.info('Media websocket connection accepted', {
260
+ event: 'media-ws-accepted',
261
+ });
262
+ new MediaStream(connection, mediaStreamDeps);
263
+ });
264
+
265
+ /** @type {import('./src/core/media-stream').MediaStreamDeps} */
266
+ const mediaStreamDeps = {
267
+ logger: mediaWsLogger,
268
+ state,
269
+ homekit,
270
+ registry: activeMediaStreamsByCallSid,
271
+ verifyStreamToken: verifyAndConsumeStreamToken,
272
+ tokenParameterName: STREAM_TOKEN_PARAMETER_NAME,
273
+ startTimeoutMs: STREAM_START_TIMEOUT_MS,
274
+ maxUtf8Bytes: MAX_WS_UTF8_BYTES,
275
+ mediaPayloadMaxBytes: config.twilioMediaPayloadMaxBytes,
276
+ };
277
+
278
+ /**
279
+ * @param {import('http').IncomingMessage} req
280
+ * @param {number} maxBytes
281
+ * @returns {Promise<string>}
282
+ */
283
+ function readRequestBody(req, maxBytes) {
284
+ return new Promise((resolve, reject) => {
285
+ let total = 0;
286
+ const chunks = [];
287
+ req.on('data', (chunk) => {
288
+ total += chunk.length;
289
+ if (total > maxBytes) {
290
+ reject(new Error('Body too large'));
291
+ req.destroy();
292
+ return;
293
+ }
294
+ chunks.push(chunk);
295
+ });
296
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
297
+ req.on('error', reject);
298
+ });
299
+ }
300
+
301
+ /**
302
+ * @param {string} body
303
+ * @returns {Record<string, string | string[]>}
304
+ */
305
+ function parseFormUrlEncoded(body) {
306
+ /** @type {Record<string, string | string[]>} */
307
+ const parsed = {};
308
+ const params = new URLSearchParams(body);
309
+ for (const [key, value] of params) {
310
+ if (Object.prototype.hasOwnProperty.call(parsed, key)) {
311
+ const existing = parsed[key];
312
+ parsed[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
313
+ } else {
314
+ parsed[key] = value;
315
+ }
316
+ }
317
+ return parsed;
318
+ }
319
+
320
+ /**
321
+ * @param {import('http').IncomingMessage} req
322
+ * @param {string} rawBody
323
+ * @returns {boolean}
324
+ */
325
+ function isValidTwilioRequest(req, rawBody) {
326
+ const signature = req.headers['x-twilio-signature'];
327
+ if (typeof signature !== 'string' || !signature) return false;
328
+
329
+ const url = new URL(req.url || '/twiml', config.twilioWebhookBaseUrl);
330
+ const requestUrl = `${config.twilioWebhookBaseUrl}${url.pathname}${url.search}`;
331
+ const params = parseFormUrlEncoded(rawBody);
332
+
333
+ return twilio.validateRequest(config.twilioAuthToken, signature, requestUrl, params);
334
+ }
335
+
336
+ /**
337
+ * @param {import('http').IncomingMessage} req
338
+ * @returns {boolean}
339
+ */
340
+ function isAuthorizedForStatus(req) {
341
+ const authHeader = req.headers.authorization;
342
+ if (typeof authHeader !== 'string' || !authHeader.startsWith(STATUS_BEARER_PREFIX)) {
343
+ return false;
344
+ }
345
+ const provided = authHeader.slice(STATUS_BEARER_PREFIX.length);
346
+ return safeEqualString(provided, config.statusApiToken);
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Start server
351
+ // ---------------------------------------------------------------------------
352
+
353
+ wsserver.listen(HTTP_SERVER_PORT, () => {
354
+ logger.info('Server listening', {
355
+ event: 'server-start',
356
+ port: HTTP_SERVER_PORT,
357
+ });
358
+ });
359
+
360
+ function beginShutdown(signal) {
361
+ if (shuttingDown) return;
362
+ shuttingDown = true;
363
+ const shutdownStartedAt = Date.now();
364
+ logger.info('Graceful shutdown started', {
365
+ event: 'shutdown-start',
366
+ reason: signal,
367
+ });
368
+
369
+ const forceExitTimer = setTimeout(() => {
370
+ logger.error('Shutdown grace period exceeded; forcing exit', {
371
+ event: 'shutdown-force-exit',
372
+ reason: 'grace-period-exceeded',
373
+ durationMs: SHUTDOWN_GRACE_MS,
374
+ });
375
+ process.exit(1);
376
+ }, SHUTDOWN_GRACE_MS);
377
+ if (typeof forceExitTimer.unref === 'function') forceExitTimer.unref();
378
+
379
+ state.clearActiveCall('server-shutdown');
380
+ homekit.shutdown();
381
+
382
+ for (const connection of activeWsConnections) {
383
+ try {
384
+ connection.close();
385
+ } catch {}
386
+ }
387
+
388
+ wsserver.close(() => {
389
+ clearTimeout(forceExitTimer);
390
+ state.stop();
391
+ logger.info('Shutdown complete', {
392
+ event: 'shutdown-complete',
393
+ durationMs: Date.now() - shutdownStartedAt,
394
+ });
395
+ process.exit(0);
396
+ });
397
+ }
398
+
399
+ process.on('SIGINT', () => beginShutdown('SIGINT'));
400
+ process.on('SIGTERM', () => beginShutdown('SIGTERM'));
401
+
402
+ // Exit on unexpected errors instead of continuing in an undefined state;
403
+ // systemd restarts the service.
404
+ process.on('uncaughtException', (err) => {
405
+ logger.error('Uncaught exception; exiting', {
406
+ event: 'uncaught-exception',
407
+ reason: 'uncaught-exception',
408
+ error: err,
409
+ });
410
+ process.exit(1);
411
+ });
412
+
413
+ process.on('unhandledRejection', (reason) => {
414
+ logger.error('Unhandled promise rejection; exiting', {
415
+ event: 'unhandled-rejection',
416
+ reason: 'unhandled-rejection',
417
+ error: reason instanceof Error ? reason : new Error(String(reason)),
418
+ });
419
+ process.exit(1);
420
+ });
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // DOTENV_CONFIG_PATH lets tests point at an empty file so the local .env
4
+ // cannot leak values into hermetic env-var assertions.
5
+ require('dotenv').config(
6
+ process.env.DOTENV_CONFIG_PATH ? { path: process.env.DOTENV_CONFIG_PATH } : undefined
7
+ );
8
+
9
+ function fail(message) {
10
+ throw new Error(`[config] ${message}`);
11
+ }
12
+
13
+ function required(name) {
14
+ const value = process.env[name];
15
+ if (!value || !value.trim()) fail(`Missing required env var: ${name}`);
16
+ return value.trim();
17
+ }
18
+
19
+ function optional(name) {
20
+ const value = process.env[name];
21
+ return value && value.trim() ? value.trim() : null;
22
+ }
23
+
24
+ function parsePositiveInt(name, fallback) {
25
+ const raw = process.env[name];
26
+ const value = raw && raw.trim() ? raw.trim() : fallback;
27
+ const parsed = Number.parseInt(value, 10);
28
+ if (!Number.isInteger(parsed) || parsed <= 0) {
29
+ fail(`${name} must be a positive integer`);
30
+ }
31
+ return parsed;
32
+ }
33
+
34
+ function parsePort(name, fallback) {
35
+ const raw = process.env[name];
36
+ const value = raw && raw.trim() ? raw.trim() : fallback;
37
+ const port = Number.parseInt(value, 10);
38
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
39
+ fail(`${name} must be a valid TCP/UDP port`);
40
+ }
41
+ return port;
42
+ }
43
+
44
+ function validateRegex(name, value, regex, description) {
45
+ if (!regex.test(value)) {
46
+ fail(`${name} must match ${description}`);
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function sanitizeBaseUrl(input) {
52
+ let parsed;
53
+ try {
54
+ parsed = new URL(input);
55
+ } catch {
56
+ fail('TWILIO_WEBHOOK_BASE_URL must be a valid absolute URL');
57
+ }
58
+ if (!['https:', 'http:'].includes(parsed.protocol)) {
59
+ fail('TWILIO_WEBHOOK_BASE_URL must use http:// or https://');
60
+ }
61
+ parsed.pathname = '';
62
+ parsed.search = '';
63
+ parsed.hash = '';
64
+ return parsed.toString().replace(/\/$/, '');
65
+ }
66
+
67
+ const tunnelHostname = validateRegex(
68
+ 'TUNNEL_HOSTNAME',
69
+ required('TUNNEL_HOSTNAME'),
70
+ /^(?=.{1,253}$)(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-))*$/,
71
+ 'a valid hostname'
72
+ );
73
+
74
+ const streamAuthSecret = required('STREAM_AUTH_SECRET');
75
+ if (streamAuthSecret.length < 32) {
76
+ fail('STREAM_AUTH_SECRET must be at least 32 characters');
77
+ }
78
+
79
+ const statusApiToken = required('STATUS_API_TOKEN');
80
+ if (statusApiToken.length < 16) {
81
+ fail('STATUS_API_TOKEN must be at least 16 characters');
82
+ }
83
+
84
+ const twilioUnlockDigits = optional('TWILIO_UNLOCK_DIGITS') || 'w9w';
85
+ if (!/^[0-9A-Da-d*#wW]+$/.test(twilioUnlockDigits)) {
86
+ fail('TWILIO_UNLOCK_DIGITS may only contain DTMF digits 0-9, A-D, *, #, w, and W');
87
+ }
88
+
89
+ const twilioWebhookBaseUrl = sanitizeBaseUrl(
90
+ optional('TWILIO_WEBHOOK_BASE_URL') || `https://${tunnelHostname}`
91
+ );
92
+
93
+ module.exports = {
94
+ port: parsePort('PORT', '8080'),
95
+ tunnelHostname,
96
+ twilioWebhookBaseUrl,
97
+ twilioAccountSid: validateRegex(
98
+ 'TWILIO_ACCOUNT_SID',
99
+ required('TWILIO_ACCOUNT_SID'),
100
+ /^AC[a-zA-Z0-9]{32}$/,
101
+ 'AC followed by 32 alphanumeric chars'
102
+ ),
103
+ twilioAuthToken: required('TWILIO_AUTH_TOKEN'),
104
+ twilioUnlockDigits,
105
+ twilioPhoneNumber: validateRegex(
106
+ 'TWILIO_PHONE_NUMBER',
107
+ required('TWILIO_PHONE_NUMBER'),
108
+ /^\+[1-9]\d{1,14}$/,
109
+ 'E.164 format (e.g. +15551234567)'
110
+ ),
111
+ hapUsername: validateRegex(
112
+ 'HAP_USERNAME',
113
+ required('HAP_USERNAME'),
114
+ /^[0-9A-F]{2}(:[0-9A-F]{2}){5}$/,
115
+ 'MAC-style uppercase hex (AA:BB:CC:DD:EE:FF)'
116
+ ),
117
+ hapPincode: validateRegex(
118
+ 'HAP_PINCODE',
119
+ required('HAP_PINCODE'),
120
+ /^\d{3}-\d{2}-\d{3}$/,
121
+ 'XXX-XX-XXX'
122
+ ),
123
+ hapPort: parsePort('HAP_PORT', '47129'),
124
+ callSessionStaleSec: parsePositiveInt('CALL_SESSION_STALE_SEC', '900'),
125
+ wsMaxMessageBytes: parsePositiveInt('WS_MAX_MESSAGE_BYTES', '4096'),
126
+ wsMaxConnections: parsePositiveInt('WS_MAX_CONNECTIONS', '64'),
127
+ streamStartTimeoutMs: parsePositiveInt('STREAM_START_TIMEOUT_MS', '3000'),
128
+ twilioMediaPayloadMaxBytes: parsePositiveInt('TWILIO_MEDIA_PAYLOAD_MAX_BYTES', '512'),
129
+ shutdownGraceMs: parsePositiveInt('SHUTDOWN_GRACE_MS', '10000'),
130
+ streamAuthSecret,
131
+ streamAuthTtlSec: 90,
132
+ statusApiToken,
133
+ };