@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.
@@ -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
+ }