@noodleseed/one 0.186.1 → 0.188.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.
Files changed (80) hide show
  1. package/dist/commands/solutions-conversations.js +1 -1
  2. package/dist/commands/solutions-conversations.js.map +1 -1
  3. package/dist/commands/solutions-history.d.ts.map +1 -1
  4. package/dist/commands/solutions-history.js +5 -2
  5. package/dist/commands/solutions-history.js.map +1 -1
  6. package/dist/commands/whatsapp-ops.d.ts +1 -1
  7. package/dist/commands/whatsapp-ops.d.ts.map +1 -1
  8. package/dist/commands/whatsapp-ops.js +31 -9
  9. package/dist/commands/whatsapp-ops.js.map +1 -1
  10. package/dist/commands/whatsapp-output.d.ts +1 -1
  11. package/dist/commands/whatsapp-output.d.ts.map +1 -1
  12. package/dist/commands/whatsapp-output.js +8 -6
  13. package/dist/commands/whatsapp-output.js.map +1 -1
  14. package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +1 -0
  15. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
  16. package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +1 -0
  17. package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-auth-ref.js +1 -0
  18. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +3 -1
  19. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  20. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +1 -0
  21. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +89 -0
  22. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +20 -1
  23. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +16 -1
  24. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +1 -1
  25. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +34 -3
  26. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +10 -4
  27. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +1 -1
  28. package/node_modules/@noodle-borg/authoring/dist/assistant.js +1 -1
  29. package/node_modules/@noodle-borg/authoring/dist/server.d.ts +6 -0
  30. package/node_modules/@noodle-borg/authoring/dist/server.js +1 -0
  31. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +26 -0
  32. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +1 -1
  33. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +1 -0
  34. package/node_modules/@noodle-borg/compiler/dist/artifact/catalog-emission.js +3 -0
  35. package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +5 -0
  36. package/node_modules/@noodle-borg/compiler/dist/artifact/version.d.ts +2 -1
  37. package/node_modules/@noodle-borg/compiler/dist/artifact/version.js +2 -1
  38. package/node_modules/@noodle-borg/compiler/dist/compile.js +2 -1
  39. package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
  40. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +6 -0
  41. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +4 -1
  42. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +3 -0
  43. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +23 -2
  44. package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +1 -1
  45. package/node_modules/@noodle-borg/service/dist/application-history-settings.js +2 -0
  46. package/node_modules/@noodle-borg/service/dist/build-info.js +1 -0
  47. package/node_modules/@noodle-borg/service/dist/business-information/managed-solution-executable.js +2 -0
  48. package/node_modules/@noodle-borg/service/dist/channels/composition.js +3 -2
  49. package/node_modules/@noodle-borg/service/dist/channels/messaging-eligibility.js +6 -1
  50. package/node_modules/@noodle-borg/service/dist/channels/reply-bound.js +4 -0
  51. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +38 -10
  52. package/node_modules/@noodle-borg/service/dist/channels/twilio-sms.js +188 -0
  53. package/node_modules/@noodle-borg/service/dist/channels/twilio-webhook.js +16 -0
  54. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +1 -0
  55. package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +3 -1
  56. package/node_modules/@noodle-borg/service/dist/routes/service-probes.js +1 -0
  57. package/node_modules/@noodle-borg/service/dist/routes/sms-twilio.js +66 -0
  58. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +31 -21
  59. package/node_modules/@noodle-borg/service/dist/serve-local.js +2 -0
  60. package/node_modules/@noodle-borg/service/dist/service.js +3 -2
  61. package/node_modules/@noodle-borg/service/package.json +1 -1
  62. package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts +5 -0
  63. package/node_modules/@noodle-borg/transport-http/dist/handler.js +2 -0
  64. package/node_modules/@noodle-borg/transport-http/dist/serve-request.js +3 -1
  65. package/node_modules/@noodle-borg/transport-http/dist/tool-authorization.js +25 -3
  66. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
  67. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +3 -1
  68. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +7 -0
  69. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +2 -1
  70. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +12 -0
  71. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +1 -1
  72. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  73. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  74. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
  75. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +2 -0
  76. package/node_modules/@noodle-borg/wire-contracts/dist/sms.d.ts +176 -0
  77. package/node_modules/@noodle-borg/wire-contracts/dist/sms.js +41 -0
  78. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +10 -2
  79. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +2 -1
  80. package/package.json +2 -2
@@ -17,6 +17,7 @@ import { messagingProjectionIneligibility } from './messaging-eligibility.js';
17
17
  import { MetaCloud, WHATSAPP_META_CALLBACK_PATH } from './meta-cloud.js';
18
18
  import { boundReply } from './reply-bound.js';
19
19
  import { reviewButtonBinding } from './reply-buttons.js';
