@forklaunch/core 2.1.0 → 3.0.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 (52) hide show
  1. package/bin/gateway-mock/routes/email.mjs +233 -0
  2. package/bin/gateway-mock/routes/models.mjs +192 -0
  3. package/bin/gateway-mock/routes/payments.mjs +541 -0
  4. package/bin/gateway-mock/routes/sms.mjs +209 -0
  5. package/bin/gateway-mock/routes/voice.mjs +237 -0
  6. package/bin/gateway-mock/routes/whatsapp.mjs +227 -0
  7. package/bin/gateway-mock/server.mjs +321 -0
  8. package/bin/migrate-compliant-fields.mjs +312 -0
  9. package/bin/model-gateway-mock.mjs +13 -0
  10. package/lib/http/emailClient.d.ts +102 -0
  11. package/lib/http/emailClient.d.ts.map +1 -0
  12. package/lib/http/index.d.ts +7 -0
  13. package/lib/http/index.d.ts.map +1 -1
  14. package/lib/http/index.js +850 -4
  15. package/lib/http/index.js.map +1 -1
  16. package/lib/http/index.mjs +809 -1
  17. package/lib/http/index.mjs.map +1 -1
  18. package/lib/http/instanceGateway.d.ts +113 -0
  19. package/lib/http/instanceGateway.d.ts.map +1 -0
  20. package/lib/http/modelGatewayClient.d.ts +126 -0
  21. package/lib/http/modelGatewayClient.d.ts.map +1 -0
  22. package/lib/http/paymentsClient.d.ts +123 -0
  23. package/lib/http/paymentsClient.d.ts.map +1 -0
  24. package/lib/http/smsClient.d.ts +123 -0
  25. package/lib/http/smsClient.d.ts.map +1 -0
  26. package/lib/http/voiceClient.d.ts +105 -0
  27. package/lib/http/voiceClient.d.ts.map +1 -0
  28. package/lib/http/whatsappClient.d.ts +118 -0
  29. package/lib/http/whatsappClient.d.ts.map +1 -0
  30. package/lib/objectstore/interfaces/objectstore.interface.d.ts +45 -0
  31. package/lib/objectstore/interfaces/objectstore.interface.d.ts.map +1 -1
  32. package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
  33. package/lib/persistence/complianceTypes.d.ts +10 -1
  34. package/lib/persistence/complianceTypes.d.ts.map +1 -1
  35. package/lib/persistence/compliantField.d.ts +203 -0
  36. package/lib/persistence/compliantField.d.ts.map +1 -0
  37. package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
  38. package/lib/persistence/encryptedType.d.ts +8 -1
  39. package/lib/persistence/encryptedType.d.ts.map +1 -1
  40. package/lib/persistence/fieldEncryptor.d.ts +47 -6
  41. package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
  42. package/lib/persistence/index.d.ts +3 -2
  43. package/lib/persistence/index.d.ts.map +1 -1
  44. package/lib/persistence/index.js +987 -259
  45. package/lib/persistence/index.js.map +1 -1
  46. package/lib/persistence/index.mjs +976 -257
  47. package/lib/persistence/index.mjs.map +1 -1
  48. package/lib/persistence/keyRotation.d.ts +5 -0
  49. package/lib/persistence/keyRotation.d.ts.map +1 -1
  50. package/lib/services/index.js.map +1 -1
  51. package/lib/services/index.mjs.map +1 -1
  52. package/package.json +29 -13
