Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3b2b017
feat(inkbox): add identity-scoped messaging actions to existing REST …
drewstone Sep 12, 2026
e076e1e
feat(linq): reuse connector contract for owned lines, history and rep…
drewstone Sep 12, 2026
39f393b
fix(contiguity): match documented endpoints and explicit sender seman…
drewstone Sep 12, 2026
c8ddf18
feat(webhooks): verify Inkbox, Linq and Contiguity with existing ingr…
drewstone Sep 12, 2026
3094e1b
feat(conversations): normalize verified mobile events without widenin…
drewstone Sep 12, 2026
ba82336
feat(conversations): derive reply actions from verified source events
drewstone Sep 12, 2026
8720b19
feat(conversations): expose mobile normalization while preserving exi…
drewstone Sep 12, 2026
9882a58
test(messaging): cover signed ingress, replay, identity-bound replies…
drewstone Sep 12, 2026
1b0aa39
test(contiguity): assert current wire schema and prevent alternate bo…
drewstone Sep 12, 2026
3fc04e3
ci: verify messaging package and retain a testable packed artifact
drewstone Sep 12, 2026
8ee3840
chore: generate messaging adapter registry
github-actions[bot] Sep 12, 2026
da2df5e
test(contiguity): supply operation identity on rejected sends
drewstone Sep 12, 2026
abd0144
ci: verify committed registry read-only and retain bounded test evidence
drewstone Sep 12, 2026
3ce918a
feat(messaging): add distinct Linq WhatsApp transport and shared chan…
drewstone Sep 15, 2026
da88165
chore(messaging): commit generated WhatsApp catalog and typed regress…
drewstone Sep 15, 2026
0e022d8
feat: reusable Router search, verified phone, and Twilio webhook prim…
drewstone Sep 15, 2026
851bf18
feat(hosted): reconcile messaging providers and bind durable app gran…
drewstone Sep 17, 2026
6407dcc
fix(messaging): use the canonical Linq inventory and reply actions
drewstone Sep 17, 2026
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
45 changes: 45 additions & 0 deletions .github/workflows/verify-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Verify PR
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: 25
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Retain exact source
run: |
mkdir -p verification
git rev-parse HEAD > verification/commit.txt
git archive --format=tar.gz HEAD > verification/source.tar.gz
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile > verification/install.log 2>&1 || { tail -60 verification/install.log; exit 1; }
- run: pnpm run typecheck > verification/typecheck.log 2>&1 || { cat verification/typecheck.log; exit 1; }
- name: Test all source
run: pnpm exec vitest run --reporter=json --outputFile=verification/tests.json > verification/tests.log 2>&1 || { tail -100 verification/tests.log; exit 1; }
- name: Verify packed release without publishing
run: pnpm run release > verification/release.log 2>&1 || { tail -100 verification/release.log; exit 1; }
- name: Pack tested consumer
run: |
npm pack --ignore-scripts --pack-destination verification > verification/pack.log
sha256sum verification/*.tgz verification/source.tar.gz > verification/SHA256SUMS
git diff --exit-code
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
name: messaging-verification-${{ github.event.pull_request.number }}
path: verification/
retention-days: 7
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Changelog

## Unreleased

### Added
- Durable broker consent receipts expose the grant, app, connection and owner identity. `requireBrokerGrantReceipt` verifies exact identity and scope before unattended use; older host responses remain usable for immediate calls but cannot invent a durable grant.
- Shared Linq owned-line inventory projection for hosted assistants, preserving unknown/critical line health and distinguishing provider-assigned numbers from an unsupported purchase API.
- `@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.
2 changes: 1 addition & 1 deletion data/bundled-adapter-manifests.json

Large diffs are not rendered by default.

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
29 changes: 28 additions & 1 deletion src/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export interface BrokerToken {
expiresIn: number
scope: string
connectionId?: string
/** Durable, consented grant identity. Older hosts omit these receipt fields. */
grantId?: string
ownerUserId?: string
clientId?: string
/** Absolute expiry derived from the broker response. */
expiresAt: number
}
Expand All @@ -83,6 +87,9 @@ interface TokenResponse {
expires_in?: unknown
scope?: unknown
connection_id?: string
grant_id?: unknown
user_id?: unknown
client_id?: unknown
}

const MAX_BROKER_TOKEN_TTL_SECONDS = 3_600
Expand Down Expand Up @@ -283,7 +290,8 @@ function toBrokerToken(data: TokenResponse): BrokerToken {
data.expires_in > MAX_BROKER_TOKEN_TTL_SECONDS ||
typeof data.scope !== 'string' ||
data.scope.trim().length === 0 ||
(data.connection_id !== undefined && (typeof data.connection_id !== 'string' || data.connection_id.trim().length === 0))
(data.connection_id !== undefined && (typeof data.connection_id !== 'string' || data.connection_id.trim().length === 0)) ||
[data.grant_id, data.user_id, data.client_id].some(value => value !== undefined && (typeof value !== 'string' || !value.trim() || value.length > 256))
) {
throw new IntegrationRuntimeError({
code: 'input_invalid',
Expand All @@ -308,6 +316,9 @@ function toBrokerToken(data: TokenResponse): BrokerToken {
scope,
expiresAt,
connectionId: data.connection_id,
...(typeof data.grant_id === 'string' ? { grantId: data.grant_id } : {}),
...(typeof data.user_id === 'string' ? { ownerUserId: data.user_id } : {}),
...(typeof data.client_id === 'string' ? { clientId: data.client_id } : {}),
}
}

Expand Down Expand Up @@ -336,3 +347,19 @@ function assertOwnerBearer(value: unknown): asserts value is string {
})
}
}

/** Require the authenticated host receipt before retaining a grant for unattended
* use. The browser's state and connection id are expectations, not evidence. */
export function requireBrokerGrantReceipt(token: BrokerToken, expected: {
ownerUserId: string; clientId: string; connectionId: string; scopes: readonly string[]; grantId?: string
}): { grantId: string; connectionId: string; ownerUserId: string; clientId: string; scopes: string[] } {
const scopes = [...new Set(token.scope.split(/\s+/).filter(Boolean))].sort()
const expectedScopes = [...new Set(expected.scopes)].sort()
if (!token.grantId || token.ownerUserId !== expected.ownerUserId || token.clientId !== expected.clientId ||
token.connectionId !== expected.connectionId || (expected.grantId && token.grantId !== expected.grantId) ||
JSON.stringify(scopes) !== JSON.stringify(expectedScopes)) {
throw new IntegrationRuntimeError({ code: 'provider_auth_failed', status: 403,
message: 'The broker grant receipt does not match the authenticated owner, app, connection and requested scopes' })
}
return { grantId: token.grantId, connectionId: expected.connectionId, ownerUserId: expected.ownerUserId, clientId: expected.clientId, scopes }
}
Loading
Loading