From f08c153cf6f065988baa752ed34b0bb2c4d5932b Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 15 Sep 2026 14:35:28 -0700 Subject: [PATCH 1/2] feat: share Router search and verified phone protocols --- CHANGELOG.md | 20 +++++ docs/host-search-and-phone.md | 107 +++++++++++++++++++++++ package.json | 10 +++ src/http/response-json.ts | 71 ++++++++++++++++ src/tangle-search/index.ts | 113 +++++++++++++++++++++++++ src/twilio/index.ts | 134 +++++++++++++++++++++++++++++ src/webhooks/index.ts | 1 + src/webhooks/twilio.ts | 36 ++++++++ tests/host-primitives.test.ts | 154 ++++++++++++++++++++++++++++++++++ tests/twilio-webhook.test.ts | 50 +++++++++++ tsup.config.ts | 2 + 11 files changed, 698 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 docs/host-search-and-phone.md create mode 100644 src/http/response-json.ts create mode 100644 src/tangle-search/index.ts create mode 100644 src/twilio/index.ts create mode 100644 src/webhooks/twilio.ts create mode 100644 tests/host-primitives.test.ts create mode 100644 tests/twilio-webhook.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6f6b0a58 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +### Added +- `@tangle-network/agent-integrations/tangle-search`: typed Router search client and + request/response helpers. Provider-neutral, explicit host credentials, request + correlation, unknown-cost preservation, cancellation and bounded JSON reads. +- `@tangle-network/agent-integrations/twilio`: managed phone verification, correlated + SMS receipts, native form-webhook authentication and phone normalization. +- `createTwilioWebhookProvider` in the existing `/webhooks` entrypoint. Uses the + existing replay/delivery router; delivery statuses have distinct event identities. + +No new agent loop, evaluator, enrollment database, session store, provider registry, +or dependency was introduced. These are additive APIs; existing connectors and +exports are unchanged. Form signatures do not themselves prevent replay and no +client automatically retries an uncertain message or a billable search. + +Migration and validation: [host-search-and-phone](docs/host-search-and-phone.md). +Release versions remain owned by the existing release workflow. diff --git a/docs/host-search-and-phone.md b/docs/host-search-and-phone.md new file mode 100644 index 00000000..da8a37ee --- /dev/null +++ b/docs/host-search-and-phone.md @@ -0,0 +1,107 @@ +# Router search and verified phone transports + +These primitives were extracted from SUPER, but have no SUPER database, project, +pricing policy, sender number, UI, or workflow dependency. Existing Hub invocation, +connector, idempotency and webhook infrastructure is retained. + +## Search: one Router, not another provider registry + +```ts +import { TangleSearchClient } from '@tangle-network/agent-integrations/tangle-search' +const search = new TangleSearchClient({ apiKey: () => secretStore.routerKey(), provider: 'you' }) +const result = await search.search({ query: '300 mm linear guide', maxResults: 10 }, signal) +``` + +`apiKey`, `baseUrl`, and the optional provider pin are host choices. No environment +variable is read automatically and no model key is repurposed. The default host is +`https://router.tangle.tools`; an explicit HTTPS origin or HTTP loopback base may +be supplied for another Router deployment. Responses cannot redirect credentials. + +The Router owns upstream API keys, provider availability, billing and fallback. +The client sends exactly one POST to `/v1/search`; it does not retry a potentially +billable request. The response must match the query and any explicit provider. +The returned `id`, provider and reported costs support provenance; missing or +malformed costs stay null. No thumbnails, pagination, inventory or fair value are +invented. Provider IDs are open strings, not a second hardcoded provider registry. +The current Router protocol has no offset. Invalid options fail before dispatch. + +For an application that already has an audited bounded JSON transport, +`buildTangleSearchRequest` and `parseTangleSearchResult` expose the same protocol +without another network client. Input uses `query`, `provider`, `maxResults`, +`searchRecency`, `includeDomains`, and `excludeDomains`. Provider support for filters +still depends on the Router. `maxResults` is 1–25, matching the inspected API. + +Protocol reference: `tangle-network/tangle-router` commit +`8999a6a9a01d6c2872010e265327c207a73adbe9`, `app/v1/search/route.ts` and `lib/web-search.ts`. +Requalify against the actual deployment; source compatibility is not live access. + +## Phone verification and SMS + +```ts +import { TwilioPhoneClient, authenticateTwilioForm } from '@tangle-network/agent-integrations/twilio' +const phone = new TwilioPhoneClient({ accountSid, authToken, verifyServiceSid }) +const verification = await phone.startVerification('+13105551234', signal) +const approved = await phone.checkVerification(verification.id, '+13105551234', suppliedCode, signal) +const receipt = await phone.sendMessage({ + to: '+13105551234', from: serviceNumber, body: reply, + statusCallback: 'https://app.example/sms/status/opaque-delivery-id', +}, signal) +``` + +This is host-side infrastructure, **not an agent verification tool**. Twilio owns +code generation and checking. The client verifies account, service, verification +SID and phone before returning approval. The application must bind that result to +its original challenge, consent, invitation and session; a model-supplied claim +of approval is never enough. OTPs, credentials and returned provider error bodies +are not logged by this library. + +SMS uses form encoding, correlation to account/from/to/SID, an optional exact status +callback, bounded response reads and no automatic retry. `queued`/`accepted`/`sent` +are not delivery. An unknown send result stays unknown; a caller must reconcile +provider history before retrying. `inspect(number)` is a read-only account/service +check, not proof that OTP or conversational messaging is deliverable. Registration, +consent, fraud/rate limits, legal policies and cost caps remain deployment work. +A messaging-only consumer can omit `verifyServiceSid`; verification calls then fail before dispatch. +The client does not shorten messages: presentation and SMS segmentation policy +belong to the application. No public signup routes are installed by this package. + +## Existing webhook router + +```ts +import { createTwilioWebhookProvider } from '@tangle-network/agent-integrations/webhooks' +const provider = createTwilioWebhookProvider({ + url: 'https://app.example/sms', accountSid, kind: 'message', +}) +// Register this in the EXISTING WebhookRouter with durable idempotency and deliver(). +``` + +For dynamic callback routes, the host binds the exact externally configured URL +for that route. Never trust forwarded Host headers. `kind: 'status'` includes the +status in event identity, so a queued callback cannot suppress a later delivered +callback. State ordering and workspace routing remain the consumer's concern. + +`authenticateTwilioForm` is also available independently. It signs the exact URL +and all received form fields; repeated fields are rejected rather than ambiguously +normalized. It is only for form-encoded webhooks, not JSON/bodySHA256. Twilio form +signatures do not establish freshness: use the existing router's durable replay +protection. Keep the raw body. Do not accept callbacks based only on a parsed phone. + +Provider references: https://www.twilio.com/docs/usage/security, +https://www.twilio.com/docs/verify/api/verification-check, +https://www.twilio.com/docs/messaging/api/message-resource. + +## Validation and rollout + +Run `pnpm test tests/host-primitives.test.ts tests/twilio-webhook.test.ts`, +`pnpm typecheck`, and `pnpm build` on a full checkout. Test importing the built +`tangle-search`, `twilio`, and `webhooks` package subpaths before release. +No dependency versions or lockfile entries are changed by this extraction. +See CHANGELOG.md for unreleased notes. Publish with the existing release workflow; +consumer PRs must pin an actually published version, not an invented next version. +A pre-release consumer may use reproducible build artifacts pinned to the exact upstream +commit and source hashes; those are not a claim that an npm release exists. + +Authoring checks compile the new protocol modules with TypeScript 5.8.3 and execute +the same assertion bodies with Node's test runner (only the Vitest registration +import and source-to-dist paths are changed). Full-package Vitest, tsup and live +provider qualification are separate gates; no live search, OTP or SMS is claimed. diff --git a/package.json b/package.json index 3eb48ed9..5152ea73 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,16 @@ "types": "./dist/mcp.d.ts", "import": "./dist/mcp.js", "default": "./dist/mcp.js" + }, + "./tangle-search": { + "types": "./dist/tangle-search/index.d.ts", + "import": "./dist/tangle-search/index.js", + "default": "./dist/tangle-search/index.js" + }, + "./twilio": { + "types": "./dist/twilio/index.d.ts", + "import": "./dist/twilio/index.js", + "default": "./dist/twilio/index.js" } }, "files": [ diff --git a/src/http/response-json.ts b/src/http/response-json.ts new file mode 100644 index 00000000..719f20ae --- /dev/null +++ b/src/http/response-json.ts @@ -0,0 +1,71 @@ +/** Internal transport for small JSON APIs. One attempt; callers own retry policy. */ +export class ProviderProtocolError extends Error { + constructor(message: string, readonly code: string, readonly status = 502, readonly definitive = false) { + super(message) + this.name = 'ProviderProtocolError' + } +} + +export interface JsonRequestOptions { + fetch?: typeof fetch + timeoutMs?: number + maxResponseBytes?: number +} + +/** Do not let an injected transport that ignores AbortSignal strand its caller. */ +async function abortable(pending: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { void pending.catch(() => {}); throw signal.reason } + let abort!: () => void + const cancelled = new Promise((_, reject) => { + abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) abort() + }) + try { return await Promise.race([pending, cancelled]) } + finally { signal.removeEventListener('abort', abort) } +} + +export async function requestJson(url: string, init: RequestInit, options: JsonRequestOptions): Promise { + const timeoutMs = options.timeoutMs ?? 15_000 + const max = options.maxResponseBytes ?? 1_000_000 + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || !Number.isSafeInteger(max) || max <= 0) { + throw new ProviderProtocolError('Invalid response limits', 'invalid_options', 400, true) + } + const signal = AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(init.signal ? [init.signal] : [])]) + signal.throwIfAborted() + const pending = (options.fetch ?? fetch)(url, { ...init, redirect: 'error', signal }) + // Late responses from a cancelled injected transport must not retain a socket/body. + void pending.then(r => { if (signal.aborted) void r.body?.cancel().catch(() => {}) }, () => {}) + const response = await abortable(pending, signal) + if (!response.ok) { + void response.body?.cancel().catch(() => {}) + // Deliberately do not surface provider error bodies, which can reflect secrets. + throw new ProviderProtocolError(`Provider returned HTTP ${response.status}`, 'provider_http_error', + response.status, response.status >= 400 && response.status < 500 && response.status !== 408) + } + if (!response.body) throw new ProviderProtocolError('Provider returned no body', 'invalid_response') + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + try { + for (;;) { + const { done, value } = await abortable(reader.read(), signal) + if (done) break + size += value.byteLength + if (size > max) throw new ProviderProtocolError('Provider response exceeded its byte limit', 'response_limit') + chunks.push(value) + } + } finally { + void reader.cancel().catch(() => {}) + reader.releaseLock() + } + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength } + try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) } + catch { throw new ProviderProtocolError('Provider returned invalid JSON', 'invalid_response') } +} + +export function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/tangle-search/index.ts b/src/tangle-search/index.ts new file mode 100644 index 00000000..c1e16529 --- /dev/null +++ b/src/tangle-search/index.ts @@ -0,0 +1,113 @@ +import { ProviderProtocolError, record, requestJson, type JsonRequestOptions } from '../http/response-json.js' +export { ProviderProtocolError } from '../http/response-json.js' + +/** Router protocol, not provider-specific APIs. New provider IDs require no client release. */ +export interface TangleSearchInput { + query: string + provider?: string + maxResults?: number + searchRecency?: 'day' | 'week' | 'month' | 'year' + includeDomains?: readonly string[] + excludeDomains?: readonly string[] +} +export interface TangleSearchHit { + title: string + url: string + snippet?: string + publishedAt?: string + score?: number + source?: string +} +export interface TangleSearchResult { + id: string + object: 'search.result' + provider: string + query: string + data: TangleSearchHit[] + citations: string[] + usage: { upstream_cost: number | null; billed_cost: number | null } +} +export interface TangleSearchClientOptions extends JsonRequestOptions { + /** Host-selected credential. Never infer one from a model URL or an agent argument. */ + apiKey: string | (() => string | Promise) + /** Explicit trusted base: HTTPS, or HTTP loopback for tests/self-hosting. */ + baseUrl?: string + /** A deployment/evaluation pin wins over a per-call preference. */ + provider?: string +} + +function invalid(message: string): never { + throw new ProviderProtocolError(message, 'invalid_search_request', 400, true) +} +function domains(values: readonly string[] | undefined): string[] | undefined { + if (values === undefined) return undefined + if (!Array.isArray(values) || values.length > 50 || values.some(v => typeof v !== 'string' || v.length > 255 || + !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(v))) { + invalid('Domain filters must be host names, not URLs') + } + return [...new Set(values.map(v => v.toLowerCase()))] +} +/** Useful for an existing bounded HTTP transport; never sends a request itself. */ +export function buildTangleSearchRequest(input: TangleSearchInput, pinnedProvider?: string) { + if (!record(input) || typeof input.query !== 'string' || !input.query.trim()) invalid('A search query is required') + const allowed = new Set(['query', 'provider', 'maxResults', 'searchRecency', 'includeDomains', 'excludeDomains']) + if (Object.keys(input).some(k => !allowed.has(k))) invalid('Unsupported search option; the Router has no page-offset parameter') + const max = input.maxResults ?? 20 + if (!Number.isInteger(max) || max < 1 || max > 25) invalid('maxResults must be between 1 and 25') + const provider = pinnedProvider || input.provider + if (provider !== undefined && (typeof provider !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(provider))) invalid('Invalid Router provider ID') + if (input.searchRecency !== undefined && !['day', 'week', 'month', 'year'].includes(input.searchRecency)) invalid('Unsupported search recency') + return { query: input.query.trim(), max_results: max, + ...(provider ? { provider } : {}), ...(input.searchRecency ? { search_recency: input.searchRecency } : {}), + ...(input.includeDomains ? { include_domains: domains(input.includeDomains) } : {}), + ...(input.excludeDomains ? { exclude_domains: domains(input.excludeDomains) } : {}) } +} +function safeURL(value: unknown): value is string { + if (typeof value !== 'string') return false + try { const u = new URL(value); return ['http:', 'https:'].includes(u.protocol) && !u.username && !u.password } + catch { return false } +} +/** Correlate a response without inventing pagination, images, stock or missing costs. */ +export function parseTangleSearchResult(raw: unknown, request: { query: string; provider?: string }): TangleSearchResult { + const bad = () => { throw new ProviderProtocolError('Invalid or uncorrelated Router search response', 'invalid_search_response') } + if (!record(raw) || raw.object !== 'search.result' || typeof raw.id !== 'string' || !raw.id || + typeof raw.provider !== 'string' || !raw.provider || raw.query !== request.query || !Array.isArray(raw.data)) return bad() + if (request.provider && raw.provider !== request.provider) { + throw new ProviderProtocolError('Router served a different search provider than requested', 'search_provider_mismatch') + } + const hits: TangleSearchHit[] = raw.data.map(hit => { + if (!record(hit) || !safeURL(hit.url) || typeof hit.title !== 'string' || + (hit.snippet !== undefined && typeof hit.snippet !== 'string')) return bad() + return { url: hit.url, title: hit.title, ...(typeof hit.snippet === 'string' ? { snippet: hit.snippet } : {}), + ...(typeof hit.publishedAt === 'string' ? { publishedAt: hit.publishedAt } : {}), + ...(typeof hit.score === 'number' && Number.isFinite(hit.score) ? { score: hit.score } : {}), + ...(typeof hit.source === 'string' ? { source: hit.source } : {}) } + }) + const cost = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null + const usage = record(raw.usage) ? raw.usage : {} + return { id: raw.id, object: 'search.result', provider: raw.provider, query: request.query, data: hits, + citations: Array.isArray(raw.citations) ? raw.citations.filter(safeURL) : [], + usage: { upstream_cost: cost(usage.upstream_cost), billed_cost: cost(usage.billed_cost) } } +} +export class TangleSearchClient { + private readonly endpoint: string + private readonly options: TangleSearchClientOptions + constructor(options: TangleSearchClientOptions) { + const u = new URL(options.baseUrl ?? 'https://router.tangle.tools') + if (u.username || u.password || u.search || u.hash || !['/', '/v1', '/v1/'].includes(u.pathname) || + !(u.protocol === 'https:' || u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname))) { + invalid('Use an explicit trusted Router HTTPS origin, or HTTP loopback') + } + this.endpoint = u.origin + '/v1/search' + this.options = { ...options } + } + async search(input: TangleSearchInput, signal?: AbortSignal): Promise { + const body = buildTangleSearchRequest(input, this.options.provider) + signal?.throwIfAborted() + const key = typeof this.options.apiKey === 'function' ? await this.options.apiKey() : this.options.apiKey + if (!key || /[\r\n]/.test(key)) throw new ProviderProtocolError('A Router credential is required', 'search_not_configured', 503, true) + const raw = await requestJson(this.endpoint, { method: 'POST', signal, + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, this.options) + return parseTangleSearchResult(raw, body) + } +} diff --git a/src/twilio/index.ts b/src/twilio/index.ts new file mode 100644 index 00000000..3d2da4b0 --- /dev/null +++ b/src/twilio/index.ts @@ -0,0 +1,134 @@ +/** Host-side protocol primitives. Enrollment, sessions, consent and workspaces belong to the app. */ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { ProviderProtocolError, record, requestJson, type JsonRequestOptions } from '../http/response-json.js' +export { ProviderProtocolError } from '../http/response-json.js' + +export function normalizePhoneNumber(value: unknown): string { + if (typeof value !== 'string') throw new ProviderProtocolError('An international phone number is required', 'invalid_phone', 400, true) + const phone = value.trim().replace(/[ ()-]/g, '') + if (!/^\+[1-9]\d{7,14}$/.test(phone)) throw new ProviderProtocolError('Include the phone country code', 'invalid_phone', 400, true) + return phone +} +function requireValue(condition: unknown, message: string, code = 'provider_protocol', status = 502): asserts condition { + if (!condition) throw new ProviderProtocolError(message, code, status) +} +const sid = (value: unknown, prefix: string): value is string => typeof value === 'string' && new RegExp(`^${prefix}[a-f0-9]{32}$`, 'i').test(value) +export interface TwilioPhoneOptions extends JsonRequestOptions { + accountSid: string + authToken: string + /** Required only for verification; messaging-only consumers do not need a Verify service. */ + verifyServiceSid?: string +} +export interface TwilioSendInput { + to: string + from: string + body: string + /** Host-chosen URL, never taken from the provider response. */ + statusCallback?: string +} +export interface TwilioMessageReceipt { + receiptId: string + status: string + /** False for queued/accepted/sent. No assertion about human reading. */ + deliveryConfirmed: boolean +} + +/** Fixed Twilio endpoints, one attempt. A send has no automatic retry or claimed native idempotency. */ +export class TwilioPhoneClient { + private readonly options: TwilioPhoneOptions + constructor(options: TwilioPhoneOptions) { + requireValue(sid(options.accountSid, 'AC') && (options.verifyServiceSid === undefined || sid(options.verifyServiceSid, 'VA')) && + typeof options.authToken === 'string' && options.authToken.length >= 16, + 'Configure valid Twilio account credentials', 'phone_not_configured', 503) + this.options = { ...options } + } + private async request(host: 'verify' | 'api', path: string, input?: Record, signal?: AbortSignal) { + const value = await requestJson(`https://${host}.twilio.com${path}`, { + method: input ? 'POST' : 'GET', signal, + headers: { Authorization: 'Basic ' + Buffer.from(`${this.options.accountSid}:${this.options.authToken}`).toString('base64'), + ...(input ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}) }, + body: input ? new URLSearchParams(input).toString() : undefined, + }, { maxResponseBytes: 128_000, ...this.options }) + requireValue(record(value), 'Twilio returned no structured response') + return value + } + private service(): string { + requireValue(this.options.verifyServiceSid, 'Configure a Verify service for phone verification', 'phone_not_configured', 503) + return this.options.verifyServiceSid + } + async startVerification(to: string, signal?: AbortSignal): Promise<{ id: string }> { + this.service() + const phone = normalizePhoneNumber(to) + const r = await this.request('verify', `/v2/Services/${this.options.verifyServiceSid}/Verifications`, { To: phone, Channel: 'sms' }, signal) + requireValue(sid(r.sid, 'VE') && r.account_sid === this.options.accountSid && r.service_sid === this.options.verifyServiceSid && + r.to === phone && r.status === 'pending', 'Verification did not match the requested account, service and phone', 'verification_mismatch') + return { id: r.sid } + } + async checkVerification(id: string, to: string, code: string, signal?: AbortSignal): Promise { + this.service() + requireValue(sid(id, 'VE') && typeof code === 'string' && /^\d{4,10}$/.test(code), 'Invalid verification identity or code', 'invalid_code', 400) + const phone = normalizePhoneNumber(to) + const r = await this.request('verify', `/v2/Services/${this.options.verifyServiceSid}/VerificationCheck`, { VerificationSid: id, Code: code }, signal) + requireValue(r.sid === id && r.account_sid === this.options.accountSid && r.service_sid === this.options.verifyServiceSid && + r.to === phone, 'Verification did not match the requested account, service and phone', 'verification_mismatch') + return r.status === 'approved' + } + async sendMessage(input: TwilioSendInput, signal?: AbortSignal): Promise { + const to = normalizePhoneNumber(input.to), from = normalizePhoneNumber(input.from) + requireValue(typeof input.body === 'string' && input.body.length > 0, 'A message body is required', 'invalid_message', 400) + if (input.statusCallback) { + const u = new URL(input.statusCallback) + requireValue(u.protocol === 'https:' && !u.username && !u.password && !u.hash, + 'Status callback must be a host-chosen HTTPS URL', 'invalid_callback', 400) + } + const r = await this.request('api', `/2010-04-01/Accounts/${this.options.accountSid}/Messages.json`, + { To: to, From: from, Body: input.body, ...(input.statusCallback ? { StatusCallback: input.statusCallback } : {}) }, signal) + requireValue(sid(r.sid, 'SM') && r.account_sid === this.options.accountSid && r.to === to && r.from === from, + 'Message receipt did not match the requested account and participants', 'receipt_mismatch') + if (['failed', 'undelivered', 'canceled'].includes(String(r.status))) { + throw new ProviderProtocolError('Twilio rejected message delivery', 'sms_rejected', 502, true) + } + requireValue(typeof r.status === 'string' && ['accepted', 'scheduled', 'queued', 'sending', 'sent', 'delivered', 'read'].includes(r.status), + 'Twilio returned an unrecognized message state', 'receipt_mismatch') + return { receiptId: r.sid, status: r.status, deliveryConfirmed: r.status === 'delivered' || r.status === 'read' } + } + async inspect(number: string, signal?: AbortSignal) { + const phone = normalizePhoneNumber(number) + const service = this.options.verifyServiceSid ? await this.request('verify', `/v2/Services/${this.options.verifyServiceSid}`, undefined, signal) : null + const numbers = await this.request('api', `/2010-04-01/Accounts/${this.options.accountSid}/IncomingPhoneNumbers.json?PhoneNumber=${encodeURIComponent(phone)}`, undefined, signal) + return { verifyConnected: service !== null && service.sid === this.options.verifyServiceSid && service.account_sid === this.options.accountSid, + senderOwned: Array.isArray(numbers.incoming_phone_numbers) && numbers.incoming_phone_numbers.some(n => record(n) && + n.account_sid === this.options.accountSid && n.phone_number === phone && record(n.capabilities) && n.capabilities.sms === true), + liveDeliveryProven: false as const } + } +} + +export interface TwilioFormInput { + /** Exact external URL (including its query). Never derive this from untrusted forwarded headers. */ + url: string + rawBody: string | Uint8Array + signature: unknown + authToken: string + accountSid: string +} +/** Form webhooks only. Does not support JSON bodySHA256 or establish freshness/replay protection. */ +export function authenticateTwilioForm(input: TwilioFormInput): Record { + const u = new URL(input.url) + requireValue(u.protocol === 'https:' && !u.username && !u.password && !u.hash, 'An exact HTTPS webhook URL is required', 'invalid_callback', 400) + requireValue(typeof input.authToken === 'string' && input.authToken.length > 0 && sid(input.accountSid, 'AC'), 'Invalid verification configuration', 'phone_not_configured', 503) + const body = typeof input.rawBody === 'string' ? input.rawBody : new TextDecoder('utf-8', { fatal: true }).decode(input.rawBody) + requireValue(new TextEncoder().encode(body).length <= 64_000, 'Webhook body is too large', 'invalid_webhook', 413) + const params: Record = Object.create(null) + for (const [key, value] of new URLSearchParams(body)) { + requireValue(!Object.hasOwn(params, key), 'Repeated webhook fields are ambiguous', 'invalid_webhook', 400) + params[key] = value + } + requireValue(typeof input.signature === 'string' && /^[A-Za-z0-9+/]{27}=$/.test(input.signature), 'Invalid Twilio signature', 'webhook_signature', 401) + // Use the exact caller-supplied URL, not URL.href (which normalizes ports/escaping). + const material = input.url + Object.keys(params).sort().map(k => k + params[k]).join('') + const expected = createHmac('sha1', input.authToken).update(material).digest() + const actual = Buffer.from(input.signature, 'base64') + requireValue(actual.length === expected.length && timingSafeEqual(actual, expected), 'Invalid Twilio signature', 'webhook_signature', 401) + requireValue(params.AccountSid === input.accountSid, 'Unexpected Twilio account', 'webhook_account', 403) + return params +} diff --git a/src/webhooks/index.ts b/src/webhooks/index.ts index 2fae55d3..73871765 100644 --- a/src/webhooks/index.ts +++ b/src/webhooks/index.ts @@ -9,3 +9,4 @@ export * from './router.js' export * from './providers.js' +export * from './twilio.js' diff --git a/src/webhooks/twilio.ts b/src/webhooks/twilio.ts new file mode 100644 index 00000000..8765f332 --- /dev/null +++ b/src/webhooks/twilio.ts @@ -0,0 +1,36 @@ +import { authenticateTwilioForm } from '../twilio/index.js' +import type { WebhookProvider } from './router.js' + +/** Bind to a trusted exact external route; forwarded Host headers are not a signing authority. */ +export function createTwilioWebhookProvider(options: { + url: string + accountSid: string + kind: 'message' | 'status' +}): WebhookProvider { + if (!['message', 'status'].includes(options.kind)) throw new Error('Choose message or status webhook mode') + const bound = { ...options } + return { + id: 'twilio', + verifySignature({ rawBody, headers, secret }) { + const key = Object.keys(headers).find(k => k.toLowerCase() === 'x-twilio-signature') + const signature = key ? headers[key] : undefined + try { + authenticateTwilioForm({ url: bound.url, rawBody, signature, authToken: secret, accountSid: bound.accountSid }) + return { valid: true } + } catch { return { valid: false, reason: 'invalid_twilio_form_or_signature' } } + }, + parse({ rawBody, now }) { + const data = Object.fromEntries(new URLSearchParams(rawBody)) + if (!/^SM[a-f0-9]{32}$/i.test(data.MessageSid ?? '')) throw new Error('Twilio event has no valid message identity') + const status = bound.kind === 'status' ? data.MessageStatus : 'received' + if (!status || !/^[a-z_]+$/.test(status)) throw new Error('Twilio status event has no status') + return [{ provider: 'twilio', eventType: `twilio.message.${bound.kind === 'status' ? 'status' : 'received'}`, + // A message SID alone would incorrectly deduplicate all subsequent delivery states. + providerEventId: `${bound.accountSid}:${data.MessageSid}:${bound.kind}:${status}`, + receivedAt: now ?? Date.now(), payload: data, headers: {} }] + }, + successResponse: { body: '', headers: { 'Content-Type': 'text/xml; charset=utf-8' } }, + eventCatalog: { namespace: 'twilio.', closed: true, + events: [{ id: 'twilio.message.received' }, { id: 'twilio.message.status' }] }, + } +} diff --git a/tests/host-primitives.test.ts b/tests/host-primitives.test.ts new file mode 100644 index 00000000..b7561d6e --- /dev/null +++ b/tests/host-primitives.test.ts @@ -0,0 +1,154 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' +import { TangleSearchClient, buildTangleSearchRequest, parseTangleSearchResult } from '../src/tangle-search/index.js' +import { TwilioPhoneClient, authenticateTwilioForm, normalizePhoneNumber } from '../src/twilio/index.js' +import { requestJson } from '../src/http/response-json.js' + +const accountSid = 'AC' + 'a'.repeat(32), verifyServiceSid = 'VA' + 'b'.repeat(32) +const authToken = 'fixture-auth-token-only', phone = '+13105551234', from = '+15555550123' +const verifyId = 'VE' + 'c'.repeat(32), messageId = 'SM' + 'd'.repeat(32) +const envelope = (overrides = {}) => ({ id: 'search-1', object: 'search.result', provider: 'you', query: 'linear guide', + data: [{ title: 'Guide', url: 'https://parts.example/guide', snippet: '300 mm' }], + usage: { billed_cost: 0.005, upstream_cost: null }, ...overrides }) +const options = { accountSid, verifyServiceSid, authToken } +function sign(url: string, params: Record) { + return createHmac('sha1', authToken).update(url + Object.keys(params).sort().map(k => k + params[k]).join('')).digest('base64') +} + +test('Search uses one canonical Router request, explicit key and host pin', async () => { + let calls = 0 + const client = new TangleSearchClient({ apiKey: 'router-key', provider: 'you', fetch: async (url, init) => { + calls++; assert.equal(url, 'https://router.tangle.tools/v1/search'); assert.equal(init?.redirect, 'error'); assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer router-key') + assert.deepEqual(JSON.parse(String(init?.body)), { query: 'linear guide', max_results: 4, provider: 'you', include_domains: ['parts.example'] }) + return Response.json(envelope()) + } }) + const r = await client.search({ query: ' linear guide ', provider: 'another-provider', maxResults: 4, includeDomains: ['PARTS.example'] }) + assert.equal(calls, 1); assert.equal(r.provider, 'you'); assert.equal(r.usage.upstream_cost, null) +}) +test('Arbitrary valid future provider IDs stay server-owned rather than an enum fork', () => { + assert.equal(buildTangleSearchRequest({ query: 'part', provider: 'new-provider-2027' }).provider, 'new-provider-2027') +}) +test('Unknown pagination and date filters fail instead of silently buying page one', () => { + assert.throws(() => buildTangleSearchRequest({ query: 'part', offset: 2 } as any), { code: 'invalid_search_request' }) + assert.throws(() => buildTangleSearchRequest({ query: 'part', searchRecency: '2026-01-01' } as any), { code: 'invalid_search_request' }) +}) +test('No unrelated endpoint or embedded credentials are accepted', () => { + for (const baseUrl of ['http://router.example', 'https://u:p@router.example', 'https://router.example/other', 'https://router.example/#token']) { + assert.throws(() => new TangleSearchClient({ apiKey: 'secret', baseUrl })) + } +}) +test('Explicit custom HTTPS and loopback Router deployments retain the canonical route', async () => { + for (const baseUrl of ['https://router.example/v1/', 'http://127.0.0.1:8787']) { + const c = new TangleSearchClient({ baseUrl, apiKey: 'test', fetch: async url => { + assert.equal(String(url), new URL(baseUrl).origin + '/v1/search'); return Response.json(envelope()) + } }); await c.search({ query: 'linear guide' }) + } +}) +test('A pinned provider or query mismatch cannot become a successful search', () => { + assert.throws(() => parseTangleSearchResult(envelope({ provider: 'brave' }), { query: 'linear guide', provider: 'you' }), { code: 'search_provider_mismatch' }) + assert.throws(() => parseTangleSearchResult(envelope(), { query: 'different query' })) +}) +test('Malformed hits and active links are rejected; absent costs are not zero', () => { + assert.throws(() => parseTangleSearchResult(envelope({ data: [{ url: 'javascript:alert(1)', title: 'Bad' }] }), { query: 'linear guide' })) + const r = parseTangleSearchResult(envelope({ usage: { billed_cost: '0.001', upstream_cost: -1 } }), { query: 'linear guide' }) + assert.deepEqual(r.usage, { billed_cost: null, upstream_cost: null }); assert(!('thumbnail' in r.data[0])) +}) +test('Router does not retry/fallback after a potentially billed failed request', async () => { + let calls = 0; const c = new TangleSearchClient({ apiKey: 'test', fetch: async () => { calls++; return new Response('failed', { status: 503 }) } }) + await assert.rejects(() => c.search({ query: 'part' })); assert.equal(calls, 1) +}) +test('Missing credentials and invalid result counts fail before dispatch', async () => { + let calls = 0; const c = new TangleSearchClient({ apiKey: '', fetch: async () => { calls++; return Response.json(envelope()) } }) + await assert.rejects(() => c.search({ query: 'part' }), { code: 'search_not_configured' }) + for (const maxResults of [0, 26, 1.5]) assert.throws(() => buildTangleSearchRequest({ query: 'part', maxResults })) + assert.equal(calls, 0) +}) +test('No country is inferred when normalizing a phone number', () => { + assert.equal(normalizePhoneNumber('+1 (310) 555-1234'), phone) + for (const value of ['3105551234', '+0000000000', null]) assert.throws(() => normalizePhoneNumber(value)) +}) +test('Verify owns the OTP; the client correlates actual account, service and phone', async () => { + let calls = 0; const c = new TwilioPhoneClient({ ...options, fetch: async (url, init) => { + const form = Object.fromEntries(new URLSearchParams(String(init?.body))) + assert.equal(new Headers(init?.headers).get('authorization'), 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64')) + calls++ + if (String(url).endsWith('/Verifications')) assert.deepEqual(form, { To: phone, Channel: 'sms' }) + else assert.deepEqual(form, { VerificationSid: verifyId, Code: '123456' }) + return Response.json({ sid: verifyId, account_sid: accountSid, service_sid: verifyServiceSid, to: phone, status: calls === 1 ? 'pending' : 'approved' }) + } }) + assert.deepEqual(await c.startVerification(phone), { id: verifyId }); assert(await c.checkVerification(verifyId, phone, '123456')); assert.equal(calls, 2) +}) +test('An approved response for another account/service/phone/id cannot verify a user', async () => { + for (const altered of [{ account_sid: 'AC' + '0'.repeat(32) }, { service_sid: 'VA' + '0'.repeat(32) }, { sid: 'VE' + '0'.repeat(32) }, { to: from }]) { + const c = new TwilioPhoneClient({ ...options, fetch: async () => Response.json({ sid: verifyId, account_sid: accountSid, service_sid: verifyServiceSid, to: phone, status: 'approved', ...altered }) }) + await assert.rejects(() => c.checkVerification(verifyId, phone, '123456'), { code: 'verification_mismatch' }) + } +}) +test('Invalid phone and code are rejected before provider calls', async () => { + let n = 0; const c = new TwilioPhoneClient({ ...options, fetch: async () => { n++; return Response.json({}) } }) + await assert.rejects(() => c.startVerification('local number')); await assert.rejects(() => c.checkVerification(verifyId, phone, '