diff --git a/.env.example b/.env.example index 36d52fc..3e6390f 100644 --- a/.env.example +++ b/.env.example @@ -29,16 +29,32 @@ WHATSAPP_API_URL=http://127.0.0.1:8080/api # Twilio webhook transport (only needed when using --transport twilio) # --------------------------------------------------------------------------- -# Twilio Auth Token — used to validate X-Twilio-Signature on each request. -# If unset, signature validation is skipped (unsafe; only for local testing). +# Twilio credentials for the TypeScript wa-channel server. +# TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # TWILIO_AUTH_TOKEN=your_twilio_auth_token_here +# TWILIO_PHONE_NUMBER=whatsapp:+14155238886 -# Public URL Twilio will POST to, used for signature validation. -# PUBLIC_WEBHOOK_URL=https://your-domain.example.com/webhook +# Public URL Twilio will POST to, used for X-Twilio-Signature validation. +# This must exactly match the public webhook URL configured in Twilio. +# WEBHOOK_URL=https://your-domain.example.com/webhook -# Port and host for the FastAPI webhook server. -# WEBHOOK_PORT=8000 +# Dev only: bypasses Twilio signature validation. Unsafe in production. +# SKIP_TWILIO_VALIDATION=true + +# Attachment safety limits for inbound media downloads. +# WA_CHANNEL_MAX_ATTACHMENTS=4 +# WA_CHANNEL_MAX_MEDIA_BYTES=10485760 +# WA_CHANNEL_ALLOWED_MEDIA_TYPES=image/*,audio/*,video/*,application/pdf,text/plain + +# Port for the TypeScript/Bun wa-channel webhook server. +# WEBHOOK_PORT=3000 + +# --------------------------------------------------------------------------- +# Legacy FastAPI webhook transport (Python webhook/server.py only) +# --------------------------------------------------------------------------- + +# Host for the legacy FastAPI webhook server. # WEBHOOK_HOST=0.0.0.0 -# SQLite DB for webhook inbound/outbound messages. +# SQLite DB for legacy FastAPI webhook inbound/outbound messages. # WEBHOOK_DB_PATH=/home/deet/whatsapp-claude/webhook_messages.db diff --git a/.gitignore b/.gitignore index 2e0eb67..30286bb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ poller/__pycache__/ .pytest_cache/ .ruff_cache/ .mypy_cache/ + +node_modules/ diff --git a/README.md b/README.md index bf1c756..2d76328 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,24 @@ systemctl --user status whatsapp-bridge whatsapp-mcp-server whatsapp-poller journalctl --user -u whatsapp-bridge -f ``` +## Twilio wa-channel notes + +The TypeScript `wa-channel` transport uses Twilio WhatsApp webhooks plus an allowlist in `~/.claude/channels/wa-channel/access.json`. + +Required Twilio env vars: +- `TWILIO_ACCOUNT_SID` +- `TWILIO_AUTH_TOKEN` +- `TWILIO_PHONE_NUMBER` (for example, `whatsapp:+14155238886`) +- `WEBHOOK_URL` — must exactly match the public webhook URL configured in Twilio; it is used for `X-Twilio-Signature` validation. + +Security notes: +- `SKIP_TWILIO_VALIDATION=true` bypasses Twilio signature validation. Use it only for local development, never production. +- Outbound replies are sent only to allowlisted WhatsApp numbers. +- Unknown senders do **not** receive pairing codes. The webhook stores a pending request in `~/.claude/channels/wa-channel/access.json` under `pending`, logs `wa-channel access request: from=... code=...` to the wa-channel process/container logs, and sends the sender only: `Access request sent; an owner must approve you.` +- Owners approve requests from a Claude Code session with the `approve_access_request` MCP tool using the code from the logs. `list_access_requests` shows pending numbers and timestamps. Approval adds the normalized number to `allowFrom` and removes it from `pending`. +- Inbound `PAIR ` messages from unknown senders are ignored for authorization and receive the same owner-approval message. +- Attachment handling is bounded by `WA_CHANNEL_MAX_ATTACHMENTS` (default `4`), `WA_CHANNEL_MAX_MEDIA_BYTES` (default `10485760`), and `WA_CHANNEL_ALLOWED_MEDIA_TYPES` (default `image/*,audio/*,video/*,application/pdf,text/plain`). + ## Repo layout - `install.sh` — Linux server bootstrap diff --git a/allowlist.test.ts b/allowlist.test.ts new file mode 100644 index 0000000..b98452b --- /dev/null +++ b/allowlist.test.ts @@ -0,0 +1,58 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +let tempHome: string; +let allowlist: any; +let originalChannelHome: string | undefined; + +beforeAll(async () => { + originalChannelHome = process.env.WA_CHANNEL_HOME; + tempHome = await mkdtemp(join(tmpdir(), "wa-channel-access-")); + process.env.WA_CHANNEL_HOME = join(tempHome, "wa-channel"); + allowlist = await import(`./allowlist.ts?test=${Date.now()}`); +}); + +afterAll(async () => { + if (originalChannelHome === undefined) delete process.env.WA_CHANNEL_HOME; + else process.env.WA_CHANNEL_HOME = originalChannelHome; + await rm(tempHome, { recursive: true, force: true }); +}); + +test("access requests stay pending until owner approval by code", async () => { + const requested = await allowlist.issueAccessRequest("+15551234567"); + + expect(requested.from).toBe("whatsapp:+15551234567"); + expect(requested.code).toMatch(/^[A-F0-9]{8}$/); + expect(await allowlist.isAllowlisted("+15551234567")).toBe(false); + expect(await allowlist.listAccessRequests()).toEqual([ + { from: "whatsapp:+15551234567", createdAt: requested.createdAt }, + ]); + + expect(await allowlist.approveAccessRequest("WRONG-CODE")).toEqual({ approved: false }); + expect(await allowlist.isAllowlisted("+15551234567")).toBe(false); + + expect(await allowlist.approveAccessRequest(requested.code)).toEqual({ + approved: true, + from: "whatsapp:+15551234567", + }); + expect(await allowlist.isAllowlisted("+15551234567")).toBe(true); + expect(await allowlist.listAccessRequests()).toEqual([]); +}); + +test("repeat access requests do not rotate the owner-visible code", async () => { + const first = await allowlist.issueAccessRequest("+15557654321"); + const second = await allowlist.issueAccessRequest("whatsapp:+15557654321"); + + expect(first.alreadyPending).toBe(false); + expect(second).toEqual({ + from: "whatsapp:+15557654321", + code: "", + createdAt: first.createdAt, + alreadyPending: true, + }); + expect(await allowlist.listAccessRequests()).toEqual([ + { from: "whatsapp:+15557654321", createdAt: first.createdAt }, + ]); +}); diff --git a/allowlist.ts b/allowlist.ts new file mode 100644 index 0000000..3fc73f2 --- /dev/null +++ b/allowlist.ts @@ -0,0 +1,156 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { randomBytes, timingSafeEqual } from "node:crypto"; + +export type AccessRequest = { + from: string; + codeHash: string; + createdAt: string; +}; + +export type AccessConfig = { + allowFrom: string[]; + dmPolicy: "allowlisted"; + pending: AccessRequest[]; +}; + +const LEGACY_PENDING_KEY = "pairings"; + +type RawAccessConfig = Partial> & { + pending?: unknown; + pairings?: unknown; +}; + +export const CHANNEL_HOME = process.env.WA_CHANNEL_HOME ?? join(homedir(), ".claude", "channels", "wa-channel"); +export const ACCESS_PATH = join(CHANNEL_HOME, "access.json"); +const LEGACY_PENDING_PATH = join(CHANNEL_HOME, "pairing.json"); + +const DEFAULT_ACCESS: AccessConfig = { allowFrom: [], dmPolicy: "allowlisted", pending: [] }; + +async function ensureHome(): Promise { + await mkdir(CHANNEL_HOME, { recursive: true }); +} + +export function normalizeWhatsAppAddress(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith("whatsapp:")) return trimmed; + if (trimmed.startsWith("+")) return `whatsapp:${trimmed}`; + return trimmed; +} + +function normalizePending(value: unknown): AccessRequest[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => { + if (!entry || typeof entry !== "object") return undefined; + const candidate = entry as Partial; + if (!candidate.from || !candidate.codeHash || !candidate.createdAt) return undefined; + return { + from: normalizeWhatsAppAddress(String(candidate.from)), + codeHash: String(candidate.codeHash), + createdAt: String(candidate.createdAt), + }; + }) + .filter((entry): entry is AccessRequest => Boolean(entry)); +} + +async function readLegacyPending(): Promise { + try { + const parsed = JSON.parse(await readFile(LEGACY_PENDING_PATH, "utf8")) as { pairings?: unknown }; + return normalizePending(parsed.pairings); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return []; + } +} + +export async function readAccessConfig(): Promise { + await ensureHome(); + try { + const parsed = JSON.parse(await readFile(ACCESS_PATH, "utf8")) as RawAccessConfig; + const rawPending = parsed.pending ?? parsed[LEGACY_PENDING_KEY]; + const access: AccessConfig = { + allowFrom: Array.isArray(parsed.allowFrom) + ? parsed.allowFrom.map((n) => normalizeWhatsAppAddress(String(n))) + : [], + dmPolicy: "allowlisted", + pending: normalizePending(rawPending), + }; + if (rawPending === undefined) { + access.pending = await readLegacyPending(); + } + return access; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + await writeAccessConfig(DEFAULT_ACCESS); + return { ...DEFAULT_ACCESS, allowFrom: [], pending: [] }; + } +} + +export async function writeAccessConfig(config: AccessConfig): Promise { + await mkdir(dirname(ACCESS_PATH), { recursive: true }); + const clean: AccessConfig = { + allowFrom: Array.from(new Set(config.allowFrom.map(normalizeWhatsAppAddress))).sort(), + dmPolicy: "allowlisted", + pending: normalizePending(config.pending).sort((a, b) => a.createdAt.localeCompare(b.createdAt)), + }; + await writeFile(ACCESS_PATH, `${JSON.stringify(clean, null, 2)}\n`, "utf8"); +} + +export async function isAllowlisted(from: string): Promise { + const access = await readAccessConfig(); + return access.allowFrom.includes(normalizeWhatsAppAddress(from)); +} + +function hashCode(code: string): string { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(code); + return hasher.digest("hex"); +} + +function codeEquals(code: string, hash: string): boolean { + const left = Buffer.from(hashCode(code), "hex"); + const right = Buffer.from(hash, "hex"); + return left.length === right.length && timingSafeEqual(left, right); +} + +export async function issueAccessRequest(from: string): Promise<{ from: string; code: string; createdAt: string; alreadyPending: boolean }> { + const normalized = normalizeWhatsAppAddress(from); + const access = await readAccessConfig(); + const existing = access.pending.find((p) => p.from === normalized); + if (existing) { + return { from: normalized, code: "", createdAt: existing.createdAt, alreadyPending: true }; + } + + const code = randomBytes(4).toString("hex").toUpperCase(); + const createdAt = new Date().toISOString(); + access.pending.push({ from: normalized, codeHash: hashCode(code), createdAt }); + await writeAccessConfig(access); + console.error(`wa-channel access request: from=${normalized} code=${code} createdAt=${createdAt}`); + return { from: normalized, code, createdAt, alreadyPending: false }; +} + +export async function hasPendingAccessRequest(from: string): Promise { + const normalized = normalizeWhatsAppAddress(from); + const access = await readAccessConfig(); + return access.pending.some((p) => p.from === normalized); +} + +export async function listAccessRequests(): Promise> { + const access = await readAccessConfig(); + return access.pending.map(({ from, createdAt }) => ({ from, createdAt })); +} + +export async function approveAccessRequest(code: string): Promise<{ approved: boolean; from?: string }> { + const access = await readAccessConfig(); + const match = access.pending.find((p) => codeEquals(code, p.codeHash)); + if (!match) return { approved: false }; + + if (!access.allowFrom.includes(match.from)) { + access.allowFrom.push(match.from); + } + access.pending = access.pending.filter((p) => p !== match); + await writeAccessConfig(access); + return { approved: true, from: match.from }; +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..85e2845 --- /dev/null +++ b/bun.lock @@ -0,0 +1,273 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "wa-channel", + "dependencies": { + "@modelcontextprotocol/sdk": "latest", + "twilio": "latest", + "zod": "latest", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "latest", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "twilio": ["twilio@6.0.0", "", { "dependencies": { "axios": "^1.13.5", "dayjs": "^1.11.9", "https-proxy-agent": "^5.0.0", "jsonwebtoken": "^9.0.3", "qs": "^6.14.1", "scmp": "^2.1.0", "xmlbuilder": "^13.0.2" } }, "sha512-MAie5DJ3KLpcKlDaYtNzsKMQXcCi+YHWKvZjuSpm27vJAO/l8PanJA0LkkJ03sbh+Kwe5NeL0Q2+y6IjNUYeUA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + } +} diff --git a/chunker.ts b/chunker.ts new file mode 100644 index 0000000..73c0773 --- /dev/null +++ b/chunker.ts @@ -0,0 +1,30 @@ +export const DEFAULT_CHUNK_LIMIT = 1600; + +export function chunkMessage(text: string, limit = DEFAULT_CHUNK_LIMIT): string[] { + if (!Number.isFinite(limit) || limit < 1) { + throw new Error("chunk limit must be a positive number"); + } + + const normalized = String(text ?? ""); + if (normalized.length <= limit) return [normalized]; + + const chunks: string[] = []; + let remaining = normalized.trim(); + + while (remaining.length > limit) { + let splitAt = remaining.lastIndexOf("\n", limit); + if (splitAt < Math.floor(limit * 0.6)) { + splitAt = remaining.lastIndexOf(" ", limit); + } + if (splitAt < Math.floor(limit * 0.6)) { + splitAt = limit; + } + + const chunk = remaining.slice(0, splitAt).trimEnd(); + if (chunk.length > 0) chunks.push(chunk); + remaining = remaining.slice(splitAt).trimStart(); + } + + if (remaining.length > 0) chunks.push(remaining); + return chunks.length > 0 ? chunks : [""]; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5561f98 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{"name":"wa-channel","version":"0.1.0","description":"Claude Code channel plugin for WhatsApp via Twilio","type":"module","private":true,"scripts":{"start":"bun run server.ts","typecheck":"tsc --noEmit"},"dependencies":{"@modelcontextprotocol/sdk":"latest","twilio":"latest","zod":"latest"},"devDependencies":{"@types/bun":"latest","typescript":"latest"},"engines":{"bun":">=1.1.0"}} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..590d5cd --- /dev/null +++ b/plugin.json @@ -0,0 +1 @@ +{"name":"wa-channel","version":"0.1.0","description":"Claude Code channel plugin for WhatsApp via Twilio","runtime":"bun","entrypoint":"server.ts","capabilities":{"experimental":{"claude/channel":{}},"tools":{}},"env":["TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_PHONE_NUMBER","WEBHOOK_PORT","WEBHOOK_URL","SKIP_TWILIO_VALIDATION"]} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..3cae215 --- /dev/null +++ b/server.ts @@ -0,0 +1,198 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { approveAccessRequest, isAllowlisted, listAccessRequests, normalizeWhatsAppAddress } from "./allowlist"; +import { downloadStoredAttachment, startWebhookServer, type InboundMessage } from "./webhook"; +import { getTwilioConfigFromEnv, TwilioWhatsAppClient } from "./twilio-client"; + +const ReplyArgs = z.object({ + chat_id: z.string().min(1), + text: z.string(), +}); + +const DownloadAttachmentArgs = z.object({ + chat_id: z.string().min(1), + message_id: z.string().min(1), +}); + +const ApproveAccessRequestArgs = z.object({ + code: z.string().min(4).max(64), +}); + +const twilioConfig = getTwilioConfigFromEnv(); +const twilioClient = new TwilioWhatsAppClient(twilioConfig); + +const server = new Server( + { + name: "wa-channel", + version: "0.1.0", + }, + { + capabilities: { + experimental: { + "claude/channel": {}, + }, + tools: {}, + }, + }, +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "reply", + description: "Send a WhatsApp reply via Twilio.", + inputSchema: { + type: "object", + properties: { + chat_id: { type: "string", description: "Twilio WhatsApp address, e.g. whatsapp:+521XXXXXXXXXX" }, + text: { type: "string", description: "Reply text. Long texts are split into 1600-character chunks." }, + }, + required: ["chat_id", "text"], + }, + }, + { + name: "download_attachment", + description: "Download media from a previously received Twilio WhatsApp message into ~/.claude/channels/wa-channel/inbox/.", + inputSchema: { + type: "object", + properties: { + chat_id: { type: "string", description: "Twilio WhatsApp address for the conversation." }, + message_id: { type: "string", description: "Twilio MessageSid from the inbound channel notification." }, + }, + required: ["chat_id", "message_id"], + }, + }, + { + name: "list_access_requests", + description: "List pending WhatsApp access requests awaiting owner approval.", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "approve_access_request", + description: "Approve a pending WhatsApp access request by the code shown in wa-channel logs.", + inputSchema: { + type: "object", + properties: { + code: { type: "string", description: "Access request code from the wa-channel container logs." }, + }, + required: ["code"], + }, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: rawArgs } = request.params; + + if (name === "reply") { + const args = ReplyArgs.parse(rawArgs ?? {}); + const chatId = normalizeWhatsAppAddress(args.chat_id); + if (!(await isAllowlisted(chatId))) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ ok: false, error: "chat_id is not allowlisted" }, null, 2), + }, + ], + }; + } + + const results = await twilioClient.sendMessage(chatId, args.text); + return { + content: [ + { + type: "text", + text: JSON.stringify({ ok: true, sent: results }, null, 2), + }, + ], + }; + } + + if (name === "download_attachment") { + const args = DownloadAttachmentArgs.parse(rawArgs ?? {}); + const paths = await downloadStoredAttachment(twilioClient, args.chat_id, args.message_id); + return { + content: [ + { + type: "text", + text: JSON.stringify({ ok: true, paths }, null, 2), + }, + ], + }; + } + + if (name === "list_access_requests") { + const pending = await listAccessRequests(); + return { + content: [ + { + type: "text", + text: JSON.stringify({ ok: true, pending }, null, 2), + }, + ], + }; + } + + if (name === "approve_access_request") { + const args = ApproveAccessRequestArgs.parse(rawArgs ?? {}); + const result = await approveAccessRequest(args.code); + return { + content: [ + { + type: "text", + text: JSON.stringify(result.approved ? { ok: true, from: result.from } : { ok: false, error: "access request code not found" }, null, 2), + }, + ], + }; + } + + throw new Error(`Unknown tool: ${name}`); +}); + +async function emitChannelNotification(message: InboundMessage): Promise { + const attachmentNote = message.attachments.length + ? `\n\n[${message.attachments.length} attachment(s) available. Use download_attachment with message_id=${message.message_id}.]` + : ""; + + await server.notification({ + method: "notifications/claude/channel", + params: { + source: "wa-channel", + content: `${message.text}${attachmentNote}`, + meta: { + chat_id: message.chat_id, + message_id: message.message_id, + user: message.user, + ts: message.ts, + }, + }, + }); +} + +const webhookServer = startWebhookServer({ + port: Number.parseInt(process.env.WEBHOOK_PORT ?? "3000", 10), + webhookUrl: process.env.WEBHOOK_URL, + twilioAuthToken: twilioConfig.authToken, + twilioClient, + onMessage: emitChannelNotification, +}); + +console.error(`wa-channel webhook listening on port ${webhookServer.port}`); + +function shutdown(): void { + webhookServer.stop(true); + process.exit(0); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); +process.stdin.on("close", shutdown); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c8a3747 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","strict":true,"esModuleInterop":true,"skipLibCheck":true,"forceConsistentCasingInFileNames":true,"types":["bun"]},"include":["*.ts"]} diff --git a/twilio-client.test.ts b/twilio-client.test.ts new file mode 100644 index 0000000..5707e3a --- /dev/null +++ b/twilio-client.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { TwilioWhatsAppClient } from "./twilio-client"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function client(): TwilioWhatsAppClient { + return new TwilioWhatsAppClient({ + accountSid: "AC00000000000000000000000000000000", + authToken: "token", + from: "whatsapp:+15551234567", + }); +} + +function mockFetchResponse(response: Response): void { + globalThis.fetch = (() => Promise.resolve(response)) as unknown as typeof fetch; +} + +describe("TwilioWhatsAppClient.downloadMedia", () => { + test("streams media without Content-Length and returns bytes within the limit", async () => { + mockFetchResponse( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])); + controller.enqueue(new Uint8Array([3])); + controller.close(); + }, + }), + { headers: { "content-type": "image/png" } }, + ), + ); + + const media = await client().downloadMedia("https://api.twilio.test/media", { + maxBytes: 3, + allowedContentTypes: ["image/*"], + }); + + expect([...media.bytes]).toEqual([1, 2, 3]); + expect(media.contentType).toBe("image/png"); + }); + + test("rejects and cancels the stream as soon as media without Content-Length exceeds the limit", async () => { + let canceled = false; + let pulls = 0; + const chunks = Array.from({ length: 100 }, (_, index) => new Uint8Array([index, index, index])); + const totalChunks = chunks.length; + + mockFetchResponse( + new Response( + new ReadableStream({ + pull(controller) { + pulls += 1; + const chunk = chunks.shift(); + if (chunk) controller.enqueue(chunk); + else controller.close(); + }, + cancel() { + canceled = true; + }, + }), + { headers: { "content-type": "image/png" } }, + ), + ); + + await expect( + client().downloadMedia("https://api.twilio.test/media", { + maxBytes: 5, + allowedContentTypes: ["image/*"], + }), + ).rejects.toThrow("Twilio media exceeds limit of 5 bytes during download"); + + expect(canceled).toBe(true); + expect(pulls).toBeLessThan(totalChunks); + }); +}); diff --git a/twilio-client.ts b/twilio-client.ts new file mode 100644 index 0000000..2c268f7 --- /dev/null +++ b/twilio-client.ts @@ -0,0 +1,153 @@ +import twilio from "twilio"; +import { chunkMessage } from "./chunker"; + +export const DEFAULT_MAX_MEDIA_BYTES = 10 * 1024 * 1024; + +export type DownloadMediaOptions = { + maxBytes?: number; + allowedContentTypes?: readonly string[]; +}; + +export type DownloadedMedia = { + bytes: Uint8Array; + contentType: string; +}; + +function mediaLimitFromEnv(): number { + const raw = process.env.WA_CHANNEL_MAX_MEDIA_BYTES; + if (!raw) return DEFAULT_MAX_MEDIA_BYTES; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error("WA_CHANNEL_MAX_MEDIA_BYTES must be a positive integer"); + } + return parsed; +} + +function isAllowedContentType(contentType: string, allowed?: readonly string[]): boolean { + if (!allowed || allowed.length === 0) return true; + const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? ""; + return allowed.some((entry) => { + const rule = entry.toLowerCase(); + if (rule.endsWith("/*")) return normalized.startsWith(rule.slice(0, -1)); + return normalized === rule; + }); +} + +export type TwilioConfig = { + accountSid: string; + authToken: string; + from: string; +}; + +export type SendMessageResult = { + sid: string; + status: string; +}; + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required env var: ${name}`); + return value; +} + +export function getTwilioConfigFromEnv(): TwilioConfig { + return { + accountSid: requiredEnv("TWILIO_ACCOUNT_SID"), + authToken: requiredEnv("TWILIO_AUTH_TOKEN"), + from: requiredEnv("TWILIO_PHONE_NUMBER"), + }; +} + +export class TwilioWhatsAppClient { + private readonly client: ReturnType; + private readonly from: string; + + constructor(config = getTwilioConfigFromEnv()) { + this.client = twilio(config.accountSid, config.authToken); + this.from = config.from; + } + + async sendMessage(to: string, text: string): Promise { + const chunks = chunkMessage(text); + const results: SendMessageResult[] = []; + + for (const body of chunks) { + const message = await this.client.messages.create({ + from: this.from, + to, + body, + }); + results.push({ sid: message.sid, status: message.status }); + } + + return results; + } + + async downloadMedia(mediaUrl: string, options: DownloadMediaOptions = {}): Promise { + const maxBytes = options.maxBytes ?? mediaLimitFromEnv(); + const abortController = new AbortController(); + const response = await fetch(mediaUrl, { + signal: abortController.signal, + headers: { + Authorization: `Basic ${Buffer.from( + `${process.env.TWILIO_ACCOUNT_SID ?? ""}:${process.env.TWILIO_AUTH_TOKEN ?? ""}`, + ).toString("base64")}`, + }, + }); + + if (!response.ok) { + throw new Error(`Twilio media download failed: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get("content-type") ?? "application/octet-stream"; + if (!isAllowedContentType(contentType, options.allowedContentTypes)) { + abortController.abort(); + await response.body?.cancel().catch(() => undefined); + throw new Error(`Twilio media content type is not allowed: ${contentType}`); + } + + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const parsedLength = Number.parseInt(contentLength, 10); + if (Number.isFinite(parsedLength) && parsedLength > maxBytes) { + abortController.abort(); + await response.body?.cancel().catch(() => undefined); + throw new Error(`Twilio media exceeds limit of ${maxBytes} bytes`); + } + } + + const reader = response.body?.getReader(); + if (!reader) { + return { bytes: new Uint8Array(), contentType }; + } + + const chunks: Uint8Array[] = []; + let received = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + received += value.byteLength; + if (received > maxBytes) { + abortController.abort(); + await reader.cancel().catch(() => undefined); + throw new Error(`Twilio media exceeds limit of ${maxBytes} bytes during download`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + return { bytes, contentType }; + } +} diff --git a/webhook.ts b/webhook.ts new file mode 100644 index 0000000..845f044 --- /dev/null +++ b/webhook.ts @@ -0,0 +1,244 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { validateRequest } from "twilio"; +import { + CHANNEL_HOME, + issueAccessRequest, + isAllowlisted, + normalizeWhatsAppAddress, +} from "./allowlist"; +import { DEFAULT_MAX_MEDIA_BYTES, TwilioWhatsAppClient } from "./twilio-client"; + +const DEFAULT_MAX_ATTACHMENTS = 4; +const DEFAULT_ALLOWED_ATTACHMENT_TYPES = ["image/*", "audio/*", "video/*", "application/pdf", "text/plain"] as const; + +function positiveIntFromEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +function attachmentLimit(): number { + return positiveIntFromEnv("WA_CHANNEL_MAX_ATTACHMENTS", DEFAULT_MAX_ATTACHMENTS); +} + +function mediaByteLimit(): number { + return positiveIntFromEnv("WA_CHANNEL_MAX_MEDIA_BYTES", DEFAULT_MAX_MEDIA_BYTES); +} + +function allowedAttachmentTypes(): string[] { + const raw = process.env.WA_CHANNEL_ALLOWED_MEDIA_TYPES; + if (!raw) return [...DEFAULT_ALLOWED_ATTACHMENT_TYPES]; + return raw + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); +} + +function isAllowedContentType(contentType: string, allowed: readonly string[]): boolean { + const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? ""; + return allowed.some((entry) => { + if (entry.endsWith("/*")) return normalized.startsWith(entry.slice(0, -1)); + return normalized === entry; + }); +} + +export type InboundAttachment = { + index: number; + url: string; + contentType: string; +}; + +export type InboundMessage = { + chat_id: string; + message_id: string; + user?: string; + text: string; + ts: string; + attachments: InboundAttachment[]; +}; + +export type StoredAttachmentMessage = InboundMessage & { + attachments: InboundAttachment[]; +}; + +export type WebhookOptions = { + port?: number; + webhookUrl?: string; + twilioAuthToken: string; + twilioClient: TwilioWhatsAppClient; + onMessage: (message: InboundMessage) => Promise | void; +}; + +export type WebhookServer = ReturnType; + +const attachmentIndex = new Map(); + +function publicWebhookUrl(request: Request, configured?: string): string { + if (!configured) return request.url; + const configuredUrl = new URL(configured); + if (configuredUrl.pathname === "/" || configuredUrl.pathname === "") { + const requestUrl = new URL(request.url); + configuredUrl.pathname = requestUrl.pathname; + configuredUrl.search = requestUrl.search; + } + return configuredUrl.toString(); +} + +async function parseForm(request: Request): Promise> { + const form = await request.formData(); + const params: Record = {}; + for (const [key, value] of form.entries()) { + params[key] = String(value); + } + return params; +} + +function validateTwilioSignature(request: Request, url: string, params: Record, token: string): boolean { + if (process.env.SKIP_TWILIO_VALIDATION === "true") return true; + const signature = request.headers.get("x-twilio-signature") ?? ""; + if (!signature) return false; + return validateRequest(token, signature, url, params); +} + +function attachmentsFromParams(params: Record): InboundAttachment[] { + const count = Number.parseInt(params.NumMedia ?? "0", 10); + if (!Number.isFinite(count) || count <= 0) return []; + + const maxAttachments = attachmentLimit(); + if (count > maxAttachments) { + throw new Error(`Too many attachments: ${count} exceeds limit of ${maxAttachments}`); + } + + const allowedTypes = allowedAttachmentTypes(); + const attachments: InboundAttachment[] = []; + for (let index = 0; index < count; index += 1) { + const url = params[`MediaUrl${index}`]; + if (!url) continue; + const contentType = params[`MediaContentType${index}`] ?? "application/octet-stream"; + if (!isAllowedContentType(contentType, allowedTypes)) { + throw new Error(`Attachment ${index} content type is not allowed: ${contentType}`); + } + attachments.push({ + index, + url, + contentType, + }); + } + return attachments; +} + +function extensionFor(contentType: string): string { + const subtype = contentType.split("/")[1]?.split(";")[0]?.trim(); + if (!subtype) return ".bin"; + if (subtype === "jpeg") return ".jpg"; + if (/^[a-z0-9.+-]+$/i.test(subtype)) return `.${subtype.replace("+", ".")}`; + return ".bin"; +} + +export function getStoredAttachment(chatId: string, messageId: string): StoredAttachmentMessage | undefined { + return attachmentIndex.get(`${normalizeWhatsAppAddress(chatId)}:${messageId}`); +} + +export async function downloadStoredAttachment( + twilioClient: TwilioWhatsAppClient, + chatId: string, + messageId: string, +): Promise { + const stored = getStoredAttachment(chatId, messageId); + if (!stored || stored.attachments.length === 0) { + throw new Error(`No attachment found for ${chatId}/${messageId}. Attachment lookup is in-memory for this process.`); + } + + const inboxDir = join(CHANNEL_HOME, "inbox"); + await mkdir(inboxDir, { recursive: true }); + + const allowedTypes = allowedAttachmentTypes(); + const saved: string[] = []; + for (const attachment of stored.attachments) { + const media = await twilioClient.downloadMedia(attachment.url, { + maxBytes: mediaByteLimit(), + allowedContentTypes: allowedTypes, + }); + const extFromUrl = extname(new URL(attachment.url).pathname); + const ext = extFromUrl || extensionFor(media.contentType || attachment.contentType); + const path = join(inboxDir, `${messageId}-${attachment.index}${ext}`); + await writeFile(path, media.bytes); + saved.push(path); + } + return saved; +} + +function twiml(text: string): Response { + const escaped = text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + return new Response(`${escaped}`, { + headers: { "content-type": "text/xml; charset=utf-8" }, + }); +} + +export function startWebhookServer(options: WebhookOptions): WebhookServer { + const port = options.port ?? Number.parseInt(process.env.WEBHOOK_PORT ?? "3000", 10); + + return Bun.serve({ + port, + async fetch(request) { + const path = new URL(request.url).pathname; + if (request.method === "GET" && path === "/health") { + return Response.json({ ok: true }); + } + if (request.method !== "POST") { + return new Response("not found", { status: 404 }); + } + + const params = await parseForm(request); + const validationUrl = publicWebhookUrl(request, options.webhookUrl ?? process.env.WEBHOOK_URL); + if (!validateTwilioSignature(request, validationUrl, params, options.twilioAuthToken)) { + console.error("Dropped inbound WhatsApp webhook with invalid Twilio signature"); + return new Response("forbidden", { status: 403 }); + } + + const from = normalizeWhatsAppAddress(params.From ?? ""); + if (!from) return new Response("missing From", { status: 400 }); + + if (!(await isAllowlisted(from))) { + await issueAccessRequest(from); + const message = "Access request sent; an owner must approve you."; + try { + await options.twilioClient.sendMessage(from, message); + } catch (error) { + console.error("Failed to send WhatsApp access request acknowledgement", error); + return twiml(message); + } + return new Response("access request pending", { status: 202 }); + } + + let attachments: InboundAttachment[]; + try { + attachments = attachmentsFromParams(params); + } catch (error) { + console.error("Dropped inbound WhatsApp webhook with rejected attachment metadata", error); + return twiml("Attachment rejected because it exceeds this channel's safety limits."); + } + + const inbound: InboundMessage = { + chat_id: from, + message_id: params.MessageSid || params.SmsMessageSid || crypto.randomUUID(), + user: params.ProfileName || params.WaId || from, + text: params.Body ?? "", + ts: new Date().toISOString(), + attachments, + }; + + if (inbound.attachments.length > 0) { + attachmentIndex.set(`${inbound.chat_id}:${inbound.message_id}`, inbound); + } + + await options.onMessage(inbound); + return new Response("ok", { status: 200 }); + }, + }); +}