@volter/twin-twilio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +118 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +53 -0
- package/dist/src/twilio-budget.d.ts +83 -0
- package/dist/src/twilio-budget.js +411 -0
- package/dist/src/twilio-capabilities.d.ts +4 -0
- package/dist/src/twilio-capabilities.js +414 -0
- package/dist/src/twilio-conformance.d.ts +7 -0
- package/dist/src/twilio-conformance.js +41 -0
- package/dist/src/twilio-connector.d.ts +60 -0
- package/dist/src/twilio-connector.js +115 -0
- package/dist/src/twilio-credentials.d.ts +43 -0
- package/dist/src/twilio-credentials.js +161 -0
- package/dist/src/twilio-server.d.ts +14 -0
- package/dist/src/twilio-server.js +41 -0
- package/dist/src/twilio-signature.d.ts +51 -0
- package/dist/src/twilio-signature.js +78 -0
- package/dist/src/twilio-twin.d.ts +31 -0
- package/dist/src/twilio-twin.js +641 -0
- package/package.json +51 -0
- package/src/cli.ts +25 -0
- package/src/index.ts +107 -0
- package/src/twilio-budget.ts +457 -0
- package/src/twilio-capabilities.ts +436 -0
- package/src/twilio-conformance.ts +45 -0
- package/src/twilio-connector.ts +162 -0
- package/src/twilio-credentials.ts +171 -0
- package/src/twilio-server.ts +49 -0
- package/src/twilio-signature.ts +132 -0
- package/src/twilio-twin.ts +699 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
// Twilio API twin REQUEST HANDLER — a v1 slice of the Twilio messaging/telephony surface,
|
|
2
|
+
// backed by the event/action-log kernel (@volter/world-core). Contract:
|
|
3
|
+
// handleTwilioTwinRequest({ method, path, body, host?, headers, root, readOnly }) -> { status, body, headers }
|
|
4
|
+
//
|
|
5
|
+
// SOURCE OF TRUTH: grounded read-only against Twilio's own published OpenAPI documents
|
|
6
|
+
// (github.com/twilio/twilio-oai — twilio_api_v2010.json, twilio_verify_v2.json,
|
|
7
|
+
// twilio_lookups_v2.json), the ACTUALLY-INSTALLED `twilio@6.0.2` npm SDK's own compiled source
|
|
8
|
+
// (fetched via `npm pack`, read-only), and the public Twilio error-code reference pages
|
|
9
|
+
// (twilio.com/docs/api/errors/<code>, fetched read-only) — see spec-sources.json for the full
|
|
10
|
+
// grounding record and which facts came from which source. A doc-UNVERIFIED shape is annotated
|
|
11
|
+
// inline (⚠) and NOT claimed beyond what the verify() actually proves.
|
|
12
|
+
//
|
|
13
|
+
// GROUNDED (live OpenAPI + live SDK source + live error-docs fetch, all during this build):
|
|
14
|
+
// - POST .../Messages.json -> 201 (twilio_api_v2010.json's own responses map: only "201" is
|
|
15
|
+
// documented for message create — NOT doc-UNVERIFIED, confirmed against the published spec).
|
|
16
|
+
// - GET .../Messages.json (list) envelope is EXACTLY {end, first_page_uri, next_page_uri, page,
|
|
17
|
+
// page_size, previous_page_uri, messages:[...]} (the real OpenAPI example, byte-for-byte —
|
|
18
|
+
// this pack models that shape, not the build spec's approximate {messages,page,page_size,uri}
|
|
19
|
+
// guess; num_segments/num_media are STRINGS on the wire, confirmed from the same example).
|
|
20
|
+
// - Message resource fields (sid/status/direction/...) confirmed against BOTH the OpenAPI
|
|
21
|
+
// schema AND the installed SDK's `MessageInstance` constructor (message.js) — no top-level
|
|
22
|
+
// `type` field exists on a Message (unlike Account/IncomingPhoneNumber below), so no kernel
|
|
23
|
+
// type/id/updatedAt collision risk there.
|
|
24
|
+
// - Verify Verifications POST -> 201, requires {To, Channel} (twilio_verify_v2.json's own
|
|
25
|
+
// `required` array). VerificationCheck POST documents BOTH "200" and "201" as valid response
|
|
26
|
+
// codes (no semantic split in the generated doc) — this twin picks 200 consistently for
|
|
27
|
+
// VerificationCheck (a "check", not a "create"), which is itself doc-legitimate, not a guess.
|
|
28
|
+
// - Lookup PhoneNumbers GET -> 200; `line_type_intelligence.type` confirmed NESTED (never a
|
|
29
|
+
// top-level field) against both the OpenAPI schema and the SDK's `LineTypeIntelligenceInfo`
|
|
30
|
+
// class — the kernel type/id/updatedAt reserved-field gotcha (actions.ts:153,219) does NOT
|
|
31
|
+
// bite here even before accounting for Lookup being a pure derivation with no kernel row.
|
|
32
|
+
// - Exact error {code,message} pairs for 21211/21604/20404/60200/60202/20003 fetched LIVE from
|
|
33
|
+
// twilio.com/docs/api/errors/<code> (read-only) — GROUNDED, not the build spec's own
|
|
34
|
+
// doc-UNVERIFIED placeholder text. HTTP status per code (400/404/401) is modeled by
|
|
35
|
+
// well-established REST convention (not independently re-confirmed byte-for-byte per code
|
|
36
|
+
// from that page, which does not expose an explicit http_status_code field) — noted honestly.
|
|
37
|
+
// - X-Twilio-Signature scheme (base64(HMAC-SHA1(AuthToken, URL + sorted-concat params))) is
|
|
38
|
+
// VERBATIM-ported from the installed SDK's OWN `lib/webhooks/webhooks.js` source — see
|
|
39
|
+
// twilio-signature.ts's header for the exact function-level citation.
|
|
40
|
+
// - THE SDK HTTPCLIENT SEAM (build spec §7.2/§13.1, HIGHEST RISK): `new Twilio(sid, token, {
|
|
41
|
+
// httpClient })` accepts an INJECTABLE httpClient (`opts.httpClient`, `lib/base/BaseTwilio.js`
|
|
42
|
+
// `setOpts`/`get httpClient()`) that receives the FULL absolute `uri` per call — verified LIVE
|
|
43
|
+
// in Node against the installed 6.0.2 package (a throwaway `Bun.serve` server + a custom
|
|
44
|
+
// httpClient forwarding every domain's request to it) BEFORE twilio-sdk.integration.test.ts
|
|
45
|
+
// was written: `client.messages.create`, `client.verify.v2.services(...).verifications.create`,
|
|
46
|
+
// and `client.lookups.v2.phoneNumbers(...).fetch()` ALL routed correctly, with the real
|
|
47
|
+
// x-www-form-urlencoded body encoding and Basic-auth header construction, and a REAL
|
|
48
|
+
// `RestException` round-tripped through our {code,message,more_info,status} envelope with the
|
|
49
|
+
// correct `.code`/`.status`/`.message`. This is a REAL rung-4 SDK test (not the fetch-fallback
|
|
50
|
+
// path).
|
|
51
|
+
//
|
|
52
|
+
// A THIRD KERNEL-GOTCHA APPLICATION FOUND DURING GROUNDING (beyond the build spec's own Lookup
|
|
53
|
+
// callout): BOTH the real Twilio `Account` resource (`account_enum_type`: Trial|Full — confirmed
|
|
54
|
+
// in twilio_api_v2010.json's `api.v2010.account` schema AND the SDK's `AccountInstance.type`) AND
|
|
55
|
+
// the real `IncomingPhoneNumber` resource (`payload.type`, confirmed in the SDK's
|
|
56
|
+
// `IncomingPhoneNumberInstance` constructor) carry a TOP-LEVEL `type` field that would collide
|
|
57
|
+
// with the kernel's reserved id/type/updatedAt row-meta (actions.ts:153,219 — `META.has(k)`
|
|
58
|
+
// silently DROPS any field literally named `type` when re-serializing a kernel resource) if either
|
|
59
|
+
// were ever written through `applyTwinWrite`/read through the generic `view()` helper unchanged.
|
|
60
|
+
// This build avoids the collision two different ways, chosen per resource:
|
|
61
|
+
// - `accounts.fetch` is modeled as a PURE per-root derivation (mirrors fal-webhooks.ts's JWKS/
|
|
62
|
+
// signing_key pattern) — it never calls `applyTwinWrite`, so its `type` field is a plain
|
|
63
|
+
// JSON-literal property, never touches the kernel merge/META-filter pipeline at all.
|
|
64
|
+
// - `phone_number` rows (IncomingPhoneNumbers) genuinely ARE kernel-persisted (lazily seeded on
|
|
65
|
+
// first list, per root — the fal signing_key "fold on first touch" idiom), so THIS one takes
|
|
66
|
+
// the build spec's prescribed rename-and-remap: stored as `number_type` internally, renamed
|
|
67
|
+
// back to `type` only in `phoneNumberView()`'s output — never a bare top-level `type` field in
|
|
68
|
+
// a `fields` write.
|
|
69
|
+
//
|
|
70
|
+
// State lives ENTIRELY in the kernel action log: writes go through `applyTwinWrite`, reads are
|
|
71
|
+
// the projection (`projectResources`). There is NO Map/array side-store (D1). No real Twilio is
|
|
72
|
+
// ever contacted.
|
|
73
|
+
//
|
|
74
|
+
// KERNEL GOTCHA — field-MERGE null-erasure (actions.ts:202, `overlay()`'s `{...existing.fields,
|
|
75
|
+
// ...fields}` shallow merge NEVER removes a field): `handleMessageCreate` writes EXPLICIT nulls
|
|
76
|
+
// for `date_sent`/`error_code`/`error_message` at creation (so the real API's always-present-but-
|
|
77
|
+
// initially-null fields show up from the start, matching the OpenAPI example's shape) and the
|
|
78
|
+
// poll-fold ONLY ever sets NEW values on top (never needs to re-null anything in this pack's
|
|
79
|
+
// deterministic non-failing lifecycle, but the discipline — write intended nulls explicitly,
|
|
80
|
+
// never rely on omission to clear a field — is applied throughout, mirroring fal-twin.ts's own
|
|
81
|
+
// `request.create` write).
|
|
82
|
+
//
|
|
83
|
+
// Honesty (D2): an unmodeled route / unknown resource returns Twilio's OWN documented error
|
|
84
|
+
// envelope `{code, message, more_info, status}` (grounded — twilio.com/docs/api/errors), never a
|
|
85
|
+
// fabricated success. readOnly rejects writes with 405.
|
|
86
|
+
//
|
|
87
|
+
// Kernel SUBJECT ids are type-prefixed (`message:<sid>`, `verification:<sid>`,
|
|
88
|
+
// `phone_number:<sid>`) — the PUBLIC sid emitted to clients is the bare Twilio-shaped sid
|
|
89
|
+
// (SM…/VE…/PN…). `kid()` builds the subject id; `rows()` strips the prefix back off.
|
|
90
|
+
import { createHash } from 'node:crypto';
|
|
91
|
+
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
92
|
+
import { presentedTwilioCredentials, storedTwilioCredentials, twilioCredentials } from "./twilio-credentials.js";
|
|
93
|
+
const SERVICE = 'twilio';
|
|
94
|
+
const API_VERSION = '2010-04-01';
|
|
95
|
+
// Resource types the twin actually PROJECTS in the kernel — the honest v1 subset. `signing_key`
|
|
96
|
+
// is the world's OWN AccountSid/AuthToken pair (never itself REST-addressable — see fal's
|
|
97
|
+
// `signing_key` precedent): a kernel-persisted singleton established exactly once per root,
|
|
98
|
+
// entropy-born or seeded from the Basic pair a caller presents (twilio-credentials.ts), and the
|
|
99
|
+
// credential `accounts.fetch` serves and every `X-Twilio-Signature` is computed with. It was a
|
|
100
|
+
// declaration with nothing behind it while the pair was derived from the world's directory path.
|
|
101
|
+
export const TWILIO_RESOURCE_TYPES = ['message', 'verification', 'phone_number', 'signing_key'];
|
|
102
|
+
function lowerHeaders(h) {
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const [k, v] of Object.entries(h ?? {}))
|
|
105
|
+
out[k.toLowerCase()] = v;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
export function routeTwilioSurface(req) {
|
|
109
|
+
const headers = lowerHeaders(req.headers);
|
|
110
|
+
const path = (req.path.split('?')[0] ?? '/').replace(/\/+$/, '') || '/';
|
|
111
|
+
const explicitHost = req.host ?? headers['host'];
|
|
112
|
+
if (explicitHost === 'api.twilio.com')
|
|
113
|
+
return 'api';
|
|
114
|
+
if (explicitHost === 'verify.twilio.com')
|
|
115
|
+
return 'verify';
|
|
116
|
+
if (explicitHost === 'lookups.twilio.com')
|
|
117
|
+
return 'lookup';
|
|
118
|
+
if (path.includes('/Services/'))
|
|
119
|
+
return 'verify';
|
|
120
|
+
if (path.includes('/PhoneNumbers/'))
|
|
121
|
+
return 'lookup';
|
|
122
|
+
if (path.includes('/Accounts/'))
|
|
123
|
+
return 'api';
|
|
124
|
+
return 'api'; // default (documented above)
|
|
125
|
+
}
|
|
126
|
+
// ── error envelope (Twilio's OWN documented shape — NOT fal's {detail}) ──────────────────────
|
|
127
|
+
// {code, message, more_info, status} — grounded verbatim from twilio.com/docs/api/errors/<code>
|
|
128
|
+
// (live fetch) AND the installed SDK's own `RestException` constructor (`body.message`/
|
|
129
|
+
// `body.code`/`body.more_info` — this envelope round-trips through it unchanged, confirmed live).
|
|
130
|
+
function twilioError(code, message, httpStatus) {
|
|
131
|
+
return { status: httpStatus, body: { code, message, more_info: `https://www.twilio.com/docs/errors/${code}`, status: httpStatus } };
|
|
132
|
+
}
|
|
133
|
+
function notFound() {
|
|
134
|
+
return twilioError(20404, 'The requested resource was not found', 404);
|
|
135
|
+
}
|
|
136
|
+
// ── form-urlencoded body parsing (build spec §1 divergence 2 / §13.9) — NOT JSON.parse ────────
|
|
137
|
+
// A JSON-shaped body (starts with `{`/`[` after trimming) is rejected vendor-shaped 400 — a real
|
|
138
|
+
// Twilio endpoint fed a JSON content body would fail to parse its expected form fields the same
|
|
139
|
+
// way (this twin makes that failure explicit and typed rather than silently misparsing).
|
|
140
|
+
function parseFormBody(body) {
|
|
141
|
+
if (body === undefined || body === '')
|
|
142
|
+
return { ok: true, value: {} };
|
|
143
|
+
const trimmed = body.trim();
|
|
144
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('['))
|
|
145
|
+
return { ok: false };
|
|
146
|
+
const params = new URLSearchParams(body);
|
|
147
|
+
const value = {};
|
|
148
|
+
for (const [k, v] of params)
|
|
149
|
+
value[k] = v;
|
|
150
|
+
return { ok: true, value };
|
|
151
|
+
}
|
|
152
|
+
function nowIso(occurredAt) {
|
|
153
|
+
return occurredAt ?? new Date().toISOString();
|
|
154
|
+
}
|
|
155
|
+
/** Twilio's own message/account timestamp wire format (RFC 2822-ish, e.g. "Fri, 24 May 2019
|
|
156
|
+
* 17:44:46 +0000") — grounded from the twilio_api_v2010.json Messages-list example AND the
|
|
157
|
+
* SDK's own `deserialize.rfc2822DateTime` parse target. */
|
|
158
|
+
function rfc2822(occurredAt) {
|
|
159
|
+
return new Date(nowIso(occurredAt)).toUTCString().replace('GMT', '+0000');
|
|
160
|
+
}
|
|
161
|
+
function kid(type, id) {
|
|
162
|
+
return `${type}:${id}`;
|
|
163
|
+
}
|
|
164
|
+
// ── projection helpers ──────────────────────────────────────────────────────────────
|
|
165
|
+
function rows(type, root) {
|
|
166
|
+
const prefix = `${type}:`;
|
|
167
|
+
return projectResources(SERVICE, root)
|
|
168
|
+
.filter((r) => r.type === type && r.id.startsWith(prefix) && r._deleted !== true)
|
|
169
|
+
.map((r) => ({ ...r, id: r.id.slice(prefix.length) }));
|
|
170
|
+
}
|
|
171
|
+
function getRow(type, id, root) {
|
|
172
|
+
return rows(type, root).find((r) => r.id === id);
|
|
173
|
+
}
|
|
174
|
+
function view(r) {
|
|
175
|
+
const { type: _t, updatedAt: _u, ...rest } = r;
|
|
176
|
+
const out = {};
|
|
177
|
+
for (const [k, v] of Object.entries(rest))
|
|
178
|
+
if (!k.startsWith('_'))
|
|
179
|
+
out[k] = v;
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/** EVERY Twilio resource this pack serves is public-addressed by `sid` (SM…/VE…/PN…), never the
|
|
183
|
+
* kernel's generic `id` field name — `rows()`/`getRow()` strip the kernel's type-prefix off the
|
|
184
|
+
* subject id into `.id` (an internal convention shared with every sibling pack), but Twilio's
|
|
185
|
+
* OWN wire shape has no bare `id` field at all. This renames it on the way out — the one place a
|
|
186
|
+
* kernel-internal field name touches an HTTP response body. */
|
|
187
|
+
function sidView(r) {
|
|
188
|
+
const v = view(r);
|
|
189
|
+
const { id, ...rest } = v;
|
|
190
|
+
return { sid: id, ...rest };
|
|
191
|
+
}
|
|
192
|
+
/** phone_number rows rename the reserved `type` field to `number_type` on write (kernel gotcha —
|
|
193
|
+
* see header) — this remaps it BACK to `type` on read, the only place the real vendor field name
|
|
194
|
+
* is restored (and, like `sidView`, renames `id` -> `sid`). */
|
|
195
|
+
function phoneNumberView(r) {
|
|
196
|
+
const base = sidView(r);
|
|
197
|
+
const { number_type, ...rest } = base;
|
|
198
|
+
return { ...rest, type: number_type ?? null };
|
|
199
|
+
}
|
|
200
|
+
async function write(type, id, fields, op, req) {
|
|
201
|
+
const { resource } = await applyTwinWrite(SERVICE, { operation: op, subjectType: type, subjectId: kid(type, id), fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
202
|
+
return sidView({ ...resource, id });
|
|
203
|
+
}
|
|
204
|
+
// ── ids ────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
// DETERMINISTIC (R9, resource level): every SID's 32 hex characters are a stable hash of
|
|
206
|
+
// `${type}:${occurredAt}:${ordinal}` — the world instant the write happened at plus the count of
|
|
207
|
+
// rows the type ALREADY holds, read BEFORE the insert. It used to be a sha256 of a fresh
|
|
208
|
+
// per-call UUID, so two identical worlds served different SIDs and no replay of this pack could
|
|
209
|
+
// ever be byte-identical. Two identical sends at ONE instant still land as two messages with
|
|
210
|
+
// distinct SIDs: the ordinal moved between them. The SHAPE is unchanged (the vendor's
|
|
211
|
+
// `SM`/`VE`/`VL` + 32 lowercase hex).
|
|
212
|
+
function stableSid(prefix, seed) {
|
|
213
|
+
return `${prefix}${createHash('sha256').update(seed).digest('hex').slice(0, 32)}`;
|
|
214
|
+
}
|
|
215
|
+
function newMessageSid(req) {
|
|
216
|
+
return stableSid('SM', `message:${req.occurredAt ?? ''}:${rows('message', req.root).length}`);
|
|
217
|
+
}
|
|
218
|
+
function newVerificationSid(req) {
|
|
219
|
+
return stableSid('VE', `verification:${req.occurredAt ?? ''}:${rows('verification', req.root).length}`);
|
|
220
|
+
}
|
|
221
|
+
function newPhoneNumberSid(seed) {
|
|
222
|
+
return `PN${createHash('sha256').update(seed).digest('hex').slice(0, 32)}`;
|
|
223
|
+
}
|
|
224
|
+
// ── MESSAGES ──────────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
const E164_RE = /^\+[1-9]\d{6,14}$/;
|
|
226
|
+
/** num_segments is computed once at send time from the body's length (GSM-7 single-segment
|
|
227
|
+
* threshold 160 chars, concatenated-segment threshold 153 — a well-established, if not
|
|
228
|
+
* byte-for-byte SDK-confirmed, SMS-encoding convention; the field is a STRING on the wire,
|
|
229
|
+
* confirmed from the live OpenAPI example). */
|
|
230
|
+
function computeNumSegments(body) {
|
|
231
|
+
if (body.length === 0)
|
|
232
|
+
return '1';
|
|
233
|
+
if (body.length <= 160)
|
|
234
|
+
return '1';
|
|
235
|
+
return String(Math.ceil(body.length / 153));
|
|
236
|
+
}
|
|
237
|
+
function messageUrls(accountSid, sid) {
|
|
238
|
+
const base = `/${API_VERSION}/Accounts/${accountSid}/Messages/${sid}`;
|
|
239
|
+
return { uri: `${base}.json`, subresource_uris: { media: `${base}/Media.json`, feedback: `${base}/Feedback.json` } };
|
|
240
|
+
}
|
|
241
|
+
async function handleMessageCreate(accountSid, form, req) {
|
|
242
|
+
const to = form.To;
|
|
243
|
+
const from = form.From ?? '';
|
|
244
|
+
const body = form.Body ?? '';
|
|
245
|
+
if (!to)
|
|
246
|
+
return twilioError(21604, "The destination 'To' phone number is required to send an SMS", 400);
|
|
247
|
+
if (!E164_RE.test(to))
|
|
248
|
+
return twilioError(21211, "Invalid 'To' Phone Number", 400);
|
|
249
|
+
const sid = newMessageSid(req);
|
|
250
|
+
const createdAt = nowIso(req.occurredAt);
|
|
251
|
+
await write('message', sid, {
|
|
252
|
+
account_sid: accountSid,
|
|
253
|
+
api_version: API_VERSION,
|
|
254
|
+
body,
|
|
255
|
+
to,
|
|
256
|
+
from,
|
|
257
|
+
direction: 'outbound-api',
|
|
258
|
+
status: 'queued',
|
|
259
|
+
num_segments: computeNumSegments(body),
|
|
260
|
+
num_media: '0',
|
|
261
|
+
messaging_service_sid: form.MessagingServiceSid ?? null,
|
|
262
|
+
date_created: rfc2822(createdAt),
|
|
263
|
+
date_updated: rfc2822(createdAt),
|
|
264
|
+
date_sent: null,
|
|
265
|
+
error_code: null,
|
|
266
|
+
error_message: null,
|
|
267
|
+
price: null,
|
|
268
|
+
price_unit: null,
|
|
269
|
+
poll_count: 0,
|
|
270
|
+
_status_callback: form.StatusCallback ?? null, // internal bookkeeping (not a real Message response field) -> stripped by view()
|
|
271
|
+
}, 'message.create', req);
|
|
272
|
+
const row = getRow('message', sid, req.root);
|
|
273
|
+
return { status: 201, body: { ...sidView(row), ...messageUrls(accountSid, sid) } };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Deterministic, kernel-folded poll-count progression (mirrors fal-twin.ts's `progressOnce`,
|
|
277
|
+
* ported to Twilio's 4-step delivery lifecycle, build spec §2/§6): the FIRST status poll after
|
|
278
|
+
* send is a `queued` echo, the SECOND transitions to `sending`, the THIRD to `sent` (stamping
|
|
279
|
+
* `date_sent`), the FOURTH (and every poll thereafter) to `delivered` — terminal, idempotent. No
|
|
280
|
+
* wall clock, no timers.
|
|
281
|
+
*/
|
|
282
|
+
async function progressMessageOnce(sid, req) {
|
|
283
|
+
const row = getRow('message', sid, req.root);
|
|
284
|
+
if (!row)
|
|
285
|
+
return;
|
|
286
|
+
const pollCount = row.poll_count ?? 0;
|
|
287
|
+
const status = row.status;
|
|
288
|
+
if (status === 'queued') {
|
|
289
|
+
if (pollCount === 0) {
|
|
290
|
+
await write('message', sid, { poll_count: pollCount + 1 }, 'message.poll', req);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
await write('message', sid, { status: 'sending', poll_count: pollCount + 1 }, 'message.progress', req);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (status === 'sending') {
|
|
297
|
+
await write('message', sid, { status: 'sent', date_sent: rfc2822(nowIso(req.occurredAt)), date_updated: rfc2822(nowIso(req.occurredAt)), poll_count: pollCount + 1 }, 'message.progress', req);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (status === 'sent') {
|
|
301
|
+
await write('message', sid, { status: 'delivered', date_updated: rfc2822(nowIso(req.occurredAt)), poll_count: pollCount + 1 }, 'message.complete', req);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// delivered / undelivered / failed: terminal — idempotent no-op.
|
|
305
|
+
}
|
|
306
|
+
async function handleMessageGet(accountSid, sid, req) {
|
|
307
|
+
const existing = getRow('message', sid, req.root);
|
|
308
|
+
if (!existing)
|
|
309
|
+
return notFound();
|
|
310
|
+
await progressMessageOnce(sid, req);
|
|
311
|
+
const row = getRow('message', sid, req.root);
|
|
312
|
+
return { status: 200, body: { ...sidView(row), ...messageUrls(accountSid, sid) } };
|
|
313
|
+
}
|
|
314
|
+
function handleMessageList(accountSid, req) {
|
|
315
|
+
const messages = rows('message', req.root)
|
|
316
|
+
.filter((r) => r.account_sid === accountSid)
|
|
317
|
+
.map((r) => ({ ...sidView(r), ...messageUrls(accountSid, r.id) }));
|
|
318
|
+
const listUri = `/${API_VERSION}/Accounts/${accountSid}/Messages.json`;
|
|
319
|
+
return {
|
|
320
|
+
status: 200,
|
|
321
|
+
body: {
|
|
322
|
+
end: Math.max(messages.length - 1, 0),
|
|
323
|
+
first_page_uri: `${listUri}?PageSize=50&Page=0`,
|
|
324
|
+
next_page_uri: null,
|
|
325
|
+
page: 0,
|
|
326
|
+
page_size: 50,
|
|
327
|
+
previous_page_uri: null,
|
|
328
|
+
messages,
|
|
329
|
+
uri: listUri,
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// ── ACCOUNTS (the world's OWN credential, established once and persisted — see header note) ───
|
|
334
|
+
// The `type` field is still a plain JSON literal on the way out, never a `fields` write, so the
|
|
335
|
+
// kernel's reserved-row-meta collision the build spec found stays avoided; what changed is WHERE
|
|
336
|
+
// `auth_token` comes from — the root's persisted `signing_key` row (twilio-credentials.ts), not a
|
|
337
|
+
// hash of the world's directory. Async because reading a virgin root's credential establishes it.
|
|
338
|
+
async function handleAccountFetch(sid, req) {
|
|
339
|
+
const at = rfc2822(nowIso(req.occurredAt));
|
|
340
|
+
const uri = `/${API_VERSION}/Accounts/${sid}.json`;
|
|
341
|
+
// A read-only world cannot be written to, so it reports what it already holds and otherwise an
|
|
342
|
+
// empty token — never a fresh one it would forget the moment the response is sent.
|
|
343
|
+
const creds = req.readOnly ? storedTwilioCredentials(req.root) : await twilioCredentials(req.root);
|
|
344
|
+
return {
|
|
345
|
+
status: 200,
|
|
346
|
+
body: {
|
|
347
|
+
sid,
|
|
348
|
+
auth_token: creds?.authToken ?? '',
|
|
349
|
+
friendly_name: 'Twin Account',
|
|
350
|
+
status: 'active',
|
|
351
|
+
type: 'Full',
|
|
352
|
+
owner_account_sid: sid,
|
|
353
|
+
date_created: at,
|
|
354
|
+
date_updated: at,
|
|
355
|
+
uri,
|
|
356
|
+
subresource_uris: {
|
|
357
|
+
messages: `/${API_VERSION}/Accounts/${sid}/Messages.json`,
|
|
358
|
+
incoming_phone_numbers: `/${API_VERSION}/Accounts/${sid}/IncomingPhoneNumbers.json`,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
// ── INCOMING PHONE NUMBERS (lazily-seeded, kernel-persisted — "fold on first touch") ──────────
|
|
364
|
+
const SEED_PHONE_NUMBERS = [
|
|
365
|
+
{ phone_number: '+15550001111', friendly_name: 'Twin Local Number', numberType: 'local' },
|
|
366
|
+
{ phone_number: '+18005550100', friendly_name: 'Twin Toll-Free Number', numberType: 'toll-free' },
|
|
367
|
+
];
|
|
368
|
+
async function ensureSeedPhoneNumbers(accountSid, req) {
|
|
369
|
+
const existing = rows('phone_number', req.root);
|
|
370
|
+
if (existing.length > 0)
|
|
371
|
+
return;
|
|
372
|
+
const at = rfc2822(nowIso(req.occurredAt));
|
|
373
|
+
for (const seed of SEED_PHONE_NUMBERS) {
|
|
374
|
+
const sid = newPhoneNumberSid(seed.phone_number);
|
|
375
|
+
await write('phone_number', sid, {
|
|
376
|
+
account_sid: accountSid,
|
|
377
|
+
phone_number: seed.phone_number,
|
|
378
|
+
friendly_name: seed.friendly_name,
|
|
379
|
+
number_type: seed.numberType, // renamed from Twilio's real top-level `type` — kernel gotcha (see header)
|
|
380
|
+
capabilities: { voice: true, sms: true, mms: true, fax: false },
|
|
381
|
+
sms_url: null,
|
|
382
|
+
voice_url: null,
|
|
383
|
+
status_callback: null,
|
|
384
|
+
date_created: at,
|
|
385
|
+
date_updated: at,
|
|
386
|
+
uri: `/${API_VERSION}/Accounts/${accountSid}/IncomingPhoneNumbers/${sid}.json`,
|
|
387
|
+
}, 'phone_number.seed', req);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async function handleIncomingPhoneNumbersList(accountSid, req) {
|
|
391
|
+
await ensureSeedPhoneNumbers(accountSid, req);
|
|
392
|
+
const listUri = `/${API_VERSION}/Accounts/${accountSid}/IncomingPhoneNumbers.json`;
|
|
393
|
+
const numbers = rows('phone_number', req.root).filter((r) => r.account_sid === accountSid).map(phoneNumberView);
|
|
394
|
+
return {
|
|
395
|
+
status: 200,
|
|
396
|
+
body: {
|
|
397
|
+
end: Math.max(numbers.length - 1, 0),
|
|
398
|
+
first_page_uri: `${listUri}?PageSize=50&Page=0`,
|
|
399
|
+
next_page_uri: null,
|
|
400
|
+
page: 0,
|
|
401
|
+
page_size: 50,
|
|
402
|
+
previous_page_uri: null,
|
|
403
|
+
incoming_phone_numbers: numbers,
|
|
404
|
+
uri: listUri,
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
// ── VERIFY (start/check, twin-internal deterministic expected code) ───────────────────────────
|
|
409
|
+
// The real OTP is never sent (D2 honesty — README/`## Coverage` disclose this): the expected code
|
|
410
|
+
// is a PURE, deterministic function of (serviceSid, to) so both this handler and a verify()/test
|
|
411
|
+
// can independently RE-DERIVE the same 6-digit code without any shared mutable state, exactly the
|
|
412
|
+
// build spec's own formula (§3/§6).
|
|
413
|
+
function expectedVerificationCode(serviceSid, to) {
|
|
414
|
+
const hex = createHash('sha256').update(`${serviceSid}:${to}`).digest('hex').slice(0, 6);
|
|
415
|
+
return String(parseInt(hex, 16) % 1_000_000).padStart(6, '0');
|
|
416
|
+
}
|
|
417
|
+
function verificationLookupKey(serviceSid, to) {
|
|
418
|
+
return `${serviceSid}::${to}`;
|
|
419
|
+
}
|
|
420
|
+
function findVerificationByLookup(serviceSid, to, root) {
|
|
421
|
+
return rows('verification', root).find((r) => r._lookup_key === verificationLookupKey(serviceSid, to));
|
|
422
|
+
}
|
|
423
|
+
async function handleVerificationStart(serviceSid, form, req) {
|
|
424
|
+
const to = form.To;
|
|
425
|
+
const channel = form.Channel;
|
|
426
|
+
if (!to || !channel)
|
|
427
|
+
return twilioError(60200, 'Invalid parameter', 400);
|
|
428
|
+
const sid = newVerificationSid(req);
|
|
429
|
+
const at = nowIso(req.occurredAt);
|
|
430
|
+
await write('verification', sid, {
|
|
431
|
+
service_sid: serviceSid,
|
|
432
|
+
account_sid: (await twilioCredentials(req.root)).accountSid,
|
|
433
|
+
to,
|
|
434
|
+
channel,
|
|
435
|
+
status: 'pending',
|
|
436
|
+
valid: false,
|
|
437
|
+
lookup: {},
|
|
438
|
+
amount: null,
|
|
439
|
+
payee: null,
|
|
440
|
+
send_code_attempts: [{ time: at, channel, attempt_sid: stableSid('VL', `${sid}:attempt:0`) }],
|
|
441
|
+
sna: null,
|
|
442
|
+
date_created: at,
|
|
443
|
+
date_updated: at,
|
|
444
|
+
_lookup_key: verificationLookupKey(serviceSid, to), // internal index -> stripped by view() (never leaked to a caller)
|
|
445
|
+
_expected_code: expectedVerificationCode(serviceSid, to), // underscore-prefixed -> stripped by view() (never leaked to a caller)
|
|
446
|
+
}, 'verification.create', req);
|
|
447
|
+
const row = getRow('verification', sid, req.root);
|
|
448
|
+
return { status: 201, body: { ...sidView(row), url: `https://verify.twilio.com/v2/Services/${serviceSid}/Verifications/${sid}` } };
|
|
449
|
+
}
|
|
450
|
+
async function handleVerificationCheck(serviceSid, form, req) {
|
|
451
|
+
const code = form.Code ?? '';
|
|
452
|
+
const to = form.To;
|
|
453
|
+
const verificationSid = form.VerificationSid;
|
|
454
|
+
let row = verificationSid ? getRow('verification', verificationSid, req.root) : undefined;
|
|
455
|
+
if (!row && to)
|
|
456
|
+
row = findVerificationByLookup(serviceSid, to, req.root);
|
|
457
|
+
if (!row)
|
|
458
|
+
return notFound();
|
|
459
|
+
const sid = row.id;
|
|
460
|
+
const currentStatus = row.status;
|
|
461
|
+
if (currentStatus === 'approved') {
|
|
462
|
+
return { status: 200, body: { ...sidView(row), url: `https://verify.twilio.com/v2/Services/${serviceSid}/Verifications/${sid}` } };
|
|
463
|
+
}
|
|
464
|
+
const expected = row._expected_code;
|
|
465
|
+
const approves = code.length > 0 && code === expected;
|
|
466
|
+
if (approves) {
|
|
467
|
+
await write('verification', sid, { status: 'approved', valid: true, date_updated: nowIso(req.occurredAt) }, 'verification.check', req);
|
|
468
|
+
}
|
|
469
|
+
const updated = getRow('verification', sid, req.root);
|
|
470
|
+
return { status: 200, body: { ...sidView(updated), url: `https://verify.twilio.com/v2/Services/${serviceSid}/Verifications/${sid}` } };
|
|
471
|
+
}
|
|
472
|
+
// ── LOOKUP (PURE deterministic derivation — no kernel row, build spec §3) ─────────────────────
|
|
473
|
+
function deriveLineType(digits) {
|
|
474
|
+
const h = parseInt(createHash('sha256').update(digits).digest('hex').slice(0, 4), 16);
|
|
475
|
+
const kinds = ['mobile', 'landline', 'voip'];
|
|
476
|
+
return kinds[h % kinds.length];
|
|
477
|
+
}
|
|
478
|
+
function handleLookup(e164, fields, req) {
|
|
479
|
+
if (!E164_RE.test(e164))
|
|
480
|
+
return notFound();
|
|
481
|
+
const digits = e164.slice(1);
|
|
482
|
+
const countryCode = digits.startsWith('1') && digits.length === 11 ? 'US' : 'XX';
|
|
483
|
+
const nationalFormat = countryCode === 'US'
|
|
484
|
+
? `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`
|
|
485
|
+
: digits;
|
|
486
|
+
const body = {
|
|
487
|
+
calling_country_code: digits.startsWith('1') ? '1' : digits.slice(0, 2),
|
|
488
|
+
country_code: countryCode,
|
|
489
|
+
phone_number: e164,
|
|
490
|
+
national_format: nationalFormat,
|
|
491
|
+
valid: true,
|
|
492
|
+
validation_errors: null,
|
|
493
|
+
caller_name: null,
|
|
494
|
+
sim_swap: null,
|
|
495
|
+
call_forwarding: null,
|
|
496
|
+
live_activity: null,
|
|
497
|
+
line_type_intelligence: null,
|
|
498
|
+
identity_match: null,
|
|
499
|
+
reassigned_number: null,
|
|
500
|
+
sms_pumping_risk: null,
|
|
501
|
+
phone_number_quality_score: null,
|
|
502
|
+
pre_fill: null,
|
|
503
|
+
url: `https://lookups.twilio.com/v2/PhoneNumbers/${encodeURIComponent(e164)}`,
|
|
504
|
+
};
|
|
505
|
+
const requested = fields.split(',').map((f) => f.trim()).filter(Boolean);
|
|
506
|
+
if (requested.includes('line_type_intelligence')) {
|
|
507
|
+
body.line_type_intelligence = {
|
|
508
|
+
// `type` is NESTED here (never a top-level kernel field) — see header. Lookup has no kernel
|
|
509
|
+
// row at all, so the reserved-field gotcha is moot for this route regardless.
|
|
510
|
+
type: deriveLineType(digits),
|
|
511
|
+
mobile_country_code: countryCode === 'US' ? '310' : null,
|
|
512
|
+
mobile_network_code: countryCode === 'US' ? '150' : null,
|
|
513
|
+
carrier_name: `Twin Carrier ${digits.slice(-3)}`,
|
|
514
|
+
error_code: null,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
return { status: 200, body };
|
|
518
|
+
}
|
|
519
|
+
// ── route handler ────────────────────────────────────────────────────────────────────
|
|
520
|
+
export async function handleTwilioTwinRequest(req) {
|
|
521
|
+
const method = req.method.toUpperCase();
|
|
522
|
+
const [rawPath, rawQuery] = req.path.split('?');
|
|
523
|
+
const path = (rawPath ?? '/').replace(/\/+$/, '') || '/';
|
|
524
|
+
const surface = routeTwilioSurface({ host: req.host, headers: req.headers, path });
|
|
525
|
+
const query = new URLSearchParams(rawQuery ?? '');
|
|
526
|
+
if (req.readOnly && method !== 'GET' && method !== 'HEAD') {
|
|
527
|
+
return { status: 405, body: { code: 20005, message: 'twin is read-only; omit readOnly to accept writes', more_info: 'https://www.twilio.com/docs/errors/20005', status: 405 } };
|
|
528
|
+
}
|
|
529
|
+
// TRUST ON FIRST USE — the behavioral contract's (adding-a-twin.md, "Keep the three kinds of credential apart") "fake vendor creds an app presents are twin STATE,
|
|
530
|
+
// seeded via the wire". The real Twilio SDK authenticates every call with
|
|
531
|
+
// `Authorization: Basic base64(AccountSid:AuthToken)`, so an app wired at this twin already
|
|
532
|
+
// presents its pair without being asked to do anything special: on a VIRGIN world that pair
|
|
533
|
+
// becomes the world's own (persisted as the declared `signing_key` resource), which is what
|
|
534
|
+
// makes `GET Accounts/{Sid}.json` hand the app back its OWN AuthToken and what makes every
|
|
535
|
+
// `X-Twilio-Signature` this world signs verify against the key the app already holds. It happens
|
|
536
|
+
// exactly once: `twilioCredentials` short-circuits on a stored pair, and a world that has one is
|
|
537
|
+
// never re-seeded. A read-only world cannot be written to, so it is never seeded at all.
|
|
538
|
+
if (!req.readOnly) {
|
|
539
|
+
const presented = presentedTwilioCredentials(req.headers);
|
|
540
|
+
if (presented)
|
|
541
|
+
await twilioCredentials(req.root, presented);
|
|
542
|
+
}
|
|
543
|
+
let form = {};
|
|
544
|
+
if (method === 'POST' || method === 'PUT') {
|
|
545
|
+
const parsed = parseFormBody(req.body);
|
|
546
|
+
if (!parsed.ok)
|
|
547
|
+
return twilioError(60200, 'Invalid parameter: request body must be application/x-www-form-urlencoded, not JSON', 400);
|
|
548
|
+
form = parsed.value;
|
|
549
|
+
}
|
|
550
|
+
if (path === '/' || path === '') {
|
|
551
|
+
return { status: 200, body: { service: 'twilio', object: 'twin' } };
|
|
552
|
+
}
|
|
553
|
+
const seg = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
554
|
+
// ── api.twilio.com: /2010-04-01/Accounts/{AccountSid}/... ──
|
|
555
|
+
if (surface === 'api') {
|
|
556
|
+
if (seg[0] !== API_VERSION || seg[1] !== 'Accounts' || !seg[2])
|
|
557
|
+
return notFound();
|
|
558
|
+
const third = decodeURIComponent(seg[2]);
|
|
559
|
+
// Accounts/{Sid}.json — the ACCOUNT ITSELF: exactly 3 segments, the 3rd IS "{Sid}.json"
|
|
560
|
+
// (no further nesting — distinct from the {AccountSid}/{Resource...} shape below, where the
|
|
561
|
+
// 3rd segment is the bare AccountSid with NO .json suffix).
|
|
562
|
+
if (seg.length === 3 && third.endsWith('.json') && method === 'GET') {
|
|
563
|
+
return await handleAccountFetch(third.slice(0, -'.json'.length), req);
|
|
564
|
+
}
|
|
565
|
+
const accountSid = third;
|
|
566
|
+
const rest = seg.slice(3);
|
|
567
|
+
if (rest.length === 1 && rest[0] === 'Messages.json' && method === 'POST') {
|
|
568
|
+
return handleMessageCreate(accountSid, form, req);
|
|
569
|
+
}
|
|
570
|
+
if (rest.length === 1 && rest[0] === 'Messages.json' && method === 'GET') {
|
|
571
|
+
return handleMessageList(accountSid, req);
|
|
572
|
+
}
|
|
573
|
+
if (rest.length === 2 && rest[0] === 'Messages' && rest[1].endsWith('.json') && method === 'GET') {
|
|
574
|
+
const sid = rest[1].slice(0, -'.json'.length);
|
|
575
|
+
return handleMessageGet(accountSid, sid, req);
|
|
576
|
+
}
|
|
577
|
+
if (rest.length === 1 && rest[0] === 'IncomingPhoneNumbers.json' && method === 'GET') {
|
|
578
|
+
return handleIncomingPhoneNumbersList(accountSid, req);
|
|
579
|
+
}
|
|
580
|
+
return notFound();
|
|
581
|
+
}
|
|
582
|
+
// ── verify.twilio.com: /v2/Services/{ServiceSid}/Verifications[Check] ──
|
|
583
|
+
if (surface === 'verify') {
|
|
584
|
+
// seg: ['v2','Services',serviceSid, 'Verifications' | 'VerificationCheck' | ...]
|
|
585
|
+
if (seg[0] !== 'v2' || seg[1] !== 'Services' || !seg[2])
|
|
586
|
+
return notFound();
|
|
587
|
+
const serviceSid = decodeURIComponent(seg[2]);
|
|
588
|
+
const rest = seg.slice(3);
|
|
589
|
+
if (rest.length === 1 && rest[0] === 'Verifications' && method === 'POST') {
|
|
590
|
+
return handleVerificationStart(serviceSid, form, req);
|
|
591
|
+
}
|
|
592
|
+
if (rest.length === 1 && rest[0] === 'VerificationCheck' && method === 'POST') {
|
|
593
|
+
return handleVerificationCheck(serviceSid, form, req);
|
|
594
|
+
}
|
|
595
|
+
// NIT: GET fetch-by-sid — an unmodeled-but-present convenience route (not a claimed capability;
|
|
596
|
+
// harmless superset over the start/check surface actually tracked in capability-manifests.ts).
|
|
597
|
+
if (rest.length === 2 && rest[0] === 'Verifications' && method === 'GET') {
|
|
598
|
+
const row = getRow('verification', decodeURIComponent(rest[1]), req.root);
|
|
599
|
+
if (!row)
|
|
600
|
+
return notFound();
|
|
601
|
+
return { status: 200, body: { ...sidView(row), url: `https://verify.twilio.com/v2/Services/${serviceSid}/Verifications/${row.id}` } };
|
|
602
|
+
}
|
|
603
|
+
return notFound();
|
|
604
|
+
}
|
|
605
|
+
// ── lookups.twilio.com: /v2/PhoneNumbers/{E164} ──
|
|
606
|
+
if (surface === 'lookup') {
|
|
607
|
+
if (seg[0] !== 'v2' || seg[1] !== 'PhoneNumbers' || !seg[2])
|
|
608
|
+
return notFound();
|
|
609
|
+
if (method !== 'GET')
|
|
610
|
+
return notFound();
|
|
611
|
+
const e164 = decodeURIComponent(seg.slice(2).join('/'));
|
|
612
|
+
return handleLookup(e164, query.get('Fields') ?? '', req);
|
|
613
|
+
}
|
|
614
|
+
return notFound();
|
|
615
|
+
}
|
|
616
|
+
// Endpoint inventory used by the conformance snapshot (self-referential — see
|
|
617
|
+
// twilio-conformance.ts header note and docs/contributing/conformance.md's "2 spec" discussion, shared with fal/
|
|
618
|
+
// replicate/elevenlabs/polar).
|
|
619
|
+
export function twilioTwinSnapshot() {
|
|
620
|
+
return {
|
|
621
|
+
resourceTypes: TWILIO_RESOURCE_TYPES,
|
|
622
|
+
implementedEndpoints: [
|
|
623
|
+
'POST api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json (send, 201)',
|
|
624
|
+
'POST api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json (400 21604 missing To)',
|
|
625
|
+
'POST api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json (400 21211 invalid To)',
|
|
626
|
+
'GET api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages/{Sid}.json (status poll-progression)',
|
|
627
|
+
'GET api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages/{Sid}.json (404 unknown sid)',
|
|
628
|
+
'GET api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json (list envelope)',
|
|
629
|
+
'GET api.twilio.com/2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json (seeded list)',
|
|
630
|
+
'GET api.twilio.com/2010-04-01/Accounts/{Sid}.json (accounts fetch, pure derivation)',
|
|
631
|
+
'POST verify.twilio.com/v2/Services/{ServiceSid}/Verifications (start, 201)',
|
|
632
|
+
'POST verify.twilio.com/v2/Services/{ServiceSid}/Verifications (400 60200 missing param)',
|
|
633
|
+
'POST verify.twilio.com/v2/Services/{ServiceSid}/VerificationCheck (correct code -> approved)',
|
|
634
|
+
'POST verify.twilio.com/v2/Services/{ServiceSid}/VerificationCheck (wrong code -> pending)',
|
|
635
|
+
'POST verify.twilio.com/v2/Services/{ServiceSid}/VerificationCheck (404 unknown)',
|
|
636
|
+
'GET lookups.twilio.com/v2/PhoneNumbers/{E164} (basic)',
|
|
637
|
+
'GET lookups.twilio.com/v2/PhoneNumbers/{E164}?Fields=line_type_intelligence',
|
|
638
|
+
'GET lookups.twilio.com/v2/PhoneNumbers/{E164} (404 invalid number)',
|
|
639
|
+
],
|
|
640
|
+
};
|
|
641
|
+
}
|