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,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Return-audio SDP file handling for the outbound (HomeKit → Twilio) ffmpeg
5
+ * pipeline. The SDP embeds the SRTP master key/salt, so files are written
6
+ * into a process-private 0700 temp directory with mode 0600 instead of
7
+ * world-readable predictable paths directly under /tmp.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ // Created lazily (not eagerly at module load) so that merely importing this
15
+ // module — e.g. in tests that never write an SDP — has no filesystem side
16
+ // effect, and so removeSdpDir() can reset it for recreation.
17
+ let sdpDir = null;
18
+
19
+ /** Lazily create the private per-process SDP directory (mkdtemp → 0700). */
20
+ function ensureSdpDir() {
21
+ if (!sdpDir) {
22
+ sdpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'intercom-'));
23
+ }
24
+ return sdpDir;
25
+ }
26
+
27
+ /**
28
+ * @param {{ port: number, payloadType: number, srtpParams: string }} input
29
+ * @returns {string}
30
+ */
31
+ function buildReturnAudioSdp({ port, payloadType, srtpParams }) {
32
+ return [
33
+ 'v=0',
34
+ 'o=- 0 0 IN IP4 127.0.0.1',
35
+ 's=Return Audio',
36
+ 'c=IN IP4 127.0.0.1',
37
+ 't=0 0',
38
+ `m=audio ${port} RTP/SAVP ${payloadType}`,
39
+ `a=rtpmap:${payloadType} opus/48000/2`,
40
+ `a=fmtp:${payloadType} minptime=10;useinbandfec=1`,
41
+ `a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:${srtpParams}`,
42
+ 'a=recvonly',
43
+ '',
44
+ ].join('\r\n');
45
+ }
46
+
47
+ /**
48
+ * Write the SDP for a HAP session into the private directory.
49
+ *
50
+ * The 0700 directory (owned by this process) is what prevents another local
51
+ * user from reading the SRTP key or planting a symlink at the target path, so
52
+ * an overwrite (`w`) is safe: a re-issued START for the same session simply
53
+ * refreshes the file rather than throwing, keeping _startSession idempotent.
54
+ *
55
+ * @param {{ sessionID: string, port: number, payloadType: number, srtpParams: string }} input
56
+ * @returns {string} absolute path of the written SDP file
57
+ */
58
+ function writeReturnAudioSdp({ sessionID, port, payloadType, srtpParams }) {
59
+ const safeName = String(sessionID).replace(/[^A-Za-z0-9_-]/g, '_');
60
+ const sdpPath = path.join(ensureSdpDir(), `return_${safeName}.sdp`);
61
+ fs.writeFileSync(sdpPath, buildReturnAudioSdp({ port, payloadType, srtpParams }), {
62
+ mode: 0o600,
63
+ flag: 'w',
64
+ });
65
+ return sdpPath;
66
+ }
67
+
68
+ /**
69
+ * Best-effort removal of a previously written SDP file.
70
+ * @param {string} sdpPath
71
+ */
72
+ function removeReturnAudioSdp(sdpPath) {
73
+ try {
74
+ fs.unlinkSync(sdpPath);
75
+ } catch {}
76
+ }
77
+
78
+ /**
79
+ * Best-effort removal of the private SDP directory itself, called on graceful
80
+ * shutdown so the intercom-* dir does not linger. Resets the cached path so a
81
+ * later write (should one occur) recreates a fresh private directory.
82
+ */
83
+ function removeSdpDir() {
84
+ if (!sdpDir) return;
85
+ try {
86
+ fs.rmSync(sdpDir, { recursive: true, force: true });
87
+ } catch {}
88
+ sdpDir = null;
89
+ }
90
+
91
+ module.exports = {
92
+ removeReturnAudioSdp,
93
+ removeSdpDir,
94
+ writeReturnAudioSdp,
95
+ };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * Constant-time string comparison. Inputs are padded to a common length so
7
+ * unequal lengths do not short-circuit the comparison.
8
+ *
9
+ * @param {string} a
10
+ * @param {string} b
11
+ * @returns {boolean}
12
+ */
13
+ function safeEqualString(a, b) {
14
+ const left = Buffer.from(a, 'utf8');
15
+ const right = Buffer.from(b, 'utf8');
16
+ const len = Math.max(left.length, right.length);
17
+ const paddedLeft = Buffer.concat([left, Buffer.alloc(len - left.length)]);
18
+ const paddedRight = Buffer.concat([right, Buffer.alloc(len - right.length)]);
19
+ return crypto.timingSafeEqual(paddedLeft, paddedRight);
20
+ }
21
+
22
+ module.exports = { safeEqualString };
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+
3
+ const config = require('./config');
4
+ const { createLogger } = require('./log');
5
+ const logger = createLogger({ component: 'state' });
6
+
7
+ /** @import { connection as WebSocketConnection } from 'websocket' */
8
+ /** @import { CallSession, CallStatus, StartCallResult, ClearCallResult, ClearedCallSession } from './types.js' */
9
+
10
+ class CallSessionManager {
11
+ /**
12
+ * @param {number} staleSec
13
+ */
14
+ constructor(staleSec) {
15
+ /** @type {Map<string, CallSession>} */
16
+ this._sessions = new Map();
17
+ /** @type {string | null} */
18
+ this._activeCallSid = null;
19
+ this._staleMs = staleSec * 1000;
20
+ /** @type {((session: ClearedCallSession) => void) | null} */
21
+ this._onSessionStale = null;
22
+
23
+ const tickMs = Math.max(5000, Math.min(30000, Math.floor(this._staleMs / 3)));
24
+ this._reaper = setInterval(() => this._reapStaleSession(), tickMs);
25
+ if (typeof this._reaper.unref === 'function') this._reaper.unref();
26
+ }
27
+
28
+ /**
29
+ * @param {((session: ClearedCallSession) => void) | null | undefined} handler
30
+ */
31
+ setOnSessionStale(handler) {
32
+ this._onSessionStale = typeof handler === 'function' ? handler : null;
33
+ }
34
+
35
+ /**
36
+ * @param {{ callSid: string, streamSid: string, wsConnection: WebSocketConnection }} input
37
+ * @returns {StartCallResult}
38
+ */
39
+ startCall({ callSid, streamSid, wsConnection }) {
40
+ if (!callSid || !streamSid || !wsConnection) {
41
+ return { ok: false, reason: 'invalid session payload' };
42
+ }
43
+ const active = this.getActiveCall();
44
+ if (active && active.callSid !== callSid) {
45
+ return { ok: false, reason: 'another call is already active' };
46
+ }
47
+
48
+ const now = Date.now();
49
+ /** @type {CallSession} */
50
+ const session = {
51
+ callSid,
52
+ streamSid,
53
+ wsConnection,
54
+ createdAtMs: now,
55
+ lastEventAtMs: now,
56
+ lastEvent: 'start',
57
+ };
58
+
59
+ this._sessions.set(callSid, session);
60
+ this._activeCallSid = callSid;
61
+ return { ok: true, session };
62
+ }
63
+
64
+ /**
65
+ * @returns {CallSession | null}
66
+ */
67
+ getActiveCall() {
68
+ if (!this._activeCallSid) return null;
69
+ return this._sessions.get(this._activeCallSid) || null;
70
+ }
71
+
72
+ /**
73
+ * @returns {CallStatus}
74
+ */
75
+ getStatus() {
76
+ const active = this.getActiveCall();
77
+ if (!active) return { active: false };
78
+ return { active: true, callSid: active.callSid };
79
+ }
80
+
81
+ /**
82
+ * @param {string | null | undefined} callSid
83
+ * @param {string | null | undefined} eventName
84
+ * @returns {boolean}
85
+ */
86
+ markActivity(callSid, eventName) {
87
+ const session = callSid ? this._sessions.get(callSid) : this.getActiveCall();
88
+ if (!session) return false;
89
+ session.lastEventAtMs = Date.now();
90
+ if (eventName) session.lastEvent = eventName;
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * @param {string | null | undefined} reason
96
+ * @returns {ClearCallResult}
97
+ */
98
+ clearActiveCall(reason) {
99
+ const active = this.getActiveCall();
100
+ if (!active) return { cleared: false, session: null };
101
+ this._sessions.delete(active.callSid);
102
+ this._activeCallSid = null;
103
+ return { cleared: true, session: { ...active, clearedReason: reason || 'cleared' } };
104
+ }
105
+
106
+ /**
107
+ * @param {WebSocketConnection} wsConnection
108
+ * @param {string | null | undefined} reason
109
+ * @returns {ClearCallResult}
110
+ */
111
+ clearIfConnection(wsConnection, reason) {
112
+ const active = this.getActiveCall();
113
+ if (!active || active.wsConnection !== wsConnection) {
114
+ return { cleared: false, session: null };
115
+ }
116
+ return this.clearActiveCall(reason || 'connection-closed');
117
+ }
118
+
119
+ stop() {
120
+ if (this._reaper) {
121
+ clearInterval(this._reaper);
122
+ this._reaper = null;
123
+ }
124
+ this._sessions.clear();
125
+ this._activeCallSid = null;
126
+ }
127
+
128
+ _reapStaleSession() {
129
+ const active = this.getActiveCall();
130
+ if (!active) return;
131
+ if (Date.now() - active.lastEventAtMs <= this._staleMs) return;
132
+
133
+ const { cleared, session } = this.clearActiveCall('stale-timeout');
134
+ if (!cleared || !this._onSessionStale) return;
135
+
136
+ try {
137
+ this._onSessionStale(session);
138
+ } catch (err) {
139
+ logger.error('stale session handler failed', {
140
+ event: 'stale-handler-failed',
141
+ reason: 'handler-throw',
142
+ callSid: session.callSid,
143
+ error: err,
144
+ });
145
+ }
146
+ }
147
+ }
148
+
149
+ module.exports = new CallSessionManager(config.callSessionStaleSec);
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const config = require('./config');
5
+ const { safeEqualString } = require('./safe-equal');
6
+
7
+ const STREAM_TOKEN_PARAMETER_NAME = 'token';
8
+ const STREAM_TOKEN_VERSION = 1;
9
+ const STREAM_PATH = '/media';
10
+ const pendingStreamNonces = new Map();
11
+
12
+ function toBase64Url(buffer) {
13
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
14
+ }
15
+
16
+ function fromBase64Url(value) {
17
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
18
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
19
+ return Buffer.from(padded, 'base64');
20
+ }
21
+
22
+ function pruneExpiredNonces() {
23
+ const now = Math.floor(Date.now() / 1000);
24
+ for (const [nonce, data] of pendingStreamNonces.entries()) {
25
+ if (data.exp <= now) pendingStreamNonces.delete(nonce);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * @param {string | null} callSid
31
+ * @returns {string}
32
+ */
33
+ function issueStreamToken(callSid) {
34
+ pruneExpiredNonces();
35
+
36
+ const now = Math.floor(Date.now() / 1000);
37
+ const exp = now + config.streamAuthTtlSec;
38
+ const nonce = crypto.randomBytes(16).toString('hex');
39
+
40
+ pendingStreamNonces.set(nonce, { exp, callSid });
41
+
42
+ const payload = {
43
+ v: STREAM_TOKEN_VERSION,
44
+ iat: now,
45
+ exp,
46
+ nonce,
47
+ callSid,
48
+ };
49
+
50
+ const payloadEncoded = toBase64Url(Buffer.from(JSON.stringify(payload), 'utf8'));
51
+ const signature = toBase64Url(
52
+ crypto.createHmac('sha256', config.streamAuthSecret).update(payloadEncoded).digest()
53
+ );
54
+ return `${payloadEncoded}.${signature}`;
55
+ }
56
+
57
+ /**
58
+ * @param {string} token
59
+ * @returns {import('./types').StreamTokenVerificationResult}
60
+ */
61
+ function verifyAndConsumeStreamToken(token) {
62
+ pruneExpiredNonces();
63
+ if (!token) return { ok: false, reason: 'missing token' };
64
+
65
+ const pieces = token.split('.');
66
+ if (pieces.length !== 2) return { ok: false, reason: 'invalid token format' };
67
+ const [payloadEncoded, providedSig] = pieces;
68
+ const expectedSig = toBase64Url(
69
+ crypto.createHmac('sha256', config.streamAuthSecret).update(payloadEncoded).digest()
70
+ );
71
+ if (!safeEqualString(providedSig, expectedSig)) {
72
+ return { ok: false, reason: 'invalid token signature' };
73
+ }
74
+
75
+ let payload;
76
+ try {
77
+ payload = JSON.parse(fromBase64Url(payloadEncoded).toString('utf8'));
78
+ } catch {
79
+ return { ok: false, reason: 'invalid token payload' };
80
+ }
81
+
82
+ const now = Math.floor(Date.now() / 1000);
83
+ if (
84
+ payload.v !== STREAM_TOKEN_VERSION ||
85
+ !payload.nonce ||
86
+ payload.exp <= now ||
87
+ payload.iat > now + 30
88
+ ) {
89
+ return { ok: false, reason: 'expired or malformed token' };
90
+ }
91
+
92
+ const pending = pendingStreamNonces.get(payload.nonce);
93
+ if (!pending) return { ok: false, reason: 'nonce not pending' };
94
+ if (pending.exp !== payload.exp || pending.callSid !== payload.callSid) {
95
+ pendingStreamNonces.delete(payload.nonce);
96
+ return { ok: false, reason: 'nonce payload mismatch' };
97
+ }
98
+
99
+ pendingStreamNonces.delete(payload.nonce);
100
+ return { ok: true, callSid: payload.callSid || null };
101
+ }
102
+
103
+ /**
104
+ * @param {string | null} callSid
105
+ * @param {string} [tunnelHostname]
106
+ * @returns {string}
107
+ */
108
+ function buildConnectStreamTwiml(callSid, tunnelHostname = config.tunnelHostname) {
109
+ return `<Connect><Stream url="wss://${tunnelHostname}${STREAM_PATH}"><Parameter name="${STREAM_TOKEN_PARAMETER_NAME}" value="${escapeXmlAttribute(issueStreamToken(callSid))}"/></Stream></Connect>`;
110
+ }
111
+
112
+ /**
113
+ * @param {string} value
114
+ * @returns {string}
115
+ */
116
+ function escapeXmlAttribute(value) {
117
+ return value
118
+ .replace(/&/g, '&amp;')
119
+ .replace(/"/g, '&quot;')
120
+ .replace(/'/g, '&apos;')
121
+ .replace(/</g, '&lt;')
122
+ .replace(/>/g, '&gt;');
123
+ }
124
+
125
+ module.exports = {
126
+ buildConnectStreamTwiml,
127
+ issueStreamToken,
128
+ STREAM_TOKEN_PARAMETER_NAME,
129
+ verifyAndConsumeStreamToken,
130
+ };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Twilio identifier formats and normalization.
5
+ *
6
+ * Dependency-free on purpose: this module is imported by both the token layer
7
+ * (stream-auth.js) and the pure protocol schema (ws-events-schema.js), and the
8
+ * latter must not transitively pull in config.js (which validates env at load).
9
+ */
10
+
11
+ const CALL_SID_REGEX = /^CA[0-9a-f]{32}$/;
12
+
13
+ /**
14
+ * Normalize an untrusted CallSid value (e.g. from webhook form data or the
15
+ * Twilio WebSocket start event). Twilio always sends CallSid as "CA" + 32
16
+ * lowercase hex chars; anything else (wrong shape, duplicate form keys parsed
17
+ * as arrays, non-strings) is treated as absent so it never reaches logs,
18
+ * tokens, or REST URLs.
19
+ *
20
+ * @param {unknown} value
21
+ * @returns {string | null}
22
+ */
23
+ function normalizeCallSid(value) {
24
+ return typeof value === 'string' && CALL_SID_REGEX.test(value) ? value : null;
25
+ }
26
+
27
+ module.exports = { CALL_SID_REGEX, normalizeCallSid };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared JSDoc typedefs for runtime JS type-checking.
5
+ * This file is intentionally declarations-only; no runtime exports.
6
+ */
7
+
8
+ /**
9
+ * @typedef {'connected' | 'start' | 'media' | 'stop'} SupportedWsEventName
10
+ */
11
+
12
+ /**
13
+ * @typedef {{ event: 'connected' }} ConnectedEvent
14
+ */
15
+
16
+ /**
17
+ * @typedef {{
18
+ * event: 'start',
19
+ * start: { callSid: string, streamSid: string, customParameters?: Record<string, string> }
20
+ * }} StartEvent
21
+ */
22
+
23
+ /**
24
+ * @typedef {{
25
+ * event: 'media',
26
+ * media: { payload: string }
27
+ * }} MediaEvent
28
+ */
29
+
30
+ /**
31
+ * @typedef {{ event: 'stop' }} StopEvent
32
+ */
33
+
34
+ /**
35
+ * @typedef {ConnectedEvent | StartEvent | MediaEvent | StopEvent} SupportedWsEventData
36
+ */
37
+
38
+ /**
39
+ * @typedef {{
40
+ * ok: true,
41
+ * unsupported: false,
42
+ * event: 'connected',
43
+ * data: ConnectedEvent
44
+ * } | {
45
+ * ok: true,
46
+ * unsupported: false,
47
+ * event: 'start',
48
+ * data: StartEvent
49
+ * } | {
50
+ * ok: true,
51
+ * unsupported: false,
52
+ * event: 'media',
53
+ * data: MediaEvent
54
+ * } | {
55
+ * ok: true,
56
+ * unsupported: false,
57
+ * event: 'stop',
58
+ * data: StopEvent
59
+ * }} WsEventParseOkSupported
60
+ */
61
+
62
+ /**
63
+ * @typedef {{
64
+ * ok: true,
65
+ * unsupported: true,
66
+ * event: string
67
+ * }} WsEventParseOkUnsupported
68
+ */
69
+
70
+ /**
71
+ * @typedef {{
72
+ * ok: false,
73
+ * reason: string
74
+ * }} WsEventParseError
75
+ */
76
+
77
+ /**
78
+ * @typedef {WsEventParseOkSupported | WsEventParseOkUnsupported | WsEventParseError} WsEventParseResult
79
+ */
80
+
81
+ /**
82
+ * @typedef {{ ok: true, decoded: Buffer }} MediaPayloadParseOk
83
+ */
84
+
85
+ /**
86
+ * @typedef {{ ok: false, reason: string }} MediaPayloadParseError
87
+ */
88
+
89
+ /**
90
+ * @typedef {MediaPayloadParseOk | MediaPayloadParseError} MediaPayloadParseResult
91
+ */
92
+
93
+ /**
94
+ * @typedef {{ ok: true, callSid: string | null }} TokenVerificationOk
95
+ */
96
+
97
+ /**
98
+ * @typedef {{ ok: false, reason: string }} TokenVerificationError
99
+ */
100
+
101
+ /**
102
+ * @typedef {TokenVerificationOk | TokenVerificationError} StreamTokenVerificationResult
103
+ */
104
+
105
+ /**
106
+ * @typedef {{
107
+ * callSid: string,
108
+ * streamSid: string,
109
+ * wsConnection: import('websocket').connection,
110
+ * createdAtMs: number,
111
+ * lastEventAtMs: number,
112
+ * lastEvent: string
113
+ * }} CallSession
114
+ */
115
+
116
+ /**
117
+ * @typedef {CallSession & { clearedReason: string }} ClearedCallSession
118
+ */
119
+
120
+ /**
121
+ * @typedef {{ active: false } | { active: true, callSid: string }} CallStatus
122
+ */
123
+
124
+ /**
125
+ * @typedef {{ ok: true, session: CallSession } | { ok: false, reason: string }} StartCallResult
126
+ */
127
+
128
+ /**
129
+ * @typedef {{ cleared: true, session: ClearedCallSession } | { cleared: false, session: null }} ClearCallResult
130
+ */
131
+
132
+ /**
133
+ * @typedef {{
134
+ * ts?: string,
135
+ * level?: 'debug' | 'info' | 'warn' | 'error',
136
+ * message?: string,
137
+ * component?: string,
138
+ * callSid?: string,
139
+ * event?: string,
140
+ * reason?: string,
141
+ * durationMs?: number
142
+ * } & Record<string, unknown>} LogFields
143
+ */
144
+
145
+ module.exports = {};
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const { z } = require('zod');
4
+ const { CALL_SID_REGEX } = require('./twilio-ids');
5
+ /** @import { WsEventParseResult, MediaPayloadParseResult } from './types.js' */
6
+
7
+ const connectedEventSchema = z.object({
8
+ event: z.literal('connected'),
9
+ });
10
+
11
+ const startEventSchema = z.object({
12
+ event: z.literal('start'),
13
+ start: z.object({
14
+ callSid: z.string().regex(CALL_SID_REGEX),
15
+ streamSid: z.string().min(1),
16
+ mediaFormat: z
17
+ .object({
18
+ encoding: z.string().optional(),
19
+ sampleRate: z.number().optional(),
20
+ channels: z.number().optional(),
21
+ })
22
+ .optional(),
23
+ customParameters: z.record(z.string(), z.string()).optional(),
24
+ }),
25
+ });
26
+
27
+ const mediaEventSchema = z.object({
28
+ event: z.literal('media'),
29
+ media: z.object({
30
+ payload: z.string(),
31
+ }),
32
+ });
33
+
34
+ const stopEventSchema = z.object({
35
+ event: z.literal('stop'),
36
+ });
37
+
38
+ const supportedEventSchema = z.discriminatedUnion('event', [
39
+ connectedEventSchema,
40
+ startEventSchema,
41
+ mediaEventSchema,
42
+ stopEventSchema,
43
+ ]);
44
+ /** @type {Set<string>} */
45
+ const SUPPORTED_EVENTS = new Set(
46
+ supportedEventSchema.options.map((schema) => schema.shape.event.value)
47
+ );
48
+
49
+ const BASE64_PAYLOAD_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
50
+
51
+ /**
52
+ * @param {unknown} raw
53
+ * @returns {WsEventParseResult}
54
+ */
55
+ function parseTwilioWsEvent(raw) {
56
+ const base = z.object({ event: z.string().min(1) }).safeParse(raw);
57
+ if (!base.success) {
58
+ return { ok: false, reason: 'missing event field' };
59
+ }
60
+
61
+ if (!SUPPORTED_EVENTS.has(base.data.event)) {
62
+ return {
63
+ ok: true,
64
+ unsupported: true,
65
+ event: base.data.event,
66
+ };
67
+ }
68
+
69
+ const parsed = supportedEventSchema.safeParse(raw);
70
+ if (!parsed.success) {
71
+ return { ok: false, reason: `invalid ${base.data.event} payload` };
72
+ }
73
+
74
+ switch (parsed.data.event) {
75
+ case 'connected':
76
+ return { ok: true, unsupported: false, event: 'connected', data: parsed.data };
77
+ case 'start':
78
+ return { ok: true, unsupported: false, event: 'start', data: parsed.data };
79
+ case 'media':
80
+ return { ok: true, unsupported: false, event: 'media', data: parsed.data };
81
+ case 'stop':
82
+ return { ok: true, unsupported: false, event: 'stop', data: parsed.data };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @param {unknown} payload
88
+ * @param {number} maxDecodedBytes
89
+ * @returns {MediaPayloadParseResult}
90
+ */
91
+ function parseTwilioMediaPayload(payload, maxDecodedBytes) {
92
+ if (typeof payload !== 'string') {
93
+ return { ok: false, reason: 'invalid media payload encoding' };
94
+ }
95
+ if (payload.length % 4 !== 0 || !BASE64_PAYLOAD_REGEX.test(payload)) {
96
+ return { ok: false, reason: 'invalid media payload encoding' };
97
+ }
98
+
99
+ const decoded = Buffer.from(payload, 'base64');
100
+ if (decoded.length > maxDecodedBytes) {
101
+ return { ok: false, reason: 'media payload too large' };
102
+ }
103
+
104
+ return { ok: true, decoded };
105
+ }
106
+
107
+ module.exports = {
108
+ parseTwilioWsEvent,
109
+ parseTwilioMediaPayload,
110
+ };