@vellumai/assistant 0.4.2 → 0.4.4

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.
Files changed (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,934 @@
1
+ /**
2
+ * Route handlers for Twilio integration control-plane endpoints.
3
+ *
4
+ * GET /v1/integrations/twilio/config — get current config status
5
+ * POST /v1/integrations/twilio/credentials — set Twilio credentials
6
+ * DELETE /v1/integrations/twilio/credentials — clear Twilio credentials
7
+ * GET /v1/integrations/twilio/numbers — list account phone numbers
8
+ * POST /v1/integrations/twilio/numbers/provision — provision a new phone number
9
+ * POST /v1/integrations/twilio/numbers/assign — assign an existing number
10
+ * POST /v1/integrations/twilio/numbers/release — release a phone number
11
+ * GET /v1/integrations/twilio/sms/compliance — get SMS compliance status
12
+ * POST /v1/integrations/twilio/sms/compliance/tollfree — submit toll-free verification
13
+ * PATCH /v1/integrations/twilio/sms/compliance/tollfree/:sid — update toll-free verification
14
+ * DELETE /v1/integrations/twilio/sms/compliance/tollfree/:sid — delete toll-free verification
15
+ * POST /v1/integrations/twilio/sms/test — send a test SMS
16
+ * POST /v1/integrations/twilio/sms/doctor — run SMS diagnostics
17
+ */
18
+
19
+ import {
20
+ deleteTollFreeVerification,
21
+ fetchMessageStatus,
22
+ getPhoneNumberSid,
23
+ getTollFreeVerificationBySid,
24
+ getTollFreeVerificationStatus,
25
+ hasTwilioCredentials,
26
+ listIncomingPhoneNumbers,
27
+ provisionPhoneNumber,
28
+ releasePhoneNumber,
29
+ searchAvailableNumbers,
30
+ submitTollFreeVerification,
31
+ type TollFreeVerificationSubmitParams,
32
+ updateTollFreeVerification,
33
+ } from '../../calls/twilio-rest.js';
34
+ import { getGatewayInternalBaseUrl } from '../../config/env.js';
35
+ import { loadRawConfig, saveRawConfig } from '../../config/loader.js';
36
+ import { getReadinessService } from '../../daemon/handlers/config-channels.js';
37
+ import { syncTwilioWebhooks } from '../../daemon/handlers/config-ingress.js';
38
+ import type { IngressConfig } from '../../inbound/public-ingress-urls.js';
39
+ import { deleteSecureKey, getSecureKey, setSecureKey } from '../../security/secure-keys.js';
40
+ import { deleteCredentialMetadata, upsertCredentialMetadata } from '../../tools/credentials/metadata-store.js';
41
+ import { readHttpToken } from '../../util/platform.js';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Shared helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** In-memory store for the last SMS send test result. Shared between sms_send_test and sms_doctor. */
48
+ let _lastTestResult: {
49
+ messageSid: string;
50
+ to: string;
51
+ initialStatus: string;
52
+ finalStatus: string;
53
+ errorCode?: string;
54
+ errorMessage?: string;
55
+ timestamp: number;
56
+ } | undefined;
57
+
58
+ function mapTwilioErrorRemediation(errorCode: string | undefined): string | undefined {
59
+ if (!errorCode) return undefined;
60
+ const map: Record<string, string> = {
61
+ '30003': 'Unreachable destination. The handset may be off or out of service.',
62
+ '30004': 'Message blocked by carrier or recipient.',
63
+ '30005': 'Unknown destination phone number. Verify the number is valid.',
64
+ '30006': 'Landline or unreachable carrier. SMS cannot be delivered to this number.',
65
+ '30007': 'Message flagged as spam by carrier. Adjust content or register for A2P.',
66
+ '30008': 'Unknown error from the carrier network.',
67
+ '21610': 'Recipient has opted out (STOP). Cannot send until they opt back in.',
68
+ };
69
+ return map[errorCode];
70
+ }
71
+
72
+ const TWILIO_USE_CASE_ALIASES: Record<string, string> = {
73
+ ACCOUNT_NOTIFICATION: 'ACCOUNT_NOTIFICATIONS',
74
+ DELIVERY_NOTIFICATION: 'DELIVERY_NOTIFICATIONS',
75
+ FRAUD_ALERT: 'FRAUD_ALERT_MESSAGING',
76
+ POLLING_AND_VOTING: 'POLLING_AND_VOTING_NON_POLITICAL',
77
+ };
78
+
79
+ const TWILIO_VALID_USE_CASE_CATEGORIES = [
80
+ 'TWO_FACTOR_AUTHENTICATION',
81
+ 'ACCOUNT_NOTIFICATIONS',
82
+ 'CUSTOMER_CARE',
83
+ 'CHARITY_NONPROFIT',
84
+ 'DELIVERY_NOTIFICATIONS',
85
+ 'FRAUD_ALERT_MESSAGING',
86
+ 'EVENTS',
87
+ 'HIGHER_EDUCATION',
88
+ 'K12',
89
+ 'MARKETING',
90
+ 'POLLING_AND_VOTING_NON_POLITICAL',
91
+ 'POLITICAL_ELECTION_CAMPAIGNS',
92
+ 'PUBLIC_SERVICE_ANNOUNCEMENT',
93
+ 'SECURITY_ALERT',
94
+ ] as const;
95
+
96
+ function normalizeUseCaseCategories(rawCategories: string[]): string[] {
97
+ const normalized = rawCategories.map((value) => TWILIO_USE_CASE_ALIASES[value] ?? value);
98
+ return Array.from(new Set(normalized));
99
+ }
100
+
101
+ /** Helper to clear stale assistant phone number mappings. */
102
+ function pruneAssistantPhoneNumbers(
103
+ sms: Record<string, unknown>,
104
+ keepNumber: string,
105
+ mode: 'keep' | 'remove',
106
+ ): void {
107
+ const mappings = sms.assistantPhoneNumbers as Record<string, string> | undefined;
108
+ if (mappings && typeof mappings === 'object') {
109
+ for (const [key, value] of Object.entries(mappings)) {
110
+ const shouldDelete = mode === 'keep' ? value !== keepNumber : value === keepNumber;
111
+ if (shouldDelete) {
112
+ delete mappings[key];
113
+ }
114
+ }
115
+ if (Object.keys(mappings).length === 0) {
116
+ delete sms.assistantPhoneNumbers;
117
+ }
118
+ }
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Route handlers
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * GET /v1/integrations/twilio/config
127
+ */
128
+ export function handleGetTwilioConfig(): Response {
129
+ const hasCredentials = hasTwilioCredentials();
130
+ const raw = loadRawConfig();
131
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
132
+ const phoneNumber = (sms.phoneNumber as string) ?? '';
133
+
134
+ return Response.json({
135
+ success: true,
136
+ hasCredentials,
137
+ phoneNumber: phoneNumber || undefined,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * POST /v1/integrations/twilio/credentials
143
+ *
144
+ * Body: { accountSid: string, authToken: string }
145
+ */
146
+ export async function handleSetTwilioCredentials(req: Request): Promise<Response> {
147
+ const body = (await req.json().catch(() => ({}))) as { accountSid?: string; authToken?: string };
148
+
149
+ if (!body.accountSid || !body.authToken) {
150
+ return Response.json({
151
+ success: false,
152
+ hasCredentials: hasTwilioCredentials(),
153
+ error: 'accountSid and authToken are required',
154
+ }, { status: 400 });
155
+ }
156
+
157
+ // Validate credentials against Twilio API
158
+ const authHeader = 'Basic ' + Buffer.from(`${body.accountSid}:${body.authToken}`).toString('base64');
159
+ try {
160
+ const res = await fetch(
161
+ `https://api.twilio.com/2010-04-01/Accounts/${body.accountSid}.json`,
162
+ { method: 'GET', headers: { Authorization: authHeader } },
163
+ );
164
+ if (!res.ok) {
165
+ const errBody = await res.text();
166
+ return Response.json({
167
+ success: false,
168
+ hasCredentials: hasTwilioCredentials(),
169
+ error: `Twilio API validation failed (${res.status}): ${errBody}`,
170
+ });
171
+ }
172
+ } catch (err) {
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ return Response.json({
175
+ success: false,
176
+ hasCredentials: hasTwilioCredentials(),
177
+ error: `Failed to validate Twilio credentials: ${message}`,
178
+ });
179
+ }
180
+
181
+ // Store credentials securely
182
+ const sidStored = setSecureKey('credential:twilio:account_sid', body.accountSid);
183
+ if (!sidStored) {
184
+ return Response.json({
185
+ success: false,
186
+ hasCredentials: false,
187
+ error: 'Failed to store Account SID in secure storage',
188
+ });
189
+ }
190
+
191
+ const tokenStored = setSecureKey('credential:twilio:auth_token', body.authToken);
192
+ if (!tokenStored) {
193
+ deleteSecureKey('credential:twilio:account_sid');
194
+ return Response.json({
195
+ success: false,
196
+ hasCredentials: false,
197
+ error: 'Failed to store Auth Token in secure storage',
198
+ });
199
+ }
200
+
201
+ upsertCredentialMetadata('twilio', 'account_sid', {});
202
+ upsertCredentialMetadata('twilio', 'auth_token', {});
203
+
204
+ return Response.json({ success: true, hasCredentials: true });
205
+ }
206
+
207
+ /**
208
+ * DELETE /v1/integrations/twilio/credentials
209
+ */
210
+ export function handleClearTwilioCredentials(): Response {
211
+ deleteSecureKey('credential:twilio:account_sid');
212
+ deleteSecureKey('credential:twilio:auth_token');
213
+ deleteCredentialMetadata('twilio', 'account_sid');
214
+ deleteCredentialMetadata('twilio', 'auth_token');
215
+
216
+ return Response.json({ success: true, hasCredentials: false });
217
+ }
218
+
219
+ /**
220
+ * GET /v1/integrations/twilio/numbers
221
+ */
222
+ export async function handleListTwilioNumbers(): Promise<Response> {
223
+ if (!hasTwilioCredentials()) {
224
+ return Response.json({
225
+ success: false,
226
+ hasCredentials: false,
227
+ error: 'Twilio credentials not configured. Set credentials first.',
228
+ });
229
+ }
230
+
231
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
232
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
233
+ const numbers = await listIncomingPhoneNumbers(accountSid, authToken);
234
+
235
+ return Response.json({ success: true, hasCredentials: true, numbers });
236
+ }
237
+
238
+ /**
239
+ * POST /v1/integrations/twilio/numbers/provision
240
+ *
241
+ * Body: { country?: string, areaCode?: string }
242
+ */
243
+ export async function handleProvisionTwilioNumber(req: Request): Promise<Response> {
244
+ if (!hasTwilioCredentials()) {
245
+ return Response.json({
246
+ success: false,
247
+ hasCredentials: false,
248
+ error: 'Twilio credentials not configured. Set credentials first.',
249
+ });
250
+ }
251
+
252
+ const body = (await req.json().catch(() => ({}))) as { country?: string; areaCode?: string };
253
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
254
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
255
+ const country = body.country ?? 'US';
256
+
257
+ const available = await searchAvailableNumbers(accountSid, authToken, country, body.areaCode);
258
+ if (available.length === 0) {
259
+ return Response.json({
260
+ success: false,
261
+ hasCredentials: true,
262
+ error: `No available phone numbers found for country=${country}${body.areaCode ? ` areaCode=${body.areaCode}` : ''}`,
263
+ });
264
+ }
265
+
266
+ const purchased = await provisionPhoneNumber(accountSid, authToken, available[0].phoneNumber);
267
+
268
+ const phoneStored = setSecureKey('credential:twilio:phone_number', purchased.phoneNumber);
269
+ if (!phoneStored) {
270
+ return Response.json({
271
+ success: false,
272
+ hasCredentials: hasTwilioCredentials(),
273
+ phoneNumber: purchased.phoneNumber,
274
+ error: `Phone number ${purchased.phoneNumber} was purchased but could not be saved. Use assign to assign it manually.`,
275
+ });
276
+ }
277
+
278
+ const raw = loadRawConfig();
279
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
280
+ sms.phoneNumber = purchased.phoneNumber;
281
+ pruneAssistantPhoneNumbers(sms, purchased.phoneNumber, 'keep');
282
+ saveRawConfig({ ...raw, sms });
283
+
284
+ // Best-effort webhook configuration
285
+ const webhookResult = await syncTwilioWebhooks(
286
+ purchased.phoneNumber,
287
+ accountSid,
288
+ authToken,
289
+ loadRawConfig() as IngressConfig,
290
+ );
291
+
292
+ return Response.json({
293
+ success: true,
294
+ hasCredentials: true,
295
+ phoneNumber: purchased.phoneNumber,
296
+ warning: webhookResult.warning,
297
+ });
298
+ }
299
+
300
+ /**
301
+ * POST /v1/integrations/twilio/numbers/assign
302
+ *
303
+ * Body: { phoneNumber: string }
304
+ */
305
+ export async function handleAssignTwilioNumber(req: Request): Promise<Response> {
306
+ const body = (await req.json().catch(() => ({}))) as { phoneNumber?: string };
307
+
308
+ if (!body.phoneNumber) {
309
+ return Response.json({
310
+ success: false,
311
+ hasCredentials: hasTwilioCredentials(),
312
+ error: 'phoneNumber is required',
313
+ }, { status: 400 });
314
+ }
315
+
316
+ const phoneStored = setSecureKey('credential:twilio:phone_number', body.phoneNumber);
317
+ if (!phoneStored) {
318
+ return Response.json({
319
+ success: false,
320
+ hasCredentials: hasTwilioCredentials(),
321
+ error: 'Failed to store phone number in secure storage',
322
+ });
323
+ }
324
+
325
+ const raw = loadRawConfig();
326
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
327
+ sms.phoneNumber = body.phoneNumber;
328
+ pruneAssistantPhoneNumbers(sms, body.phoneNumber, 'keep');
329
+ saveRawConfig({ ...raw, sms });
330
+
331
+ // Best-effort webhook configuration when credentials are available
332
+ let webhookWarning: string | undefined;
333
+ if (hasTwilioCredentials()) {
334
+ const acctSid = getSecureKey('credential:twilio:account_sid')!;
335
+ const acctToken = getSecureKey('credential:twilio:auth_token')!;
336
+ const webhookResult = await syncTwilioWebhooks(
337
+ body.phoneNumber,
338
+ acctSid,
339
+ acctToken,
340
+ loadRawConfig() as IngressConfig,
341
+ );
342
+ webhookWarning = webhookResult.warning;
343
+ }
344
+
345
+ return Response.json({
346
+ success: true,
347
+ hasCredentials: hasTwilioCredentials(),
348
+ phoneNumber: body.phoneNumber,
349
+ warning: webhookWarning,
350
+ });
351
+ }
352
+
353
+ /**
354
+ * POST /v1/integrations/twilio/numbers/release
355
+ *
356
+ * Body: { phoneNumber?: string }
357
+ */
358
+ export async function handleReleaseTwilioNumber(req: Request): Promise<Response> {
359
+ if (!hasTwilioCredentials()) {
360
+ return Response.json({
361
+ success: false,
362
+ hasCredentials: false,
363
+ error: 'Twilio credentials not configured. Set credentials first.',
364
+ });
365
+ }
366
+
367
+ const body = (await req.json().catch(() => ({}))) as { phoneNumber?: string };
368
+ const raw = loadRawConfig();
369
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
370
+ const phoneNumber = body.phoneNumber || (sms.phoneNumber as string) || '';
371
+
372
+ if (!phoneNumber) {
373
+ return Response.json({
374
+ success: false,
375
+ hasCredentials: true,
376
+ error: 'No phone number to release. Specify phoneNumber or ensure one is assigned.',
377
+ });
378
+ }
379
+
380
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
381
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
382
+
383
+ await releasePhoneNumber(accountSid, authToken, phoneNumber);
384
+
385
+ if (sms.phoneNumber === phoneNumber) {
386
+ delete sms.phoneNumber;
387
+ }
388
+ pruneAssistantPhoneNumbers(sms, phoneNumber, 'remove');
389
+ saveRawConfig({ ...raw, sms });
390
+
391
+ const storedPhone = getSecureKey('credential:twilio:phone_number');
392
+ if (storedPhone === phoneNumber) {
393
+ deleteSecureKey('credential:twilio:phone_number');
394
+ }
395
+
396
+ return Response.json({
397
+ success: true,
398
+ hasCredentials: true,
399
+ warning: 'Phone number released from Twilio. Any associated toll-free verification context is lost.',
400
+ });
401
+ }
402
+
403
+ /**
404
+ * GET /v1/integrations/twilio/sms/compliance
405
+ */
406
+ export async function handleGetSmsCompliance(): Promise<Response> {
407
+ if (!hasTwilioCredentials()) {
408
+ return Response.json({
409
+ success: false,
410
+ hasCredentials: false,
411
+ error: 'Twilio credentials not configured. Set credentials first.',
412
+ });
413
+ }
414
+
415
+ const raw = loadRawConfig();
416
+ const sms = (raw?.sms ?? {}) as Record<string, unknown>;
417
+ const phoneNumber = (sms.phoneNumber as string) ?? '';
418
+
419
+ if (!phoneNumber) {
420
+ return Response.json({
421
+ success: false,
422
+ hasCredentials: true,
423
+ error: 'No phone number assigned. Assign a number first.',
424
+ });
425
+ }
426
+
427
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
428
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
429
+
430
+ const tollFreePrefixes = ['+1800', '+1833', '+1844', '+1855', '+1866', '+1877', '+1888'];
431
+ const isTollFree = tollFreePrefixes.some((prefix) => phoneNumber.startsWith(prefix));
432
+ const numberType = isTollFree ? 'toll_free' : 'local_10dlc';
433
+
434
+ if (!isTollFree) {
435
+ return Response.json({
436
+ success: true,
437
+ hasCredentials: true,
438
+ phoneNumber,
439
+ compliance: { numberType },
440
+ });
441
+ }
442
+
443
+ const phoneSid = await getPhoneNumberSid(accountSid, authToken, phoneNumber);
444
+ if (!phoneSid) {
445
+ return Response.json({
446
+ success: false,
447
+ hasCredentials: true,
448
+ phoneNumber,
449
+ error: `Phone number ${phoneNumber} not found on Twilio account`,
450
+ });
451
+ }
452
+
453
+ const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneSid);
454
+
455
+ return Response.json({
456
+ success: true,
457
+ hasCredentials: true,
458
+ phoneNumber,
459
+ compliance: {
460
+ numberType,
461
+ tollfreePhoneNumberSid: phoneSid,
462
+ verificationSid: verification?.sid,
463
+ verificationStatus: verification?.status,
464
+ rejectionReason: verification?.rejectionReason,
465
+ rejectionReasons: verification?.rejectionReasons,
466
+ errorCode: verification?.errorCode,
467
+ editAllowed: verification?.editAllowed,
468
+ editExpiration: verification?.editExpiration,
469
+ },
470
+ });
471
+ }
472
+
473
+ /**
474
+ * POST /v1/integrations/twilio/sms/compliance/tollfree
475
+ *
476
+ * Body: TollFreeVerificationSubmitParams
477
+ */
478
+ export async function handleSubmitTollfreeVerification(req: Request): Promise<Response> {
479
+ if (!hasTwilioCredentials()) {
480
+ return Response.json({
481
+ success: false,
482
+ hasCredentials: false,
483
+ error: 'Twilio credentials not configured. Set credentials first.',
484
+ });
485
+ }
486
+
487
+ const vp = (await req.json().catch(() => ({}))) as Record<string, unknown>;
488
+
489
+ const requiredFields: Array<[string, unknown]> = [
490
+ ['tollfreePhoneNumberSid', vp.tollfreePhoneNumberSid],
491
+ ['businessName', vp.businessName],
492
+ ['businessWebsite', vp.businessWebsite],
493
+ ['notificationEmail', vp.notificationEmail],
494
+ ['useCaseCategories', vp.useCaseCategories],
495
+ ['useCaseSummary', vp.useCaseSummary],
496
+ ['productionMessageSample', vp.productionMessageSample],
497
+ ['optInImageUrls', vp.optInImageUrls],
498
+ ['optInType', vp.optInType],
499
+ ['messageVolume', vp.messageVolume],
500
+ ];
501
+
502
+ const missing = requiredFields
503
+ .filter(([, v]) => v == null || v === '' || (Array.isArray(v) && v.length === 0))
504
+ .map(([name]) => name);
505
+
506
+ if (missing.length > 0) {
507
+ return Response.json({
508
+ success: false,
509
+ hasCredentials: true,
510
+ error: `Missing required verification fields: ${missing.join(', ')}`,
511
+ }, { status: 400 });
512
+ }
513
+
514
+ const normalizedUseCaseCategories = normalizeUseCaseCategories(vp.useCaseCategories as string[]);
515
+ const invalidCategories = normalizedUseCaseCategories.filter(
516
+ (c) => !TWILIO_VALID_USE_CASE_CATEGORIES.includes(c as (typeof TWILIO_VALID_USE_CASE_CATEGORIES)[number]),
517
+ );
518
+ if (invalidCategories.length > 0) {
519
+ return Response.json({
520
+ success: false,
521
+ hasCredentials: true,
522
+ error: `Invalid useCaseCategories: ${invalidCategories.join(', ')}. Valid values: ${TWILIO_VALID_USE_CASE_CATEGORIES.join(', ')}`,
523
+ }, { status: 400 });
524
+ }
525
+
526
+ const validOptInTypes = ['VERBAL', 'WEB_FORM', 'PAPER_FORM', 'VIA_TEXT', 'MOBILE_QR_CODE'];
527
+ if (!validOptInTypes.includes(vp.optInType as string)) {
528
+ return Response.json({
529
+ success: false,
530
+ hasCredentials: true,
531
+ error: `Invalid optInType: ${vp.optInType}. Valid values: ${validOptInTypes.join(', ')}`,
532
+ }, { status: 400 });
533
+ }
534
+
535
+ const validMessageVolumes = [
536
+ '10', '100', '1,000', '10,000', '100,000', '250,000',
537
+ '500,000', '750,000', '1,000,000', '5,000,000', '10,000,000+',
538
+ ];
539
+ if (!validMessageVolumes.includes(vp.messageVolume as string)) {
540
+ return Response.json({
541
+ success: false,
542
+ hasCredentials: true,
543
+ error: `Invalid messageVolume: ${vp.messageVolume}. Valid values: ${validMessageVolumes.join(', ')}`,
544
+ }, { status: 400 });
545
+ }
546
+
547
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
548
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
549
+
550
+ const submitParams: TollFreeVerificationSubmitParams = {
551
+ tollfreePhoneNumberSid: vp.tollfreePhoneNumberSid as string,
552
+ businessName: vp.businessName as string,
553
+ businessWebsite: vp.businessWebsite as string,
554
+ notificationEmail: vp.notificationEmail as string,
555
+ useCaseCategories: normalizedUseCaseCategories,
556
+ useCaseSummary: vp.useCaseSummary as string,
557
+ productionMessageSample: vp.productionMessageSample as string,
558
+ optInImageUrls: vp.optInImageUrls as string[],
559
+ optInType: vp.optInType as string,
560
+ messageVolume: vp.messageVolume as string,
561
+ businessType: (vp.businessType as string) ?? 'SOLE_PROPRIETOR',
562
+ customerProfileSid: vp.customerProfileSid as string | undefined,
563
+ };
564
+
565
+ const verification = await submitTollFreeVerification(accountSid, authToken, submitParams);
566
+
567
+ return Response.json({
568
+ success: true,
569
+ hasCredentials: true,
570
+ compliance: {
571
+ numberType: 'toll_free',
572
+ verificationSid: verification.sid,
573
+ verificationStatus: verification.status,
574
+ },
575
+ });
576
+ }
577
+
578
+ /**
579
+ * PATCH /v1/integrations/twilio/sms/compliance/tollfree/:verificationSid
580
+ *
581
+ * Body: partial verification params to update
582
+ */
583
+ export async function handleUpdateTollfreeVerification(req: Request, verificationSid: string): Promise<Response> {
584
+ if (!hasTwilioCredentials()) {
585
+ return Response.json({
586
+ success: false,
587
+ hasCredentials: false,
588
+ error: 'Twilio credentials not configured. Set credentials first.',
589
+ });
590
+ }
591
+
592
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
593
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
594
+
595
+ const currentVerification = await getTollFreeVerificationBySid(accountSid, authToken, verificationSid);
596
+ if (!currentVerification) {
597
+ return Response.json({
598
+ success: false,
599
+ hasCredentials: true,
600
+ error: `Verification ${verificationSid} was not found on this Twilio account.`,
601
+ });
602
+ }
603
+
604
+ if (currentVerification.status === 'TWILIO_REJECTED') {
605
+ const expirationMillis = currentVerification.editExpiration
606
+ ? Date.parse(currentVerification.editExpiration)
607
+ : Number.NaN;
608
+ const editExpired = Number.isFinite(expirationMillis) && Date.now() > expirationMillis;
609
+ if (currentVerification.editAllowed === false || editExpired) {
610
+ const detail = editExpired
611
+ ? `edit_expiration=${currentVerification.editExpiration}`
612
+ : 'edit_allowed=false';
613
+ return Response.json({
614
+ success: false,
615
+ hasCredentials: true,
616
+ error: `Verification ${verificationSid} cannot be updated (${detail}). Delete and resubmit instead.`,
617
+ compliance: {
618
+ numberType: 'toll_free',
619
+ verificationSid: currentVerification.sid,
620
+ verificationStatus: currentVerification.status,
621
+ editAllowed: currentVerification.editAllowed,
622
+ editExpiration: currentVerification.editExpiration,
623
+ },
624
+ });
625
+ }
626
+ }
627
+
628
+ const updateParams = { ...(await req.json().catch(() => ({})) as Record<string, unknown>) };
629
+ if (updateParams.useCaseCategories) {
630
+ updateParams.useCaseCategories = normalizeUseCaseCategories(updateParams.useCaseCategories as string[]);
631
+ }
632
+
633
+ const verification = await updateTollFreeVerification(
634
+ accountSid,
635
+ authToken,
636
+ verificationSid,
637
+ updateParams,
638
+ );
639
+
640
+ return Response.json({
641
+ success: true,
642
+ hasCredentials: true,
643
+ compliance: {
644
+ numberType: 'toll_free',
645
+ verificationSid: verification.sid,
646
+ verificationStatus: verification.status,
647
+ editAllowed: verification.editAllowed,
648
+ editExpiration: verification.editExpiration,
649
+ },
650
+ });
651
+ }
652
+
653
+ /**
654
+ * DELETE /v1/integrations/twilio/sms/compliance/tollfree/:verificationSid
655
+ */
656
+ export async function handleDeleteTollfreeVerification(verificationSid: string): Promise<Response> {
657
+ if (!hasTwilioCredentials()) {
658
+ return Response.json({
659
+ success: false,
660
+ hasCredentials: false,
661
+ error: 'Twilio credentials not configured. Set credentials first.',
662
+ });
663
+ }
664
+
665
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
666
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
667
+
668
+ await deleteTollFreeVerification(accountSid, authToken, verificationSid);
669
+
670
+ return Response.json({
671
+ success: true,
672
+ hasCredentials: true,
673
+ warning: 'Toll-free verification deleted. Re-submitting may reset your position in the review queue.',
674
+ });
675
+ }
676
+
677
+ /**
678
+ * POST /v1/integrations/twilio/sms/test
679
+ *
680
+ * Body: { phoneNumber: string, text?: string }
681
+ */
682
+ export async function handleSmsSendTest(req: Request): Promise<Response> {
683
+ if (!hasTwilioCredentials()) {
684
+ return Response.json({
685
+ success: false,
686
+ hasCredentials: false,
687
+ error: 'Twilio credentials not configured. Set credentials first.',
688
+ });
689
+ }
690
+
691
+ const body = (await req.json().catch(() => ({}))) as { phoneNumber?: string; text?: string };
692
+ const to = body.phoneNumber;
693
+ if (!to) {
694
+ return Response.json({
695
+ success: false,
696
+ hasCredentials: true,
697
+ error: 'phoneNumber is required for SMS send test.',
698
+ }, { status: 400 });
699
+ }
700
+
701
+ const raw = loadRawConfig();
702
+ const smsSection = (raw?.sms ?? {}) as Record<string, unknown>;
703
+ const from = (smsSection.phoneNumber as string | undefined)
704
+ || getSecureKey('credential:twilio:phone_number')
705
+ || '';
706
+ if (!from) {
707
+ return Response.json({
708
+ success: false,
709
+ hasCredentials: true,
710
+ error: 'No phone number assigned. Run the twilio-setup skill to assign a number.',
711
+ });
712
+ }
713
+
714
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
715
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
716
+ const text = body.text || 'Test SMS from your Vellum assistant';
717
+
718
+ // Send via gateway's /deliver/sms endpoint
719
+ const bearerToken = readHttpToken();
720
+ const gatewayUrl = getGatewayInternalBaseUrl();
721
+
722
+ const sendResp = await fetch(`${gatewayUrl}/deliver/sms`, {
723
+ method: 'POST',
724
+ headers: {
725
+ 'Content-Type': 'application/json',
726
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
727
+ },
728
+ body: JSON.stringify({ to, text }),
729
+ signal: AbortSignal.timeout(30_000),
730
+ });
731
+
732
+ if (!sendResp.ok) {
733
+ const errBody = await sendResp.text().catch(() => '<unreadable>');
734
+ return Response.json({
735
+ success: false,
736
+ hasCredentials: true,
737
+ error: `SMS send failed (${sendResp.status}): ${errBody}`,
738
+ });
739
+ }
740
+
741
+ const sendData = await sendResp.json().catch(() => ({})) as {
742
+ messageSid?: string;
743
+ status?: string;
744
+ };
745
+ const messageSid = sendData.messageSid || '';
746
+ const initialStatus = sendData.status || 'unknown';
747
+
748
+ // Poll Twilio for final status (up to 3 times, 2s apart)
749
+ let finalStatus = initialStatus;
750
+ let errorCode: string | undefined;
751
+ let errorMessage: string | undefined;
752
+
753
+ if (messageSid) {
754
+ for (let i = 0; i < 3; i++) {
755
+ await new Promise((r) => setTimeout(r, 2000));
756
+ try {
757
+ const pollResult = await fetchMessageStatus(accountSid, authToken, messageSid);
758
+ finalStatus = pollResult.status;
759
+ errorCode = pollResult.errorCode;
760
+ errorMessage = pollResult.errorMessage;
761
+ if (['delivered', 'undelivered', 'failed'].includes(finalStatus)) break;
762
+ } catch {
763
+ break;
764
+ }
765
+ }
766
+ }
767
+
768
+ const testResult = {
769
+ messageSid,
770
+ to,
771
+ initialStatus,
772
+ finalStatus,
773
+ ...(errorCode ? { errorCode } : {}),
774
+ ...(errorMessage ? { errorMessage } : {}),
775
+ };
776
+
777
+ _lastTestResult = { ...testResult, timestamp: Date.now() };
778
+
779
+ return Response.json({
780
+ success: true,
781
+ hasCredentials: true,
782
+ testResult,
783
+ });
784
+ }
785
+
786
+ /**
787
+ * POST /v1/integrations/twilio/sms/doctor
788
+ */
789
+ export async function handleSmsDoctor(): Promise<Response> {
790
+ const hasCredentials = hasTwilioCredentials();
791
+
792
+ // 1. Channel readiness check
793
+ let readinessReady = false;
794
+ const readinessIssues: string[] = [];
795
+ try {
796
+ const readinessService = getReadinessService();
797
+ const snapshots = await readinessService.getReadiness('sms', false);
798
+ const snapshot = snapshots[0];
799
+ if (snapshot) {
800
+ readinessReady = snapshot.ready;
801
+ for (const r of snapshot.reasons) {
802
+ readinessIssues.push(r.text);
803
+ }
804
+ } else {
805
+ readinessIssues.push('No readiness snapshot returned for SMS channel');
806
+ }
807
+ } catch (err) {
808
+ readinessIssues.push(`Readiness check failed: ${err instanceof Error ? err.message : String(err)}`);
809
+ }
810
+
811
+ // 2. Compliance status
812
+ let complianceStatus = 'unknown';
813
+ let complianceDetail: string | undefined;
814
+ let complianceRemediation: string | undefined;
815
+ if (hasCredentials) {
816
+ try {
817
+ const raw = loadRawConfig();
818
+ const smsSection = (raw?.sms ?? {}) as Record<string, unknown>;
819
+ const phoneNumber = (smsSection.phoneNumber as string | undefined) || getSecureKey('credential:twilio:phone_number') || '';
820
+ if (phoneNumber) {
821
+ const accountSid = getSecureKey('credential:twilio:account_sid')!;
822
+ const authToken = getSecureKey('credential:twilio:auth_token')!;
823
+ const isTollFree = phoneNumber.startsWith('+1') && ['800','888','877','866','855','844','833'].some(
824
+ (p) => phoneNumber.startsWith(`+1${p}`),
825
+ );
826
+ if (isTollFree) {
827
+ try {
828
+ const phoneSid = await getPhoneNumberSid(accountSid, authToken, phoneNumber);
829
+ if (!phoneSid) {
830
+ complianceStatus = 'check_failed';
831
+ complianceDetail = `Assigned number ${phoneNumber} was not found on the Twilio account`;
832
+ complianceRemediation = 'Reassign the number in twilio-setup or update credentials to the matching account.';
833
+ } else {
834
+ const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneSid);
835
+ if (verification) {
836
+ const status = verification.status;
837
+ complianceStatus = status;
838
+ complianceDetail = `Toll-free verification: ${status}`;
839
+ if (status === 'TWILIO_APPROVED') {
840
+ complianceRemediation = undefined;
841
+ } else if (status === 'PENDING_REVIEW' || status === 'IN_REVIEW') {
842
+ complianceRemediation = 'Toll-free verification is pending. Messaging may have limited throughput until approved.';
843
+ } else if (status === 'TWILIO_REJECTED') {
844
+ if (verification.editAllowed) {
845
+ complianceRemediation = verification.editExpiration
846
+ ? `Toll-free verification was rejected but can still be edited until ${verification.editExpiration}. Update and resubmit it.`
847
+ : 'Toll-free verification was rejected but can still be edited. Update and resubmit it.';
848
+ } else {
849
+ complianceRemediation = 'Toll-free verification was rejected and is no longer editable. Delete and resubmit it.';
850
+ }
851
+ } else {
852
+ complianceRemediation = 'Submit a toll-free verification to enable full messaging throughput.';
853
+ }
854
+ } else {
855
+ complianceStatus = 'unverified';
856
+ complianceDetail = 'Toll-free number without verification';
857
+ complianceRemediation = 'Submit a toll-free verification request to avoid filtering.';
858
+ }
859
+ }
860
+ } catch {
861
+ complianceStatus = 'check_failed';
862
+ complianceDetail = 'Could not retrieve toll-free verification status';
863
+ }
864
+ } else {
865
+ complianceStatus = 'local_10dlc';
866
+ complianceDetail = 'Local/10DLC number — carrier registration handled externally';
867
+ }
868
+ } else {
869
+ complianceStatus = 'no_number';
870
+ complianceDetail = 'No phone number assigned';
871
+ complianceRemediation = 'Assign a phone number via the twilio-setup skill.';
872
+ }
873
+ } catch {
874
+ complianceStatus = 'check_failed';
875
+ complianceDetail = 'Could not determine compliance status';
876
+ }
877
+ } else {
878
+ complianceStatus = 'no_credentials';
879
+ complianceDetail = 'Twilio credentials are not configured';
880
+ complianceRemediation = 'Set Twilio credentials via the twilio-setup skill.';
881
+ }
882
+
883
+ // 3. Last send test result
884
+ let lastSend: { status: string; errorCode?: string; remediation?: string } | undefined;
885
+ if (_lastTestResult) {
886
+ lastSend = {
887
+ status: _lastTestResult.finalStatus,
888
+ ...((_lastTestResult.errorCode) ? { errorCode: _lastTestResult.errorCode } : {}),
889
+ ...((_lastTestResult.errorCode) ? { remediation: mapTwilioErrorRemediation(_lastTestResult.errorCode) } : {}),
890
+ };
891
+ }
892
+
893
+ // 4. Overall status
894
+ const actionItems: string[] = [];
895
+ let overallStatus: 'healthy' | 'degraded' | 'broken' = 'healthy';
896
+
897
+ if (!hasCredentials) {
898
+ overallStatus = 'broken';
899
+ actionItems.push('Configure Twilio credentials.');
900
+ }
901
+ if (!readinessReady) {
902
+ overallStatus = 'broken';
903
+ for (const issue of readinessIssues) actionItems.push(issue);
904
+ }
905
+ if (complianceStatus === 'unverified' || complianceStatus === 'PENDING_REVIEW' || complianceStatus === 'IN_REVIEW') {
906
+ if (overallStatus === 'healthy') overallStatus = 'degraded';
907
+ if (complianceRemediation) actionItems.push(complianceRemediation);
908
+ }
909
+ if (complianceStatus === 'TWILIO_REJECTED' || complianceStatus === 'no_number') {
910
+ overallStatus = 'broken';
911
+ if (complianceRemediation) actionItems.push(complianceRemediation);
912
+ }
913
+ if (_lastTestResult && ['failed', 'undelivered'].includes(_lastTestResult.finalStatus)) {
914
+ if (overallStatus === 'healthy') overallStatus = 'degraded';
915
+ const remediation = mapTwilioErrorRemediation(_lastTestResult.errorCode);
916
+ actionItems.push(remediation || `Last test SMS ${_lastTestResult.finalStatus}. Check Twilio logs for details.`);
917
+ }
918
+
919
+ return Response.json({
920
+ success: true,
921
+ hasCredentials,
922
+ diagnostics: {
923
+ readiness: { ready: readinessReady, issues: readinessIssues },
924
+ compliance: {
925
+ status: complianceStatus,
926
+ ...(complianceDetail ? { detail: complianceDetail } : {}),
927
+ ...(complianceRemediation ? { remediation: complianceRemediation } : {}),
928
+ },
929
+ ...(lastSend ? { lastSend } : {}),
930
+ overallStatus,
931
+ actionItems,
932
+ },
933
+ });
934
+ }