niahere 0.3.0 → 0.3.1

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,107 @@
1
+ /**
2
+ * Disk-backed cache for outbound Twilio media.
3
+ *
4
+ * Twilio fetches outbound MMS/WhatsApp media by URL, so we write the
5
+ * payload to ~/.niahere/tmp/outbound/<sha>.<ext>, expose it under
6
+ * GET /twilio/media/<sha>.<ext>, and Twilio retrieves it. Disk (not
7
+ * memory) so the URL survives daemon restarts and Twilio's webhook
8
+ * retries within the eviction window.
9
+ *
10
+ * Eviction is opportunistic: capped at 100 files, 10MB total, 24h max
11
+ * age. Oldest-first; expired-first. Runs after every write; cheap
12
+ * enough at this scale (single user, low traffic).
13
+ */
14
+ import { mkdir, readdir, readFile, stat, unlink, writeFile } from "fs/promises";
15
+ import { createHash } from "crypto";
16
+ import { join } from "path";
17
+ import { getNiaHome } from "../../utils/paths";
18
+ import { log } from "../../utils/log";
19
+
20
+ const MAX_FILES = 100;
21
+ const MAX_BYTES = 10 * 1024 * 1024;
22
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
23
+
24
+ const MIME_TO_EXT: Record<string, string> = {
25
+ "image/jpeg": "jpg",
26
+ "image/png": "png",
27
+ "image/webp": "webp",
28
+ "image/gif": "gif",
29
+ "audio/mpeg": "mp3",
30
+ "audio/mp4": "m4a",
31
+ "audio/ogg": "ogg",
32
+ "audio/wav": "wav",
33
+ "video/mp4": "mp4",
34
+ "application/pdf": "pdf",
35
+ };
36
+
37
+ const FILENAME_RE = /^[a-f0-9]{16,64}\.[a-z0-9]{1,8}$/i;
38
+
39
+ export function getMediaDir(): string {
40
+ return join(getNiaHome(), "tmp", "outbound");
41
+ }
42
+
43
+ export interface CachedMedia {
44
+ filename: string;
45
+ path: string;
46
+ }
47
+
48
+ export async function cacheMedia(buffer: Uint8Array, mime: string, ext?: string): Promise<CachedMedia> {
49
+ const dir = getMediaDir();
50
+ await mkdir(dir, { recursive: true });
51
+ const resolvedExt = (ext ?? MIME_TO_EXT[mime] ?? "bin").replace(/[^a-z0-9]/gi, "").slice(0, 8) || "bin";
52
+ const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 32);
53
+ const filename = `${hash}.${resolvedExt}`;
54
+ const path = join(dir, filename);
55
+ await writeFile(path, buffer);
56
+ await evict().catch((err) => log.warn({ err }, "media-cache: eviction failed"));
57
+ return { filename, path };
58
+ }
59
+
60
+ export async function readCachedMedia(filename: string): Promise<{ buffer: Buffer; mime: string } | null> {
61
+ if (!FILENAME_RE.test(filename)) return null;
62
+ const path = join(getMediaDir(), filename);
63
+ try {
64
+ const buffer = await readFile(path);
65
+ const ext = filename.split(".").pop()!.toLowerCase();
66
+ const mime = Object.entries(MIME_TO_EXT).find(([, e]) => e === ext)?.[0] ?? "application/octet-stream";
67
+ return { buffer, mime };
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ async function evict(): Promise<void> {
74
+ const dir = getMediaDir();
75
+ const entries = await readdir(dir);
76
+ const stats = await Promise.all(
77
+ entries.map(async (name) => {
78
+ const path = join(dir, name);
79
+ const s = await stat(path);
80
+ return { name, path, mtime: s.mtimeMs, size: s.size };
81
+ }),
82
+ );
83
+
84
+ const now = Date.now();
85
+ let alive: typeof stats = [];
86
+ for (const s of stats) {
87
+ if (now - s.mtime > MAX_AGE_MS) {
88
+ await unlink(s.path).catch(() => {});
89
+ } else {
90
+ alive.push(s);
91
+ }
92
+ }
93
+
94
+ alive.sort((a, b) => a.mtime - b.mtime);
95
+
96
+ while (alive.length > MAX_FILES) {
97
+ const victim = alive.shift()!;
98
+ await unlink(victim.path).catch(() => {});
99
+ }
100
+
101
+ let totalBytes = alive.reduce((sum, f) => sum + f.size, 0);
102
+ while (totalBytes > MAX_BYTES && alive.length > 0) {
103
+ const victim = alive.shift()!;
104
+ totalBytes -= victim.size;
105
+ await unlink(victim.path).catch(() => {});
106
+ }
107
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Download inbound media attached to a Twilio webhook.
3
+ *
4
+ * Twilio's MediaUrlN values are HTTPS URLs that 302-redirect to S3.
5
+ * They sit behind Basic auth (same Twilio creds), so a vanilla fetch
6
+ * without an Authorization header gets 401. Bun's fetch follows the
7
+ * redirect transparently.
8
+ *
9
+ * Concurrency is bounded (4 in flight, 10s per item) so a sender that
10
+ * attaches many large files cannot stall the webhook handler.
11
+ */
12
+ import { log } from "../../utils/log";
13
+ import type { TwilioCreds } from "./rest";
14
+
15
+ const MAX_CONCURRENT = 4;
16
+ const TIMEOUT_MS = 10_000;
17
+
18
+ export interface InboundMedia {
19
+ index: number;
20
+ url: string;
21
+ mime: string;
22
+ data: Buffer;
23
+ }
24
+
25
+ export interface MediaDescriptor {
26
+ index: number;
27
+ url: string;
28
+ mime: string;
29
+ }
30
+
31
+ /**
32
+ * Pull out the NumMedia / MediaUrlN / MediaContentTypeN fields from a
33
+ * Twilio webhook form body.
34
+ */
35
+ export function extractMedia(params: Record<string, string>): MediaDescriptor[] {
36
+ const num = parseInt(params.NumMedia || "0", 10);
37
+ if (!Number.isFinite(num) || num <= 0) return [];
38
+ const items: MediaDescriptor[] = [];
39
+ for (let i = 0; i < num; i++) {
40
+ const url = params[`MediaUrl${i}`];
41
+ const mime = params[`MediaContentType${i}`];
42
+ if (url && mime) items.push({ index: i, url, mime });
43
+ }
44
+ return items;
45
+ }
46
+
47
+ export async function downloadInboundMedia(
48
+ descriptors: MediaDescriptor[],
49
+ creds: TwilioCreds,
50
+ ): Promise<InboundMedia[]> {
51
+ const out: InboundMedia[] = [];
52
+ for (let i = 0; i < descriptors.length; i += MAX_CONCURRENT) {
53
+ const slice = descriptors.slice(i, i + MAX_CONCURRENT);
54
+ const results = await Promise.allSettled(slice.map((d) => downloadOne(d, creds)));
55
+ for (let j = 0; j < results.length; j++) {
56
+ const r = results[j];
57
+ if (r.status === "fulfilled") {
58
+ out.push(r.value);
59
+ } else {
60
+ log.warn({ err: r.reason, descriptor: slice[j] }, "twilio: media download failed");
61
+ }
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ async function downloadOne(d: MediaDescriptor, creds: TwilioCreds): Promise<InboundMedia> {
68
+ const auth = `Basic ${Buffer.from(`${creds.authSid}:${creds.authSecret}`).toString("base64")}`;
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
71
+ try {
72
+ const resp = await fetch(d.url, { headers: { Authorization: auth }, signal: controller.signal });
73
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
74
+ const buffer = Buffer.from(await resp.arrayBuffer());
75
+ return { index: d.index, url: d.url, mime: d.mime, data: buffer };
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Sliding-window rate limiter keyed by an arbitrary string (e.g. caller
3
+ * E.164). Protects against runaway costs when the WhatsApp Sandbox's
4
+ * shared number gets random opt-ins and someone spams.
5
+ */
6
+ export class RateLimiter {
7
+ private readonly hits = new Map<string, number[]>();
8
+
9
+ constructor(
10
+ private readonly maxPerWindow: number = 30,
11
+ private readonly windowMs: number = 60_000,
12
+ ) {}
13
+
14
+ /** Returns true if this hit was allowed (and recorded); false if over limit. */
15
+ allow(key: string): boolean {
16
+ const now = Date.now();
17
+ const cutoff = now - this.windowMs;
18
+ const arr = this.hits.get(key) ?? [];
19
+ const recent = arr.filter((t) => t > cutoff);
20
+ if (recent.length >= this.maxPerWindow) {
21
+ this.hits.set(key, recent);
22
+ return false;
23
+ }
24
+ recent.push(now);
25
+ this.hits.set(key, recent);
26
+ return true;
27
+ }
28
+
29
+ /** Drop tracking for a key (e.g. owner number — never rate-limit yourself). */
30
+ exempt(key: string): void {
31
+ this.hits.delete(key);
32
+ }
33
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Minimal Twilio REST API helpers shared by all Twilio-based channels
3
+ * (voice, SMS, WhatsApp). No SDK — keeps dependency surface small and
4
+ * the helpers easy to read.
5
+ *
6
+ * Auth: Basic with `${authSid}:${authSecret}`. authSid can be either an
7
+ * Account SID (AC…) or an API Key SID (SK…); Twilio resolves both. The
8
+ * URL path always uses the Account SID — when using an API Key, pass
9
+ * `accountSid` separately.
10
+ */
11
+
12
+ const TWILIO_BASE = "https://api.twilio.com/2010-04-01";
13
+
14
+ export interface TwilioCreds {
15
+ /** Account SID (AC…) — required for the URL path. */
16
+ accountSid: string;
17
+ /** SID used for Basic-auth username. Can be the same Account SID or an API Key SID (SK…). */
18
+ authSid: string;
19
+ /** Basic-auth password (Account Auth Token, or API Key Secret if authSid is SK…). */
20
+ authSecret: string;
21
+ }
22
+
23
+ function basicAuth({ authSid, authSecret }: TwilioCreds): string {
24
+ return `Basic ${Buffer.from(`${authSid}:${authSecret}`).toString("base64")}`;
25
+ }
26
+
27
+ function accountUrl({ accountSid }: TwilioCreds, suffix: string): string {
28
+ return `${TWILIO_BASE}/Accounts/${encodeURIComponent(accountSid)}${suffix}`;
29
+ }
30
+
31
+ async function twilioPost<T = unknown>(creds: TwilioCreds, suffix: string, body: URLSearchParams): Promise<T> {
32
+ const resp = await fetch(accountUrl(creds, suffix), {
33
+ method: "POST",
34
+ headers: { Authorization: basicAuth(creds), "Content-Type": "application/x-www-form-urlencoded" },
35
+ body: body.toString(),
36
+ });
37
+ if (!resp.ok) {
38
+ const text = await resp.text().catch(() => "");
39
+ throw new Error(`Twilio ${suffix} failed: ${resp.status} ${text}`);
40
+ }
41
+ return (await resp.json()) as T;
42
+ }
43
+
44
+ // --- Voice ---
45
+
46
+ export interface PlaceCallOpts extends TwilioCreds {
47
+ to: string;
48
+ from: string;
49
+ twimlUrl: string;
50
+ statusCallbackUrl?: string;
51
+ maxDurationSec?: number;
52
+ }
53
+
54
+ export interface PlaceCallResult {
55
+ callSid: string;
56
+ status: string;
57
+ }
58
+
59
+ export async function placeCall(opts: PlaceCallOpts): Promise<PlaceCallResult> {
60
+ const body = new URLSearchParams({ To: opts.to, From: opts.from, Url: opts.twimlUrl, Method: "POST" });
61
+ if (opts.statusCallbackUrl) {
62
+ body.set("StatusCallback", opts.statusCallbackUrl);
63
+ body.set("StatusCallbackMethod", "POST");
64
+ body.append("StatusCallbackEvent", "initiated");
65
+ body.append("StatusCallbackEvent", "answered");
66
+ body.append("StatusCallbackEvent", "completed");
67
+ }
68
+ if (opts.maxDurationSec && opts.maxDurationSec > 0) {
69
+ body.set("TimeLimit", String(opts.maxDurationSec));
70
+ }
71
+ const data = await twilioPost<{ sid: string; status: string }>(opts, "/Calls.json", body);
72
+ return { callSid: data.sid, status: data.status };
73
+ }
74
+
75
+ export async function updateCallUrl(opts: TwilioCreds & { callSid: string; url: string }): Promise<void> {
76
+ const body = new URLSearchParams({ Url: opts.url, Method: "POST" });
77
+ await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
78
+ }
79
+
80
+ export async function hangupCall(opts: TwilioCreds & { callSid: string }): Promise<void> {
81
+ const body = new URLSearchParams({ Status: "completed" });
82
+ await twilioPost(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`, body);
83
+ }
84
+
85
+ // --- Messages (SMS / WhatsApp) ---
86
+
87
+ export interface SendMessageOpts extends TwilioCreds {
88
+ /** E.164 for SMS, "whatsapp:+E164" for WhatsApp. */
89
+ to: string;
90
+ from: string;
91
+ body: string;
92
+ statusCallbackUrl?: string;
93
+ /** Optional MMS media URLs. */
94
+ mediaUrl?: string[];
95
+ }
96
+
97
+ export interface SendMessageResult {
98
+ messageSid: string;
99
+ status: string;
100
+ }
101
+
102
+ export async function sendMessage(opts: SendMessageOpts): Promise<SendMessageResult> {
103
+ const body = new URLSearchParams({ To: opts.to, From: opts.from, Body: opts.body });
104
+ if (opts.statusCallbackUrl) body.set("StatusCallback", opts.statusCallbackUrl);
105
+ if (opts.mediaUrl) {
106
+ for (const u of opts.mediaUrl) body.append("MediaUrl", u);
107
+ }
108
+ const data = await twilioPost<{ sid: string; status: string }>(opts, "/Messages.json", body);
109
+ return { messageSid: data.sid, status: data.status };
110
+ }
111
+
112
+ // --- Phone number config (update inbound webhook on a number) ---
113
+
114
+ export async function updateIncomingPhoneNumber(
115
+ opts: TwilioCreds & {
116
+ phoneNumberSid: string;
117
+ voiceUrl?: string;
118
+ voiceMethod?: "GET" | "POST";
119
+ smsUrl?: string;
120
+ smsMethod?: "GET" | "POST";
121
+ statusCallback?: string;
122
+ statusCallbackMethod?: "GET" | "POST";
123
+ },
124
+ ): Promise<void> {
125
+ const body = new URLSearchParams();
126
+ if (opts.voiceUrl !== undefined) body.set("VoiceUrl", opts.voiceUrl);
127
+ if (opts.voiceMethod) body.set("VoiceMethod", opts.voiceMethod);
128
+ if (opts.smsUrl !== undefined) body.set("SmsUrl", opts.smsUrl);
129
+ if (opts.smsMethod) body.set("SmsMethod", opts.smsMethod);
130
+ if (opts.statusCallback !== undefined) body.set("StatusCallback", opts.statusCallback);
131
+ if (opts.statusCallbackMethod) body.set("StatusCallbackMethod", opts.statusCallbackMethod);
132
+ await twilioPost(opts, `/IncomingPhoneNumbers/${encodeURIComponent(opts.phoneNumberSid)}.json`, body);
133
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Shared Bun HTTP + WebSocket server for all Twilio-based channels
3
+ * (voice, SMS, WhatsApp). Channels register routes during their start();
4
+ * the server lazily binds the port on first start() and stays bound until
5
+ * stop() is called. Idempotent in both directions.
6
+ *
7
+ * Cross-cutting features applied per route:
8
+ * - X-Twilio-Signature HMAC-SHA1 validation (default on for /twilio/*).
9
+ * - Dedup based on a configurable form field (e.g. MessageSid, CallSid).
10
+ * - Per-key rate limit (e.g. From), to bound cost when a stranger
11
+ * guesses the shared WhatsApp Sandbox join code.
12
+ *
13
+ * Channels keep their domain logic; the server keeps the wiring.
14
+ */
15
+ import type { Server, ServerWebSocket } from "bun";
16
+ import { log } from "../../utils/log";
17
+ import { validateTwilioSignature } from "./signature";
18
+ import { Dedup } from "./dedup";
19
+ import { RateLimiter } from "./rate-limit";
20
+ import { cacheMedia, readCachedMedia } from "./media-cache";
21
+
22
+ export interface TwilioRequestContext {
23
+ /** Form-decoded body. */
24
+ params: Record<string, string>;
25
+ }
26
+
27
+ export type HttpHandler = (req: Request, ctx: TwilioRequestContext) => Promise<Response> | Response;
28
+
29
+ export interface HttpRouteOpts {
30
+ /** Validate X-Twilio-Signature. Defaults to true. Set false for /healthz etc. */
31
+ verifySignature?: boolean;
32
+ /** If set, dedup based on this form field. Repeated values within the dedup window 204 silently. */
33
+ dedupOn?: string;
34
+ /** If set, rate-limit based on this form field. Over-limit 429s. */
35
+ rateLimitOn?: string;
36
+ }
37
+
38
+ export interface WsConnectionData {
39
+ path: string;
40
+ channel: Record<string, unknown>;
41
+ }
42
+
43
+ export interface WsRoute {
44
+ /** Called when a frame arrives. */
45
+ onMessage(ws: ServerWebSocket<WsConnectionData>, data: string): void;
46
+ /** Called when the connection closes. Optional. */
47
+ onClose?(ws: ServerWebSocket<WsConnectionData>): void;
48
+ }
49
+
50
+ export interface ServerConfig {
51
+ port: number;
52
+ /** Public base URL the server is reachable at (e.g. https://nia.example.com). No trailing slash. */
53
+ publicBaseUrl: string | null;
54
+ /** Account Auth Token used to verify X-Twilio-Signature. */
55
+ signingToken: string | null;
56
+ }
57
+
58
+ export interface TwilioServer {
59
+ configure(cfg: ServerConfig): void;
60
+ registerHttp(path: string, handler: HttpHandler, opts?: HttpRouteOpts): void;
61
+ registerWs(path: string, route: WsRoute): void;
62
+ /** Mark a key as exempt from rate-limiting (e.g. the owner's number). */
63
+ exemptFromRateLimit(key: string): void;
64
+ start(): Promise<void>;
65
+ stop(): void;
66
+ /** Build the WSS URL for a registered path, using publicBaseUrl. */
67
+ buildWssUrl(path: string): string;
68
+ /** Build the HTTPS URL for a registered path, using publicBaseUrl. */
69
+ buildHttpUrl(path: string): string;
70
+ /**
71
+ * Write a media payload to the on-disk outbound cache and return its
72
+ * publicly-reachable URL. Twilio fetches the URL when delivering MMS /
73
+ * WhatsApp media. Requires publicBaseUrl to be configured.
74
+ */
75
+ serveMedia(buffer: Uint8Array, mime: string, ext?: string): Promise<string>;
76
+ }
77
+
78
+ class TwilioServerImpl implements TwilioServer {
79
+ private cfg: ServerConfig | null = null;
80
+ private bunServer: Server<WsConnectionData> | null = null;
81
+ private readonly httpRoutes = new Map<string, { handler: HttpHandler; opts: HttpRouteOpts }>();
82
+ private readonly wsRoutes = new Map<string, WsRoute>();
83
+ private readonly dedup = new Dedup();
84
+ private readonly rateLimit = new RateLimiter();
85
+ private readonly rateLimitExempt = new Set<string>();
86
+
87
+ configure(cfg: ServerConfig): void {
88
+ this.cfg = cfg;
89
+ }
90
+
91
+ registerHttp(path: string, handler: HttpHandler, opts: HttpRouteOpts = {}): void {
92
+ if (this.httpRoutes.has(path)) {
93
+ log.warn({ path }, "twilio-server: overwriting existing HTTP route");
94
+ }
95
+ this.httpRoutes.set(path, { handler, opts });
96
+ }
97
+
98
+ registerWs(path: string, route: WsRoute): void {
99
+ if (this.wsRoutes.has(path)) {
100
+ log.warn({ path }, "twilio-server: overwriting existing WS route");
101
+ }
102
+ this.wsRoutes.set(path, route);
103
+ }
104
+
105
+ exemptFromRateLimit(key: string): void {
106
+ this.rateLimitExempt.add(key);
107
+ this.rateLimit.exempt(key);
108
+ }
109
+
110
+ async start(): Promise<void> {
111
+ if (this.bunServer) return;
112
+ if (!this.cfg) throw new Error("twilio-server: configure() must be called before start()");
113
+ const cfg = this.cfg;
114
+ const self = this;
115
+
116
+ this.bunServer = Bun.serve<WsConnectionData, never>({
117
+ port: cfg.port,
118
+ async fetch(req, server) {
119
+ const path = new URL(req.url).pathname;
120
+
121
+ if (path === "/healthz") return new Response("ok", { status: 200 });
122
+ if (path === "/twilio/health") return new Response("ok", { status: 200 });
123
+
124
+ if (req.method === "GET" && path.startsWith("/twilio/media/")) {
125
+ return await self.handleMedia(path.slice("/twilio/media/".length));
126
+ }
127
+
128
+ const wsRoute = self.wsRoutes.get(path);
129
+ if (wsRoute) {
130
+ const ok = server.upgrade(req, { data: { path, channel: {} } });
131
+ return ok ? undefined : new Response("expected websocket", { status: 400 });
132
+ }
133
+
134
+ const httpRoute = self.httpRoutes.get(path);
135
+ if (!httpRoute) return new Response("not found", { status: 404 });
136
+
137
+ return await self.handleHttp(req, httpRoute);
138
+ },
139
+ websocket: {
140
+ message(ws, message) {
141
+ const route = self.wsRoutes.get(ws.data.path);
142
+ if (!route) {
143
+ try {
144
+ ws.close();
145
+ } catch {}
146
+ return;
147
+ }
148
+ const data = typeof message === "string" ? message : new TextDecoder().decode(message);
149
+ route.onMessage(ws, data);
150
+ },
151
+ close(ws) {
152
+ const route = self.wsRoutes.get(ws.data.path);
153
+ route?.onClose?.(ws);
154
+ },
155
+ },
156
+ });
157
+
158
+ log.info({ port: cfg.port, publicBaseUrl: cfg.publicBaseUrl }, "twilio-server: started");
159
+ }
160
+
161
+ stop(): void {
162
+ if (!this.bunServer) return;
163
+ this.bunServer.stop(true);
164
+ this.bunServer = null;
165
+ log.info("twilio-server: stopped");
166
+ }
167
+
168
+ buildWssUrl(path: string): string {
169
+ if (!this.cfg?.publicBaseUrl) throw new Error("twilio-server: publicBaseUrl not configured");
170
+ return this.cfg.publicBaseUrl.replace(/^http/, "ws") + path;
171
+ }
172
+
173
+ buildHttpUrl(path: string): string {
174
+ if (!this.cfg?.publicBaseUrl) throw new Error("twilio-server: publicBaseUrl not configured");
175
+ return this.cfg.publicBaseUrl + path;
176
+ }
177
+
178
+ async serveMedia(buffer: Uint8Array, mime: string, ext?: string): Promise<string> {
179
+ if (!this.cfg?.publicBaseUrl) {
180
+ throw new Error("twilio-server: serveMedia requires channels.twilio.public_base_url to be configured");
181
+ }
182
+ const { filename } = await cacheMedia(buffer, mime, ext);
183
+ return `${this.cfg.publicBaseUrl}/twilio/media/${filename}`;
184
+ }
185
+
186
+ private async handleMedia(filename: string): Promise<Response> {
187
+ const hit = await readCachedMedia(filename);
188
+ if (!hit) return new Response("not found", { status: 404 });
189
+ return new Response(new Uint8Array(hit.buffer), {
190
+ status: 200,
191
+ headers: {
192
+ "Content-Type": hit.mime,
193
+ "Cache-Control": "private, max-age=300",
194
+ },
195
+ });
196
+ }
197
+
198
+ private async handleHttp(req: Request, route: { handler: HttpHandler; opts: HttpRouteOpts }): Promise<Response> {
199
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
200
+
201
+ const params = await this.readForm(req);
202
+
203
+ if (route.opts.verifySignature !== false) {
204
+ if (!this.verifySignature(req, params)) {
205
+ log.warn({ path: new URL(req.url).pathname }, "twilio-server: signature check failed");
206
+ return new Response("invalid signature", { status: 403 });
207
+ }
208
+ }
209
+
210
+ if (route.opts.rateLimitOn) {
211
+ const key = params[route.opts.rateLimitOn];
212
+ if (key && !this.rateLimitExempt.has(key) && !this.rateLimit.allow(key)) {
213
+ log.warn({ key, field: route.opts.rateLimitOn }, "twilio-server: rate limit exceeded");
214
+ return new Response("too many requests", { status: 429 });
215
+ }
216
+ }
217
+
218
+ if (route.opts.dedupOn) {
219
+ const id = params[route.opts.dedupOn];
220
+ if (id && this.dedup.check(id)) {
221
+ log.debug({ id, field: route.opts.dedupOn }, "twilio-server: duplicate webhook dropped");
222
+ return new Response("", { status: 204 });
223
+ }
224
+ }
225
+
226
+ return await route.handler(req, { params });
227
+ }
228
+
229
+ private async readForm(req: Request): Promise<Record<string, string>> {
230
+ const body = await req.text();
231
+ const params: Record<string, string> = {};
232
+ for (const [k, v] of new URLSearchParams(body)) params[k] = v;
233
+ return params;
234
+ }
235
+
236
+ private verifySignature(req: Request, params: Record<string, string>): boolean {
237
+ const token = this.cfg?.signingToken;
238
+ if (!token) return false;
239
+ const signature = req.headers.get("X-Twilio-Signature") || "";
240
+ const fullUrl = this.cfg?.publicBaseUrl ? `${this.cfg.publicBaseUrl}${new URL(req.url).pathname}` : req.url;
241
+ return validateTwilioSignature({ authToken: token, fullUrl, params, signature });
242
+ }
243
+ }
244
+
245
+ let _instance: TwilioServer | null = null;
246
+
247
+ export function getTwilioServer(): TwilioServer {
248
+ if (!_instance) _instance = new TwilioServerImpl();
249
+ return _instance;
250
+ }
251
+
252
+ /** Test-only: drop the singleton so tests get a fresh server. */
253
+ export function resetTwilioServer(): void {
254
+ _instance = null;
255
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Validate Twilio's X-Twilio-Signature header.
3
+ *
4
+ * Algorithm (per Twilio webhook security docs):
5
+ * 1. Take the full URL Twilio sent the request to (including ?query).
6
+ * 2. For application/x-www-form-urlencoded bodies, sort POST keys and
7
+ * append each "key" + "value" to the URL string.
8
+ * 3. HMAC-SHA1 with the account AuthToken, base64-encode.
9
+ * 4. Timing-safe compare with the X-Twilio-Signature header.
10
+ *
11
+ * Signed with the account-level Auth Token, NOT the API Key Secret.
12
+ * When an API Key is used for REST auth, set TWILIO_AUTH_TOKEN separately.
13
+ */
14
+ import { createHmac, timingSafeEqual } from "crypto";
15
+
16
+ export function validateTwilioSignature(opts: {
17
+ authToken: string;
18
+ fullUrl: string;
19
+ params: Record<string, string>;
20
+ signature: string;
21
+ }): boolean {
22
+ const { authToken, fullUrl, params, signature } = opts;
23
+ if (!signature) return false;
24
+
25
+ const sortedKeys = Object.keys(params).sort();
26
+ let data = fullUrl;
27
+ for (const key of sortedKeys) {
28
+ data += key + params[key];
29
+ }
30
+
31
+ const computed = createHmac("sha1", authToken).update(data, "utf8").digest("base64");
32
+ const a = Buffer.from(computed);
33
+ const b = Buffer.from(signature);
34
+ if (a.length !== b.length) return false;
35
+ return timingSafeEqual(a, b);
36
+ }