bridgex 1.0.0 → 2.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.
package/README.md ADDED
@@ -0,0 +1,525 @@
1
+ # SMS Client SDK
2
+
3
+ A fault-tolerant TypeScript SDK for sending SMS messages — usable as a library, a standalone microservice, or a background job processor.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Installation](#installation)
10
+ 2. [Quick Start](#quick-start)
11
+ 3. [Auto-Provisioning Credentials](#auto-provisioning-credentials)
12
+ 4. [SendConfig — Fluent Parameter Builder](#sendconfig--fluent-parameter-builder)
13
+ 5. [Send Methods](#send-methods)
14
+ 6. [Result & Error Types](#result--error-types)
15
+ 7. [SMSQueue — Background Job Queue](#smsqueue--background-job-queue)
16
+ 8. [SMSScheduler — Recurring & Scheduled Sends](#smsscheduler--recurring--scheduled-sends)
17
+ 9. [SMSService — Run as a Microservice](#smsservice--run-as-a-microservice)
18
+ 10. [Plugins & Hooks](#plugins--hooks)
19
+ 11. [Fault Tolerance (Retry + Circuit Breaker)](#fault-tolerance)
20
+ 12. [Full Configuration Reference](#full-configuration-reference)
21
+ 13. [File Structure](#file-structure)
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install bridgex
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Quick Start
34
+
35
+ ```ts
36
+ import { SMSClient, SendConfig, LoggerPlugin } from "bridgex";
37
+
38
+ const client = new SMSClient({
39
+ baseUrl: "https://api.your-service.com",
40
+ apiKey: "your-access-token",
41
+ projectKey: "your-repo-token",
42
+ });
43
+
44
+ client.use(LoggerPlugin("my-app"));
45
+
46
+ const result = await client.send({
47
+ to: "+15551234567",
48
+ template: "Hello {name}, your order {orderId} is confirmed!",
49
+ variables: { name: "Alice", orderId: "ORD-999" },
50
+ });
51
+
52
+ if (result.ok) {
53
+ console.log("Sent!", result.data);
54
+ } else {
55
+ console.error(result.error); // typed ErrorLog — never throws
56
+ }
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Auto-Provisioning Credentials
62
+
63
+ Your service auto-generates a repo token, access token, and URL. Use `CredentialProvisioner` to fetch them automatically.
64
+
65
+ ```ts
66
+ import { CredentialProvisioner, SMSClient } from "bridgex";
67
+
68
+ const credentials = await CredentialProvisioner.provision({
69
+ provisionUrl: "https://api.your-service.com/provision",
70
+ adminKey: "your-admin-key",
71
+ projectName: "my-app",
72
+ });
73
+
74
+ // credentials = { baseUrl, apiKey, projectKey }
75
+ const client = new SMSClient(credentials);
76
+ ```
77
+
78
+ Accepts `camelCase` or `snake_case` field names from your API (`apiKey`, `api_key`, `accessToken`, `access_token`, etc.).
79
+
80
+ ---
81
+
82
+ ## SendConfig — Fluent Parameter Builder
83
+
84
+ `SendConfig` lets you define templates, defaults, tags, TTLs, and priority **once** and reuse them across many calls. No more copying the same template string everywhere.
85
+
86
+ ```ts
87
+ import { SendConfig } from "bridgex";
88
+
89
+ // Build a reusable config
90
+ const otpConfig = SendConfig.otp(10) // preset: OTP with 10-min expiry
91
+ .set("appName", "MyApp"); // add a shared default variable
92
+
93
+ // Use it
94
+ const result = await client.send(
95
+ otpConfig.for("+15551234567", { code: "482910" }),
96
+ );
97
+
98
+ // Send to many recipients with per-recipient variables
99
+ const results = await client.sendMany(
100
+ otpConfig.forMany([
101
+ { to: "+15550000001", variables: { code: "111111" } },
102
+ { to: "+15550000002", variables: { code: "222222" } },
103
+ ]),
104
+ otpConfig._template, // or pass template inline
105
+ );
106
+ ```
107
+
108
+ ### Builder Methods
109
+
110
+ | Method | Description |
111
+ | ------------------------------------ | ---------------------------------------------- |
112
+ | `.template(str)` | Set the message template |
113
+ | `.defaults(vars)` | Default variable values (overridable per-call) |
114
+ | `.set(key, value)` | Add a single default variable |
115
+ | `.tag(...tags)` | Label this config (used in logs and hooks) |
116
+ | `.ttl(ms)` | Drop jobs older than this (queue only) |
117
+ | `.priority("high"\|"normal"\|"low")` | Queue priority |
118
+ | `.dedupKey(key)` | Suppress duplicate jobs in the queue |
119
+ | `.clone()` | Copy without mutating |
120
+ | `.merge(other)` | Combine two configs (other wins on conflicts) |
121
+
122
+ ### Static Presets
123
+
124
+ ```ts
125
+ SendConfig.otp(expiryMinutes?) // OTP — high priority, TTL-aware
126
+ SendConfig.orderNotification() // Order status updates
127
+ SendConfig.reminder() // Appointment / task reminders
128
+ SendConfig.promo() // Marketing messages — low priority
129
+ ```
130
+
131
+ ### Materialise Methods
132
+
133
+ ```ts
134
+ config.for(to, variables?) // → SendParams (use with client.send)
135
+ config.forMany(recipients) // → SendParams[] (use with client.sendMany)
136
+ config.forObject(to, object) // → SendObjectParams (use with client.sendObject)
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Send Methods
142
+
143
+ All methods return a `Result` and **never throw**.
144
+
145
+ ### `client.send(params)` → `Promise<Result>`
146
+
147
+ ```ts
148
+ const result = await client.send({
149
+ to: "+15551234567",
150
+ template: "Hi {user.firstName}, your balance is {account.balance}",
151
+ variables: {
152
+ user: { firstName: "Bob" },
153
+ account: { balance: "$42.00" },
154
+ },
155
+ });
156
+ ```
157
+
158
+ Supports **nested variable paths** (`{nested.key}`).
159
+
160
+ ---
161
+
162
+ ### `client.sendMany(recipients, template, sharedVars?)` → `Promise<BatchResult>`
163
+
164
+ ```ts
165
+ const result = await client.sendMany(
166
+ [
167
+ { to: "+15550000001", variables: { name: "Alice" } },
168
+ { to: "+15550000002", variables: { name: "Bob" } },
169
+ ],
170
+ "Hi {name}, your appointment is tomorrow at {time}.",
171
+ { time: "10:00 AM" },
172
+ );
173
+
174
+ console.log(`${result.successCount}/${result.total} sent`);
175
+ result.failed.forEach((f) => console.error(`[${f.to}]`, f.error.message));
176
+ ```
177
+
178
+ ---
179
+
180
+ ### `client.sendObject(params)` → `Promise<Result>`
181
+
182
+ Auto-extracts the message from `message`, `body`, `text`, or `content` fields. Or pass a template to use any field as a variable.
183
+
184
+ ```ts
185
+ // Auto-detection
186
+ await client.sendObject({
187
+ to: "+15551234567",
188
+ object: { message: "Your package has shipped!", trackingId: "TRK-123" },
189
+ });
190
+
191
+ // With template
192
+ await client.sendObject({
193
+ to: "+15551234567",
194
+ object: { firstName: "Alice", trackingId: "TRK-123" },
195
+ template: "Hi {firstName}, your tracking ID is {trackingId}.",
196
+ });
197
+ ```
198
+
199
+ ---
200
+
201
+ ### `client.sendObjectMany(items)` → `Promise<BatchResult>`
202
+
203
+ ```ts
204
+ await client.sendObjectMany([
205
+ {
206
+ to: "+15550000001",
207
+ object: { firstName: "Alice", orderId: "ORD-1" },
208
+ template: "Hi {firstName}, order {orderId} is ready.",
209
+ },
210
+ {
211
+ to: "+15550000002",
212
+ object: { firstName: "Bob", orderId: "ORD-2" },
213
+ template: "Hi {firstName}, order {orderId} is ready.",
214
+ },
215
+ ]);
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Result & Error Types
221
+
222
+ ```ts
223
+ type Result<T> =
224
+ | { ok: true; data: T; error: null }
225
+ | { ok: false; data: null; error: ErrorLog };
226
+
227
+ interface ErrorLog {
228
+ name: string;
229
+ message: string;
230
+ code: string; // see Error Codes table
231
+ isClientError: boolean; // your code caused it
232
+ isServerError: boolean; // server returned an error
233
+ details?: unknown;
234
+ timestamp: string; // ISO 8601
235
+ }
236
+ ```
237
+
238
+ ### BatchResult
239
+
240
+ ```ts
241
+ interface BatchResult<T> {
242
+ succeeded: Array<{ index: number; to: string; data: T }>;
243
+ failed: Array<{ index: number; to: string; error: ErrorLog }>;
244
+ total: number;
245
+ successCount: number;
246
+ failureCount: number;
247
+ }
248
+ ```
249
+
250
+ ### Error Codes
251
+
252
+ | Code | Client? | Server? | Cause |
253
+ | ------------------ | ------- | ------- | ------------------------------ |
254
+ | `VALIDATION_ERROR` | ✅ | ❌ | Missing required param |
255
+ | `TEMPLATE_ERROR` | ✅ | ❌ | Variable missing from template |
256
+ | `SERVER_ERROR` | ❌ | ✅ | Non-2xx HTTP response |
257
+ | `RATE_LIMIT_ERROR` | ❌ | ✅ | 429 — respects `Retry-After` |
258
+ | `NETWORK_ERROR` | ❌ | ❌ | Timeout / no connectivity |
259
+ | `CIRCUIT_OPEN` | ❌ | ❌ | Circuit breaker tripped |
260
+
261
+ ---
262
+
263
+ ## SMSQueue — Background Job Queue
264
+
265
+ Fire-and-forget sending with priority lanes, deduplication, TTL, and delayed sends.
266
+
267
+ ```ts
268
+ import { SMSQueue, SendConfig, LoggerPlugin, PluginManager } from "bridgex";
269
+
270
+ const plugins = new PluginManager().use(LoggerPlugin("worker"));
271
+ const queue = new SMSQueue(client, { concurrency: 5 }, plugins);
272
+ queue.start();
273
+
274
+ // Immediate
275
+ const otpCfg = SendConfig.otp().dedupKey(`otp:${userId}`);
276
+ queue.enqueue(otpCfg.for(phone, { code: "482910" }));
277
+
278
+ // Delayed (send in 5 minutes)
279
+ queue.enqueueAfter(reminderCfg.for(phone), 5 * 60 * 1000);
280
+
281
+ // At a specific time
282
+ queue.enqueueAt(promoCfg.for(phone), new Date("2025-12-25T09:00:00").getTime());
283
+
284
+ // Stats
285
+ console.log(queue.stats());
286
+ // { pending: 3, running: 1, completed: 47, dropped: 2 }
287
+
288
+ // Cancel a specific job
289
+ queue.cancel(jobId);
290
+
291
+ // Drain all remaining jobs then stop
292
+ await queue.drain();
293
+ ```
294
+
295
+ ### Queue Options
296
+
297
+ | Option | Default | Description |
298
+ | -------------- | ------- | -------------------------------------------- |
299
+ | `concurrency` | `3` | Max parallel workers |
300
+ | `pollInterval` | `1000` | How often (ms) to check for ready jobs |
301
+ | `autoStart` | `true` | Start workers automatically on first enqueue |
302
+
303
+ ---
304
+
305
+ ## SMSScheduler — Recurring & Scheduled Sends
306
+
307
+ ```ts
308
+ import { SMSScheduler, SendConfig } from "bridgex";
309
+
310
+ const scheduler = new SMSScheduler(queue);
311
+ scheduler.start();
312
+
313
+ // Every 24 hours
314
+ scheduler.every(
315
+ 24 * 60 * 60 * 1000,
316
+ "daily-report",
317
+ reportConfig.for(adminPhone),
318
+ );
319
+
320
+ // Cron expression: weekdays at 9am
321
+ scheduler.cron("0 9 * * 1-5", "weekday-digest", digestConfig.for(adminPhone), {
322
+ maxRuns: 50,
323
+ });
324
+
325
+ // One-shot future send
326
+ scheduler.once(
327
+ new Date("2025-06-01T09:00:00"),
328
+ "summer-launch",
329
+ promoConfig.for(phone),
330
+ );
331
+
332
+ // Control
333
+ scheduler.pause("daily-report");
334
+ scheduler.resume("daily-report");
335
+ scheduler.remove("summer-launch");
336
+
337
+ // List all scheduled jobs
338
+ console.log(scheduler.list());
339
+ ```
340
+
341
+ **Cron format:** `minute hour day-of-month month day-of-week`
342
+
343
+ - `*` = every, `,` = list, `-` = range, `/` = step
344
+ - Example: `"30 8,12,18 * * *"` = 8:30, 12:30, and 18:30 every day
345
+
346
+ ---
347
+
348
+ ## SMSService — Run as a Microservice
349
+
350
+ Deploy the SDK as a standalone REST service so any other service (Python, Go, Ruby, etc.) can send SMS without bundling this SDK.
351
+
352
+ ```ts
353
+ import {
354
+ SMSClient,
355
+ SMSQueue,
356
+ SMSService,
357
+ MetricsPlugin,
358
+ PluginManager,
359
+ } from "bridgex";
360
+
361
+ const client = new SMSClient({ baseUrl, apiKey, projectKey });
362
+ const queue = new SMSQueue(client, { concurrency: 10 });
363
+ const metrics = MetricsPlugin();
364
+
365
+ client.use(metrics);
366
+ queue.start();
367
+
368
+ const service = new SMSService(client, {
369
+ port: 4000,
370
+ apiKey: "my-service-secret",
371
+ })
372
+ .withQueue(queue)
373
+ .withMetrics(metrics);
374
+
375
+ await service.start();
376
+ // [SMSService] Listening on http://0.0.0.0:4000
377
+ ```
378
+
379
+ ### REST API
380
+
381
+ | Method | Path | Description |
382
+ | -------- | ------------------- | ------------------------------------ |
383
+ | `POST` | `/send` | Send a single message |
384
+ | `POST` | `/send/many` | Send to many recipients |
385
+ | `POST` | `/send/object` | Send from a plain object |
386
+ | `POST` | `/send/object/many` | Send objects to many recipients |
387
+ | `POST` | `/queue/enqueue` | Fire-and-forget via queue |
388
+ | `POST` | `/queue/schedule` | Schedule a future send |
389
+ | `GET` | `/queue/stats` | Queue stats |
390
+ | `DELETE` | `/queue/:id` | Cancel a queued job |
391
+ | `GET` | `/health` | Liveness + circuit state + uptime |
392
+ | `GET` | `/metrics` | Send rate, success rate, avg latency |
393
+
394
+ All requests must include `x-service-key: <your-service-secret>` if `apiKey` is configured.
395
+
396
+ #### Example: POST /send
397
+
398
+ ```bash
399
+ curl -X POST http://localhost:4000/send \
400
+ -H "Content-Type: application/json" \
401
+ -H "x-service-key: my-service-secret" \
402
+ -d '{ "to": "+15551234567", "template": "Hello {name}", "variables": { "name": "Alice" } }'
403
+ ```
404
+
405
+ #### Example: POST /queue/schedule
406
+
407
+ ```bash
408
+ # Send at a specific time
409
+ curl -X POST http://localhost:4000/queue/schedule \
410
+ -H "x-service-key: my-service-secret" \
411
+ -H "Content-Type: application/json" \
412
+ -d '{ "at": "2025-12-25T09:00:00Z", "to": "+1555...", "template": "Merry Christmas {name}!", "variables": { "name": "Bob" } }'
413
+
414
+ # Send after a delay
415
+ curl -X POST http://localhost:4000/queue/schedule \
416
+ -H "x-service-key: my-service-secret" \
417
+ -H "Content-Type: application/json" \
418
+ -d '{ "delayMs": 300000, "to": "+1555...", "template": "Your reminder: {text}", "variables": { "text": "Call the dentist" } }'
419
+ ```
420
+
421
+ #### GET /health response
422
+
423
+ ```json
424
+ {
425
+ "status": "ok",
426
+ "circuitState": "CLOSED",
427
+ "uptime": 3600,
428
+ "startedAt": "2025-01-01T09:00:00.000Z",
429
+ "queue": { "pending": 0, "running": 2, "completed": 1024, "dropped": 3 }
430
+ }
431
+ ```
432
+
433
+ ---
434
+
435
+ ## Plugins & Hooks
436
+
437
+ Plugins run on every send lifecycle event. They're async and never crash the main flow.
438
+
439
+ ```ts
440
+ import { SMSClient, LoggerPlugin, MetricsPlugin } from "bridgex";
441
+
442
+ // Built-in logger
443
+ client.use(LoggerPlugin("order-service"));
444
+
445
+ // Built-in metrics
446
+ const metrics = MetricsPlugin();
447
+ client.use(metrics);
448
+
449
+ // Later
450
+ console.log(metrics.snapshot());
451
+ // { sent: 100, succeeded: 97, failed: 3, retries: 5, successRate: 0.97, avgDurationMs: 212, ... }
452
+ metrics.reset();
453
+ ```
454
+
455
+ ### Custom Plugin
456
+
457
+ ```ts
458
+ client.use({
459
+ name: "slack-alerts",
460
+ onError: async ({ to, error }) => {
461
+ await slackClient.send(
462
+ `SMS failed to ${to}: [${error.code}] ${error.message}`,
463
+ );
464
+ },
465
+ onRetry: async ({ to, attempt }) => {
466
+ if (attempt >= 2) console.warn(`High retry count for ${to}`);
467
+ },
468
+ });
469
+ ```
470
+
471
+ ### Hook Reference
472
+
473
+ | Hook | When it fires |
474
+ | ----------- | --------------------------------------------- |
475
+ | `onSend` | Before every send attempt |
476
+ | `onSuccess` | After a successful delivery |
477
+ | `onError` | After all retries are exhausted |
478
+ | `onRetry` | On each retry attempt |
479
+ | `onDrop` | When a queued job is discarded (TTL or dedup) |
480
+
481
+ ---
482
+
483
+ ## Fault Tolerance
484
+
485
+ ### Retry Handler
486
+
487
+ Automatically retries transient failures (network errors, 5xx, rate limits). Never retries client-side errors (`VALIDATION_ERROR`, `TEMPLATE_ERROR`). Respects `Retry-After` headers on 429s.
488
+
489
+ ### Circuit Breaker
490
+
491
+ After N consecutive failures the circuit **opens** — all calls immediately fail with `CircuitOpenError` instead of hammering a down service. After a timeout the circuit goes **half-open** and probes before closing again.
492
+
493
+ ```ts
494
+ console.log(client.circuitState); // "CLOSED" | "OPEN" | "HALF_OPEN"
495
+ client.resetCircuit(); // manual reset after fixing downstream
496
+ ```
497
+
498
+ ---
499
+
500
+ ## Full Configuration Reference
501
+
502
+ ```ts
503
+ const client = new SMSClient({
504
+ baseUrl: "https://api.your-service.com",
505
+ apiKey: "your-access-token",
506
+ projectKey: "your-repo-token",
507
+
508
+ retry: {
509
+ maxAttempts: 4,
510
+ delay: 300,
511
+ strategy: "exponential", // "fixed" | "exponential"
512
+ jitter: true, // ±20% randomness
513
+ },
514
+
515
+ circuitBreaker: {
516
+ threshold: 5, // failures to open circuit
517
+ timeout: 30_000, // ms before half-open probe
518
+ successThreshold: 2, // probes to fully close
519
+ },
520
+
521
+ concurrency: 5,
522
+ batchSize: 50,
523
+ plugins: [LoggerPlugin("app")],
524
+ });
525
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bridgex",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "a library for mazz app or a bridge for messaging that allow and automate the use of our service",
5
5
  "keywords": [
6
6
  "messaging",
@@ -0,0 +1,81 @@
1
+ import { CircuitOpenError } from "./errors.js";
2
+
3
+ export interface CircuitBreakerOptions {
4
+ /** Number of consecutive failures before opening the circuit */
5
+ threshold?: number;
6
+ /** Milliseconds to wait before transitioning to half-open */
7
+ timeout?: number;
8
+ /** Number of successful probes to close circuit from half-open */
9
+ successThreshold?: number;
10
+ }
11
+
12
+ type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
13
+
14
+ export default class CircuitBreaker {
15
+ private state: CircuitState = "CLOSED";
16
+ private failures = 0;
17
+ private successes = 0;
18
+ private lastFailureTime = 0;
19
+
20
+ private readonly threshold: number;
21
+ private readonly timeout: number;
22
+ private readonly successThreshold: number;
23
+
24
+ constructor(options: CircuitBreakerOptions = {}) {
25
+ this.threshold = options.threshold ?? 5;
26
+ this.timeout = options.timeout ?? 30_000;
27
+ this.successThreshold = options.successThreshold ?? 2;
28
+ }
29
+
30
+ get currentState(): CircuitState {
31
+ return this.state;
32
+ }
33
+
34
+ async execute<T>(operation: () => Promise<T>): Promise<T> {
35
+ if (this.state === "OPEN") {
36
+ if (Date.now() - this.lastFailureTime >= this.timeout) {
37
+ this.state = "HALF_OPEN";
38
+ this.successes = 0;
39
+ } else {
40
+ throw new CircuitOpenError(
41
+ "Circuit is open — service is temporarily unavailable",
42
+ );
43
+ }
44
+ }
45
+
46
+ try {
47
+ const result = await operation();
48
+ this.onSuccess();
49
+ return result;
50
+ } catch (error) {
51
+ this.onFailure();
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ private onSuccess() {
57
+ if (this.state === "HALF_OPEN") {
58
+ this.successes++;
59
+ if (this.successes >= this.successThreshold) {
60
+ this.reset();
61
+ }
62
+ } else {
63
+ this.failures = 0;
64
+ }
65
+ }
66
+
67
+ private onFailure() {
68
+ this.failures++;
69
+ this.lastFailureTime = Date.now();
70
+
71
+ if (this.state === "HALF_OPEN" || this.failures >= this.threshold) {
72
+ this.state = "OPEN";
73
+ }
74
+ }
75
+
76
+ reset() {
77
+ this.state = "CLOSED";
78
+ this.failures = 0;
79
+ this.successes = 0;
80
+ }
81
+ }
@@ -0,0 +1,68 @@
1
+ import { ValidationError } from "./errors.js";
2
+
3
+ export interface Credentials {
4
+ baseUrl: string;
5
+ apiKey: string;
6
+ projectKey: string;
7
+ }
8
+
9
+ export interface ProvisionOptions {
10
+ /** Your service's provisioning endpoint */
11
+ provisionUrl: string;
12
+ /** Master/admin key used to call the provisioning API */
13
+ adminKey: string;
14
+ projectName?: string;
15
+ }
16
+
17
+ /**
18
+ * CredentialProvisioner
19
+ *
20
+ * Calls your service's provisioning endpoint to automatically:
21
+ * - Generate a new repo token (projectKey)
22
+ * - Generate an access token (apiKey)
23
+ * - Return the service URL
24
+ *
25
+ * Usage:
26
+ * const creds = await CredentialProvisioner.provision({ provisionUrl, adminKey });
27
+ * const client = new SMSClient(creds);
28
+ */
29
+ export default class CredentialProvisioner {
30
+ static async provision(options: ProvisionOptions): Promise<Credentials> {
31
+ const { provisionUrl, adminKey, projectName } = options;
32
+
33
+ if (!provisionUrl) throw new ValidationError("provisionUrl is required");
34
+ if (!adminKey) throw new ValidationError("adminKey is required");
35
+
36
+ const response = await fetch(provisionUrl, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Authorization: `Bearer ${adminKey}`,
41
+ },
42
+ body: JSON.stringify({ projectName }),
43
+ });
44
+
45
+ if (!response.ok) {
46
+ const text = await response.text();
47
+ throw new Error(`Provisioning failed (${response.status}): ${text}`);
48
+ }
49
+
50
+ const data = await response.json();
51
+
52
+ // Accept either snake_case or camelCase field names from your API
53
+ const baseUrl =
54
+ data.baseUrl ?? data.base_url ?? data.url ?? data.serviceUrl;
55
+ const apiKey =
56
+ data.apiKey ?? data.api_key ?? data.accessToken ?? data.access_token;
57
+ const projectKey =
58
+ data.projectKey ?? data.project_key ?? data.repoToken ?? data.repo_token;
59
+
60
+ if (!baseUrl || !apiKey || !projectKey) {
61
+ throw new Error(
62
+ "Provisioning response is missing required fields (baseUrl, apiKey, projectKey)",
63
+ );
64
+ }
65
+
66
+ return { baseUrl, apiKey, projectKey };
67
+ }
68
+ }