@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,162 @@
|
|
|
1
|
+
// Twilio CONNECTOR — the live-vendor pull path that gives the Twilio twin the "git for SaaS"
|
|
2
|
+
// pull lifecycle over an INJECTED client (the auth boundary).
|
|
3
|
+
//
|
|
4
|
+
// PULL (real -> twin) is LIST-DRIVEN for Messages (grounded — the real Twilio REST API has a
|
|
5
|
+
// genuine `GET .../Messages.json` list endpoint, unlike fal's handle-only queue surface), so
|
|
6
|
+
// `syncTwilioFromReal` calls `client.messages.list()` directly, with no caller-supplied handles.
|
|
7
|
+
//
|
|
8
|
+
// VERIFICATIONS ARE NOT PULLED YET (`twilio.connector.pull_verifications`, todo — see
|
|
9
|
+
// pull-audit.json's grounded gap citation). This is a genuine, disclosed scope decision, not an
|
|
10
|
+
// oversight: Twilio's real Verify v2 API has NO bulk "list every verification across a service"
|
|
11
|
+
// endpoint (grounded — the fetched twilio_verify_v2.json OpenAPI document exposes only
|
|
12
|
+
// POST .../Verifications (create), GET/POST .../Verifications/{Sid} (fetch/update), and
|
|
13
|
+
// POST .../VerificationCheck — never a `GET .../Verifications` collection route), so a connector
|
|
14
|
+
// would need caller-supplied handles (mirroring fal's `pullFalRequests`) to pull verifications at
|
|
15
|
+
// all. `mapVerification` (a PURE mapper, below) is written now so that future handle-driven pull
|
|
16
|
+
// entry point has a ready shape-mapper, but nothing calls it yet — kept honest as `todo` rather
|
|
17
|
+
// than silently wired into a `sync*` entrypoint the real vendor doesn't support today.
|
|
18
|
+
//
|
|
19
|
+
// The vendor I/O is an INJECTED client interface (`TwilioLikeClient`): a fake in tests, the real
|
|
20
|
+
// `twilio` SDK's own `messages` namespace in prod (`client.messages.list()` on a real
|
|
21
|
+
// `twilio(accountSid, authToken)` instance is structurally assignable). The pack imports NO SDK
|
|
22
|
+
// and holds NO key.
|
|
23
|
+
import { syncPull } from '@volter/world-core';
|
|
24
|
+
import type { SyncResource } from '@volter/world-core';
|
|
25
|
+
//
|
|
26
|
+
// ── The client-side RATE BUDGET is not optional here ────────────────────────────────────────
|
|
27
|
+
// Twilio documents the CONCEPT of REST API concurrency (a Twilio-Concurrent-Requests header, error
|
|
28
|
+
// 20429) without ever publishing the number, and GET /Messages has no documented rate at all — a
|
|
29
|
+
// vendor that 429s without a published figure is exactly the case a client-side ceiling exists for.
|
|
30
|
+
// Every entrypoint below GUARDS the injected client before touching it (`guardTwilioClient`, which is
|
|
31
|
+
// idempotent — a caller who already wrapped is not double-charged, a caller who forgot is protected
|
|
32
|
+
// anyway); there is deliberately no option that turns the budget off. See twilio-budget.ts.
|
|
33
|
+
import { twilioBudgetOf, guardTwilioClient, type TwilioBudgetedOptions } from './twilio-budget.ts';
|
|
34
|
+
|
|
35
|
+
const SERVICE = 'twilio';
|
|
36
|
+
|
|
37
|
+
export type { TwilioBudgetedOptions };
|
|
38
|
+
|
|
39
|
+
export type TwilioRealMessage = {
|
|
40
|
+
sid: string;
|
|
41
|
+
to: string;
|
|
42
|
+
from: string;
|
|
43
|
+
body: string;
|
|
44
|
+
status: string;
|
|
45
|
+
direction?: string;
|
|
46
|
+
numSegments?: string;
|
|
47
|
+
numMedia?: string;
|
|
48
|
+
dateCreated?: string | null;
|
|
49
|
+
dateSent?: string | null;
|
|
50
|
+
errorCode?: number | null;
|
|
51
|
+
errorMessage?: string | null;
|
|
52
|
+
accountSid?: string | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type TwilioRealVerification = {
|
|
56
|
+
sid: string;
|
|
57
|
+
serviceSid: string;
|
|
58
|
+
to: string;
|
|
59
|
+
channel: string;
|
|
60
|
+
status: string;
|
|
61
|
+
valid: boolean;
|
|
62
|
+
dateCreated?: string | null;
|
|
63
|
+
dateUpdated?: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// The injected client exposes the minimal subset the connector calls; a real `twilio` client
|
|
67
|
+
// instance's own `.messages.list()` method is structurally assignable (same `{sid,to,from,body,
|
|
68
|
+
// status,direction,...}` instance shape the real SDK's `MessageInstance` returns).
|
|
69
|
+
export interface TwilioLikeClient {
|
|
70
|
+
messages?: {
|
|
71
|
+
list: (opts?: { limit?: number }) => Promise<TwilioRealMessage[]>;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function kid(type: string, id: string): string {
|
|
76
|
+
return `${type}:${id}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Pure mapper (real Message -> SyncResource) — never touches a client, so the mutation-test
|
|
80
|
+
* connector-seam sweep (which sabotages every export matching the sync-or-push-or-pull-or-
|
|
81
|
+
* fullSync naming convention) leaves this real, per the pack convention (replicate-connector.ts's
|
|
82
|
+
* `mapModel` / fal-connector.ts's `mapQueueRequest`). */
|
|
83
|
+
export function mapMessage(m: TwilioRealMessage): SyncResource {
|
|
84
|
+
return {
|
|
85
|
+
type: 'message',
|
|
86
|
+
id: kid('message', m.sid),
|
|
87
|
+
fields: {
|
|
88
|
+
account_sid: m.accountSid ?? null,
|
|
89
|
+
api_version: '2010-04-01',
|
|
90
|
+
body: m.body,
|
|
91
|
+
to: m.to,
|
|
92
|
+
from: m.from,
|
|
93
|
+
direction: m.direction ?? 'outbound-api',
|
|
94
|
+
status: m.status,
|
|
95
|
+
num_segments: m.numSegments ?? '1',
|
|
96
|
+
num_media: m.numMedia ?? '0',
|
|
97
|
+
messaging_service_sid: null,
|
|
98
|
+
date_created: m.dateCreated ?? null,
|
|
99
|
+
date_updated: m.dateCreated ?? null,
|
|
100
|
+
date_sent: m.dateSent ?? null,
|
|
101
|
+
error_code: m.errorCode ?? null,
|
|
102
|
+
error_message: m.errorMessage ?? null,
|
|
103
|
+
price: null,
|
|
104
|
+
price_unit: null,
|
|
105
|
+
poll_count: 0,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Pure mapper (real Verification -> SyncResource). Not wired into `syncTwilioFromReal` today —
|
|
111
|
+
* see header (`twilio.connector.pull_verifications` is `todo`; Verify v2 has no list-all
|
|
112
|
+
* endpoint to pull from without caller-supplied handles). */
|
|
113
|
+
export function mapVerification(v: TwilioRealVerification): SyncResource {
|
|
114
|
+
return {
|
|
115
|
+
type: 'verification',
|
|
116
|
+
id: kid('verification', v.sid),
|
|
117
|
+
fields: {
|
|
118
|
+
service_sid: v.serviceSid,
|
|
119
|
+
account_sid: null,
|
|
120
|
+
to: v.to,
|
|
121
|
+
channel: v.channel,
|
|
122
|
+
status: v.status,
|
|
123
|
+
valid: v.valid,
|
|
124
|
+
lookup: {},
|
|
125
|
+
amount: null,
|
|
126
|
+
payee: null,
|
|
127
|
+
send_code_attempts: [],
|
|
128
|
+
sna: null,
|
|
129
|
+
date_created: v.dateCreated ?? null,
|
|
130
|
+
date_updated: v.dateUpdated ?? null,
|
|
131
|
+
_lookup_key: `${v.serviceSid}::${v.to}`,
|
|
132
|
+
_expected_code: null, // a pulled (real) verification has no twin-derivable expected code
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Pull real Messages via the injected client's `messages.list()` (list-driven — a genuine
|
|
138
|
+
* Twilio REST list endpoint, unlike fal's handle-only queue). An absent `client.messages`
|
|
139
|
+
* observes nothing. */
|
|
140
|
+
export async function pullTwilioMessages(rawClient: TwilioLikeClient, opts: TwilioBudgetedOptions = {}): Promise<SyncResource[]> {
|
|
141
|
+
const client = guardTwilioClient(rawClient, opts);
|
|
142
|
+
if (!client.messages) return [];
|
|
143
|
+
const list = await client.messages.list();
|
|
144
|
+
return list.map(mapMessage);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* D7 entry point: pull the CURRENT real Messages list and fold it into the twin via ONE
|
|
149
|
+
* `syncPull` (shadow-diff dedup). Returns `{observed, deltasAppended}` — a re-pull of identical
|
|
150
|
+
* state appends ZERO deltas.
|
|
151
|
+
*/
|
|
152
|
+
export async function syncTwilioFromReal(
|
|
153
|
+
rawClient: TwilioLikeClient,
|
|
154
|
+
opts: { root?: string; occurredAt?: string } & TwilioBudgetedOptions = {},
|
|
155
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
156
|
+
// Guard ONCE here and hand the guarded client down, so the list pull cannot run unbudgeted.
|
|
157
|
+
const client = guardTwilioClient(rawClient, twilioBudgetOf(opts));
|
|
158
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
159
|
+
const resources = await pullTwilioMessages(client);
|
|
160
|
+
const result = syncPull({ service: SERVICE, resources, occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
161
|
+
return { observed: result.observed, deltasAppended: result.deltasAppended };
|
|
162
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The Twilio twin's ACCOUNT CREDENTIAL — the `AccountSid` / `AuthToken` pair this world answers
|
|
2
|
+
// for — as twin STATE, never a function of the world's filesystem path.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. The pair used to be `deriveTwilioAuthToken(root)` /
|
|
5
|
+
// `deriveTwilioAccountSid(root)` — `sha256('twilio-twin-auth-token:' + root)` and
|
|
6
|
+
// `'AC' + sha256('twilio-twin-account-sid:' + root).slice(0,32)`: the world's DIRECTORY was the
|
|
7
|
+
// credential. Two byte-identical worlds in different directories then signed webhooks with
|
|
8
|
+
// different keys and served different `auth_token`s from `GET Accounts/{Sid}.json`; a world COPIED
|
|
9
|
+
// to another directory silently rotated its AuthToken out from under every signature a consumer
|
|
10
|
+
// had already verified (R4 says snapshot/fork IS a copy of the root); an addressed namespace (R7b)
|
|
11
|
+
// has no local directory at all; and anyone who knew where a world sat could compute its AuthToken
|
|
12
|
+
// — the one value Twilio's whole webhook-signature scheme rests on. That is environment
|
|
13
|
+
// masquerading as state. The path now never enters the AuthToken, the AccountSid, a signature, a
|
|
14
|
+
// served byte or a stored byte.
|
|
15
|
+
//
|
|
16
|
+
// THE MODEL (the behavioral contract (docs/contributing/adding-a-twin.md, "Keep the three kinds of credential apart"), in substance: "FAKE vendor creds an app presents are twin STATE
|
|
17
|
+
// — validated against the namespace, seeded via the wire or import"). A root's pair is established
|
|
18
|
+
// EXACTLY ONCE and persisted as the pack's declared `signing_key` resource — the declaration that
|
|
19
|
+
// was previously a name with nothing behind it, and Twilio's AuthToken *is* literally the webhook
|
|
20
|
+
// signing key, so the type is the honest one. It is folded through the kernel action log, so it
|
|
21
|
+
// rides snapshots/hydrate/flush like every other fact this twin holds, no `node:fs` touches the
|
|
22
|
+
// serve path, and two serves on one root agree across a process restart. Two ways in:
|
|
23
|
+
// • SEEDED VIA THE WIRE — the first request to reach a virgin world carries Twilio's own
|
|
24
|
+
// `Authorization: Basic base64(AccountSid:AuthToken)`, and that pair becomes the world's. An
|
|
25
|
+
// app already configured with a SID/token therefore finds a twin that agrees with it, and
|
|
26
|
+
// `GET Accounts/{Sid}.json` hands back the app's own AuthToken rather than a stranger's.
|
|
27
|
+
// • MINTED WITH REAL ENTROPY — `twilioCredentials(root)` on a virgin world (what the account
|
|
28
|
+
// read, a test or a fixture does when nothing was presented) generates a vendor-shaped pair
|
|
29
|
+
// from `randomBytes`, so two fresh worlds are distinct BY CONSTRUCTION rather than by hashing
|
|
30
|
+
// where they sit. The pack's "different roots never share a secret" doctrine holds the same
|
|
31
|
+
// way it always did — by construction, now from entropy instead of from a path.
|
|
32
|
+
// A pair that is not vendor-shaped is never adopted, so a caller's typo cannot become a world's
|
|
33
|
+
// permanent credential.
|
|
34
|
+
//
|
|
35
|
+
// This twin does NOT refuse a request whose Basic pair does not match — it never has, and adding a
|
|
36
|
+
// 401 the pack has never modeled or claimed is a separate capability with its own verify, not a
|
|
37
|
+
// side effect of moving where the secret comes from. What this file changes is the ORIGIN of the
|
|
38
|
+
// secret, not the enforcement around it.
|
|
39
|
+
//
|
|
40
|
+
// This is the runtime contract's ONE NAMED EXCEPTION to R9 (keying material generated once per
|
|
41
|
+
// root and persisted, exactly like googleoauth's RSA pair, fal's ed25519 seed and sendblue's
|
|
42
|
+
// api-key pair): same-root byte-identical, cross-root divergent by construction. The two reads
|
|
43
|
+
// that CAN carry it — `GET Accounts/{Sid}.json`'s `auth_token`, and a verification row's
|
|
44
|
+
// `account_sid` — are named on the gate: the account read joins the replay because the sequence
|
|
45
|
+
// SEEDS the pair on its first step, and no verification read is replayed.
|
|
46
|
+
//
|
|
47
|
+
// `node:crypto` is required LAZILY (server-only), the same convention twilio-signature.ts takes,
|
|
48
|
+
// so this module stays browser-bundle safe.
|
|
49
|
+
import { applyTwinWriteAtomic, projectResources, nodeBuiltin } from '@volter/world-core';
|
|
50
|
+
|
|
51
|
+
type NodeCrypto = typeof import('node:crypto');
|
|
52
|
+
function nodeCrypto(): NodeCrypto {
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
54
|
+
return nodeBuiltin('node:crypto') as NodeCrypto;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SERVICE = 'twilio';
|
|
58
|
+
/** The kernel resource type the pair is stored as — already declared in `pack.resources`. */
|
|
59
|
+
export const TWILIO_SIGNING_KEY_TYPE = 'signing_key';
|
|
60
|
+
const SIGNING_KEY_SUBJECT = 'signing_key:account';
|
|
61
|
+
|
|
62
|
+
export type TwilioCredentials = {
|
|
63
|
+
/** `AC` + 32 hex — the real vendor's own SID shape. */
|
|
64
|
+
accountSid: string;
|
|
65
|
+
/** The HMAC-SHA1 key `X-Twilio-Signature` is computed with. */
|
|
66
|
+
authToken: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A pair a caller PRESENTED over the wire — the seed for a virgin world. */
|
|
70
|
+
export type PresentedTwilioCredentials = { accountSid?: string | undefined; authToken?: string | undefined };
|
|
71
|
+
|
|
72
|
+
/** Twilio account SIDs are `AC` + 32 hex, everywhere in the product and in its own OpenAPI. */
|
|
73
|
+
export function isWellFormedTwilioAccountSid(sid: string | undefined): boolean {
|
|
74
|
+
return typeof sid === 'string' && /^AC[0-9a-fA-F]{32}$/.test(sid);
|
|
75
|
+
}
|
|
76
|
+
/** Real AuthTokens are 32 hex characters; the bar is a SHAPE bar and nothing more (a longer hex
|
|
77
|
+
* token — the 64-hex string this pack used to derive — is still recognisably one). */
|
|
78
|
+
export function isWellFormedTwilioAuthToken(token: string | undefined): boolean {
|
|
79
|
+
return typeof token === 'string' && /^[0-9a-fA-F]{32,}$/.test(token);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The `AccountSid:AuthToken` pair on a request's `Authorization: Basic …` header — how the real
|
|
84
|
+
* Twilio SDK authenticates every call, so an app wired at this twin presents it without being
|
|
85
|
+
* asked to do anything special. Returns undefined unless BOTH halves are vendor-shaped.
|
|
86
|
+
*/
|
|
87
|
+
export function presentedTwilioCredentials(headers?: Record<string, string>): PresentedTwilioCredentials | undefined {
|
|
88
|
+
let raw: string | undefined;
|
|
89
|
+
for (const [k, v] of Object.entries(headers ?? {})) if (k.toLowerCase() === 'authorization') raw = v;
|
|
90
|
+
const m = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(raw ?? '');
|
|
91
|
+
if (!m) return undefined;
|
|
92
|
+
let decoded: string;
|
|
93
|
+
try {
|
|
94
|
+
decoded = Buffer.from(m[1]!, 'base64').toString('utf8');
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const sep = decoded.indexOf(':');
|
|
99
|
+
if (sep < 0) return undefined;
|
|
100
|
+
const accountSid = decoded.slice(0, sep);
|
|
101
|
+
const authToken = decoded.slice(sep + 1);
|
|
102
|
+
if (!isWellFormedTwilioAccountSid(accountSid) || !isWellFormedTwilioAuthToken(authToken)) return undefined;
|
|
103
|
+
return { accountSid, authToken };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readStored(resources: readonly { type: string; id: string }[]): TwilioCredentials | undefined {
|
|
107
|
+
const row = resources.find((r) => r.type === TWILIO_SIGNING_KEY_TYPE && r.id === SIGNING_KEY_SUBJECT) as Record<string, unknown> | undefined;
|
|
108
|
+
const accountSid = row?.['_account_sid'];
|
|
109
|
+
const authToken = row?.['_auth_token'];
|
|
110
|
+
if (typeof accountSid !== 'string' || typeof authToken !== 'string') return undefined;
|
|
111
|
+
if (!accountSid || !authToken) return undefined;
|
|
112
|
+
return { accountSid, authToken };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The root's established pair, or undefined on a world that has never been seeded.
|
|
116
|
+
* Pure read — never writes, so a read-only serve can ask. */
|
|
117
|
+
export function storedTwilioCredentials(root?: string): TwilioCredentials | undefined {
|
|
118
|
+
return readStored(projectResources(SERVICE, root));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A freshly minted, vendor-shaped pair drawn from real entropy. */
|
|
122
|
+
export function mintTwilioCredentials(presented?: PresentedTwilioCredentials): TwilioCredentials {
|
|
123
|
+
const hex = (bytes: number) => nodeCrypto().randomBytes(bytes).toString('hex');
|
|
124
|
+
return {
|
|
125
|
+
accountSid: isWellFormedTwilioAccountSid(presented?.accountSid) ? presented!.accountSid! : `AC${hex(16)}`,
|
|
126
|
+
authToken: isWellFormedTwilioAuthToken(presented?.authToken) ? presented!.authToken! : hex(16),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The root's `AccountSid`/`AuthToken`, establishing them on first use. `presented` (the Basic pair
|
|
132
|
+
* on the incoming request) seeds a virgin world; absent it, the twin mints one from real entropy.
|
|
133
|
+
* Stable forever after — across calls AND across processes.
|
|
134
|
+
*
|
|
135
|
+
* The decide-and-append runs inside the kernel's own action lock (`applyTwinWriteAtomic`), so two
|
|
136
|
+
* processes racing a first use agree on ONE pair rather than each signing with a key the other's
|
|
137
|
+
* state does not hold.
|
|
138
|
+
*/
|
|
139
|
+
export async function twilioCredentials(root?: string, presented?: PresentedTwilioCredentials): Promise<TwilioCredentials> {
|
|
140
|
+
const fast = storedTwilioCredentials(root);
|
|
141
|
+
if (fast) return fast;
|
|
142
|
+
const { value } = await applyTwinWriteAtomic<TwilioCredentials>(SERVICE, (resources) => {
|
|
143
|
+
const existing = readStored(resources);
|
|
144
|
+
if (existing) return { kind: 'skip', value: existing };
|
|
145
|
+
const minted = mintTwilioCredentials(presented);
|
|
146
|
+
return {
|
|
147
|
+
kind: 'write',
|
|
148
|
+
value: minted,
|
|
149
|
+
write: {
|
|
150
|
+
operation: 'signing_key.create',
|
|
151
|
+
subjectType: TWILIO_SIGNING_KEY_TYPE,
|
|
152
|
+
subjectId: SIGNING_KEY_SUBJECT,
|
|
153
|
+
// `_`-prefixed fields are twin-internal: `view()` strips them and no route projects this
|
|
154
|
+
// type, so the pair only ever leaves through `GET Accounts/{Sid}.json`, which is the
|
|
155
|
+
// vendor's own endpoint for exactly that.
|
|
156
|
+
fields: { _account_sid: minted.accountSid, _auth_token: minted.authToken },
|
|
157
|
+
actor: { kind: 'system' },
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}, root);
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The root's `AuthToken` — the key `X-Twilio-Signature` is HMAC-SHA1'd with. */
|
|
165
|
+
export async function twilioAuthToken(root?: string): Promise<string> {
|
|
166
|
+
return (await twilioCredentials(root)).authToken;
|
|
167
|
+
}
|
|
168
|
+
/** The root's `AccountSid`. */
|
|
169
|
+
export async function twilioAccountSid(root?: string): Promise<string> {
|
|
170
|
+
return (await twilioCredentials(root)).accountSid;
|
|
171
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Twilio twin HTTP server — serve the full Twilio API twin handler over HTTP so the real
|
|
2
|
+
// `twilio` npm SDK (via its injectable `httpClient` constructor option — see
|
|
3
|
+
// twilio-sdk.integration.test.ts) works unmodified against it. Requests are form-urlencoded (POST/
|
|
4
|
+
// PUT) or query-string (GET); responses are plain JSON.
|
|
5
|
+
//
|
|
6
|
+
// UNLIKE fal-server.ts (which must read an `x-fal-target-url` proxy header to recover the real
|
|
7
|
+
// host, because the real `@fal-ai/client` has no injectable host and instead proxies via a
|
|
8
|
+
// `requestMiddleware` URL rewrite), this server needs NO such proxy header: the real `twilio`
|
|
9
|
+
// SDK's injectable `httpClient` receives the FULL absolute vendor URL per call (`opts.uri`, e.g.
|
|
10
|
+
// `https://api.twilio.com/2010-04-01/Accounts/...`) and a caller-supplied httpClient can extract
|
|
11
|
+
// its path+query directly — Twilio's THREE real hosts (api/verify/lookups.twilio.com) have
|
|
12
|
+
// NON-OVERLAPPING path namespaces (`/2010-04-01/...` vs `/v2/Services/...` vs
|
|
13
|
+
// `/v2/PhoneNumbers/...`, grounded live against the installed SDK's own generated `_uri`
|
|
14
|
+
// construction), so `routeTwilioSurface`'s PATH-SHAPE fallback alone disambiguates every real
|
|
15
|
+
// call once a caller's own httpClient forwards it here. The incoming HTTP `Host` header is
|
|
16
|
+
// forwarded too (in the header map), so a direct (non-SDK) caller that sets it explicitly to a
|
|
17
|
+
// real vendor hostname is also honored, per `routeTwilioSurface`'s explicit-host-wins precedence.
|
|
18
|
+
// Writable by default; pass readOnly to reject writes with 405 (D3). State is the kernel
|
|
19
|
+
// projection (no side-store) — see twilio-twin.ts.
|
|
20
|
+
//
|
|
21
|
+
// FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, built from the
|
|
22
|
+
// kernel's ONE adaptation (`createTwinFetchFromHandler`); this file contributes only VALUES. The
|
|
23
|
+
// vendor's 204s return `{ status: 204, body: null }`, which the adapter's null-body rule serves as
|
|
24
|
+
// a genuinely empty response. The server is one line of Bun.serve around that same closure.
|
|
25
|
+
import { serveHttp } from '@volter/world-core';
|
|
26
|
+
import { handleTwilioTwinRequest } from './twilio-twin.ts';
|
|
27
|
+
import { createTwinFetchFromHandler, statefulTwinManifest } from '@volter/world-core';
|
|
28
|
+
|
|
29
|
+
/** Options every Twilio-twin HTTP surface needs, independent of who owns the socket. */
|
|
30
|
+
export interface TwilioTwinFetchOptions {
|
|
31
|
+
root?: string;
|
|
32
|
+
readOnly?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createTwilioTwinFetch(options: TwilioTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
36
|
+
return createTwinFetchFromHandler(handleTwilioTwinRequest, {
|
|
37
|
+
...options,
|
|
38
|
+
manifest: statefulTwinManifest({ vendor: 'twilio', twinOf: 'the Twilio messaging API', stores: 'sent messages progressing queued→sending→sent→delivered, with signed status callbacks' }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createTwilioTwinServer(options: { root?: string; port?: number; readOnly?: boolean } = {}): Promise<{ port: number; stop: () => void }> {
|
|
43
|
+
const server = await serveHttp({
|
|
44
|
+
port: options.port ?? 0,
|
|
45
|
+
idleTimeout: 60,
|
|
46
|
+
fetch: createTwilioTwinFetch(options),
|
|
47
|
+
});
|
|
48
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
49
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { nodeBuiltin } from '@volter/world-core';
|
|
2
|
+
// Twilio X-Twilio-Signature webhook signing — REAL HMAC-SHA1 sign/verify, written FRESH for
|
|
3
|
+
// this pack (build spec §1 divergence 1: do NOT port fal-webhooks.ts, ed25519/asymmetric; do NOT
|
|
4
|
+
// port replicate-webhooks.ts/inngest-signing.ts, HMAC-SHA256 base64). NEVER imports twilio-
|
|
5
|
+
// twin.ts's handler — this module is pure local crypto, so its capabilities are legitimately
|
|
6
|
+
// mutation-test ALLOW-listed (build spec §13.8).
|
|
7
|
+
//
|
|
8
|
+
// GROUNDED (2026-07-09, read-only) against the ACTUALLY-INSTALLED `twilio@6.0.2` npm package's
|
|
9
|
+
// own compiled source (`node_modules/twilio/lib/webhooks/webhooks.js`, fetched via `npm pack`,
|
|
10
|
+
// not just docs — same discipline algolia/inngest/fal used for their own SDK-source grounding):
|
|
11
|
+
// getExpectedTwilioSignature(authToken, url, params) computes, VERBATIM:
|
|
12
|
+
// data = Object.keys(params).sort().reduce((acc,key) => acc + key + String(params[key]), url)
|
|
13
|
+
// signature = base64(HMAC-SHA1(authToken, data))
|
|
14
|
+
// — i.e. the signed string is the URL (INCLUDING query string) followed by every POST
|
|
15
|
+
// parameter's key immediately concatenated with its value (NO separator between key/value or
|
|
16
|
+
// between successive pairs), for keys in ASCII-sorted order. This is the exact algorithm this
|
|
17
|
+
// module implements (`toFormUrlEncodedParam`/`computeTwilioSignature` below are line-for-line
|
|
18
|
+
// equivalent to the real SDK's `toFormUrlEncodedParam`/`getExpectedTwilioSignature`).
|
|
19
|
+
//
|
|
20
|
+
// NO CREDENTIAL LIVES HERE. This module is PURE CRYPTO: every function takes the AuthToken (and,
|
|
21
|
+
// where a payload carries it, the AccountSid) as an argument. It used to derive them from the
|
|
22
|
+
// world's DIRECTORY PATH — `sha256('twilio-twin-auth-token:' + root)` — which made the filesystem
|
|
23
|
+
// the owner of the one secret Twilio's whole signature scheme rests on. The pair is now twin STATE
|
|
24
|
+
// (twilio-credentials.ts: established once per root, persisted, entropy-born or seeded from the
|
|
25
|
+
// Basic pair a caller presents); a caller reads it with `twilioAuthToken(root)` /
|
|
26
|
+
// `twilioAccountSid(root)` and hands it in. That keeps this module synchronous, state-free and
|
|
27
|
+
// legitimately mutation-test ALLOW-listed, and it means no path can ever reach a signature.
|
|
28
|
+
//
|
|
29
|
+
// `node:crypto` is required LAZILY (server-only), same convention as fal-webhooks.ts/replicate-
|
|
30
|
+
// webhooks.ts, so this module stays browser-bundle safe.
|
|
31
|
+
type NodeCrypto = typeof import('node:crypto');
|
|
32
|
+
function nodeCrypto(): NodeCrypto {
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
34
|
+
return nodeBuiltin('node:crypto') as NodeCrypto;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TwilioSignatureVerificationError extends Error {
|
|
38
|
+
constructor(message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'TwilioSignatureVerificationError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** VERBATIM port of the real SDK's `toFormUrlEncodedParam` for a scalar param (arrays are not
|
|
45
|
+
* used by any signed payload this pack builds — status callbacks and inbound messages are all
|
|
46
|
+
* flat string params). */
|
|
47
|
+
function toFormUrlEncodedParam(key: string, value: string): string {
|
|
48
|
+
return key + value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** VERBATIM port of the real SDK's `getExpectedTwilioSignature`: base64(HMAC-SHA1(authToken,
|
|
52
|
+
* url + sorted-key-concatenated params)). */
|
|
53
|
+
export function computeTwilioSignature(authToken: string, url: string, params: Record<string, string>): string {
|
|
54
|
+
const data = Object.keys(params)
|
|
55
|
+
.sort()
|
|
56
|
+
.reduce((acc, key) => acc + toFormUrlEncodedParam(key, params[key] ?? ''), url);
|
|
57
|
+
return nodeCrypto().createHmac('sha1', authToken).update(Buffer.from(data, 'utf-8')).digest('base64');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Constant-time-ish comparison (length-checked first, then a real `crypto.timingSafeEqual`) —
|
|
61
|
+
* avoids a naive `===` timing side-channel on the signature string. */
|
|
62
|
+
function signaturesEqual(a: string, b: string): boolean {
|
|
63
|
+
const bufA = Buffer.from(a, 'utf-8');
|
|
64
|
+
const bufB = Buffer.from(b, 'utf-8');
|
|
65
|
+
if (bufA.length !== bufB.length) return false;
|
|
66
|
+
return nodeCrypto().timingSafeEqual(bufA, bufB);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Verify a received X-Twilio-Signature header against an AuthToken the caller supplies (the
|
|
70
|
+
* world's own comes from `twilioAuthToken(root)`). Pure local crypto — never touches the twin
|
|
71
|
+
* handler and never reads state. Throws on mismatch. */
|
|
72
|
+
export function verifyTwilioSignature(url: string, params: Record<string, string>, signature: string, authToken: string): void {
|
|
73
|
+
const expected = computeTwilioSignature(authToken, url, params);
|
|
74
|
+
if (!signaturesEqual(expected, signature)) {
|
|
75
|
+
throw new TwilioSignatureVerificationError('X-Twilio-Signature verification failed.');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build a signed request envelope (headers + form params) for ANY Twilio-shaped webhook POST
|
|
80
|
+
* (status callbacks, inbound messages) against a supplied AuthToken. Pure builder — never calls
|
|
81
|
+
* the twin handler and never reads state. */
|
|
82
|
+
export function buildSignedTwilioRequest(args: { url: string; params: Record<string, string>; authToken: string }): { headers: Record<string, string>; params: Record<string, string> } {
|
|
83
|
+
const signature = computeTwilioSignature(args.authToken, args.url, args.params);
|
|
84
|
+
return { headers: { 'x-twilio-signature': signature }, params: args.params };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build a signed Message status-callback delivery (the shape Twilio POSTs to a message's
|
|
88
|
+
* `StatusCallback` URL): {MessageSid, MessageStatus, To, From, AccountSid, ...}. */
|
|
89
|
+
export function buildSignedStatusCallback(args: {
|
|
90
|
+
url: string;
|
|
91
|
+
messageSid: string;
|
|
92
|
+
messageStatus: 'queued' | 'sending' | 'sent' | 'delivered' | 'undelivered' | 'failed';
|
|
93
|
+
to: string;
|
|
94
|
+
from: string;
|
|
95
|
+
accountSid: string;
|
|
96
|
+
authToken: string;
|
|
97
|
+
errorCode?: string;
|
|
98
|
+
}): { headers: Record<string, string>; params: Record<string, string> } {
|
|
99
|
+
const params: Record<string, string> = {
|
|
100
|
+
MessageSid: args.messageSid,
|
|
101
|
+
SmsSid: args.messageSid,
|
|
102
|
+
MessageStatus: args.messageStatus,
|
|
103
|
+
To: args.to,
|
|
104
|
+
From: args.from,
|
|
105
|
+
AccountSid: args.accountSid,
|
|
106
|
+
...(args.errorCode ? { ErrorCode: args.errorCode } : {}),
|
|
107
|
+
};
|
|
108
|
+
return buildSignedTwilioRequest({ url: args.url, params, authToken: args.authToken });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Build a signed INBOUND message webhook (the shape Twilio POSTs to a phone number's
|
|
112
|
+
* `SmsUrl`/`smsUrl` when a real SMS arrives): {MessageSid, From, To, Body, ...}. Pure builder —
|
|
113
|
+
* never calls the twin handler (build spec §3 webhooks.inbound_message_shape — ALLOW builder). */
|
|
114
|
+
export function buildSignedInboundMessage(args: {
|
|
115
|
+
url: string;
|
|
116
|
+
messageSid?: string;
|
|
117
|
+
from: string;
|
|
118
|
+
to: string;
|
|
119
|
+
body: string;
|
|
120
|
+
accountSid: string;
|
|
121
|
+
authToken: string;
|
|
122
|
+
}): { headers: Record<string, string>; params: Record<string, string> } {
|
|
123
|
+
const params: Record<string, string> = {
|
|
124
|
+
MessageSid: args.messageSid ?? `SM${nodeCrypto().createHash('sha256').update(`${args.from}:${args.to}:${args.body}`).digest('hex').slice(0, 32)}`,
|
|
125
|
+
From: args.from,
|
|
126
|
+
To: args.to,
|
|
127
|
+
Body: args.body,
|
|
128
|
+
AccountSid: args.accountSid,
|
|
129
|
+
NumMedia: '0',
|
|
130
|
+
};
|
|
131
|
+
return buildSignedTwilioRequest({ url: args.url, params, authToken: args.authToken });
|
|
132
|
+
}
|