@forklaunch/core 2.1.1 → 3.0.1

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 +27 -11
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Email: the platform's SES-backed email gateway (createEmailClient).
3
+ *
4
+ * Messages are recorded, never sent. Outcomes follow the SES mailbox
5
+ * simulator's spirit with addresses anyone can type:
6
+ * - `bounce@<any>` or any address at `bounce.test`: a permanent bounce
7
+ * (`email.bounced`), and the address joins the instance's suppression
8
+ * list — GET /email/suppressions/:address answers true and a later send
9
+ * to it is refused with 422;
10
+ * - `complaint@<any>`: delivered, then a complaint (`email.delivered`,
11
+ * `email.complained`), and the address is suppressed;
12
+ * - anything else: `email.delivered`.
13
+ * Suppression is applied when the send is accepted; events are delivered
14
+ * just after the send answers, as SES reports them after accepting.
15
+ *
16
+ * Refusals, as the platform answers them: 400 for a malformed request, 422
17
+ * for a suppressed recipient, 429 (with retry-after) past the daily quota.
18
+ * The quota counts recipients, as SES does.
19
+ *
20
+ * Vendor route (unauthenticated): GET /__mock/email/messages[?to=address]
21
+ * lists what was "sent", with its from address and configuration set.
22
+ *
23
+ * MOCK_EMAIL_DAILY_QUOTA recipients per UTC day, default 200
24
+ * MOCK_EMAIL_SENDING_DOMAIN default mail.forklaunch.test; the from address
25
+ * is no-reply@<instance id>.<this>
26
+ * MOCK_EMAIL_EVENT_DELAY_MS wait before delivering events, default 0
27
+ */
28
+ import { randomUUID } from 'node:crypto';
29
+
30
+ const env = process.env;
31
+ const DAILY_QUOTA = Number(env.MOCK_EMAIL_DAILY_QUOTA ?? 200);
32
+ const SENDING_DOMAIN = env.MOCK_EMAIL_SENDING_DOMAIN ?? 'mail.forklaunch.test';
33
+ const EVENT_DELAY_MS = Number(env.MOCK_EMAIL_EVENT_DELAY_MS ?? 0);
34
+
35
+ const LIMITS = {
36
+ maxRecipients: 50,
37
+ maxSubjectLength: 998,
38
+ maxBodyBytes: 512 * 1024,
39
+ maxTags: 10
40
+ };
41
+ const ADDRESS =
42
+ /^[^\s@<>()[\]\\,;:"]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
43
+ const TAG_PART = /^[A-Za-z0-9_-]{1,256}$/;
44
+
45
+ const isAddress = (a) =>
46
+ typeof a === 'string' && a.length <= 254 && ADDRESS.test(a);
47
+ const today = () => new Date().toISOString().slice(0, 10);
48
+ const secondsToMidnight = () => {
49
+ const now = new Date();
50
+ const next = Date.UTC(
51
+ now.getUTCFullYear(),
52
+ now.getUTCMonth(),
53
+ now.getUTCDate() + 1
54
+ );
55
+ return Math.max(1, Math.ceil((next - now.getTime()) / 1000));
56
+ };
57
+
58
+ function outcome(address) {
59
+ const [local, domain] = address.toLowerCase().split('@');
60
+ if (local === 'bounce' || domain === 'bounce.test') return 'bounce';
61
+ if (local === 'complaint') return 'complaint';
62
+ return 'deliver';
63
+ }
64
+
65
+ function init(state) {
66
+ state.messages ??= [];
67
+ state.suppressed ??= new Map();
68
+ state.day ??= today();
69
+ state.sent ??= 0;
70
+ if (state.day !== today()) {
71
+ state.day = today();
72
+ state.sent = 0;
73
+ }
74
+ return state;
75
+ }
76
+
77
+ /** The gateway's validation: a message, or the reason it is refused. */
78
+ function validate(body) {
79
+ const list = (v) =>
80
+ v === undefined ? undefined : Array.isArray(v) ? v : [v];
81
+ const to = list(body.to);
82
+ const replyTo = list(body.replyTo);
83
+ if (!to || to.length === 0) return 'to is required';
84
+ if (to.length > LIMITS.maxRecipients)
85
+ return `to has ${to.length} addresses; the limit is ${LIMITS.maxRecipients}`;
86
+ for (const a of [...to, ...(replyTo ?? [])]) {
87
+ if (!isAddress(a))
88
+ return `'${String(a).slice(0, 80)}' is not an email address`;
89
+ }
90
+ if (typeof body.subject !== 'string' || !body.subject.trim())
91
+ return 'subject is required';
92
+ if (/[\r\n]/.test(body.subject)) return 'subject must be one line';
93
+ if (body.subject.length > LIMITS.maxSubjectLength)
94
+ return 'subject is too long';
95
+ if (!body.text && !body.html) return 'text or html is required';
96
+ const bytes =
97
+ Buffer.byteLength(String(body.text ?? '')) +
98
+ Buffer.byteLength(String(body.html ?? ''));
99
+ if (bytes > LIMITS.maxBodyBytes)
100
+ return `body is ${bytes} bytes; the limit is ${LIMITS.maxBodyBytes}`;
101
+ const tags = body.tags ?? {};
102
+ if (typeof tags !== 'object' || Array.isArray(tags))
103
+ return 'tags must be an object';
104
+ if (Object.keys(tags).length > LIMITS.maxTags)
105
+ return `the limit is ${LIMITS.maxTags} tags`;
106
+ for (const [name, value] of Object.entries(tags)) {
107
+ if (!TAG_PART.test(name) || !TAG_PART.test(String(value)))
108
+ return `tag '${name}' is malformed`;
109
+ if (name.startsWith('fl-') || name.startsWith('ses:'))
110
+ return `tag '${name}' is reserved`;
111
+ }
112
+ return { to, replyTo, tags };
113
+ }
114
+
115
+ export default {
116
+ feature: 'email',
117
+ routes: [
118
+ {
119
+ method: 'POST',
120
+ path: '/email/send',
121
+ handler: (ctx) => {
122
+ const state = init(ctx.state);
123
+ const checked = validate(ctx.body);
124
+ if (typeof checked === 'string') return ctx.send(400, checked);
125
+ const { to, replyTo, tags } = checked;
126
+ const refused = to.filter((a) => state.suppressed.has(a.toLowerCase()));
127
+ if (refused.length) {
128
+ return ctx.send(
129
+ 422,
130
+ `Suppressed after a bounce or complaint: ${refused.join(', ')}`
131
+ );
132
+ }
133
+ if (state.sent + to.length > DAILY_QUOTA) {
134
+ return ctx.send(
135
+ 429,
136
+ `Daily email quota of ${DAILY_QUOTA} recipients is spent`,
137
+ { 'retry-after': String(secondsToMidnight()) }
138
+ );
139
+ }
140
+ state.sent += to.length;
141
+ const messageId = `mock-${randomUUID()}`;
142
+ const configurationSet = `fl-${ctx.instanceId}`;
143
+ state.messages.push({
144
+ messageId,
145
+ instanceId: ctx.instanceId,
146
+ configurationSet,
147
+ from: `no-reply@${ctx.instanceId}.${SENDING_DOMAIN}`,
148
+ to,
149
+ replyTo,
150
+ subject: ctx.body.subject,
151
+ text: ctx.body.text,
152
+ html: ctx.body.html,
153
+ tags,
154
+ at: new Date().toISOString()
155
+ });
156
+
157
+ // What SES will report, decided now so a follow-up call sees it.
158
+ const events = [];
159
+ const delivered = to.filter((a) => outcome(a) !== 'bounce');
160
+ const bounced = to.filter((a) => outcome(a) === 'bounce');
161
+ const complained = to.filter((a) => outcome(a) === 'complaint');
162
+ for (const a of bounced)
163
+ state.suppressed.set(a.toLowerCase(), 'bounce');
164
+ for (const a of complained)
165
+ state.suppressed.set(a.toLowerCase(), 'complaint');
166
+ const base = {
167
+ messageId,
168
+ ...(Object.keys(tags).length ? { tags } : {})
169
+ };
170
+ if (delivered.length)
171
+ events.push(['email.delivered', { ...base, recipients: delivered }]);
172
+ if (bounced.length) {
173
+ events.push([
174
+ 'email.bounced',
175
+ {
176
+ ...base,
177
+ recipients: bounced,
178
+ bounceType: 'Permanent',
179
+ bounceSubType: 'General',
180
+ permanent: true
181
+ }
182
+ ]);
183
+ }
184
+ if (complained.length) {
185
+ events.push([
186
+ 'email.complained',
187
+ { ...base, recipients: complained, feedbackType: 'abuse' }
188
+ ]);
189
+ }
190
+
191
+ ctx.send(200, { messageId });
192
+ setTimeout(async () => {
193
+ for (const [type, data] of events) await ctx.emit(type, data);
194
+ }, EVENT_DELAY_MS);
195
+ }
196
+ },
197
+ {
198
+ method: 'GET',
199
+ path: /^\/email\/suppressions\/(?<address>[^/]+)$/,
200
+ handler: (ctx) => {
201
+ const state = init(ctx.state);
202
+ const address = decodeURIComponent(ctx.params.address).toLowerCase();
203
+ if (!isAddress(address)) return ctx.send(400, 'Not an email address');
204
+ const reason = state.suppressed.get(address);
205
+ return ctx.send(200, {
206
+ address,
207
+ suppressed: Boolean(reason),
208
+ ...(reason ? { reason } : {})
209
+ });
210
+ }
211
+ }
212
+ ],
213
+ vendorRoutes: [
214
+ {
215
+ method: 'GET',
216
+ path: '/__mock/email/messages',
217
+ handler: (ctx) => {
218
+ const state = init(ctx.state);
219
+ const to = new URL(ctx.req.url ?? '/', 'http://mock').searchParams.get(
220
+ 'to'
221
+ );
222
+ return ctx.send(
223
+ 200,
224
+ to
225
+ ? state.messages.filter((m) =>
226
+ m.to.some((a) => a.toLowerCase() === to.toLowerCase())
227
+ )
228
+ : state.messages
229
+ );
230
+ }
231
+ }
232
+ ]
233
+ };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Models: the model gateway (createModelGatewayClient) and, for platform
3
+ * developers, the Azure AI Foundry / Entra APIs behind it.
4
+ *
5
+ * Answers are `[mock <model>] <the last user message>`; token counts are
6
+ * words, so budgets move predictably.
7
+ *
8
+ * MOCK_MODELS comma-separated aliases, default sol,terra,luna
9
+ * MOCK_MONTHLY_TOKENS default 2000000
10
+ * MOCK_FAIL_EVERY answer 503 to every Nth completion (default off)
11
+ */
12
+ import { randomUUID } from 'node:crypto';
13
+
14
+ const env = process.env;
15
+ const MODELS = (env.MOCK_MODELS ?? 'sol,terra,luna')
16
+ .split(',')
17
+ .map((m) => m.trim())
18
+ .filter(Boolean);
19
+ const BUDGET = Number(env.MOCK_MONTHLY_TOKENS ?? 2_000_000);
20
+ const FAIL_EVERY = Number(env.MOCK_FAIL_EVERY ?? 0);
21
+
22
+ let tokensUsed = 0;
23
+ let completions = 0;
24
+
25
+ const words = (text) =>
26
+ String(text ?? '')
27
+ .split(/\s+/)
28
+ .filter(Boolean).length;
29
+
30
+ function lastUserText(messages) {
31
+ const user = [...(messages ?? [])].reverse().find((m) => m?.role === 'user');
32
+ if (!user) return '';
33
+ if (typeof user.content === 'string') return user.content;
34
+ if (Array.isArray(user.content)) {
35
+ return user.content
36
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
37
+ .join(' ');
38
+ }
39
+ return '';
40
+ }
41
+
42
+ function answer(body) {
43
+ const model = String(body.model ?? 'mock');
44
+ const content = `[mock ${model}] ${lastUserText(body.messages)}`.slice(0, 2000);
45
+ const usage = {
46
+ prompt_tokens: (body.messages ?? []).reduce(
47
+ (n, m) => n + words(typeof m?.content === 'string' ? m.content : ''),
48
+ 0
49
+ ),
50
+ completion_tokens: words(content)
51
+ };
52
+ usage.total_tokens = usage.prompt_tokens + usage.completion_tokens;
53
+ return { model, content, usage };
54
+ }
55
+
56
+ function completion(body) {
57
+ const { model, content, usage } = answer(body);
58
+ return {
59
+ id: `chatcmpl-mock-${randomUUID()}`,
60
+ object: 'chat.completion',
61
+ created: Math.floor(Date.now() / 1000),
62
+ model,
63
+ choices: [
64
+ { index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }
65
+ ],
66
+ usage
67
+ };
68
+ }
69
+
70
+ function chunks(body) {
71
+ const { model, content, usage } = answer(body);
72
+ const base = {
73
+ id: `chatcmpl-mock-${randomUUID()}`,
74
+ object: 'chat.completion.chunk',
75
+ created: Math.floor(Date.now() / 1000),
76
+ model
77
+ };
78
+ const out = [{ ...base, choices: [{ index: 0, delta: { role: 'assistant' } }] }];
79
+ for (const word of content.split(/(?<= )/)) {
80
+ out.push({ ...base, choices: [{ index: 0, delta: { content: word } }] });
81
+ }
82
+ out.push({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
83
+ if (body.stream_options?.include_usage !== false) {
84
+ out.push({ ...base, choices: [], usage });
85
+ }
86
+ out.push('[DONE]');
87
+ return out;
88
+ }
89
+
90
+ function failed(ctx) {
91
+ completions += 1;
92
+ if (FAIL_EVERY > 0 && completions % FAIL_EVERY === 0) {
93
+ ctx.send(503, 'Mock outage (MOCK_FAIL_EVERY)');
94
+ return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ function completionRoute(streaming) {
100
+ return (ctx) => {
101
+ const { body } = ctx;
102
+ if (!MODELS.includes(body.model)) {
103
+ return ctx.send(403, `'${body.model}' is not enabled; allowed: ${MODELS.join(', ')}`);
104
+ }
105
+ if (!Array.isArray(body.messages) || body.messages.length === 0) {
106
+ return ctx.send(400, 'messages must be a non-empty array');
107
+ }
108
+ if (tokensUsed >= BUDGET) {
109
+ return ctx.send(429, `Monthly token budget of ${BUDGET} is spent`, {
110
+ 'retry-after': '3600'
111
+ });
112
+ }
113
+ if (failed(ctx)) return;
114
+ tokensUsed += answer(body).usage.total_tokens;
115
+ const headers = {
116
+ 'x-forklaunch-model': String(body.model),
117
+ 'x-forklaunch-tokens-remaining': String(Math.max(0, BUDGET - tokensUsed))
118
+ };
119
+ if (streaming) return ctx.stream(chunks(body), headers, true);
120
+ if (body.stream === true) {
121
+ return ctx.send(
122
+ 400,
123
+ 'For streaming, POST /instance-gateway/models/chat/completions/stream'
124
+ );
125
+ }
126
+ return ctx.send(200, completion(body), headers);
127
+ };
128
+ }
129
+
130
+ export default {
131
+ feature: 'models',
132
+ reset() {
133
+ tokensUsed = 0;
134
+ completions = 0;
135
+ },
136
+ routes: [
137
+ {
138
+ method: 'GET',
139
+ path: '/models',
140
+ handler: (ctx) =>
141
+ ctx.send(200, {
142
+ models: MODELS.map((id) => ({ id, baa: true })),
143
+ monthlyTokenBudget: BUDGET,
144
+ tokensUsedThisMonth: tokensUsed,
145
+ requestsPerMinute: 600
146
+ })
147
+ },
148
+ { method: 'POST', path: '/models/chat/completions', handler: completionRoute(false) },
149
+ {
150
+ method: 'POST',
151
+ path: '/models/chat/completions/stream',
152
+ handler: completionRoute(true)
153
+ }
154
+ ],
155
+ vendorRoutes: [
156
+ // The Foundry (Azure OpenAI v1) API.
157
+ {
158
+ method: 'POST',
159
+ path: /\/v1\/chat\/completions$/,
160
+ handler: (ctx) => {
161
+ if (!ctx.req.headers.authorization?.startsWith('Bearer ')) {
162
+ return ctx.send(401, { error: { message: 'Missing bearer credential' } });
163
+ }
164
+ if (failed(ctx)) return;
165
+ return ctx.body.stream === true
166
+ ? ctx.stream(chunks(ctx.body))
167
+ : ctx.send(200, completion(ctx.body));
168
+ }
169
+ },
170
+ // An Entra token endpoint.
171
+ {
172
+ method: 'POST',
173
+ path: /\/oauth2\/v2\.0\/token$/,
174
+ handler: (ctx) => {
175
+ const form = new URLSearchParams(ctx.raw);
176
+ if (
177
+ !form.get('client_id') ||
178
+ !(form.get('client_secret') || form.get('client_assertion'))
179
+ ) {
180
+ return ctx.send(401, {
181
+ error_description: 'AADSTS7000215: mock requires a secret or an assertion'
182
+ });
183
+ }
184
+ return ctx.send(200, {
185
+ token_type: 'Bearer',
186
+ expires_in: 3600,
187
+ access_token: `mock-entra-token-${randomUUID()}`
188
+ });
189
+ }
190
+ }
191
+ ]
192
+ };