@@ -0,0 +1,209 @@
1
+ /**
2
+ * SMS: the platform's End User Messaging SMS gateway (createSmsClient), and
3
+ * the handset on the other end.
4
+ *
5
+ * Gateway route (HMAC-verified like every gateway route):
6
+ * POST /sms/send { to, body, purpose? } -> { messageId, segments }
7
+ * 400 `to` is not E.164, the body is empty or over 10 segments, or
8
+ * `purpose` is not transactional/promotional
9
+ * 422 the number opted out (texted STOP) — the platform refuses it too
10
+ * 429 the monthly segment cap or the per-minute limit is spent
11
+ * (with retry-after)
12
+ * After answering, emits `sms.delivered` — or `sms.failed` for a number
13
+ * ending in 0000, as a carrier would report an unreachable handset.
14
+ *
15
+ * Vendor-side routes (unauthenticated, for tests and local play):
16
+ * GET /__mock/sms/messages every accepted send, in order
17
+ * POST /__mock/sms/inbound { from, body }
18
+ * a text arriving from a handset: emits `sms.received`; STOP (and the
19
+ * other opt-out keywords) also records the opt-out and emits
20
+ * `sms.opted_out`; START/UNSTOP removes it again. HELP is answered by
21
+ * the carrier-required help reply and still relayed as received.
22
+ *
23
+ * Settings:
24
+ * MOCK_SMS_MONTHLY_CAP segments per calendar month, default 100
25
+ * MOCK_SMS_PER_MINUTE sends per minute, default 60
26
+ */
27
+ import { randomUUID } from 'node:crypto';
28
+
29
+ const E164 = /^\+[1-9]\d{6,14}$/;
30
+ const MAX_SEGMENTS = 10;
31
+ // The keywords AWS End User Messaging treats as opt-out / opt-in by default.
32
+ const OPT_OUT = new Set([
33
+ 'STOP',
34
+ 'STOPALL',
35
+ 'UNSUBSCRIBE',
36
+ 'CANCEL',
37
+ 'END',
38
+ 'QUIT',
39
+ 'OPTOUT',
40
+ 'OPT-OUT',
41
+ 'REVOKE'
42
+ ]);
43
+ const OPT_IN = new Set(['START', 'UNSTOP', 'YES']);
44
+ const HELP = new Set(['HELP', 'INFO']);
45
+
46
+ const GSM_BASIC = new Set(
47
+ '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'
48
+ );
49
+ const GSM_EXTENDED = new Set('^{}\\[~]|€\f');
50
+
51
+ /** The same segment arithmetic as smsSegments in @forklaunch/core/http. */
52
+ function segmentsOf(body) {
53
+ let septets = 0;
54
+ for (const ch of body) {
55
+ if (GSM_BASIC.has(ch)) septets += 1;
56
+ else if (GSM_EXTENDED.has(ch)) septets += 2;
57
+ else return body.length <= 70 ? 1 : Math.ceil(body.length / 67);
58
+ }
59
+ return septets <= 160 ? 1 : Math.ceil(septets / 153);
60
+ }
61
+
62
+ const monthOf = (d) =>
63
+ `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
64
+
65
+ function secondsUntilNextMonth(now) {
66
+ const next = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
67
+ return Math.max(1, Math.ceil((next - now.getTime()) / 1000));
68
+ }
69
+
70
+ function init(state) {
71
+ state.messages ??= [];
72
+ state.optedOut ??= new Set();
73
+ state.segmentsByMonth ??= {};
74
+ state.sendTimes ??= [];
75
+ return state;
76
+ }
77
+
78
+ const keywordOf = (body) =>
79
+ String(body ?? '')
80
+ .trim()
81
+ .split(/\s+/)[0]
82
+ ?.toUpperCase() ?? '';
83
+
84
+ async function send(ctx) {
85
+ const state = init(ctx.state);
86
+ const { to, body, purpose = 'transactional' } = ctx.body ?? {};
87
+ if (typeof to !== 'string' || !E164.test(to)) {
88
+ return ctx.send(400, `'to' must be an E.164 number like +14155550123`);
89
+ }
90
+ if (typeof body !== 'string' || !body.trim()) {
91
+ return ctx.send(400, "'body' must be non-empty text");
92
+ }
93
+ if (purpose !== 'transactional' && purpose !== 'promotional') {
94
+ return ctx.send(400, "'purpose' must be 'transactional' or 'promotional'");
95
+ }
96
+ const segments = segmentsOf(body);
97
+ if (segments > MAX_SEGMENTS) {
98
+ return ctx.send(
99
+ 400,
100
+ `'body' is ${segments} segments; the limit is ${MAX_SEGMENTS}`
101
+ );
102
+ }
103
+ if (state.optedOut.has(to)) {
104
+ return ctx.send(
105
+ 422,
106
+ `${to} has opted out of texts from this product (STOP)`
107
+ );
108
+ }
109
+
110
+ const now = new Date();
111
+ const perMinute = Number(ctx.env.MOCK_SMS_PER_MINUTE ?? 60);
112
+ state.sendTimes = state.sendTimes.filter((t) => now.getTime() - t < 60_000);
113
+ if (state.sendTimes.length >= perMinute) {
114
+ return ctx.send(429, `Rate limited: ${perMinute} texts per minute`, {
115
+ 'retry-after': '60'
116
+ });
117
+ }
118
+ const cap = Number(ctx.env.MOCK_SMS_MONTHLY_CAP ?? 100);
119
+ const month = monthOf(now);
120
+ const used = state.segmentsByMonth[month] ?? 0;
121
+ if (used + segments > cap) {
122
+ return ctx.send(
123
+ 429,
124
+ `Monthly SMS cap reached: ${used} of ${cap} segments used this month`,
125
+ { 'retry-after': String(secondsUntilNextMonth(now)) }
126
+ );
127
+ }
128
+ state.segmentsByMonth[month] = used + segments;
129
+ state.sendTimes.push(now.getTime());
130
+
131
+ const messageId = `mock-sms-${randomUUID()}`;
132
+ const failed = to.endsWith('0000');
133
+ state.messages.push({
134
+ messageId,
135
+ instanceId: ctx.instanceId,
136
+ to,
137
+ body,
138
+ purpose,
139
+ segments,
140
+ status: failed ? 'failed' : 'delivered',
141
+ sentAt: now.toISOString()
142
+ });
143
+ ctx.send(200, { messageId, segments });
144
+
145
+ // The carrier's receipt, after the send was accepted.
146
+ if (failed) {
147
+ await ctx.emit('sms.failed', {
148
+ messageId,
149
+ to,
150
+ reason: 'UNREACHABLE: the handset is not reachable'
151
+ });
152
+ } else {
153
+ await ctx.emit('sms.delivered', {
154
+ messageId,
155
+ to,
156
+ deliveredAt: new Date().toISOString()
157
+ });
158
+ }
159
+ }
160
+
161
+ async function inbound(ctx) {
162
+ const state = init(ctx.state);
163
+ const { from, body } = ctx.body ?? {};
164
+ if (typeof from !== 'string' || !E164.test(from)) {
165
+ return ctx.send(400, "'from' must be an E.164 number");
166
+ }
167
+ if (typeof body !== 'string') {
168
+ return ctx.send(400, "'body' must be text");
169
+ }
170
+ const receivedAt = new Date().toISOString();
171
+ const word = keywordOf(body);
172
+ const keyword =
173
+ OPT_OUT.has(word) || OPT_IN.has(word) || HELP.has(word) ? word : undefined;
174
+ const delivered = [];
175
+ delivered.push(
176
+ await ctx.emit('sms.received', {
177
+ from,
178
+ body,
179
+ receivedAt,
180
+ ...(keyword ? { keyword } : {})
181
+ })
182
+ );
183
+ if (OPT_OUT.has(word)) {
184
+ state.optedOut.add(from);
185
+ delivered.push(
186
+ await ctx.emit('sms.opted_out', { phone: from, optedOutAt: receivedAt })
187
+ );
188
+ } else if (OPT_IN.has(word)) {
189
+ state.optedOut.delete(from);
190
+ }
191
+ return ctx.send(200, {
192
+ keyword: keyword ?? null,
193
+ optedOut: state.optedOut.has(from),
194
+ events: delivered.map((d) => ({ type: d.event.type, status: d.status }))
195
+ });
196
+ }
197
+
198
+ export default {
199
+ feature: 'sms',
200
+ routes: [{ method: 'POST', path: '/sms/send', handler: send }],
201
+ vendorRoutes: [
202
+ {
203
+ method: 'GET',
204
+ path: '/__mock/sms/messages',
205
+ handler: (ctx) => ctx.send(200, init(ctx.state).messages)
206
+ },
207
+ { method: 'POST', path: '/__mock/sms/inbound', handler: inbound }
208
+ ]
209
+ };
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Voice: outbound calls (createVoiceClient) as the platform places them
3
+ * through Amazon Connect, and the call events Connect reports back.
4
+ *
5
+ * POST /voice/calls { to, flow, attributes? } -> { callId }
6
+ * GET /voice/calls/:id -> { status, startedAt, endedAt?, durationSeconds?, disconnectReason? }
7
+ * DELETE /voice/calls/:id hang up (reason `api`)
8
+ *
9
+ * A call starts `in_progress` and emits `voice.call.started` at once; after
10
+ * MOCK_VOICE_CALL_MS it ends by itself with `voice.call.ended`
11
+ * { callId, durationSeconds, disconnectReason: 'customer' }, then, with
12
+ * MOCK_VOICE_RECORD=1, `voice.recording.ready` { callId, recordingKey }.
13
+ * A number ending in 9999 is busy: it ends at once, reason `busy`, 0 seconds.
14
+ * Events are always delivered in that order.
15
+ *
16
+ * Refusals, as the platform answers them:
17
+ * 400 bad number, a flow not in the catalog, a contact-flow ARN, bad attributes
18
+ * 404 a call this instance did not place
19
+ * 409 hanging up a call that already ended; no number claimed (MOCK_VOICE_NO_NUMBER=1)
20
+ * 429 too many calls in progress, or the month's minutes are spent
21
+ *
22
+ * MOCK_VOICE_FLOWS comma-separated flow names, default appointment_reminder
23
+ * MOCK_VOICE_CALL_MS how long a call lasts in real time, default 200
24
+ * MOCK_VOICE_DURATION_SECONDS the duration a completed call reports, default 42
25
+ * MOCK_VOICE_MAX_CONCURRENT calls in progress per instance, default 2
26
+ * MOCK_VOICE_MONTHLY_MINUTES billed minutes per instance, default 1000
27
+ * MOCK_VOICE_RECORD 1: emit voice.recording.ready after each answered call
28
+ * MOCK_VOICE_NO_NUMBER 1: the instance has no claimed number (409)
29
+ */
30
+ import { randomUUID } from 'node:crypto';
31
+
32
+ const env = process.env;
33
+ const FLOWS = (env.MOCK_VOICE_FLOWS ?? 'appointment_reminder')
34
+ .split(',')
35
+ .map((f) => f.trim())
36
+ .filter(Boolean);
37
+ const CALL_MS = Number(env.MOCK_VOICE_CALL_MS ?? 200);
38
+ const DURATION_SECONDS = Number(env.MOCK_VOICE_DURATION_SECONDS ?? 42);
39
+ const MAX_CONCURRENT = Number(env.MOCK_VOICE_MAX_CONCURRENT ?? 2);
40
+ const MONTHLY_MINUTES = Number(env.MOCK_VOICE_MONTHLY_MINUTES ?? 1000);
41
+ const RECORD = env.MOCK_VOICE_RECORD === '1';
42
+ const NO_NUMBER = env.MOCK_VOICE_NO_NUMBER === '1';
43
+
44
+ // The gateway's attribute limits (VOICE_ATTRIBUTE_LIMITS in the framework).
45
+ const MAX_KEYS = 20;
46
+ const MAX_KEY_LENGTH = 64;
47
+ const MAX_VALUE_LENGTH = 256;
48
+ const MAX_TOTAL_BYTES = 4096;
49
+
50
+ const timers = new Set();
51
+
52
+ function problem(body) {
53
+ if (typeof body.to !== 'string' || !/^\+[1-9]\d{6,14}$/.test(body.to)) {
54
+ return '`to` must be an E.164 number such as +15551230000';
55
+ }
56
+ if (typeof body.flow !== 'string' || body.flow.startsWith('arn:')) {
57
+ return '`flow` is a flow name from the product catalog, not a contact-flow ARN';
58
+ }
59
+ if (!FLOWS.includes(body.flow)) {
60
+ return `Unknown flow '${body.flow}'; this product's flows: ${FLOWS.join(', ')}`;
61
+ }
62
+ const attributes = body.attributes ?? {};
63
+ if (typeof attributes !== 'object' || Array.isArray(attributes)) {
64
+ return '`attributes` must be an object of strings';
65
+ }
66
+ const entries = Object.entries(attributes);
67
+ if (entries.length > MAX_KEYS) return `at most ${MAX_KEYS} attributes`;
68
+ let total = 0;
69
+ for (const [key, value] of entries) {
70
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(key) || key.length > MAX_KEY_LENGTH) {
71
+ return `attribute key '${key}' must be letters, digits or _`;
72
+ }
73
+ if (key.toLowerCase().startsWith('fl_')) {
74
+ return "attribute keys starting with 'fl_' are reserved for the platform";
75
+ }
76
+ if (typeof value !== 'string') return `attribute '${key}' must be a string`;
77
+ if (value.length > MAX_VALUE_LENGTH) {
78
+ return `attribute '${key}' is longer than ${MAX_VALUE_LENGTH} characters`;
79
+ }
80
+ total += Buffer.byteLength(key) + Buffer.byteLength(value);
81
+ }
82
+ if (total > MAX_TOTAL_BYTES)
83
+ return `attributes exceed ${MAX_TOTAL_BYTES} bytes`;
84
+ return undefined;
85
+ }
86
+
87
+ function calls(state) {
88
+ state.calls ??= new Map();
89
+ return state.calls;
90
+ }
91
+
92
+ function minutesUsed(state, instanceId) {
93
+ state.minutes ??= new Map();
94
+ return state.minutes.get(instanceId) ?? 0;
95
+ }
96
+
97
+ /** End a call once: record it, bill it, then emit ended (after started). */
98
+ function finish(ctx, call, reason, durationSeconds) {
99
+ if (call.status === 'ended') return;
100
+ if (call.timer) {
101
+ clearTimeout(call.timer);
102
+ timers.delete(call.timer);
103
+ }
104
+ call.status = 'ended';
105
+ call.endedAt = new Date().toISOString();
106
+ call.durationSeconds = durationSeconds;
107
+ call.disconnectReason = reason;
108
+ ctx.state.minutes ??= new Map();
109
+ ctx.state.minutes.set(
110
+ call.instanceId,
111
+ minutesUsed(ctx.state, call.instanceId) + Math.ceil(durationSeconds / 60)
112
+ );
113
+ call.events = call.events.then(async () => {
114
+ await ctx.emit('voice.call.ended', {
115
+ callId: call.id,
116
+ durationSeconds,
117
+ disconnectReason: reason
118
+ });
119
+ if (RECORD && durationSeconds > 0) {
120
+ await ctx.emit('voice.recording.ready', {
121
+ callId: call.id,
122
+ recordingKey: `voice/recordings/${call.id}.wav`
123
+ });
124
+ }
125
+ });
126
+ }
127
+
128
+ function view(call) {
129
+ return {
130
+ status: call.status,
131
+ startedAt: call.startedAt,
132
+ ...(call.endedAt ? { endedAt: call.endedAt } : {}),
133
+ ...(call.durationSeconds !== undefined
134
+ ? { durationSeconds: call.durationSeconds }
135
+ : {}),
136
+ ...(call.disconnectReason
137
+ ? { disconnectReason: call.disconnectReason }
138
+ : {})
139
+ };
140
+ }
141
+
142
+ function ownCall(ctx) {
143
+ const call = calls(ctx.state).get(ctx.params.id);
144
+ return call && call.instanceId === ctx.instanceId ? call : undefined;
145
+ }
146
+
147
+ async function startCall(ctx) {
148
+ const body = ctx.body ?? {};
149
+ const refused = problem(body);
150
+ if (refused) return ctx.send(400, refused);
151
+ if (NO_NUMBER) {
152
+ return ctx.send(
153
+ 409,
154
+ 'No phone number is claimed for this instance; the platform claims one when voice is provisioned'
155
+ );
156
+ }
157
+ const active = [...calls(ctx.state).values()].filter(
158
+ (c) => c.instanceId === ctx.instanceId && c.status !== 'ended'
159
+ ).length;
160
+ if (active >= MAX_CONCURRENT) {
161
+ return ctx.send(
162
+ 429,
163
+ `${active} calls in progress; this instance may have ${MAX_CONCURRENT} at once`,
164
+ { 'retry-after': '1' }
165
+ );
166
+ }
167
+ if (minutesUsed(ctx.state, ctx.instanceId) >= MONTHLY_MINUTES) {
168
+ return ctx.send(
169
+ 429,
170
+ `This month's ${MONTHLY_MINUTES} call minutes are spent`,
171
+ {
172
+ 'retry-after': '86400'
173
+ }
174
+ );
175
+ }
176
+
177
+ const call = {
178
+ id: `mock-call-${randomUUID()}`,
179
+ instanceId: ctx.instanceId,
180
+ flow: body.flow,
181
+ to: body.to,
182
+ status: 'in_progress',
183
+ startedAt: new Date().toISOString(),
184
+ startedMs: Date.now(),
185
+ events: Promise.resolve()
186
+ };
187
+ calls(ctx.state).set(call.id, call);
188
+ ctx.send(200, { callId: call.id });
189
+
190
+ call.events = call.events.then(() =>
191
+ ctx.emit('voice.call.started', { callId: call.id, flow: call.flow })
192
+ );
193
+ if (call.to.endsWith('9999')) {
194
+ finish(ctx, call, 'busy', 0);
195
+ return;
196
+ }
197
+ call.timer = setTimeout(() => {
198
+ timers.delete(call.timer);
199
+ call.timer = undefined;
200
+ finish(ctx, call, 'customer', DURATION_SECONDS);
201
+ }, CALL_MS);
202
+ timers.add(call.timer);
203
+ }
204
+
205
+ function endCall(ctx) {
206
+ const call = ownCall(ctx);
207
+ if (!call) return ctx.send(404, 'No such call for this instance');
208
+ if (call.status === 'ended') return ctx.send(409, 'The call already ended');
209
+ finish(
210
+ ctx,
211
+ call,
212
+ 'api',
213
+ Math.max(1, Math.ceil((Date.now() - call.startedMs) / 1000))
214
+ );
215
+ return ctx.send(200, { callId: call.id, status: 'ended' });
216
+ }
217
+
218
+ function getCall(ctx) {
219
+ const call = ownCall(ctx);
220
+ if (!call) return ctx.send(404, 'No such call for this instance');
221
+ return ctx.send(200, view(call));
222
+ }
223
+
224
+ const CALL_ID = /^\/voice\/calls\/(?<id>[A-Za-z0-9_-]{1,128})$/;
225
+
226
+ export default {
227
+ feature: 'voice',
228
+ routes: [
229
+ { method: 'POST', path: '/voice/calls', handler: startCall },
230
+ { method: 'GET', path: CALL_ID, handler: getCall },
231
+ { method: 'DELETE', path: CALL_ID, handler: endCall }
232
+ ],
233
+ reset() {
234
+ for (const timer of timers) clearTimeout(timer);
235
+ timers.clear();
236
+ }
237
+ };
@@ -0,0 +1,227 @@
1
+ /**
2
+ * WhatsApp: the instance gateway's WhatsApp routes (createWhatsAppClient),
3
+ * standing in for the platform and AWS End User Messaging Social / Meta.
4
+ *
5
+ * Gateway routes (HMAC-verified like every gateway route):
6
+ * GET /whatsapp/templates { templates: [{ name, language, status, category }] }
7
+ * POST /whatsapp/messages { to, type: 'template', template, language, components? }
8
+ * { to, type: 'text', body }
9
+ * → { messageId }
10
+ *
11
+ * What it refuses, as the platform does:
12
+ * 400 bad recipient (not E.164), unknown template or language, a template
13
+ * that is not APPROVED, empty text, an unknown type
14
+ * 403 MOCK_WHATSAPP_HIPAA=1: a HIPAA product may not send WhatsApp at all
15
+ * 409 MOCK_WHATSAPP_UNCONFIGURED=1: no phone number linked for the instance
16
+ * 422 free-form text outside the 24-hour customer-service window
17
+ * 429 more than MOCK_WHATSAPP_RPM sends in a minute (retry-after set)
18
+ *
19
+ * Events (to /platform-events/whatsapp on the app):
20
+ * whatsapp.status { messageId, status, recipient, timestamp } — `sent`
21
+ * then `delivered` after every accepted send
22
+ * whatsapp.received { from, text, type, receivedAt, messageId } — on inbound
23
+ *
24
+ * Control (unauthenticated, for tests and local development):
25
+ * POST /__mock/whatsapp/inbound { from, text, receivedAt? }
26
+ * simulates a person writing to the business number: emits
27
+ * whatsapp.received and opens the 24-hour window for `from` (from
28
+ * `receivedAt` when given, so an expired window can be simulated).
29
+ * GET /__mock/whatsapp/messages the messages sent so far
30
+ *
31
+ * Environment:
32
+ * MOCK_WHATSAPP_TEMPLATES name:language:STATUS[:CATEGORY], comma-separated;
33
+ * default appointment_reminder:en_US:APPROVED:UTILITY
34
+ * MOCK_WHATSAPP_HIPAA 1: refuse every send with 403
35
+ * MOCK_WHATSAPP_UNCONFIGURED 1: refuse every call with 409
36
+ * MOCK_WHATSAPP_RPM sends per minute, default 60
37
+ */
38
+ import { randomUUID } from 'node:crypto';
39
+
40
+ const WINDOW_MS = 24 * 60 * 60 * 1000;
41
+ const E164 = /^\+[1-9]\d{6,14}$/;
42
+
43
+ function templatesFrom(env) {
44
+ return String(
45
+ env.MOCK_WHATSAPP_TEMPLATES ?? 'appointment_reminder:en_US:APPROVED:UTILITY'
46
+ )
47
+ .split(',')
48
+ .map((t) => t.trim())
49
+ .filter(Boolean)
50
+ .map((t) => {
51
+ const [name, language = 'en_US', status = 'APPROVED', category] =
52
+ t.split(':');
53
+ return { name, language, status, ...(category ? { category } : {}) };
54
+ });
55
+ }
56
+
57
+ const on = (value) => value === '1' || value === 'true';
58
+
59
+ function st(ctx) {
60
+ ctx.state.windows ??= {};
61
+ ctx.state.sent ??= [];
62
+ ctx.state.minute ??= { start: 0, count: 0 };
63
+ return ctx.state;
64
+ }
65
+
66
+ /** Platform-level refusals that apply to every call. */
67
+ function refused(ctx) {
68
+ if (on(ctx.env.MOCK_WHATSAPP_UNCONFIGURED)) {
69
+ ctx.send(
70
+ 409,
71
+ 'No WhatsApp phone number is linked for this instance or its product: link a WhatsApp Business Account (Meta embedded signup in AWS End User Messaging Social) and set its phone number id in the product settings'
72
+ );
73
+ return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function statusEvents(ctx, messageId, to) {
79
+ // After the answer, as the vendor reports: sent, then delivered.
80
+ return (async () => {
81
+ for (const status of ['sent', 'delivered']) {
82
+ await ctx.emit('whatsapp.status', {
83
+ messageId,
84
+ status,
85
+ recipient: to,
86
+ timestamp: new Date().toISOString()
87
+ });
88
+ }
89
+ })();
90
+ }
91
+
92
+ async function sendMessage(ctx) {
93
+ if (refused(ctx)) return;
94
+ const state = st(ctx);
95
+ const { body, env } = ctx;
96
+ if (on(env.MOCK_WHATSAPP_HIPAA)) {
97
+ return ctx.send(
98
+ 403,
99
+ 'WhatsApp is disabled for HIPAA products: it is not covered by the AWS BAA and Meta signs no BAA, so no message may be sent'
100
+ );
101
+ }
102
+ if (typeof body.to !== 'string' || !E164.test(body.to)) {
103
+ return ctx.send(400, "'to' must be an E.164 phone number");
104
+ }
105
+ const rpm = Number(env.MOCK_WHATSAPP_RPM ?? 60);
106
+ const now = Date.now();
107
+ if (now - state.minute.start >= 60_000)
108
+ state.minute = { start: now, count: 0 };
109
+ if (state.minute.count >= rpm) {
110
+ const wait = Math.max(
111
+ 1,
112
+ Math.ceil((state.minute.start + 60_000 - now) / 1000)
113
+ );
114
+ return ctx.send(429, `Rate limited: ${rpm} WhatsApp sends per minute`, {
115
+ 'retry-after': String(wait)
116
+ });
117
+ }
118
+
119
+ let record;
120
+ if (body.type === 'template') {
121
+ const templates = templatesFrom(env);
122
+ const named = templates.filter((t) => t.name === body.template);
123
+ if (named.length === 0) {
124
+ return ctx.send(
125
+ 400,
126
+ `Unknown template '${body.template}'; known: ${templates.map((t) => t.name).join(', ')}`
127
+ );
128
+ }
129
+ const template = named.find((t) => t.language === body.language);
130
+ if (!template) {
131
+ return ctx.send(
132
+ 400,
133
+ `Template '${body.template}' has no '${body.language}' translation; it has ${named.map((t) => t.language).join(', ')}`
134
+ );
135
+ }
136
+ if (template.status !== 'APPROVED') {
137
+ return ctx.send(
138
+ 400,
139
+ `Template '${body.template}' (${body.language}) is ${template.status}, not APPROVED by Meta`
140
+ );
141
+ }
142
+ record = {
143
+ type: 'template',
144
+ template: body.template,
145
+ language: body.language,
146
+ components: body.components ?? []
147
+ };
148
+ } else if (body.type === 'text') {
149
+ if (typeof body.body !== 'string' || !body.body.trim()) {
150
+ return ctx.send(400, 'body must be non-empty text');
151
+ }
152
+ const lastInbound = state.windows[body.to];
153
+ if (!lastInbound || now - lastInbound > WINDOW_MS) {
154
+ return ctx.send(
155
+ 422,
156
+ `Free-form text is only allowed within 24 hours of ${body.to}'s last message; send an approved template instead`
157
+ );
158
+ }
159
+ record = { type: 'text', body: body.body };
160
+ } else {
161
+ return ctx.send(400, "type must be 'template' or 'text'");
162
+ }
163
+
164
+ state.minute.count += 1;
165
+ const messageId = `wamid.mock-${randomUUID()}`;
166
+ state.sent.push({
167
+ messageId,
168
+ to: body.to,
169
+ instanceId: ctx.instanceId,
170
+ ...record,
171
+ at: new Date(now).toISOString()
172
+ });
173
+ ctx.send(200, { messageId });
174
+ await statusEvents(ctx, messageId, body.to);
175
+ }
176
+
177
+ export default {
178
+ feature: 'whatsapp',
179
+ routes: [
180
+ {
181
+ method: 'GET',
182
+ path: '/whatsapp/templates',
183
+ handler: (ctx) => {
184
+ if (refused(ctx)) return;
185
+ return ctx.send(200, { templates: templatesFrom(ctx.env) });
186
+ }
187
+ },
188
+ { method: 'POST', path: '/whatsapp/messages', handler: sendMessage }
189
+ ],
190
+ vendorRoutes: [
191
+ {
192
+ method: 'POST',
193
+ path: '/__mock/whatsapp/inbound',
194
+ handler: async (ctx) => {
195
+ const state = st(ctx);
196
+ const { from, text } = ctx.body ?? {};
197
+ if (typeof from !== 'string' || !E164.test(from)) {
198
+ return ctx.send(400, "'from' must be an E.164 phone number");
199
+ }
200
+ const at = ctx.body.receivedAt
201
+ ? new Date(ctx.body.receivedAt)
202
+ : new Date();
203
+ if (Number.isNaN(at.getTime()))
204
+ return ctx.send(400, 'receivedAt is not a date');
205
+ state.windows[from] = Math.max(state.windows[from] ?? 0, at.getTime());
206
+ const messageId = `wamid.mock-in-${randomUUID()}`;
207
+ const delivery = await ctx.emit('whatsapp.received', {
208
+ from,
209
+ ...(typeof text === 'string' ? { text } : {}),
210
+ type: typeof text === 'string' ? 'text' : 'unknown',
211
+ receivedAt: at.toISOString(),
212
+ messageId
213
+ });
214
+ return ctx.send(200, {
215
+ messageId,
216
+ delivered: delivery.status,
217
+ error: delivery.error
218
+ });
219
+ }
220
+ },
221
+ {
222
+ method: 'GET',
223
+ path: '/__mock/whatsapp/messages',
224
+ handler: (ctx) => ctx.send(200, st(ctx).sent)
225
+ }
226
+ ]
227
+ };