20
+ import { TwilioSms } from './twilio-sms.js';
20
21
  export class WhatsAppRuntime {
21
22
  options;
22
23
  deps;
@@ -35,6 +36,18 @@ export class WhatsAppRuntime {
35
36
  async provider(binding) {
36
37
  const values = await this.deps.registry.configStore.resolveConfigValues('secret', resolveConfigScope(binding.tenant));
37
38
  const apiKey = values[binding.apiKeySecret];
39
+ if (binding.channel === 'sms') {
40
+ if (!apiKey)
41
+ throw new ChannelError('channel_credentials_missing');
42
+ const base = this.options.publicBaseUrl;
43
+ if (!base || !base.startsWith('https://'))
44
+ throw new ChannelError('sms_public_url_required');
45
+ await this.channels.checkCredentials(binding.id, channelDigest(JSON.stringify([apiKey])));
46
+ return {
47
+ adapter: new TwilioSms(apiKey, binding, `${base.replace(/\/$/, '')}/v1/channels/sms/webhooks/${binding.id}`, this.options.providerFetch),
48
+ webhookSecret: apiKey,
49
+ };
50
+ }
38
51
  if (binding.provider === 'meta') {
39
52
  const meta = this.options.meta;
40
53
  if (!meta)
@@ -62,7 +75,9 @@ export class WhatsAppRuntime {
62
75
  };
63
76
  }
64
77
  webhookUrl(req, binding) {
65
- const base = this.deps.serviceBase(req).replace(/\/$/, '');
78
+ const base = (binding.channel === 'sms' ? (this.options.publicBaseUrl ?? '') : this.deps.serviceBase(req)).replace(/\/$/, '');
79
+ if (binding.channel === 'sms')
80
+ return `${base}/v1/channels/sms/webhooks/${binding.id}`;
66
81
  if (binding.channel === 'instagram')
67
82
  return `${base}${INSTAGRAM_META_CALLBACK_PATH}`;
68
83
  return binding.provider === 'meta'
@@ -79,7 +94,7 @@ export class WhatsAppRuntime {
79
94
  if (binding.capabilities.some((cap) => !surface.capabilities.some((authored) => authored.kind === cap.kind && authored.name === cap.name)))
80
95
  throw new ChannelError('capability_not_authored');
81
96
  const artifact = projectArtifactForSurface(target.served.artifact, binding.capabilities);
82
- const ineligible = messagingProjectionIneligibility(artifact);
97
+ const ineligible = messagingProjectionIneligibility(artifact, binding.channel);
83
98
  if (ineligible !== undefined)
84
99
  throw new ChannelError(ineligible);
85
100
  return { ...target, served: { ...target.served, artifact } };
@@ -129,7 +144,11 @@ export class WhatsAppRuntime {
129
144
  });
130
145
  await check('provider_asset', async () => {
131
146
  const health = await (await this.provider(binding)).adapter.health();
132
- const asset = binding.channel === 'instagram' ? binding.instagramAccountId : binding.phoneNumberId;
147
+ const asset = binding.channel === 'sms'
148
+ ? binding.phoneNumberSid
149
+ : binding.channel === 'instagram'
150
+ ? binding.instagramAccountId
151
+ : binding.phoneNumberId;
133
152
  if (health.assetId !== asset)
134
153
  throw new ChannelError('asset_mismatch');
135
154
  if (!health.canSend)
@@ -178,7 +197,7 @@ export class WhatsAppRuntime {
178
197
  return {
179
198
  status: 'unavailable',
180
199
  capabilities: [],
181
- reason: `${channel === 'instagram' ? 'Instagram' : 'WhatsApp'} is not configured.`,
200
+ reason: `${channel === 'sms' ? 'SMS' : channel === 'instagram' ? 'Instagram' : 'WhatsApp'} is not configured.`,
182
201
  };
183
202
  const view = {
184
203
  bindingId: binding.id,
@@ -236,10 +255,12 @@ export class WhatsAppRuntime {
236
255
  }
237
256
  async blocks(id, after) {
238
257
  const local = await this.channels.blocks(id, after);
258
+ const optouts = await this.channels.optouts(id, after);
239
259
  const provider = await channelProviderBlocks(this.channels.store, id, this.channels.now(), after);
240
260
  const ids = [
241
261
  ...new Set([
242
262
  ...local.map((item) => item.participantId),
263
+ ...optouts.map((item) => item.participantId),
243
264
  ...provider.map((item) => item.participantId),
244
265
  ]),
245
266
  ];
@@ -248,14 +269,16 @@ export class WhatsAppRuntime {
248
269
  .slice(0, 100)
249
270
  .map((participantId) => {
250
271
  const block = local.find((item) => item.participantId === participantId);
272
+ const optout = optouts.find((item) => item.participantId === participantId);
251
273
  const match = provider.find((item) => item.participantId === participantId);
252
274
  return {
253
275
  ...(block ?? {
254
276
  participantId,
255
- actor: match.actor,
256
- reason: 'operator',
277
+ actor: match?.actor ?? 'recipient',
278
+ reason: match ? 'operator' : 'recipient_opt_out',
257
279
  until: null,
258
280
  }),
281
+ ...(optout ? { optedOut: optout.blocked } : {}),
259
282
  local: !!block && (block.until === null || block.until > this.channels.now()),
260
283
  ...(match
261
284
  ? {
@@ -463,10 +486,15 @@ export class WhatsAppRuntime {
463
486
  ? ((await this.deps.conversations?.retentionDays(binding.tenant, binding.channel, surface)) ?? 0)
464
487
  : 0;
465
488
  const kept = days > 0 ? ` ${historyNoticeSentence(days, surface.noticeTemplate)}` : '';
466
- const disclosure = participant.history.length === 0
467
- ? `I’m ${artifact.server.branding?.name ?? artifact.server.title}’s AI assistant.${kept}\n\n`
468
- : '';
469
- answer = disclosure + text.trim();
489
+ const brand = artifact.server.branding?.name ?? artifact.server.title;
490
+ const displayName = binding.channel === 'sms' ? [...brand].slice(0, 80).join('') : brand;
491
+ const disclosure = participant.history.length === 0 ? `I’m ${displayName}’s AI assistant.${kept}\n\n` : '';
492
+ answer =
493
+ disclosure +
494
+ text.trim() +
495
+ (binding.channel === 'sms' && participant.history.length === 0
496
+ ? '\nReply STOP to unsubscribe.'
497
+ : '');
470
498
  }
471
499
  const bounded = boundReply(binding.channel, answer);
472
500
  // Marked-private values never persist: history and the event row keep redacted text, and the
@@ -0,0 +1,188 @@
1
+ import { ChannelError } from '@noodle-borg/assistant-gateway/portable';
2
+ import { z } from 'zod';
3
+ import { boundedJson, } from './provider.js';
4
+ const phone = z.string().regex(/^\+[1-9]\d{1,14}$/);
5
+ const messageId = z.string().regex(/^(SM|MM)[a-fA-F0-9]{32}$/);
6
+ const numberSchema = z.object({
7
+ sid: z.string(),
8
+ account_sid: z.string(),
9
+ phone_number: phone,
10
+ capabilities: z.object({ sms: z.boolean() }),
11
+ sms_url: z.string().nullable(),
12
+ sms_method: z.string(),
13
+ sms_application_sid: z.string().nullable(),
14
+ });
15
+ /** Twilio REST transport, contained behind the existing messaging provider port. */
16
+ export class TwilioSms {
17
+ token;
18
+ asset;
19
+ callback;
20
+ fetcher;
21
+ constructor(token, asset, callback, fetcher = fetch) {
22
+ this.token = token;
23
+ this.asset = asset;
24
+ this.callback = callback;
25
+ this.fetcher = fetcher;
26
+ }
27
+ request(path, fields) {
28
+ return this.fetcher(`https://api.twilio.com/2010-04-01/Accounts/${this.asset.accountSid}${path}`, {
29
+ method: fields ? 'POST' : 'GET',
30
+ headers: {
31
+ authorization: `Basic ${Buffer.from(`${this.asset.accountSid}:${this.token}`).toString('base64')}`,
32
+ ...(fields ? { 'content-type': 'application/x-www-form-urlencoded' } : {}),
33
+ },
34
+ ...(fields ? { body: new URLSearchParams(fields).toString() } : {}),
35
+ redirect: 'error',
36
+ signal: AbortSignal.timeout(8_000),
37
+ });
38
+ }
39
+ async number() {
40
+ const response = await this.request(`/IncomingPhoneNumbers/${this.asset.phoneNumberSid}.json`);
41
+ if (!response.ok) {
42
+ await response.body?.cancel();
43
+ throw new ChannelError('provider_unavailable');
44
+ }
45
+ const parsed = numberSchema.safeParse(await boundedJson(response));
46
+ if (!parsed.success)
47
+ throw new ChannelError('provider_response_invalid');
48
+ const number = parsed.data;
49
+ if (number.sid !== this.asset.phoneNumberSid ||
50
+ number.account_sid !== this.asset.accountSid ||
51
+ number.phone_number !== this.asset.phoneNumber)
52
+ throw new ChannelError('asset_mismatch');
53
+ return number;
54
+ }
55
+ async health() {
56
+ const number = await this.number();
57
+ const response = await this.request('.json');
58
+ if (!response.ok) {
59
+ await response.body?.cancel();
60
+ throw new ChannelError('provider_unavailable');
61
+ }
62
+ const account = z
63
+ .object({ sid: z.string(), status: z.string() })
64
+ .safeParse(await boundedJson(response));
65
+ if (!account.success || account.data.sid !== this.asset.accountSid)
66
+ throw new ChannelError('asset_mismatch');
67
+ const canSend = number.capabilities.sms && account.data.status === 'active';
68
+ return {
69
+ assetId: number.sid,
70
+ canSend,
71
+ status: canSend ? 'AVAILABLE' : 'BLOCKED',
72
+ };
73
+ }
74
+ async inspectWebhook(url) {
75
+ const number = await this.number();
76
+ return {
77
+ matches: number.sms_url === url && number.sms_method === 'POST' && !number.sms_application_sid,
78
+ authenticated: true,
79
+ };
80
+ }
81
+ async configureWebhook(url) {
82
+ const number = await this.number();
83
+ if (number.sms_application_sid ||
84
+ (number.sms_url &&
85
+ number.sms_url !== url &&
86
+ number.sms_url !== 'https://demo.twilio.com/welcome/sms/reply'))
87
+ throw new ChannelError('webhook_ownership_conflict');
88
+ const response = await this.request(`/IncomingPhoneNumbers/${this.asset.phoneNumberSid}.json`, {
89
+ SmsUrl: url,
90
+ SmsMethod: 'POST',
91
+ });
92
+ await response.body?.cancel();
93
+ if (!response.ok)
94
+ throw new ChannelError('webhook_configuration_failed');
95
+ }
96
+ async setBlocked() {
97
+ return { state: 'error', code: 'provider_block_unsupported' };
98
+ }
99
+ async send(input) {
100
+ if (input.to.kind !== 'phone' ||
101
+ !phone.safeParse(input.to.value).success ||
102
+ !input.text ||
103
+ input.text.length > 600)
104
+ return { state: 'failed', code: 'reply_invalid' };
105
+ try {
106
+ const response = await this.request('/Messages.json', {
107
+ From: this.asset.phoneNumber,
108
+ To: input.to.value,
109
+ Body: input.text,
110
+ StatusCallback: `${this.callback}/status`,
111
+ ValidityPeriod: '300',
112
+ });
113
+ if (!response.ok) {
114
+ await response.body?.cancel();
115
+ return {
116
+ state: response.status >= 500 || response.status === 408 ? 'unknown' : 'failed',
117
+ code: `provider_http_${response.status}`,
118
+ };
119
+ }
120
+ const parsed = z.object({ sid: messageId }).safeParse(await boundedJson(response));
121
+ return parsed.success
122
+ ? { state: 'accepted', providerMessageId: parsed.data.sid }
123
+ : { state: 'unknown', code: 'provider_receipt_invalid' };
124
+ }
125
+ catch {
126
+ return { state: 'unknown', code: 'provider_outcome_unknown' };
127
+ }
128
+ }
129
+ /** Twilio callbacks lack a creation timestamp. Read the immutable resource timestamp so retries
130
+ * deduplicate identically and a signed callback cannot revive a message after retention expires. */
131
+ async receive(sid) {
132
+ if (!messageId.safeParse(sid).success)
133
+ throw new ChannelError('message_invalid');
134
+ const response = await this.request(`/Messages/${sid}.json`);
135
+ if (!response.ok) {
136
+ await response.body?.cancel();
137
+ throw new ChannelError('provider_unavailable');
138
+ }
139
+ const parsed = z
140
+ .object({
141
+ sid: messageId,
142
+ account_sid: z.string(),
143
+ direction: z.string(),
144
+ from: phone,
145
+ to: phone,
146
+ body: z.string().max(16_384),
147
+ num_media: z.string(),
148
+ date_created: z.string(),
149
+ })
150
+ .safeParse(await boundedJson(response));
151
+ if (!parsed.success)
152
+ throw new ChannelError('provider_response_invalid');
153
+ const value = parsed.data;
154
+ if (value.sid !== sid ||
155
+ value.account_sid !== this.asset.accountSid ||
156
+ value.to !== this.asset.phoneNumber ||
157
+ value.direction !== 'inbound')
158
+ throw new ChannelError('message_asset_mismatch');
159
+ const eventAt = Date.parse(value.date_created);
160
+ if (!Number.isSafeInteger(eventAt))
161
+ throw new ChannelError('timestamp_invalid');
162
+ const word = value.body.trim().toUpperCase();
163
+ const control = [
164
+ 'STOP',
165
+ 'STOPALL',
166
+ 'UNSUBSCRIBE',
167
+ 'CANCEL',
168
+ 'END',
169
+ 'QUIT',
170
+ 'REVOKE',
171
+ 'OPTOUT',
172
+ ].includes(word)
173
+ ? 'stop'
174
+ : ['START', 'UNSTOP'].includes(word)
175
+ ? 'start'
176
+ : ['HELP', 'INFO'].includes(word)
177
+ ? 'help'
178
+ : undefined;
179
+ return {
180
+ providerId: sid,
181
+ address: { kind: 'phone', value: value.from },
182
+ eventAt,
183
+ ...(value.num_media === '0' && value.body ? { text: value.body } : {}),
184
+ ...(control ? { control } : {}),
185
+ };
186
+ }
187
+ }
188
+ //# sourceMappingURL=twilio-sms.js.map
@@ -0,0 +1,16 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ /** Twilio's documented form signature: the exact public URL plus every sorted POST parameter.
3
+ * Duplicate parameters are rejected before this boundary; unknown provider fields stay signed. */
4
+ export function verifyTwilioSignature(url, params, signature, token) {
5
+ if (typeof signature !== 'string' || !/^[A-Za-z0-9+/]{27}=$/.test(signature))
6
+ return false;
7
+ const payload = url +
8
+ [...params.keys()]
9
+ .sort()
10
+ .map((key) => key + params.get(key))
11
+ .join('');
12
+ const expected = createHmac('sha1', token).update(payload).digest();
13
+ const provided = Buffer.from(signature, 'base64');
14
+ return provided.length === expected.length && timingSafeEqual(provided, expected);
15
+ }
16
+ //# sourceMappingURL=twilio-webhook.js.map
@@ -10,6 +10,7 @@ export const ALL_CONVERSATION_SOURCES = {
10
10
  signed_in_customers: true,
11
11
  whatsapp: true,
12
12
  instagram: true,
13
+ sms: true,
13
14
  };
14
15
  /**
15
16
  * The latest expiry a shorter window allows an item from `at`; Off (0 days) ends every item now. The
@@ -59,9 +59,10 @@ export async function projectBusinessChannels(installation, canEdit, base, deps)
59
59
  };
60
60
  const registered = await deps.registry.getActiveByTenant(tenant);
61
61
  const target = registered && (await resolveTargetOrigins(registered));
62
- const [whatsapp, instagram] = await Promise.all([
62
+ const [whatsapp, instagram, sms] = await Promise.all([
63
63
  deps.readMessaging?.(tenant, 'whatsapp'),
64
64
  deps.readMessaging?.(tenant, 'instagram'),
65
+ deps.readMessaging?.(tenant, 'sms'),
65
66
  ]);
66
67
  // Pausing agent channels pauses every messaging channel with them.
67
68
  const intake = (view) => installation.intakeActive ? view : { ...view, status: 'paused' };
@@ -74,6 +75,7 @@ export async function projectBusinessChannels(installation, canEdit, base, deps)
74
75
  : {}),
75
76
  ...(whatsapp ? { whatsapp: intake(whatsapp) } : {}),
76
77
  ...(instagram ? { instagram: intake(instagram) } : {}),
78
+ ...(sms ? { sms: intake(sms) } : {}),
77
79
  };
78
80
  if (!target?.deploymentId)
79
81
  return {
@@ -28,6 +28,7 @@ function handleServiceProbe(req, res, url, { tls, moduleHost, buildInfo, options
28
28
  applySecurityHeaders(res, tls);
29
29
  return sendJson(res, 200, serviceInfoPayload(buildInfo, options.developerMcp === true, {
30
30
  whatsapp: whatsapp !== undefined,
31
+ sms: whatsapp !== undefined,
31
32
  instagram: whatsapp?.options.meta !== undefined,
32
33
  }));
33
34
  }
@@ -0,0 +1,66 @@
1
+ import { recordChannelDelivery } from '@noodle-borg/assistant-gateway/portable';
2
+ import { readBodyBuffer, sendJson } from '@noodle-borg/transport-http';
3
+ import { TwilioSms } from '../channels/twilio-sms.js';
4
+ import { verifyTwilioSignature } from '../channels/twilio-webhook.js';
5
+ /** Per-binding routing selects only a credential candidate. Signature, account and number must all
6
+ * match before a callback may change durable state. The public URL comes from operator config. */
7
+ export async function smsWebhook(req, res, id, status, url, runtime) {
8
+ if (req.method !== 'POST')
9
+ return sendJson(res, 405, { error: 'method_not_allowed' });
10
+ if (url.search ||
11
+ req.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded')
12
+ return sendJson(res, 400, { error: 'webhook_invalid' });
13
+ const binding = await runtime.channels.internal(id);
14
+ if (binding.channel !== 'sms')
15
+ return sendJson(res, 404, { error: 'not_found' });
16
+ const { adapter, webhookSecret } = await runtime.provider(binding);
17
+ const body = await readBodyBuffer(req, 65_536);
18
+ if (!body.ok)
19
+ return sendJson(res, 413, { error: 'webhook_invalid' });
20
+ const params = new URLSearchParams(body.buffer.toString('utf8'));
21
+ if ([...params.keys()].length > 100 ||
22
+ [...params.keys()].some((key) => params.getAll(key).length !== 1))
23
+ return sendJson(res, 400, { error: 'webhook_invalid' });
24
+ const callback = runtime.webhookUrl(req, binding) + (status ? '/status' : '');
25
+ if (!webhookSecret ||
26
+ !verifyTwilioSignature(callback, params, req.headers['x-twilio-signature'], webhookSecret))
27
+ return sendJson(res, 401, { error: 'unauthorized' });
28
+ if (params.get('AccountSid') !== binding.accountSid ||
29
+ params.get(status ? 'From' : 'To') !== binding.phoneNumber)
30
+ return sendJson(res, 400, { error: 'asset_mismatch' });
31
+ const sid = params.get('MessageSid');
32
+ if (!sid || !/^(SM|MM)[a-fA-F0-9]{32}$/.test(sid))
33
+ return sendJson(res, 400, { error: 'message_invalid' });
34
+ if (status) {
35
+ const state = params.get('MessageStatus');
36
+ const delivery = state === 'delivered'
37
+ ? 'delivered'
38
+ : state === 'sent'
39
+ ? 'sent'
40
+ : state === 'failed' || state === 'undelivered'
41
+ ? 'failed'
42
+ : undefined;
43
+ if (delivery)
44
+ await recordChannelDelivery(runtime.channels.store, id, sid, delivery, runtime.channels.now());
45
+ }
46
+ else {
47
+ if (!(adapter instanceof TwilioSms))
48
+ return sendJson(res, 503, { error: 'provider_unavailable' });
49
+ const message = await adapter.receive(sid);
50
+ if (message.address.value !== params.get('From'))
51
+ return sendJson(res, 400, { error: 'asset_mismatch' });
52
+ const optOut = params.get('OptOutType');
53
+ const control = optOut === 'STOP'
54
+ ? 'stop'
55
+ : optOut === 'START'
56
+ ? 'start'
57
+ : optOut === 'HELP'
58
+ ? 'help'
59
+ : message.control;
60
+ await runtime.channels.receive(id, [{ ...message, ...(control ? { control } : {}) }], await runtime.retention(binding));
61
+ }
62
+ res.writeHead(200, { 'content-type': 'text/xml; charset=utf-8' });
63
+ res.end('<Response/>');
64
+ runtime.options.worker.wake();
65
+ }
66
+ //# sourceMappingURL=sms-twilio.js.map
@@ -6,6 +6,7 @@ import { INSTAGRAM_META_CALLBACK_PATH } from '../channels/instagram-meta.js';
6
6
  import { WHATSAPP_META_CALLBACK_PATH } from '../channels/meta-cloud.js';
7
7
  import { authorizeTenantControl, developerGrantRouteAccess } from './control-plane.js';
8
8
  import { instagramWebhook } from './instagram-meta.js';
9
+ import { smsWebhook } from './sms-twilio.js';
9
10
  import { metaWebhook } from './whatsapp-meta.js';
10
11
  const participant = /^p_[a-f0-9]{64}$/;
11
12
  const eventId = /^e_[0-9_]+$/;
@@ -35,30 +36,33 @@ async function body(req, schema) {
35
36
  }
36
37
  export function dispatchWhatsAppRoutes(req, res, url, runtime, grants) {
37
38
  const callback = /^\/v1\/channels\/whatsapp\/webhooks\/([a-z0-9-]+)$/.exec(url.pathname);
38
- const operator = /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/channels\/(whatsapp|instagram)(?:\/(.*))?$/.exec(url.pathname);
39
+ const operator = /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/channels\/(whatsapp|instagram|sms)(?:\/(.*))?$/.exec(url.pathname);
40
+ const sms = /^\/v1\/channels\/sms\/webhooks\/([a-z0-9-]+)(\/status)?$/.exec(url.pathname);
39
41
  const meta = url.pathname === WHATSAPP_META_CALLBACK_PATH;
40
42
  const instagram = url.pathname === INSTAGRAM_META_CALLBACK_PATH;
41
- if (!callback && !operator && !meta && !instagram)
43
+ if (!callback && !operator && !meta && !instagram && !sms)
42
44
  return false;
43
45
  res.setHeader('Cache-Control', 'no-store');
44
46
  if (!runtime) {
45
47
  sendJson(res, 503, {
46
- error: 'WhatsApp is unavailable on this service',
47
- code: 'whatsapp_unavailable',
48
+ error: 'Messaging is unavailable on this service',
49
+ code: 'channel_unavailable',
48
50
  });
49
51
  return true;
50
52
  }
51
- const run = Promise.resolve().then(() => meta
52
- ? metaWebhook(req, res, url, runtime)
53
- : instagram
54
- ? instagramWebhook(req, res, url, runtime)
55
- : callback
56
- ? webhook(req, res, callback[1], runtime)
57
- : operate(req, res, url, {
58
- org: decodeURIComponent(operator[1]),
59
- app: decodeURIComponent(operator[2]),
60
- env: decodeURIComponent(operator[3]),
61
- }, operator[4], operator[5] ?? '', runtime, grants));
53
+ const run = Promise.resolve().then(() => sms
54
+ ? smsWebhook(req, res, sms[1], !!sms[2], url, runtime)
55
+ : meta
56
+ ? metaWebhook(req, res, url, runtime)
57
+ : instagram
58
+ ? instagramWebhook(req, res, url, runtime)
59
+ : callback
60
+ ? webhook(req, res, callback[1], runtime)
61
+ : operate(req, res, url, {
62
+ org: decodeURIComponent(operator[1]),
63
+ app: decodeURIComponent(operator[2]),
64
+ env: decodeURIComponent(operator[3]),
65
+ }, operator[4], operator[5] ?? '', runtime, grants));
62
66
  void run.catch((error) => {
63
67
  const code = error instanceof ChannelError ? error.code : 'channel_unavailable';
64
68
  const status = code === 'channel_not_found'
@@ -71,7 +75,7 @@ export function dispatchWhatsAppRoutes(req, res, url, runtime, grants) {
71
75
  'deployment_changed',
72
76
  ].includes(code)
73
77
  ? 409
74
- : code === 'channel_unavailable' || code === 'ingress_capacity'
78
+ : ['channel_unavailable', 'ingress_capacity', 'provider_unavailable'].includes(code)
75
79
  ? 503
76
80
  : 400;
77
81
  if (!res.headersSent)
@@ -118,9 +122,11 @@ async function operate(req, res, url, tenant, channel, path, runtime, grants) {
118
122
  const idempotency = typeof key === 'string' ? key : '';
119
123
  const visible = await channels.get(tenant, channel);
120
124
  // Every other operation's shape is channel-neutral; the binding and its configuration are not.
121
- const bindingSchema = channel === 'instagram'
122
- ? wire.InstagramBindingResponseSchema
123
- : wire.WhatsAppBindingResponseSchema;
125
+ const bindingSchema = channel === 'sms'
126
+ ? wire.SmsBindingResponseSchema
127
+ : channel === 'instagram'
128
+ ? wire.InstagramBindingResponseSchema
129
+ : wire.WhatsAppBindingResponseSchema;
124
130
  const audit = async (action) => deps.audit.emit({
125
131
  eventType: `assistant.channel.${action}`,
126
132
  ...tenant,
@@ -136,7 +142,11 @@ async function operate(req, res, url, tenant, channel, path, runtime, grants) {
136
142
  throw new ChannelError('deployment_missing');
137
143
  const deploymentId = target.deploymentId;
138
144
  let configured;
139
- if (channel === 'instagram') {
145
+ if (channel === 'sms') {
146
+ const { expectedRevision, ...configuration } = await body(req, wire.SmsConfigureRequestSchema);
147
+ configured = await channels.configure({ ...configuration, channel, tenant, deploymentId }, identity.subject, idempotency, expectedRevision);
148
+ }
149
+ else if (channel === 'instagram') {
140
150
  const { expectedRevision, ...configuration } = await body(req, wire.InstagramConfigureRequestSchema);
141
151
  configured = await channels.configure({ ...configuration, channel, tenant, deploymentId }, identity.subject, idempotency, expectedRevision);
142
152
  }
@@ -198,7 +208,7 @@ async function operate(req, res, url, tenant, channel, path, runtime, grants) {
198
208
  const participantId = 'phone' in parsed && parsed.phone
199
209
  ? channelParticipantId(await channels.internal(id), {
200
210
  kind: 'phone',
201
- value: parsed.phone.slice(1),
211
+ value: channel === 'sms' ? parsed.phone : parsed.phone.slice(1),
202
212
  })
203
213
  : parsed.participantId;
204
214
  await channels.block(id, participantId, identity.subject, idempotency, parsed.until);
@@ -66,6 +66,8 @@ export async function serveLocalService(options = {}) {
66
66
  });
67
67
  const handler = createServiceHandler(registry, {
68
68
  capabilities,
69
+ // Loopback only (checked above): the one anonymous caller is the author running `noodle dev`.
70
+ mcpAnonymousCallers: 'author',
69
71
  deployGate: {
70
72
  authorize: () => ({
71
73
  ok: true,
@@ -107,6 +107,7 @@ export function createServiceHandler(registry, options = {}) {
107
107
  logger,
108
108
  tls,
109
109
  protocolMode: options.mcpProtocolMode ?? 'dual',
110
+ ...(options.mcpAnonymousCallers ? { anonymousCallers: options.mcpAnonymousCallers } : {}),
110
111
  ...(options.mcpRequestState === undefined ? {} : { requestState: options.mcpRequestState }),
111
112
  ...(options.mcpConfirmationNonceLedger === undefined
112
113
  ? {}
@@ -199,7 +200,7 @@ export function createServiceHandler(registry, options = {}) {
199
200
  logger,
200
201
  conversationPolicy,
201
202
  });
202
- const whatsapp = createWhatsAppRuntime(options.whatsapp, assistantDeps, {
203
+ const whatsapp = createWhatsAppRuntime(options, assistantDeps, {
203
204
  allowance: moduleHost.resolveActivityHistoryAllowance,
204
205
  installations: businessInformationStore,
205
206
  activity,
@@ -356,7 +357,7 @@ export function createServiceHandler(registry, options = {}) {
356
357
  })) {
357
358
  return;
358
359
  }
359
- if (/\/channels\/(whatsapp|instagram)(\/|$)/.test(url.pathname)) {
360
+ if (/\/channels\/(whatsapp|instagram|sms)(\/|$)/.test(url.pathname)) {
360
361
  applySecurityHeaders(res, tls);
361
362
  if (enforceHttps(req, res, tls))
362
363
  return;
@@ -41,7 +41,7 @@
41
41
  "@noodle-borg/managed-capabilities": "0.0.0",
42
42
  "@modelcontextprotocol/sdk": "^1.29.0",
43
43
  "@noodle-borg/admission-limits": "0.0.0",
44
- "@noodle-borg/agent-kit": "0.110.0",
44
+ "@noodle-borg/agent-kit": "0.112.0",
45
45
  "@noodle-borg/app-package": "0.0.0",
46
46
  "@noodle-borg/assistant-gateway": "0.0.0",
47
47
  "@noodle-borg/auth": "0.0.0",
@@ -46,6 +46,11 @@ export interface HttpHandlerOptions {
46
46
  readonly authorizeDataPlaneIdentity?: DataPlaneIdentityAuthorizer;
47
47
  /** Request admission hook after authentication and JSON parsing, before SDK execution. */
48
48
  readonly admissionGate?: AdmissionGate;
49
+ /**
50
+ * `author`: every anonymous caller is the app's own author, as on the loopback `noodle dev` service, so
51
+ * tools that change something run without sign-in. Hosted services never set it.
52
+ */
53
+ readonly anonymousCallers?: 'author';
49
54
  /** Enqueue one scalar-only analytics event after the response is written. */
50
55
  readonly captureRequestEvent?: (event: RequestEventInput) => void;
51
56
  /** Separate, best-effort operator intent stream. Requires `intentCaptureMode: starter-v1`. */
@@ -37,6 +37,7 @@ export function createMcpHttpHandler(target, options = {}) {
37
37
  authentication: resolveTargetAuthentication({}, options.verifyOwnerToken),
38
38
  authorizeDataPlaneIdentity: options.authorizeDataPlaneIdentity,
39
39
  admissionGate: options.admissionGate,
40
+ anonymousCallers: options.anonymousCallers,
40
41
  captureRequestEvent: options.captureRequestEvent,
41
42
  captureIntentEvent: options.captureIntentEvent,
42
43
  intentCaptureMode: options.intentCaptureMode ?? 'off',
@@ -105,6 +106,7 @@ export function createMcpRouter(lookup, options = {}) {
105
106
  authentication: resolveTargetAuthentication(resolvedTarget, options.verifyOwnerToken),
106
107
  authorizeDataPlaneIdentity: options.authorizeDataPlaneIdentity,
107
108
  admissionGate: options.admissionGate,
109
+ anonymousCallers: options.anonymousCallers,
108
110
  captureRequestEvent: options.captureRequestEvent,
109
111
  captureIntentEvent: options.captureIntentEvent,
110
112
  intentCaptureMode: resolvedTarget.intentCaptureMode ?? 'off',
@@ -159,7 +159,9 @@ export async function serveRequest(req, res, target, auth, routeId, maxBody, all
159
159
  return denied(`admission_denied.${boundedReason(decision.reason)}`, parsed);
160
160
  }
161
161
  }
162
- const authorization = preflightToolAuthorization(parsed, target, protocolContext.caller);
162
+ const authorization = preflightToolAuthorization(parsed, target, protocolContext.caller,
163
+ // On the loopback author loop the anonymous caller is the author, so no public-write rule applies.
164
+ auth.anonymousCallers === 'author' ? undefined : auth.accessMode);
163
165
  for (const observation of authorization.observations) {
164
166
  logger.info('mcp.tool_authorization', {
165
167
  toolName: observation.toolName,