Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/verify-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Verify pull request
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
concurrency:
group: verify-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Verify actual package
shell: bash
run: |
set -euo pipefail
mkdir -p verification
git rev-parse HEAD > verification/commit.txt
pnpm install --frozen-lockfile 2>&1 | tee verification/install.log
pnpm run typecheck 2>&1 | tee verification/typecheck.log
pnpm run test -- --reporter=default --reporter=json --outputFile=verification/tests.json 2>&1 | tee verification/tests.log
pnpm run release 2>&1 | tee verification/release.log
git archive HEAD > verification/source.tar
- uses: actions/upload-artifact@v4
if: always()
with:
name: shared-primitives-${{ github.event.pull_request.number }}
path: verification/
retention-days: 7
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
107 changes: 107 additions & 0 deletions docs/host-search-and-phone.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
71 changes: 71 additions & 0 deletions src/http/response-json.ts
Original file line number Diff line number Diff line change
@@ -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<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) { void pending.catch(() => {}); throw signal.reason }
let abort!: () => void
const cancelled = new Promise<never>((_, 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<unknown> {
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<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
113 changes: 113 additions & 0 deletions src/tangle-search/index.ts
Original file line number Diff line number Diff line change
@@ -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<string>)
/** 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<TangleSearchResult> {
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)
}
}
Loading
Loading