@astermd-hq/sdk 0.0.2

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/dist/index.cjs ADDED
@@ -0,0 +1,3338 @@
1
+ 'use strict';
2
+
3
+ var promises = require('fs/promises');
4
+ var path = require('path');
5
+ var crypto = require('crypto');
6
+
7
+ // src/auth/in-memory-token-store.ts
8
+ var InMemoryTokenStore = class {
9
+ #token = null;
10
+ /**
11
+ * Returns the token held in memory, or `null` before the first exchange.
12
+ *
13
+ * @returns The cached token, or `null`.
14
+ */
15
+ get() {
16
+ return Promise.resolve(this.#token);
17
+ }
18
+ /**
19
+ * Replaces the token held in memory.
20
+ *
21
+ * @param token The token to cache.
22
+ */
23
+ put(token) {
24
+ this.#token = token;
25
+ return Promise.resolve();
26
+ }
27
+ /**
28
+ * Drops the token held in memory.
29
+ */
30
+ clear() {
31
+ this.#token = null;
32
+ return Promise.resolve();
33
+ }
34
+ };
35
+
36
+ // src/errors/astermd-error.ts
37
+ var AsterMDError = class extends Error {
38
+ constructor(message, options) {
39
+ super(message, options);
40
+ Object.setPrototypeOf(this, new.target.prototype);
41
+ this.name = "AsterMDError";
42
+ if (typeof Error.captureStackTrace === "function") {
43
+ Error.captureStackTrace(this, new.target);
44
+ }
45
+ }
46
+ };
47
+
48
+ // src/errors/api-error.ts
49
+ var ApiError = class extends AsterMDError {
50
+ #statusCode;
51
+ #envelope;
52
+ /**
53
+ * @param message Human-readable message, taken from the envelope's `message`
54
+ * field when the server sent one, otherwise `HTTP {status}`.
55
+ * @param statusCode The HTTP status code the server returned.
56
+ * @param envelope The decoded response body. Copied and frozen, so a later
57
+ * mutation of the caller's object cannot change the error.
58
+ */
59
+ constructor(message, statusCode, envelope) {
60
+ super(message);
61
+ this.name = "ApiError";
62
+ this.#statusCode = statusCode;
63
+ this.#envelope = Object.freeze({ ...envelope });
64
+ }
65
+ /**
66
+ * Returns the HTTP status code that caused this error.
67
+ *
68
+ * @returns The status code, e.g. `404` or `500`.
69
+ */
70
+ statusCode() {
71
+ return this.#statusCode;
72
+ }
73
+ /**
74
+ * Returns the decoded response envelope as the server sent it.
75
+ *
76
+ * Useful for surfacing a server-supplied message or an error code the SDK does
77
+ * not model. The object is frozen; treat it as read-only.
78
+ *
79
+ * @returns The decoded body, or an empty object when the response had none.
80
+ */
81
+ envelope() {
82
+ return this.#envelope;
83
+ }
84
+ };
85
+
86
+ // src/errors/authentication-error.ts
87
+ var AuthenticationError = class extends ApiError {
88
+ constructor(message, statusCode, envelope) {
89
+ super(message, statusCode, envelope);
90
+ this.name = "AuthenticationError";
91
+ }
92
+ };
93
+
94
+ // src/errors/not-found-error.ts
95
+ var NotFoundError = class extends ApiError {
96
+ constructor(message, statusCode, envelope) {
97
+ super(message, statusCode, envelope);
98
+ this.name = "NotFoundError";
99
+ }
100
+ };
101
+
102
+ // src/errors/rate-limit-error.ts
103
+ var RateLimitError = class extends ApiError {
104
+ #retryAfter;
105
+ /**
106
+ * @param message Human-readable summary from the envelope.
107
+ * @param statusCode Always 429 in practice.
108
+ * @param envelope The decoded response body.
109
+ * @param retryAfter Seconds to wait, from the `Retry-After` header, or `null`
110
+ * when the header was absent or not a plain integer.
111
+ */
112
+ constructor(message, statusCode, envelope, retryAfter2) {
113
+ super(message, statusCode, envelope);
114
+ this.name = "RateLimitError";
115
+ this.#retryAfter = retryAfter2;
116
+ }
117
+ /**
118
+ * Returns the server-advised wait in seconds before retrying.
119
+ *
120
+ * @returns Seconds to wait, or `null` when the server did not say.
121
+ */
122
+ retryAfter() {
123
+ return this.#retryAfter;
124
+ }
125
+ };
126
+
127
+ // src/errors/transport-error.ts
128
+ var TransportError = class extends AsterMDError {
129
+ constructor(message, options) {
130
+ super(message, options);
131
+ this.name = "TransportError";
132
+ }
133
+ };
134
+
135
+ // src/errors/validation-error.ts
136
+ var ValidationError = class extends ApiError {
137
+ #fieldErrors;
138
+ /**
139
+ * @param message Human-readable summary from the envelope.
140
+ * @param statusCode Always 422 in practice.
141
+ * @param envelope The decoded response body.
142
+ * @param fieldErrors Field name → list of messages, extracted from the
143
+ * envelope's `errors` key by {@link Transport}.
144
+ */
145
+ constructor(message, statusCode, envelope, fieldErrors2) {
146
+ super(message, statusCode, envelope);
147
+ this.name = "ValidationError";
148
+ this.#fieldErrors = Object.freeze({ ...fieldErrors2 });
149
+ }
150
+ /**
151
+ * Returns the per-field validation messages the server produced.
152
+ *
153
+ * @returns Field name → messages. Empty when the server sent no `errors` key.
154
+ */
155
+ fieldErrors() {
156
+ return this.#fieldErrors;
157
+ }
158
+ };
159
+
160
+ // src/response.ts
161
+ var Response2 = class {
162
+ #statusCode;
163
+ #data;
164
+ #meta;
165
+ #message;
166
+ #raw;
167
+ /**
168
+ * @param options The decoded envelope fields.
169
+ */
170
+ constructor(options) {
171
+ this.#statusCode = options.statusCode;
172
+ this.#data = Object.freeze(Array.isArray(options.data) ? [...options.data] : { ...options.data });
173
+ this.#meta = Object.freeze({ ...options.meta });
174
+ this.#message = options.message;
175
+ this.#raw = options.raw;
176
+ Object.freeze(this);
177
+ }
178
+ /**
179
+ * Returns the HTTP status code, always 200–299 for a `Response` instance.
180
+ *
181
+ * @returns The status code, e.g. `200` or `201`.
182
+ */
183
+ statusCode() {
184
+ return this.#statusCode;
185
+ }
186
+ /**
187
+ * Returns the primary resource payload from the envelope's `data` key.
188
+ *
189
+ * The shape varies by endpoint - see the TSDoc on the resource method you
190
+ * called. Empty when the server returned no data.
191
+ *
192
+ * @returns The decoded `data` object.
193
+ */
194
+ data() {
195
+ return this.#data;
196
+ }
197
+ /**
198
+ * Returns the envelope's `meta` key, typically pagination information.
199
+ *
200
+ * Present on paginated list endpoints such as `products/list` and
201
+ * `treatments/list`, where it may carry `total`, `per_page`, `current_page`,
202
+ * and `last_page`. Empty for endpoints that emit no `meta` block.
203
+ *
204
+ * @returns The decoded `meta` object.
205
+ */
206
+ meta() {
207
+ return this.#meta;
208
+ }
209
+ /**
210
+ * Returns the human-readable `message` string from the envelope.
211
+ *
212
+ * Usually a short confirmation such as `"Session created."`. Empty when the
213
+ * server omitted the field.
214
+ *
215
+ * @returns The envelope message, possibly empty.
216
+ */
217
+ message() {
218
+ return this.#message;
219
+ }
220
+ /**
221
+ * Returns the raw response body exactly as received, before decoding.
222
+ *
223
+ * Useful for low-level debugging. Do not log this value for endpoints carrying
224
+ * PHI, such as `patients/health-information`.
225
+ *
226
+ * @returns The raw JSON body.
227
+ */
228
+ raw() {
229
+ return this.#raw;
230
+ }
231
+ };
232
+
233
+ // src/http/transport.ts
234
+ var Transport = class {
235
+ #httpClient;
236
+ #urlBuilder;
237
+ #tokenProvider;
238
+ #onUnauthorized;
239
+ /**
240
+ * @param options The collaborators to use for every call.
241
+ */
242
+ constructor(options) {
243
+ this.#httpClient = options.httpClient;
244
+ this.#urlBuilder = options.urlBuilder;
245
+ this.#tokenProvider = options.tokenProvider;
246
+ this.#onUnauthorized = options.onUnauthorized;
247
+ }
248
+ /**
249
+ * Dispatches one API call and returns the decoded success envelope.
250
+ *
251
+ * On a 401 the token is refreshed and the call is replayed once before
252
+ * {@link AuthenticationError} is raised.
253
+ *
254
+ * @param options The call to make.
255
+ * @returns The decoded envelope; `data()` holds the resource payload.
256
+ * @throws {TypeError} If both `body` and `file` are supplied.
257
+ * @throws {AuthenticationError} On a 401 that a token refresh did not recover.
258
+ * @throws {NotFoundError} On a 404.
259
+ * @throws {ValidationError} On a 422; inspect `fieldErrors()`.
260
+ * @throws {RateLimitError} On a 429; inspect `retryAfter()`.
261
+ * @throws {ApiError} On any other non-2xx status.
262
+ * @throws {TransportError} If the request never completed.
263
+ */
264
+ async send(options) {
265
+ if (options.body != null && options.file != null) {
266
+ throw new TypeError("A request carries either a JSON body or a file upload, not both.");
267
+ }
268
+ let response = await this.#dispatch(options);
269
+ if (response.status === 401 && await this.#onUnauthorized()) {
270
+ void response.body?.cancel();
271
+ response = await this.#dispatch(options);
272
+ }
273
+ return await this.#handle(response);
274
+ }
275
+ /**
276
+ * Builds and sends one attempt.
277
+ *
278
+ * A fresh `Request` is constructed on every call rather than reused across the
279
+ * 401 replay. A request body is a single-use stream: sending an already-sent
280
+ * `Request` again transmits an empty body, silently. Keeping the description
281
+ * and materialising the request per attempt is what makes the replay correct.
282
+ */
283
+ async #dispatch(options) {
284
+ const url = this.#urlBuilder.build(options.service, options.path, options.pathParams ?? {}, options.query ?? {});
285
+ const headers = new Headers({ Accept: "application/json" });
286
+ const token = await this.#tokenProvider();
287
+ if (token !== null) {
288
+ headers.set("Authorization", `Bearer ${token}`);
289
+ }
290
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
291
+ headers.set(name, value);
292
+ }
293
+ let body = null;
294
+ if (options.body != null) {
295
+ headers.set("Content-Type", "application/json");
296
+ body = JSON.stringify(options.body);
297
+ } else if (options.file != null) {
298
+ const form = new FormData();
299
+ form.append(
300
+ "file",
301
+ new Blob([options.file.contents()], { type: options.file.mimeType() }),
302
+ options.file.fileName()
303
+ );
304
+ body = form;
305
+ }
306
+ try {
307
+ return await this.#httpClient.sendRequest(new Request(url, { method: options.method, headers, body }));
308
+ } catch (error) {
309
+ if (error instanceof AsterMDError) {
310
+ throw error;
311
+ }
312
+ const detail = error instanceof Error ? error.message : String(error);
313
+ throw new TransportError(`Request to ${url} failed: ${detail}`, {
314
+ cause: error
315
+ });
316
+ }
317
+ }
318
+ async #handle(response) {
319
+ let raw;
320
+ try {
321
+ raw = await response.text();
322
+ } catch (error) {
323
+ const detail = error instanceof Error ? error.message : String(error);
324
+ throw new TransportError(`Reading the response body failed: ${detail}`, {
325
+ cause: error
326
+ });
327
+ }
328
+ const envelope = decode(raw);
329
+ const status = response.status;
330
+ if (status >= 200 && status < 300) {
331
+ return new Response2({
332
+ statusCode: status,
333
+ data: objectField(envelope, "data"),
334
+ meta: objectField(envelope, "meta"),
335
+ message: typeof envelope.message === "string" ? envelope.message : "",
336
+ raw
337
+ });
338
+ }
339
+ const message = typeof envelope.message === "string" ? envelope.message : `HTTP ${status}`;
340
+ switch (status) {
341
+ case 401:
342
+ throw new AuthenticationError(message, status, envelope);
343
+ case 404:
344
+ throw new NotFoundError(message, status, envelope);
345
+ case 422:
346
+ throw new ValidationError(message, status, envelope, fieldErrors(envelope));
347
+ case 429:
348
+ throw new RateLimitError(message, status, envelope, retryAfter(response));
349
+ default:
350
+ throw new ApiError(message, status, envelope);
351
+ }
352
+ }
353
+ };
354
+ function decode(raw) {
355
+ if (raw === "") {
356
+ return {};
357
+ }
358
+ try {
359
+ const decoded = JSON.parse(raw);
360
+ return typeof decoded === "object" && decoded !== null ? decoded : {};
361
+ } catch {
362
+ return {};
363
+ }
364
+ }
365
+ function objectField(envelope, key) {
366
+ const value = envelope[key];
367
+ return typeof value === "object" && value !== null ? value : {};
368
+ }
369
+ function fieldErrors(envelope) {
370
+ const errors = envelope.errors;
371
+ if (typeof errors !== "object" || errors === null || Array.isArray(errors)) {
372
+ return {};
373
+ }
374
+ const out = {};
375
+ for (const [field, messages] of Object.entries(errors)) {
376
+ if (Array.isArray(messages)) {
377
+ out[field] = messages.filter((message) => typeof message === "string");
378
+ } else if (typeof messages === "string") {
379
+ out[field] = [messages];
380
+ } else if (typeof messages === "object" && messages !== null) {
381
+ out[field] = Object.values(messages).filter((message) => typeof message === "string");
382
+ }
383
+ }
384
+ return out;
385
+ }
386
+ function retryAfter(response) {
387
+ const header = response.headers.get("Retry-After");
388
+ return header !== null && /^\d+$/.test(header) ? Number(header) : null;
389
+ }
390
+
391
+ // src/auth/token.ts
392
+ var Token = class {
393
+ #value;
394
+ #expiresAtMs;
395
+ /**
396
+ * @param value The raw JWT string, to be sent as `Authorization: Bearer <value>`.
397
+ * @param expiresAt Expiry as declared by the token response's
398
+ * `access_token_expiry` field. Copied, so a later mutation of
399
+ * the caller's `Date` cannot change the token.
400
+ */
401
+ constructor(value, expiresAt) {
402
+ this.#value = value;
403
+ this.#expiresAtMs = expiresAt.getTime();
404
+ Object.freeze(this);
405
+ }
406
+ /**
407
+ * Returns the raw JWT string.
408
+ *
409
+ * @returns The JWT, ready for use as a bearer credential.
410
+ */
411
+ value() {
412
+ return this.#value;
413
+ }
414
+ /**
415
+ * Returns the server-stated expiry.
416
+ *
417
+ * A fresh `Date` each call, so callers cannot mutate the token's state. Note
418
+ * that the SDK treats the token as expired before this moment - see
419
+ * {@link Token.isExpired}.
420
+ *
421
+ * @returns The expiry as declared in the token response.
422
+ */
423
+ expiresAt() {
424
+ return new Date(this.#expiresAtMs);
425
+ }
426
+ /**
427
+ * Reports whether this token should be considered expired and replaced.
428
+ *
429
+ * The pre-buffer avoids a race where the token is valid when checked but
430
+ * expires in transit before the server processes the request. The default 30
431
+ * seconds means the SDK only trusts a token with at least half a minute of
432
+ * validity left. Pass a custom value only when you know something specific
433
+ * about clock skew in your environment.
434
+ *
435
+ * @param preBufferSeconds Seconds to subtract from the stated expiry when
436
+ * computing the effective cutoff.
437
+ * @returns `true` when the token is at or past its effective expiry.
438
+ */
439
+ isExpired(preBufferSeconds = 30) {
440
+ return Date.now() >= this.#expiresAtMs - preBufferSeconds * 1e3;
441
+ }
442
+ };
443
+
444
+ // src/auth/token-manager.ts
445
+ var TokenManager = class {
446
+ #config;
447
+ #store;
448
+ #expiryPreBufferSeconds;
449
+ #tokenTransport;
450
+ #inFlight = null;
451
+ /**
452
+ * @param options The collaborators and tuning to use.
453
+ */
454
+ constructor(options) {
455
+ this.#config = options.config;
456
+ this.#store = options.store ?? new InMemoryTokenStore();
457
+ this.#expiryPreBufferSeconds = options.expiryPreBufferSeconds ?? 30;
458
+ this.#tokenTransport = new Transport({
459
+ httpClient: options.httpClient,
460
+ urlBuilder: options.urlBuilder,
461
+ tokenProvider: () => Promise.resolve(null),
462
+ onUnauthorized: () => Promise.resolve(false)
463
+ });
464
+ }
465
+ /**
466
+ * Returns a valid bearer token, acquiring one if the cache cannot serve it.
467
+ *
468
+ * A cached, unexpired token is returned with no HTTP call. Otherwise the
469
+ * credential exchange runs and its result is stored before being returned. Two
470
+ * concurrent calls that both miss the cache share a single exchange.
471
+ *
472
+ * @returns The raw JWT, ready for `Authorization: Bearer <value>`.
473
+ * @throws {AuthenticationError} If the credentials are refused, or if the token
474
+ * response omits `access_token` or `access_token_expiry`.
475
+ * @throws {TransportError} If the exchange never completed.
476
+ */
477
+ async bearerToken() {
478
+ const cached = await this.#store.get();
479
+ if (cached !== null && !cached.isExpired(this.#expiryPreBufferSeconds)) {
480
+ return cached.value();
481
+ }
482
+ return (await this.#acquire()).value();
483
+ }
484
+ /**
485
+ * Discards the cached token, acquires a fresh one, and reports `true`.
486
+ *
487
+ * Always resolves `true` on success so it can be wired straight to
488
+ * {@link Transport}'s `onUnauthorized` hook, where `true` means "token
489
+ * refreshed, replay the request once". A second 401 on that replay raises
490
+ * {@link AuthenticationError} without calling this again.
491
+ *
492
+ * @returns `true`.
493
+ * @throws {AuthenticationError} If the fresh acquisition fails.
494
+ * @throws {TransportError} If the exchange never completed.
495
+ */
496
+ async refresh() {
497
+ await this.#store.clear();
498
+ await this.#acquire();
499
+ return true;
500
+ }
501
+ /**
502
+ * Runs the credential exchange, sharing one in-flight attempt across callers.
503
+ *
504
+ * The in-flight promise is cleared in a `finally`, so a failed exchange is
505
+ * never cached - the next caller gets a fresh attempt rather than the stored
506
+ * rejection.
507
+ */
508
+ async #acquire() {
509
+ this.#inFlight ??= this.#exchange().finally(() => {
510
+ this.#inFlight = null;
511
+ });
512
+ return await this.#inFlight;
513
+ }
514
+ async #exchange() {
515
+ const response = await this.#tokenTransport.send({
516
+ service: "auth",
517
+ method: "POST",
518
+ path: "/api-credentials/token",
519
+ body: {
520
+ client_id: this.#config.clientId(),
521
+ client_secret: this.#config.clientSecret()
522
+ }
523
+ });
524
+ const data = response.data();
525
+ const value = data.access_token;
526
+ const expiry = data.access_token_expiry;
527
+ if (typeof value !== "string" || typeof expiry !== "string") {
528
+ throw new AuthenticationError(
529
+ "Token response missing access_token / access_token_expiry.",
530
+ response.statusCode(),
531
+ { data }
532
+ );
533
+ }
534
+ const expiresAt = new Date(expiry);
535
+ if (Number.isNaN(expiresAt.getTime())) {
536
+ throw new AuthenticationError(
537
+ `Token response carried an unparseable access_token_expiry: ${expiry}`,
538
+ response.statusCode(),
539
+ { data }
540
+ );
541
+ }
542
+ const token = new Token(value, expiresAt);
543
+ await this.#store.put(token);
544
+ return token;
545
+ }
546
+ };
547
+
548
+ // src/config.ts
549
+ var ASSET_BASE_URL = "https://cdn.astermd.com/";
550
+ var DEFAULT_BASE_HOST = "api.astermd.com";
551
+ var Config = class {
552
+ #clientId;
553
+ #clientSecret;
554
+ #baseHost;
555
+ #timeoutSeconds;
556
+ /**
557
+ * @param options Credentials and connection settings.
558
+ * @throws {TypeError} If `clientId` or `clientSecret` is empty, if `baseHost`
559
+ * contains a scheme or a path, or if `timeoutSeconds` is below 1.
560
+ */
561
+ constructor(options) {
562
+ const { clientId, clientSecret, baseHost = DEFAULT_BASE_HOST, timeoutSeconds = 10 } = options;
563
+ if (clientId === "" || clientSecret === "") {
564
+ throw new TypeError("clientId and clientSecret must be non-empty.");
565
+ }
566
+ if (baseHost.includes("://") || baseHost.includes("/")) {
567
+ throw new TypeError(`baseHost must be a bare host (no scheme, no path). Got: ${baseHost}`);
568
+ }
569
+ if (timeoutSeconds < 1) {
570
+ throw new TypeError("timeoutSeconds must be >= 1.");
571
+ }
572
+ this.#clientId = clientId;
573
+ this.#clientSecret = clientSecret;
574
+ this.#baseHost = baseHost;
575
+ this.#timeoutSeconds = timeoutSeconds;
576
+ Object.freeze(this);
577
+ }
578
+ /**
579
+ * Returns the OAuth2 client ID used to acquire JWT bearer tokens.
580
+ *
581
+ * @returns The client ID, guaranteed non-empty.
582
+ */
583
+ clientId() {
584
+ return this.#clientId;
585
+ }
586
+ /**
587
+ * Returns the OAuth2 client secret used to acquire JWT bearer tokens.
588
+ *
589
+ * @returns The client secret, guaranteed non-empty.
590
+ */
591
+ clientSecret() {
592
+ return this.#clientSecret;
593
+ }
594
+ /**
595
+ * Returns the bare API hostname used to build request URLs.
596
+ *
597
+ * Never contains a scheme or a path. {@link UrlBuilder} prepends `https://`
598
+ * and appends `/v1/{service}/{path}` at request time.
599
+ *
600
+ * @returns The hostname, e.g. `api.astermd.com`.
601
+ */
602
+ baseHost() {
603
+ return this.#baseHost;
604
+ }
605
+ /**
606
+ * Returns the per-request HTTP timeout in seconds.
607
+ *
608
+ * Applied by {@link FetchHttpClient} as an `AbortSignal` deadline covering the
609
+ * whole request. A custom {@link HttpClient} may use it at its own discretion.
610
+ *
611
+ * @returns The timeout in seconds, always >= 1.
612
+ */
613
+ timeoutSeconds() {
614
+ return this.#timeoutSeconds;
615
+ }
616
+ /**
617
+ * Builds the fully-qualified CDN URL for a media asset path returned by the API.
618
+ *
619
+ * API responses carry relative asset paths such as
620
+ * `{org}/{channel}/products/{file}.png`. Pass one here to resolve it.
621
+ *
622
+ * Treat the result as a *fetch* URL, not a *serve* URL: download the asset
623
+ * once, store it on your own filesystem or CDN, and serve your copy to end
624
+ * users. Hot-linking the AsterMD CDN from a storefront is not supported and is
625
+ * subject to rate limiting.
626
+ *
627
+ * @param path Relative asset path as returned by the API; a leading slash is
628
+ * optional and is trimmed.
629
+ * @returns The fully-qualified URL.
630
+ */
631
+ assetUrl(path) {
632
+ return ASSET_BASE_URL + path.replace(/^\/+/, "");
633
+ }
634
+ };
635
+
636
+ // src/http/fetch-http-client.ts
637
+ var FetchHttpClient = class {
638
+ #timeoutSeconds;
639
+ #fetchImpl;
640
+ /**
641
+ * @param timeoutSeconds Per-request deadline in seconds. Must be >= 1.
642
+ * @param fetchImpl The `fetch` implementation to call. Defaults to the global
643
+ * one; override it to supply an instrumented or stubbed
644
+ * `fetch` without implementing the whole interface.
645
+ * @throws {TypeError} If `timeoutSeconds` is below 1.
646
+ */
647
+ constructor(timeoutSeconds = 10, fetchImpl = globalThis.fetch) {
648
+ if (timeoutSeconds < 1) {
649
+ throw new TypeError("timeoutSeconds must be >= 1.");
650
+ }
651
+ this.#timeoutSeconds = timeoutSeconds;
652
+ this.#fetchImpl = fetchImpl;
653
+ }
654
+ /**
655
+ * Sends the request through `fetch` under the configured deadline.
656
+ *
657
+ * @param request The request to send.
658
+ * @returns The response as received, whatever its status.
659
+ * @throws {TransportError} If the request aborts on the deadline or fails at
660
+ * the network layer.
661
+ */
662
+ async sendRequest(request) {
663
+ try {
664
+ return await this.#fetchImpl(request, {
665
+ signal: AbortSignal.timeout(this.#timeoutSeconds * 1e3)
666
+ });
667
+ } catch (error) {
668
+ if (error instanceof Error && error.name === "TimeoutError") {
669
+ throw new TransportError(`Request to ${request.url} timed out after ${this.#timeoutSeconds}s.`, {
670
+ cause: error
671
+ });
672
+ }
673
+ const detail = error instanceof Error ? error.message : String(error);
674
+ throw new TransportError(`Request to ${request.url} failed: ${detail}`, { cause: error });
675
+ }
676
+ }
677
+ };
678
+
679
+ // src/http/log-redactor.ts
680
+ var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "x-phi-verification-token"]);
681
+ var SENSITIVE_FIELDS = ["client_secret", "access_token", "refresh_token"];
682
+ var PHI_PATH_FRAGMENTS = ["/patients", "/extensions/identity-verify"];
683
+ var BINARY_UPLOAD_FRAGMENTS = ["/intake-submissions/upload-file/", "/upload-file-multipart/part/"];
684
+ function escapeRegExp(value) {
685
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
686
+ }
687
+ var LogRedactor = class _LogRedactor {
688
+ /** Replacement text substituted for every redacted value. */
689
+ static PLACEHOLDER = "[REDACTED]";
690
+ /** Marker written in place of a body dropped wholesale. */
691
+ static PHI_PLACEHOLDER = "[REDACTED - PHI endpoint]";
692
+ /** Marker written in place of the raw file bytes of an upload. */
693
+ static UPLOAD_PLACEHOLDER = "[REDACTED - binary upload]";
694
+ /**
695
+ * Returns the loggable form of one request header value.
696
+ *
697
+ * A sensitive header keeps its name, so the log still shows the header was
698
+ * sent, but its value is replaced. For `Authorization` the scheme survives -
699
+ * `Bearer [REDACTED]` - because knowing the scheme helps when debugging and
700
+ * carries no secret.
701
+ *
702
+ * @param name The header name as it appears on the request.
703
+ * @param value The raw header value.
704
+ * @returns The value to write to the log.
705
+ */
706
+ headerValue(name, value) {
707
+ if (!SENSITIVE_HEADERS.has(name.toLowerCase())) {
708
+ return value;
709
+ }
710
+ const scheme = /^(\S+)\s+\S/.exec(value);
711
+ return scheme === null ? _LogRedactor.PLACEHOLDER : `${scheme[1]} ${_LogRedactor.PLACEHOLDER}`;
712
+ }
713
+ /**
714
+ * Returns the loggable form of a request or response body.
715
+ *
716
+ * A body belonging to a PHI-carrying path, and the raw bytes sent to an upload
717
+ * endpoint, are replaced entirely. Every other body has its sensitive JSON
718
+ * fields rewritten in place, leaving the surrounding structure intact so the
719
+ * entry stays readable and replayable apart from the redacted values.
720
+ *
721
+ * @param body The raw body as it would otherwise be logged.
722
+ * @param path The request path, used to detect PHI and upload endpoints.
723
+ * @returns The body to write to the log.
724
+ */
725
+ body(body, path) {
726
+ if (body === "") {
727
+ return body;
728
+ }
729
+ if (this.isPhiPath(path)) {
730
+ return _LogRedactor.PHI_PLACEHOLDER;
731
+ }
732
+ if (this.isBinaryUploadPath(path)) {
733
+ return _LogRedactor.UPLOAD_PLACEHOLDER;
734
+ }
735
+ return this.#redactFields(body);
736
+ }
737
+ /**
738
+ * Reports whether a path carries PII or PHI and must have its body dropped.
739
+ *
740
+ * @param path The request path, with or without the `/v1/{service}` prefix.
741
+ * @returns `true` for a patient endpoint or the identity-verification endpoint.
742
+ */
743
+ isPhiPath(path) {
744
+ const lower = path.toLowerCase();
745
+ return PHI_PATH_FRAGMENTS.some((fragment) => lower.includes(fragment));
746
+ }
747
+ /**
748
+ * Reports whether a path carries raw file bytes and must have its body dropped.
749
+ *
750
+ * True for the single-shot intake-submission upload endpoint and the multipart
751
+ * `part` endpoint. The multipart `initiate`, `finish`, and `abort` endpoints
752
+ * carry small JSON control messages, so they are excluded and their bodies stay
753
+ * in the log where they help.
754
+ *
755
+ * @param path The request path, with or without the `/v1/{service}` prefix.
756
+ * @returns `true` when the body is file contents rather than JSON.
757
+ */
758
+ isBinaryUploadPath(path) {
759
+ const lower = path.toLowerCase();
760
+ return BINARY_UPLOAD_FRAGMENTS.some((fragment) => lower.includes(fragment));
761
+ }
762
+ /**
763
+ * Extracts the path from a request, for use with the methods above.
764
+ *
765
+ * @param request The request to inspect.
766
+ * @returns The URL path, without the query string.
767
+ */
768
+ pathOf(request) {
769
+ return new URL(request.url).pathname;
770
+ }
771
+ /**
772
+ * Replaces the value of every sensitive JSON field found in the given text.
773
+ *
774
+ * Operates on the serialised form rather than decoding, so it works for
775
+ * pretty-printed and compact JSON alike and never reorders or reformats a body
776
+ * that is only partly JSON.
777
+ */
778
+ #redactFields(body) {
779
+ let out = body;
780
+ for (const field of SENSITIVE_FIELDS) {
781
+ const pattern = new RegExp(`("${escapeRegExp(field)}"\\s*:\\s*)"(?:[^"\\\\]|\\\\.)*"`, "gi");
782
+ out = out.replace(pattern, `$1"${_LogRedactor.PLACEHOLDER}"`);
783
+ }
784
+ return out;
785
+ }
786
+ };
787
+
788
+ // src/http/curl-formatter.ts
789
+ var CurlFormatter = class {
790
+ /**
791
+ * Renders the given request as a multi-line curl invocation.
792
+ *
793
+ * Each header becomes its own `--header '...'` line. A body is appended as
794
+ * `--data '...'` with single quotes shell-escaped through the `'\''` idiom.
795
+ * With no body, the trailing backslash continuation is stripped from the last
796
+ * line so the command pastes cleanly.
797
+ *
798
+ * The request's body is read from a clone, so the request itself stays unread
799
+ * and can still be sent. Reading the original would leave the real request with
800
+ * an empty body.
801
+ *
802
+ * @param request The request to format.
803
+ * @param redactor Applied to header values and the body before rendering. Pass
804
+ * `null` or omit it to render verbatim.
805
+ * @returns The multi-line curl command.
806
+ */
807
+ static async format(request, redactor = null) {
808
+ const lines = [`curl --location --request ${request.method} '${request.url}' \\`];
809
+ const headerLines = [];
810
+ for (const [name, value] of request.headers.entries()) {
811
+ const shown = redactor?.headerValue(name, value) ?? value;
812
+ headerLines.push(` --header '${name}: ${shown}' \\`);
813
+ }
814
+ let body = await readBody(request);
815
+ if (redactor !== null) {
816
+ body = redactor.body(body, redactor.pathOf(request));
817
+ }
818
+ if (body !== "") {
819
+ lines.push(...headerLines);
820
+ lines.push(` --data '${body.replaceAll("'", String.raw`'\''`)}'`);
821
+ } else {
822
+ headerLines.forEach((line, index) => {
823
+ lines.push(index === headerLines.length - 1 ? line.replace(/ \\$/, "") : line);
824
+ });
825
+ if (headerLines.length === 0) {
826
+ lines[0] = lines[0].replace(/ \\$/, "");
827
+ }
828
+ }
829
+ return lines.join("\n");
830
+ }
831
+ };
832
+ async function readBody(request) {
833
+ if (request.body === null) {
834
+ return "";
835
+ }
836
+ const contentType = request.headers.get("content-type") ?? "";
837
+ if (contentType.startsWith("multipart/form-data")) {
838
+ return "[multipart/form-data]";
839
+ }
840
+ return await request.clone().text();
841
+ }
842
+
843
+ // src/http/logging-http-client.ts
844
+ var LoggingHttpClient = class {
845
+ #inner;
846
+ #sink;
847
+ #redactor;
848
+ #formatter;
849
+ /**
850
+ * @param options The client to wrap and where to log.
851
+ */
852
+ constructor(options) {
853
+ this.#inner = options.inner;
854
+ this.#sink = options.sink;
855
+ this.#redactor = options.redactor === void 0 ? new LogRedactor() : options.redactor;
856
+ this.#formatter = new Intl.DateTimeFormat("en-CA", {
857
+ timeZone: options.timeZone ?? "UTC",
858
+ year: "numeric",
859
+ month: "2-digit",
860
+ day: "2-digit",
861
+ hour: "2-digit",
862
+ minute: "2-digit",
863
+ second: "2-digit",
864
+ hour12: false,
865
+ hourCycle: "h23",
866
+ timeZoneName: "short"
867
+ });
868
+ }
869
+ /**
870
+ * Forwards the request to the inner client, then logs the exchange.
871
+ *
872
+ * @param request The request to send and log.
873
+ * @returns The response, with its body still readable.
874
+ * @throws The inner client's error, rethrown after being logged.
875
+ */
876
+ async sendRequest(request) {
877
+ const timestamp = this.#timestamp();
878
+ const curl = await CurlFormatter.format(request, this.#redactor);
879
+ let response;
880
+ try {
881
+ response = await this.#inner.sendRequest(request);
882
+ } catch (error) {
883
+ const detail = error instanceof Error ? error.message : String(error);
884
+ await this.#write(`[${timestamp}]
885
+ ${curl}
886
+
887
+ # Transport error: ${detail}
888
+
889
+ `);
890
+ throw error;
891
+ }
892
+ const raw = await response.clone().text();
893
+ const shown = this.#redactor?.body(raw, this.#redactor.pathOf(request)) ?? raw;
894
+ await this.#write(`[${timestamp}]
895
+ ${curl}
896
+
897
+ # Response: HTTP ${response.status}
898
+ ${shown}
899
+
900
+ `);
901
+ const nullBody = response.status === 204 || response.status === 205 || response.status === 304;
902
+ return new Response(nullBody ? null : raw, {
903
+ status: response.status,
904
+ statusText: response.statusText,
905
+ headers: response.headers
906
+ });
907
+ }
908
+ async #write(entry) {
909
+ try {
910
+ await this.#sink(entry);
911
+ } catch {
912
+ }
913
+ }
914
+ /**
915
+ * Formats the current instant as `YYYY-MM-DD HH:MM:SS.ffffff ZONE`.
916
+ *
917
+ * PHP logs microseconds; `Date` only resolves to milliseconds, so the
918
+ * millisecond value is zero-padded to six digits to keep the column width and
919
+ * the shape of the PHP entries.
920
+ */
921
+ #timestamp() {
922
+ const now = /* @__PURE__ */ new Date();
923
+ const parts = new Map(this.#formatter.formatToParts(now).map(({ type, value }) => [type, value]));
924
+ const micros = String(now.getMilliseconds()).padStart(3, "0").padEnd(6, "0");
925
+ return `${parts.get("year")}-${parts.get("month")}-${parts.get("day")} ${parts.get("hour")}:${parts.get("minute")}:${parts.get("second")}.${micros} ${parts.get("timeZoneName")}`;
926
+ }
927
+ };
928
+
929
+ // src/http/url-builder.ts
930
+ function rawUrlEncode(value) {
931
+ return encodeURIComponent(value).replace(
932
+ /[!'()*]/g,
933
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`
934
+ );
935
+ }
936
+ function buildQuery(query) {
937
+ return Object.entries(query).map(([key, value]) => {
938
+ const raw = typeof value === "boolean" ? value ? "1" : "0" : String(value);
939
+ return `${urlEncode(key)}=${urlEncode(raw)}`;
940
+ }).join("&");
941
+ }
942
+ function urlEncode(value) {
943
+ return rawUrlEncode(value).replace(/%20/g, "+").replace(/~/g, "%7E");
944
+ }
945
+ var UrlBuilder = class {
946
+ #baseHost;
947
+ /**
948
+ * @param baseHost Bare hostname to prepend, e.g. `api.astermd.com`.
949
+ */
950
+ constructor(baseHost) {
951
+ this.#baseHost = baseHost;
952
+ }
953
+ /**
954
+ * Constructs the fully-qualified URL for a service and path.
955
+ *
956
+ * Placeholder tokens in `path` - `{id}`, `{session}`, `{form_json_identifier}`
957
+ * - are replaced with the matching values from `pathParams`, percent-encoded.
958
+ * A placeholder with no matching entry is left untouched rather than guessed
959
+ * at, which surfaces the programming error in the request URL. Non-empty
960
+ * `query` entries are serialised and appended after `?`.
961
+ *
962
+ * @param service Service segment, e.g. `sales`, `auth`, `platform`.
963
+ * @param path Path relative to `/v1/{service}`, with optional `{placeholder}`
964
+ * tokens.
965
+ * @param pathParams Placeholder name → replacement value.
966
+ * @param query Query string parameters.
967
+ * @returns The fully-qualified URL.
968
+ */
969
+ build(service, path, pathParams = {}, query = {}) {
970
+ let resolved = path;
971
+ for (const [key, value] of Object.entries(pathParams)) {
972
+ resolved = resolved.split(`{${key}}`).join(rawUrlEncode(String(value)));
973
+ }
974
+ const url = `https://${this.#baseHost}/v1/${service}${resolved}`;
975
+ const queryString = Object.keys(query).length > 0 ? buildQuery(query) : "";
976
+ return queryString === "" ? url : `${url}?${queryString}`;
977
+ }
978
+ };
979
+ var DailyFileLogSink = class {
980
+ #directory;
981
+ #stem;
982
+ #extension;
983
+ #retentionDays;
984
+ #dateFormatter;
985
+ #pruned = false;
986
+ #tail = Promise.resolve();
987
+ /**
988
+ * @param options Base path, retention window, and time zone.
989
+ * @throws {TypeError} If `basePath` is empty or carries no file name, or if
990
+ * `retentionDays` is negative.
991
+ */
992
+ constructor(options) {
993
+ const { basePath, retentionDays = 7, timeZone = "UTC" } = options;
994
+ if (basePath.trim() === "") {
995
+ throw new TypeError("basePath must be a non-empty file path.");
996
+ }
997
+ if (retentionDays < 0) {
998
+ throw new TypeError("retentionDays must be >= 0 (0 disables pruning).");
999
+ }
1000
+ const extension = path.extname(basePath);
1001
+ const stem = path.basename(basePath, extension);
1002
+ if (stem === "") {
1003
+ throw new TypeError(`basePath must include a file name. Got: ${basePath}`);
1004
+ }
1005
+ this.#directory = path.dirname(basePath);
1006
+ this.#stem = stem;
1007
+ this.#extension = extension === "" ? ".log" : extension;
1008
+ this.#retentionDays = retentionDays;
1009
+ this.#dateFormatter = new Intl.DateTimeFormat("en-CA", {
1010
+ timeZone,
1011
+ year: "numeric",
1012
+ month: "2-digit",
1013
+ day: "2-digit"
1014
+ });
1015
+ }
1016
+ /**
1017
+ * Appends one entry to today's log file, pruning aged-out files on the first
1018
+ * call.
1019
+ *
1020
+ * Resolves once the entry is on disk. Writes are chained, so entries land whole
1021
+ * and in call order even under concurrency. Never rejects: an I/O failure is
1022
+ * swallowed rather than propagated into the API call being logged.
1023
+ *
1024
+ * @param entry The formatted log entry to append.
1025
+ */
1026
+ async write(entry) {
1027
+ this.#tail = this.#tail.then(() => this.#append(entry)).catch(() => void 0);
1028
+ await this.#tail;
1029
+ }
1030
+ /**
1031
+ * Returns the absolute path entries are currently written to.
1032
+ *
1033
+ * Useful in tests and for a "where are my logs" diagnostic. The path changes at
1034
+ * midnight in the configured time zone.
1035
+ *
1036
+ * @returns The dated file path, e.g. `/var/log/astermd/sdk-2026-09-08.log`.
1037
+ */
1038
+ currentFile() {
1039
+ return path.join(this.#directory, `${this.#stem}-${this.#today()}${this.#extension}`);
1040
+ }
1041
+ async #append(entry) {
1042
+ try {
1043
+ await promises.mkdir(this.#directory, { recursive: true, mode: 448 });
1044
+ await promises.appendFile(this.currentFile(), entry, { mode: 384 });
1045
+ if (!this.#pruned) {
1046
+ this.#pruned = true;
1047
+ await this.#prune();
1048
+ }
1049
+ } catch {
1050
+ }
1051
+ }
1052
+ /**
1053
+ * Deletes dated log files older than the retention window.
1054
+ *
1055
+ * Only files matching this sink's own `{stem}-YYYY-MM-DD{ext}` pattern are
1056
+ * considered, and the date comes from the filename rather than filesystem
1057
+ * metadata. A file that cannot be deleted is skipped silently.
1058
+ */
1059
+ async #prune() {
1060
+ if (this.#retentionDays === 0) {
1061
+ return;
1062
+ }
1063
+ const cutoff = this.#cutoff();
1064
+ const pattern = new RegExp(`^${escapeRegExp2(this.#stem)}-(\\d{4}-\\d{2}-\\d{2})${escapeRegExp2(this.#extension)}$`);
1065
+ let names;
1066
+ try {
1067
+ names = await promises.readdir(this.#directory);
1068
+ } catch {
1069
+ return;
1070
+ }
1071
+ for (const name of names) {
1072
+ const matched = pattern.exec(name);
1073
+ if (matched !== null && matched[1] < cutoff) {
1074
+ try {
1075
+ await promises.rm(path.join(this.#directory, name), { force: true });
1076
+ } catch {
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ #today() {
1082
+ return this.#dateFormatter.format(/* @__PURE__ */ new Date());
1083
+ }
1084
+ /** The oldest date still kept, as `YYYY-MM-DD`. */
1085
+ #cutoff() {
1086
+ const today = /* @__PURE__ */ new Date(`${this.#today()}T00:00:00.000Z`);
1087
+ today.setUTCDate(today.getUTCDate() - this.#retentionDays);
1088
+ return today.toISOString().slice(0, 10);
1089
+ }
1090
+ };
1091
+ function escapeRegExp2(value) {
1092
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1093
+ }
1094
+
1095
+ // src/resource/abstract-resource.ts
1096
+ var AbstractResource = class {
1097
+ /** The shared transport used for all outbound API calls. */
1098
+ transport;
1099
+ /**
1100
+ * @param transport The transport every call from this resource goes through.
1101
+ */
1102
+ constructor(transport) {
1103
+ this.transport = transport;
1104
+ }
1105
+ };
1106
+
1107
+ // src/resource/carts.ts
1108
+ var Carts = class extends AbstractResource {
1109
+ /**
1110
+ * @param transport The shared transport.
1111
+ */
1112
+ constructor(transport) {
1113
+ super(transport);
1114
+ }
1115
+ /**
1116
+ * Creates the cart for a session.
1117
+ *
1118
+ * Call this the first time the visitor puts something in their cart. The
1119
+ * session must already exist. If a cart is already recorded for the session,
1120
+ * use {@link Carts.update} instead.
1121
+ *
1122
+ * @param options The session and the cart's contents.
1123
+ * @returns The created cart.
1124
+ * @throws {ValidationError} If the session or an item is rejected.
1125
+ * @throws {ApiError} On any other non-2xx status.
1126
+ * @throws {TransportError} If the request never completed.
1127
+ */
1128
+ async create(options) {
1129
+ return await this.transport.send({
1130
+ service: "sales",
1131
+ method: "POST",
1132
+ path: "/carts/create",
1133
+ body: { session: options.session, items: options.items }
1134
+ });
1135
+ }
1136
+ /**
1137
+ * Replaces the contents of a session's cart.
1138
+ *
1139
+ * The item list is authoritative: whatever you send becomes the cart. Send the
1140
+ * full list on every change rather than only what moved, and pass an empty list
1141
+ * to record that the visitor emptied their cart.
1142
+ *
1143
+ * @param options The session and the cart's new contents.
1144
+ * @returns The updated cart.
1145
+ * @throws {NotFoundError} If no cart exists for the session.
1146
+ * @throws {ValidationError} If an item is rejected.
1147
+ * @throws {ApiError} On any other non-2xx status.
1148
+ * @throws {TransportError} If the request never completed.
1149
+ */
1150
+ async update(options) {
1151
+ return await this.transport.send({
1152
+ service: "sales",
1153
+ method: "PUT",
1154
+ path: "/carts/update/{session}",
1155
+ pathParams: { session: options.session },
1156
+ body: { items: options.items }
1157
+ });
1158
+ }
1159
+ };
1160
+
1161
+ // src/resource/categories.ts
1162
+ var Categories = class extends AbstractResource {
1163
+ /**
1164
+ * @param transport The shared transport.
1165
+ */
1166
+ constructor(transport) {
1167
+ super(transport);
1168
+ }
1169
+ /**
1170
+ * Lists categories, filtered and paginated.
1171
+ *
1172
+ * Pagination details come back in `meta()`. The accepted filters are in the
1173
+ * API reference in your AsterMD dashboard.
1174
+ *
1175
+ * @param query Filters and pagination parameters.
1176
+ * @returns The matching categories, with pagination in `meta()`.
1177
+ * @throws {ValidationError} If a filter is rejected.
1178
+ * @throws {ApiError} On any other non-2xx status.
1179
+ * @throws {TransportError} If the request never completed.
1180
+ */
1181
+ async list(query = {}) {
1182
+ return await this.transport.send({
1183
+ service: "sales",
1184
+ method: "GET",
1185
+ path: "/categories/list",
1186
+ query
1187
+ });
1188
+ }
1189
+ /**
1190
+ * Fetches one category.
1191
+ *
1192
+ * @param id The category's id.
1193
+ * @returns The category record.
1194
+ * @throws {NotFoundError} If the category does not exist or is out of scope.
1195
+ * @throws {ApiError} On any other non-2xx status.
1196
+ * @throws {TransportError} If the request never completed.
1197
+ */
1198
+ async view(id) {
1199
+ return await this.transport.send({
1200
+ service: "sales",
1201
+ method: "GET",
1202
+ path: "/categories/view/{id}",
1203
+ pathParams: { id }
1204
+ });
1205
+ }
1206
+ };
1207
+
1208
+ // src/resource/channels.ts
1209
+ var Channels = class extends AbstractResource {
1210
+ /**
1211
+ * @param transport The shared transport.
1212
+ */
1213
+ constructor(transport) {
1214
+ super(transport);
1215
+ }
1216
+ /**
1217
+ * Fetches a channel's own configuration.
1218
+ *
1219
+ * The channel record itself - name, type, status - without its product
1220
+ * catalogue.
1221
+ *
1222
+ * @param id The channel's id.
1223
+ * @returns The channel record.
1224
+ * @throws {NotFoundError} If the channel does not exist or is out of scope.
1225
+ * @throws {ApiError} On any other non-2xx status.
1226
+ * @throws {TransportError} If the request never completed.
1227
+ */
1228
+ async view(id) {
1229
+ return await this.transport.send({
1230
+ service: "sales",
1231
+ method: "GET",
1232
+ path: "/channels/view/{id}",
1233
+ pathParams: { id }
1234
+ });
1235
+ }
1236
+ /**
1237
+ * Fetches the products assigned to a channel.
1238
+ *
1239
+ * What this selling surface is permitted to sell. Use it to build a catalogue
1240
+ * page without pulling the full detail tree.
1241
+ *
1242
+ * @param id The channel's id.
1243
+ * @returns The assigned products.
1244
+ * @throws {NotFoundError} If the channel does not exist.
1245
+ * @throws {ApiError} On any other non-2xx status.
1246
+ * @throws {TransportError} If the request never completed.
1247
+ */
1248
+ async assignedProducts(id) {
1249
+ return await this.transport.send({
1250
+ service: "sales",
1251
+ method: "GET",
1252
+ path: "/channels/assigned-products/{id}",
1253
+ pathParams: { id }
1254
+ });
1255
+ }
1256
+ /**
1257
+ * Fetches a channel's complete configuration tree.
1258
+ *
1259
+ * Channel, payment processor, products, variants, prices, and per-integration
1260
+ * mappings in one response - everything a storefront needs to render its
1261
+ * catalogue without further calls. The envelope is deeply nested and carries
1262
+ * server-side field names; see the API reference in your AsterMD dashboard for
1263
+ * its shape. Pass `data()` through {@link ChannelDetail.from} to flatten it.
1264
+ *
1265
+ * @param id The channel's id.
1266
+ * @returns The full configuration tree.
1267
+ * @throws {NotFoundError} If the channel does not exist.
1268
+ * @throws {ApiError} On any other non-2xx status.
1269
+ * @throws {TransportError} If the request never completed.
1270
+ */
1271
+ async details(id) {
1272
+ return await this.transport.send({
1273
+ service: "sales",
1274
+ method: "GET",
1275
+ path: "/channels/detail/{id}",
1276
+ pathParams: { id }
1277
+ });
1278
+ }
1279
+ };
1280
+
1281
+ // src/enum/checkout-event.ts
1282
+ var CheckoutEvent = Object.freeze({
1283
+ /** The prospect reached the checkout page. Recorded implicitly by `create()`. */
1284
+ CheckoutVisited: "checkout_visited",
1285
+ /** An upsell was presented to the prospect. */
1286
+ UpsellOffered: "upsell_offered",
1287
+ /** The prospect accepted a presented upsell. */
1288
+ UpsellAccepted: "upsell_accepted",
1289
+ /** The prospect declined a presented upsell. */
1290
+ UpsellDeclined: "upsell_declined",
1291
+ /** The order was placed successfully. */
1292
+ OrderPlaced: "order_placed",
1293
+ /** The order was declined, typically by the payment processor. */
1294
+ OrderDeclined: "order_declined"
1295
+ });
1296
+
1297
+ // src/resource/checkout-events.ts
1298
+ var DEFAULT_CURRENCY = "USD";
1299
+ var CheckoutEvents = class extends AbstractResource {
1300
+ /**
1301
+ * @param transport The shared transport.
1302
+ */
1303
+ constructor(transport) {
1304
+ super(transport);
1305
+ }
1306
+ /**
1307
+ * Opens the checkout record for a session, registering `checkout_visited`.
1308
+ *
1309
+ * Call this once, when the visitor reaches the checkout page. The event is
1310
+ * fixed - this is the call that starts the funnel record - and `currency`
1311
+ * defaults to `USD` when you do not supply one, since the server requires a
1312
+ * currency to interpret any amounts in the payload.
1313
+ *
1314
+ * @param session The session reaching checkout.
1315
+ * @param data Additional context, e.g. `cart_total`, `items_count`,
1316
+ * `currency`. Cannot override `session` or `event`.
1317
+ * @returns The created checkout-event record.
1318
+ * @throws {ValidationError} If the session or payload is rejected.
1319
+ * @throws {ApiError} On any other non-2xx status.
1320
+ * @throws {TransportError} If the request never completed.
1321
+ */
1322
+ async create(session, data = {}) {
1323
+ const body = {
1324
+ ...data,
1325
+ session,
1326
+ event: CheckoutEvent.CheckoutVisited
1327
+ };
1328
+ if (body.currency === void 0 || body.currency === "") {
1329
+ body.currency = DEFAULT_CURRENCY;
1330
+ }
1331
+ return await this.transport.send({
1332
+ service: "sales",
1333
+ method: "POST",
1334
+ path: "/checkout-events/create",
1335
+ body
1336
+ });
1337
+ }
1338
+ /**
1339
+ * Advances a session's checkout record to a new funnel state.
1340
+ *
1341
+ * Send one call per state the visitor reaches: an upsell offered, then accepted
1342
+ * or declined, then the order placed or declined. The payload carries whatever
1343
+ * context that state needs, but it can never override `event` - the state
1344
+ * recorded is always the one you passed explicitly.
1345
+ *
1346
+ * @param options The session, the new state, and any context for it.
1347
+ * @returns The updated checkout-event record.
1348
+ * @throws {NotFoundError} If no checkout record exists for the session.
1349
+ * @throws {ValidationError} If the event or payload is rejected.
1350
+ * @throws {ApiError} On any other non-2xx status.
1351
+ * @throws {TransportError} If the request never completed.
1352
+ */
1353
+ async update(options) {
1354
+ return await this.transport.send({
1355
+ service: "sales",
1356
+ method: "PUT",
1357
+ path: "/checkout-events/update/{session_id}",
1358
+ pathParams: { session_id: options.session },
1359
+ body: { ...options.data ?? {}, event: options.event }
1360
+ });
1361
+ }
1362
+ };
1363
+
1364
+ // src/resource/doctors-networks.ts
1365
+ var DoctorsNetworks = class extends AbstractResource {
1366
+ /**
1367
+ * @param transport The shared transport.
1368
+ */
1369
+ constructor(transport) {
1370
+ super(transport);
1371
+ }
1372
+ /**
1373
+ * Pushes a case to the configured doctors' network.
1374
+ *
1375
+ * The payload must identify who the case is about, one of two ways: an
1376
+ * `opportunity_id` for a prospect already recorded, or a `user_info` object
1377
+ * carrying at least `first_name` and `email` for one who is not. Everything
1378
+ * else is forwarded unmodified.
1379
+ *
1380
+ * An `emit_opportunity` key is removed before sending - that flag is the
1381
+ * server's to set, not a caller's. Your object is not modified; the SDK copies
1382
+ * it first.
1383
+ *
1384
+ * @param payload The case to push. Requires either a non-empty
1385
+ * `opportunity_id` string, or a `user_info` object with non-empty
1386
+ * `first_name` and `email` strings.
1387
+ * @returns The sync result.
1388
+ * @throws {TypeError} If the payload identifies neither an opportunity nor a
1389
+ * person.
1390
+ * @throws {ValidationError} If the server rejects a field.
1391
+ * @throws {NotFoundError} If the referenced opportunity does not exist.
1392
+ * @throws {ApiError} On any other non-2xx status.
1393
+ * @throws {TransportError} If the request never completed.
1394
+ */
1395
+ async sync(payload) {
1396
+ const body = { ...payload };
1397
+ delete body.emit_opportunity;
1398
+ validate(body);
1399
+ return await this.transport.send({
1400
+ service: "sales",
1401
+ method: "POST",
1402
+ path: "/doctors-networks/sync",
1403
+ body
1404
+ });
1405
+ }
1406
+ };
1407
+ function isNonEmptyString(value) {
1408
+ return typeof value === "string" && value !== "";
1409
+ }
1410
+ function validate(payload) {
1411
+ const hasOpportunity = isNonEmptyString(payload.opportunity_id);
1412
+ const userInfo = payload.user_info;
1413
+ const hasMinimalUserInfo = typeof userInfo === "object" && userInfo !== null && isNonEmptyString(userInfo.first_name) && isNonEmptyString(userInfo.email);
1414
+ if (!hasOpportunity && !hasMinimalUserInfo) {
1415
+ throw new TypeError("doctors-networks/sync requires either opportunity_id or user_info with first_name + email.");
1416
+ }
1417
+ }
1418
+
1419
+ // src/resource/geo.ts
1420
+ var Geo = class extends AbstractResource {
1421
+ /**
1422
+ * @param transport The shared transport.
1423
+ */
1424
+ constructor(transport) {
1425
+ super(transport);
1426
+ }
1427
+ /**
1428
+ * Resolves an IP address to a geographic location.
1429
+ *
1430
+ * Returns the configured provider's answer, normalised. Treat it as a strong
1431
+ * hint rather than proof - a VPN or a mobile carrier's routing can place a
1432
+ * visitor a long way from where they are. For a licensing decision, prefer
1433
+ * {@link Geo.blocklist} and the state the visitor declares.
1434
+ *
1435
+ * @param ip The visitor's IP address, v4 or v6.
1436
+ * @returns The resolved location.
1437
+ * @throws {ValidationError} If the address is malformed.
1438
+ * @throws {ApiError} On any other non-2xx status.
1439
+ * @throws {TransportError} If the request never completed.
1440
+ */
1441
+ async info(ip) {
1442
+ return await this.transport.send({
1443
+ service: "platform",
1444
+ method: "GET",
1445
+ path: "/extensions/geo-info",
1446
+ query: { ip }
1447
+ });
1448
+ }
1449
+ /**
1450
+ * Reports whether an IP address is barred from starting an order flow.
1451
+ *
1452
+ * Covers jurisdictions your organisation is not licensed for, along with known
1453
+ * proxies and anonymisers. Call it as early as the landing page: refusing a
1454
+ * visitor before they invest time in a questionnaire is better for them and
1455
+ * cheaper for you.
1456
+ *
1457
+ * @param ip The visitor's IP address, v4 or v6.
1458
+ * @returns The blocklist verdict.
1459
+ * @throws {ValidationError} If the address is malformed.
1460
+ * @throws {ApiError} On any other non-2xx status.
1461
+ * @throws {TransportError} If the request never completed.
1462
+ */
1463
+ async blocklist(ip) {
1464
+ return await this.transport.send({
1465
+ service: "platform",
1466
+ method: "GET",
1467
+ path: "/extensions/geo-blocklist",
1468
+ query: { ip }
1469
+ });
1470
+ }
1471
+ };
1472
+ var MIME_TYPES = Object.freeze({
1473
+ jpg: "image/jpeg",
1474
+ jpeg: "image/jpeg",
1475
+ png: "image/png",
1476
+ webp: "image/webp",
1477
+ gif: "image/gif",
1478
+ pdf: "application/pdf",
1479
+ doc: "application/msword",
1480
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1481
+ txt: "text/plain",
1482
+ mp4: "video/mp4",
1483
+ mov: "video/quicktime",
1484
+ webm: "video/webm"
1485
+ });
1486
+ var FALLBACK_MIME_TYPE = "application/octet-stream";
1487
+ var FileUpload = class _FileUpload {
1488
+ #contents;
1489
+ #fileName;
1490
+ #mimeType;
1491
+ constructor(contents, fileName, mimeType) {
1492
+ this.#contents = contents;
1493
+ this.#fileName = fileName;
1494
+ this.#mimeType = mimeType;
1495
+ Object.freeze(this);
1496
+ }
1497
+ /**
1498
+ * Reads a file from local disk into an upload.
1499
+ *
1500
+ * The file is read in full and held in memory, so keep this for the sizes the
1501
+ * single-shot endpoints allow. Without `options.fileName` the path's base name
1502
+ * is used, and without `options.mimeType` the type is derived from the
1503
+ * extension, falling back to `application/octet-stream`.
1504
+ *
1505
+ * @param path Path to an existing, readable file.
1506
+ * @param options Optional name and MIME type overrides.
1507
+ * @returns An upload carrying the file's bytes, name, and MIME type.
1508
+ * @throws {TypeError} If `path` is not a readable file, or if the resulting
1509
+ * file name normalises to nothing usable.
1510
+ */
1511
+ static async fromPath(path$1, options = {}) {
1512
+ let isFile;
1513
+ try {
1514
+ isFile = (await promises.stat(path$1)).isFile();
1515
+ } catch {
1516
+ isFile = false;
1517
+ }
1518
+ if (!isFile) {
1519
+ throw new TypeError(`File is not readable: ${path$1}`);
1520
+ }
1521
+ let contents;
1522
+ try {
1523
+ contents = await promises.readFile(path$1);
1524
+ } catch (error) {
1525
+ throw new TypeError(`Failed to read file: ${path$1}`, { cause: error });
1526
+ }
1527
+ const name = normaliseFileName(options.fileName ?? path.basename(path$1));
1528
+ return new _FileUpload(new Uint8Array(contents), name, options.mimeType ?? guessMimeType(name));
1529
+ }
1530
+ /**
1531
+ * Wraps bytes already held in memory into an upload.
1532
+ *
1533
+ * Use this when the bytes never came from a file on disk: a slice of a larger
1534
+ * file being sent through the multipart flow, a payload received over the
1535
+ * wire, or content generated at runtime. `fileName` is required because there
1536
+ * is no path to derive one from.
1537
+ *
1538
+ * @param contents The raw bytes to send as the part body.
1539
+ * @param fileName Name to send in the part header.
1540
+ * @param options Optional MIME type override.
1541
+ * @returns An upload carrying the given bytes, name, and MIME type.
1542
+ * @throws {TypeError} If `fileName` normalises to nothing usable.
1543
+ */
1544
+ static fromContents(contents, fileName, options = {}) {
1545
+ const name = normaliseFileName(fileName);
1546
+ return new _FileUpload(contents, name, options.mimeType ?? guessMimeType(name));
1547
+ }
1548
+ /**
1549
+ * Returns the raw bytes that will form the body of the multipart part.
1550
+ *
1551
+ * @returns The file's contents.
1552
+ */
1553
+ contents() {
1554
+ return this.#contents;
1555
+ }
1556
+ /**
1557
+ * Returns the normalised file name sent in the part header.
1558
+ *
1559
+ * The server uses this to derive the stored object name, inserting a unique
1560
+ * identifier before the extension so repeat uploads of the same name never
1561
+ * collide.
1562
+ *
1563
+ * @returns The file name, stripped of directory components and
1564
+ * header-breaking characters.
1565
+ */
1566
+ fileName() {
1567
+ return this.#fileName;
1568
+ }
1569
+ /**
1570
+ * Returns the MIME type sent for the part.
1571
+ *
1572
+ * @returns The MIME type, e.g. `image/jpeg`.
1573
+ */
1574
+ mimeType() {
1575
+ return this.#mimeType;
1576
+ }
1577
+ /**
1578
+ * Returns the size of the upload in bytes.
1579
+ *
1580
+ * Useful for checking a file against an endpoint's documented limit before
1581
+ * spending a round trip on it, and for the `fileSize` value the multipart
1582
+ * initiate call requires.
1583
+ *
1584
+ * @returns The number of bytes in {@link FileUpload.contents}.
1585
+ */
1586
+ size() {
1587
+ return this.#contents.byteLength;
1588
+ }
1589
+ };
1590
+ function normaliseFileName(fileName) {
1591
+ const stripped = fileName.trim().replace(/[\r\n"\\]/g, "");
1592
+ const name = path.basename(stripped).trim();
1593
+ if (name === "" || name === "." || name === "..") {
1594
+ throw new TypeError("File name must not be empty.");
1595
+ }
1596
+ return name;
1597
+ }
1598
+ function guessMimeType(fileName) {
1599
+ const extension = path.extname(fileName).replace(/^\./, "").toLowerCase();
1600
+ return MIME_TYPES[extension] ?? FALLBACK_MIME_TYPE;
1601
+ }
1602
+
1603
+ // src/resource/intake-submissions.ts
1604
+ var IntakeSubmissions = class _IntakeSubmissions extends AbstractResource {
1605
+ /**
1606
+ * @param transport The shared transport.
1607
+ */
1608
+ constructor(transport) {
1609
+ super(transport);
1610
+ }
1611
+ /**
1612
+ * Fetches one intake submission and its recorded answers.
1613
+ *
1614
+ * The teleform id is required alongside the submission id because a submission
1615
+ * is only meaningful against the form it answers - the server uses it to
1616
+ * resolve field definitions for the stored values.
1617
+ *
1618
+ * @param options The submission id and the teleform it answers.
1619
+ * @returns The submission and its answers.
1620
+ * @throws {NotFoundError} If the submission does not exist.
1621
+ * @throws {ApiError} On any other non-2xx status.
1622
+ * @throws {TransportError} If the request never completed.
1623
+ */
1624
+ async view(options) {
1625
+ return await this.transport.send({
1626
+ service: "sales",
1627
+ method: "GET",
1628
+ path: "/intake-submissions/view/{id}",
1629
+ pathParams: { id: options.id },
1630
+ query: { teleform_id: options.teleformId }
1631
+ });
1632
+ }
1633
+ /**
1634
+ * Creates the intake-submission record for a session and records the first
1635
+ * answers.
1636
+ *
1637
+ * This is the call that opens the record, so it takes an `*Initiated` event -
1638
+ * `Event.PreQualifyingInitiated` or `Event.IntakeInitiated`. Every later step
1639
+ * goes to {@link IntakeSubmissions.update}. The server advances its order-flow
1640
+ * state machine on the event, so passing the wrong one puts the flow in the
1641
+ * wrong state rather than failing loudly.
1642
+ *
1643
+ * @param options The session, event, teleform, answers, and optional progress.
1644
+ * @returns The created submission.
1645
+ * @throws {ValidationError} If the answers or the event are rejected.
1646
+ * @throws {NotFoundError} If the session or teleform does not exist.
1647
+ * @throws {ApiError} On any other non-2xx status.
1648
+ * @throws {TransportError} If the request never completed.
1649
+ */
1650
+ async create(options) {
1651
+ return await this.transport.send({
1652
+ service: "sales",
1653
+ method: "POST",
1654
+ path: "/intake-submissions/create",
1655
+ body: intakeBody(options)
1656
+ });
1657
+ }
1658
+ /**
1659
+ * Records further answers against an existing intake submission.
1660
+ *
1661
+ * Takes an `*InProgress` event for an intermediate step or a `*Completed` event
1662
+ * for the final submission. `data` is cumulative - send every answer collected
1663
+ * so far, since the server treats the payload as the current state of the form
1664
+ * rather than a patch.
1665
+ *
1666
+ * @param options The session, event, teleform, answers, and optional progress.
1667
+ * @returns The updated submission.
1668
+ * @throws {ValidationError} If the answers or the event are rejected.
1669
+ * @throws {NotFoundError} If no submission exists for the session.
1670
+ * @throws {ApiError} On any other non-2xx status.
1671
+ * @throws {TransportError} If the request never completed.
1672
+ */
1673
+ async update(options) {
1674
+ return await this.transport.send({
1675
+ service: "sales",
1676
+ method: "PUT",
1677
+ path: "/intake-submissions/update/{session}",
1678
+ pathParams: { session: options.session },
1679
+ body: intakeBody(options)
1680
+ });
1681
+ }
1682
+ /** Smallest part the multipart endpoint accepts: 5 MB. */
1683
+ static MIN_PART_SIZE = 5 * 1024 * 1024;
1684
+ /** Largest part the multipart endpoint accepts: 25 MB. */
1685
+ static MAX_PART_SIZE = 25 * 1024 * 1024;
1686
+ /** Part size {@link IntakeSubmissions.uploadLargeFile} uses by default: 10 MB. */
1687
+ static DEFAULT_PART_SIZE = 10 * 1024 * 1024;
1688
+ /**
1689
+ * Uploads one file in a single request, for a file-type answer.
1690
+ *
1691
+ * Use this for the sizes a single request can carry - a photograph of an ID, a
1692
+ * PDF. The whole file is held in memory and sent as one `multipart/form-data`
1693
+ * body. For anything large enough to risk a timeout, use
1694
+ * {@link IntakeSubmissions.uploadLargeFile} instead.
1695
+ *
1696
+ * The response carries the stored file's reference, which is what you place in
1697
+ * the `value` of the corresponding field when you next call
1698
+ * {@link IntakeSubmissions.update}.
1699
+ *
1700
+ * @param options The session and the file.
1701
+ * @returns The stored file's reference.
1702
+ * @throws {ValidationError} If the file type or size is rejected.
1703
+ * @throws {NotFoundError} If the session does not exist.
1704
+ * @throws {ApiError} On any other non-2xx status.
1705
+ * @throws {TransportError} If the request never completed.
1706
+ */
1707
+ async uploadFile(options) {
1708
+ return await this.transport.send({
1709
+ service: "sales",
1710
+ method: "POST",
1711
+ path: "/intake-submissions/upload-file/{session_id}",
1712
+ pathParams: { session_id: options.sessionId },
1713
+ file: options.file
1714
+ });
1715
+ }
1716
+ /**
1717
+ * Opens a multipart upload and returns the id the parts will be sent under.
1718
+ *
1719
+ * The first of the four low-level multipart calls. Declare the *whole* file's
1720
+ * name, type, and size here - the server uses the size to validate the parts it
1721
+ * later receives. Prefer {@link IntakeSubmissions.uploadLargeFile}, which
1722
+ * drives all four calls; reach for these directly only when you need to
1723
+ * control the loop, for instance to upload parts from several workers.
1724
+ *
1725
+ * @param options The session and the whole file's metadata.
1726
+ * @returns The opened upload; `data().upload_id` carries the id.
1727
+ * @throws {ValidationError} If the metadata is rejected.
1728
+ * @throws {ApiError} On any other non-2xx status.
1729
+ * @throws {TransportError} If the request never completed.
1730
+ */
1731
+ async initiateMultipartUpload(options) {
1732
+ return await this.transport.send({
1733
+ service: "sales",
1734
+ method: "POST",
1735
+ path: "/intake-submissions/upload-file-multipart/initiate/{session_id}",
1736
+ pathParams: { session_id: options.sessionId },
1737
+ body: {
1738
+ file_name: options.fileName,
1739
+ mime_type: options.mimeType,
1740
+ file_size: options.fileSize
1741
+ }
1742
+ });
1743
+ }
1744
+ /**
1745
+ * Uploads one part of an open multipart upload.
1746
+ *
1747
+ * Part numbers are 1-based and must arrive with the part they label; the
1748
+ * response carries an etag you must keep, because
1749
+ * {@link IntakeSubmissions.finishMultipartUpload} needs every part's etag to
1750
+ * assemble the file. Parts may be uploaded in any order.
1751
+ *
1752
+ * @param options The upload id, the part number, and the part's bytes.
1753
+ * @returns The stored part; `data().etag` carries the etag.
1754
+ * @throws {TypeError} If `partNumber` is outside 1–10000.
1755
+ * @throws {NotFoundError} If the upload id is unknown or already finished.
1756
+ * @throws {ApiError} On any other non-2xx status.
1757
+ * @throws {TransportError} If the request never completed.
1758
+ */
1759
+ async uploadMultipartPart(options) {
1760
+ if (options.partNumber < 1 || options.partNumber > 1e4) {
1761
+ throw new TypeError(`Part number must be between 1 and 10000, got ${options.partNumber}.`);
1762
+ }
1763
+ return await this.transport.send({
1764
+ service: "sales",
1765
+ method: "POST",
1766
+ path: "/intake-submissions/upload-file-multipart/part/{upload_id}",
1767
+ pathParams: { upload_id: options.uploadId },
1768
+ query: { part_number: options.partNumber },
1769
+ file: options.part
1770
+ });
1771
+ }
1772
+ /**
1773
+ * Closes a multipart upload, assembling the parts into one stored file.
1774
+ *
1775
+ * Send every part with the etag its own upload returned. The server verifies
1776
+ * the set against what it received, so a missing or mismatched part fails here
1777
+ * rather than producing a corrupt file.
1778
+ *
1779
+ * @param options The upload id and every part with its etag.
1780
+ * @returns The stored file's reference.
1781
+ * @throws {TypeError} If `parts` is empty.
1782
+ * @throws {ValidationError} If the parts do not match what the server holds.
1783
+ * @throws {NotFoundError} If the upload id is unknown.
1784
+ * @throws {ApiError} On any other non-2xx status.
1785
+ * @throws {TransportError} If the request never completed.
1786
+ */
1787
+ async finishMultipartUpload(options) {
1788
+ if (options.parts.length === 0) {
1789
+ throw new TypeError("At least one part is required to finish a multipart upload.");
1790
+ }
1791
+ return await this.transport.send({
1792
+ service: "sales",
1793
+ method: "POST",
1794
+ path: "/intake-submissions/upload-file-multipart/finish/{upload_id}",
1795
+ pathParams: { upload_id: options.uploadId },
1796
+ body: { parts: options.parts }
1797
+ });
1798
+ }
1799
+ /**
1800
+ * Abandons a multipart upload and discards the parts already stored.
1801
+ *
1802
+ * Call this when an upload cannot be completed, so the parts do not linger.
1803
+ * {@link IntakeSubmissions.uploadLargeFile} does it for you on failure.
1804
+ *
1805
+ * @param uploadId The upload id to abandon.
1806
+ * @returns The abort confirmation.
1807
+ * @throws {NotFoundError} If the upload id is unknown or already finished.
1808
+ * @throws {ApiError} On any other non-2xx status.
1809
+ * @throws {TransportError} If the request never completed.
1810
+ */
1811
+ async abortMultipartUpload(uploadId) {
1812
+ return await this.transport.send({
1813
+ service: "sales",
1814
+ method: "POST",
1815
+ path: "/intake-submissions/upload-file-multipart/abort/{upload_id}",
1816
+ pathParams: { upload_id: uploadId }
1817
+ });
1818
+ }
1819
+ /**
1820
+ * Uploads a large file from disk, driving the whole multipart flow.
1821
+ *
1822
+ * This is the method to reach for when a file is too big for one request. It
1823
+ * streams the file one part at a time, so a 200 MB video costs one part of
1824
+ * memory rather than 200 MB: initiate, then read a part, upload it, keep its
1825
+ * etag, repeat, then finish. If any step fails the upload is aborted so no
1826
+ * parts are left behind, and the failure that broke the upload is what
1827
+ * surfaces - not the abort's own outcome.
1828
+ *
1829
+ * The first part fixes the name and MIME type for the whole upload, so
1830
+ * `initiate` and every part agree.
1831
+ *
1832
+ * ```ts
1833
+ * const stored = await client.intakeSubmissions().uploadLargeFile({
1834
+ * sessionId,
1835
+ * filePath: '/var/uploads/consult-video.mp4',
1836
+ * });
1837
+ * ```
1838
+ *
1839
+ * @param options The session, the file's path, and optional name, MIME type,
1840
+ * and part size.
1841
+ * @returns The finished upload's response - the same one
1842
+ * {@link IntakeSubmissions.finishMultipartUpload} returns.
1843
+ * @throws {TypeError} If `partSize` is outside the allowed range, or if the
1844
+ * file is missing, unreadable, or empty.
1845
+ * @throws {ApiError} If the server response omits a usable `upload_id` or
1846
+ * `etag`, or on any other non-2xx status.
1847
+ * @throws {TransportError} If a request never completed.
1848
+ */
1849
+ async uploadLargeFile(options) {
1850
+ const partSize = options.partSize ?? _IntakeSubmissions.DEFAULT_PART_SIZE;
1851
+ if (partSize < _IntakeSubmissions.MIN_PART_SIZE || partSize > _IntakeSubmissions.MAX_PART_SIZE) {
1852
+ throw new TypeError(
1853
+ `Part size must be between ${_IntakeSubmissions.MIN_PART_SIZE} and ${_IntakeSubmissions.MAX_PART_SIZE} bytes, got ${partSize}.`
1854
+ );
1855
+ }
1856
+ let fileSize;
1857
+ try {
1858
+ const stats = await promises.stat(options.filePath);
1859
+ if (!stats.isFile()) {
1860
+ throw new TypeError(`File is not readable: ${options.filePath}`);
1861
+ }
1862
+ fileSize = stats.size;
1863
+ } catch (error) {
1864
+ if (error instanceof TypeError) {
1865
+ throw error;
1866
+ }
1867
+ throw new TypeError(`File is not readable: ${options.filePath}`, {
1868
+ cause: error
1869
+ });
1870
+ }
1871
+ if (fileSize === 0) {
1872
+ throw new TypeError(`File is empty: ${options.filePath}`);
1873
+ }
1874
+ const handle = await promises.open(options.filePath, "r");
1875
+ try {
1876
+ const buffer = Buffer.alloc(partSize);
1877
+ const firstBytesRead = await readFull(handle, buffer, partSize);
1878
+ if (firstBytesRead === 0) {
1879
+ throw new TypeError(`File is empty: ${options.filePath}`);
1880
+ }
1881
+ let part = FileUpload.fromContents(
1882
+ new Uint8Array(buffer.subarray(0, firstBytesRead)),
1883
+ options.fileName ?? path.basename(options.filePath),
1884
+ options.mimeType === void 0 ? {} : { mimeType: options.mimeType }
1885
+ );
1886
+ const uploadId = stringField(
1887
+ await this.initiateMultipartUpload({
1888
+ sessionId: options.sessionId,
1889
+ fileName: part.fileName(),
1890
+ mimeType: part.mimeType(),
1891
+ fileSize
1892
+ }),
1893
+ "upload_id"
1894
+ );
1895
+ try {
1896
+ const parts = [];
1897
+ let partNumber = 1;
1898
+ for (; ; ) {
1899
+ const result = await this.uploadMultipartPart({
1900
+ uploadId,
1901
+ partNumber,
1902
+ part
1903
+ });
1904
+ parts.push({
1905
+ part_number: partNumber,
1906
+ etag: stringField(result, "etag")
1907
+ });
1908
+ const nextBytesRead = await readFull(handle, buffer, partSize);
1909
+ if (nextBytesRead === 0) {
1910
+ break;
1911
+ }
1912
+ partNumber += 1;
1913
+ part = FileUpload.fromContents(new Uint8Array(buffer.subarray(0, nextBytesRead)), part.fileName(), {
1914
+ mimeType: part.mimeType()
1915
+ });
1916
+ }
1917
+ return await this.finishMultipartUpload({ uploadId, parts });
1918
+ } catch (error) {
1919
+ try {
1920
+ await this.abortMultipartUpload(uploadId);
1921
+ } catch {
1922
+ }
1923
+ throw error;
1924
+ }
1925
+ } finally {
1926
+ await handle.close().catch(() => void 0);
1927
+ }
1928
+ }
1929
+ };
1930
+ async function readFull(handle, buffer, length) {
1931
+ let filled = 0;
1932
+ while (filled < length) {
1933
+ const { bytesRead } = await handle.read(buffer, filled, length - filled, null);
1934
+ if (bytesRead === 0) {
1935
+ break;
1936
+ }
1937
+ filled += bytesRead;
1938
+ }
1939
+ return filled;
1940
+ }
1941
+ function intakeBody(options) {
1942
+ const body = {
1943
+ session: options.session,
1944
+ event: options.event,
1945
+ teleform_id: options.teleformId,
1946
+ data: options.data
1947
+ };
1948
+ if (options.progress !== void 0) {
1949
+ body.progress = options.progress;
1950
+ }
1951
+ return body;
1952
+ }
1953
+ function stringField(response, key) {
1954
+ const value = response.data()[key];
1955
+ if (typeof value !== "string" || value === "") {
1956
+ throw new ApiError(`The server response did not include a usable ${key}.`, response.statusCode(), {});
1957
+ }
1958
+ return value;
1959
+ }
1960
+
1961
+ // src/resource/lab-tests.ts
1962
+ var LabTests = class extends AbstractResource {
1963
+ /**
1964
+ * @param transport The shared transport.
1965
+ */
1966
+ constructor(transport) {
1967
+ super(transport);
1968
+ }
1969
+ /**
1970
+ * Lists lab tests, filtered and paginated.
1971
+ *
1972
+ * Pagination details come back in `meta()`. The accepted filters are in the
1973
+ * API reference in your AsterMD dashboard.
1974
+ *
1975
+ * @param query Filters and pagination parameters.
1976
+ * @returns The matching lab tests, with pagination in `meta()`.
1977
+ * @throws {ValidationError} If a filter is rejected.
1978
+ * @throws {ApiError} On any other non-2xx status.
1979
+ * @throws {TransportError} If the request never completed.
1980
+ */
1981
+ async list(query = {}) {
1982
+ return await this.transport.send({
1983
+ service: "sales",
1984
+ method: "GET",
1985
+ path: "/lab-tests/list",
1986
+ query
1987
+ });
1988
+ }
1989
+ /**
1990
+ * Fetches one lab test.
1991
+ *
1992
+ * @param id The lab test's id.
1993
+ * @returns The lab test record.
1994
+ * @throws {NotFoundError} If the lab test does not exist or is out of scope.
1995
+ * @throws {ApiError} On any other non-2xx status.
1996
+ * @throws {TransportError} If the request never completed.
1997
+ */
1998
+ async view(id) {
1999
+ return await this.transport.send({
2000
+ service: "sales",
2001
+ method: "GET",
2002
+ path: "/lab-tests/view/{id}",
2003
+ pathParams: { id }
2004
+ });
2005
+ }
2006
+ };
2007
+
2008
+ // src/resource/medications.ts
2009
+ var Medications = class extends AbstractResource {
2010
+ /**
2011
+ * @param transport The shared transport.
2012
+ */
2013
+ constructor(transport) {
2014
+ super(transport);
2015
+ }
2016
+ /**
2017
+ * Lists medications, filtered and paginated.
2018
+ *
2019
+ * Intended to back a search or type-ahead on an intake form. Pagination comes
2020
+ * back in `meta()`; the accepted filters are in the API reference in your
2021
+ * AsterMD dashboard.
2022
+ *
2023
+ * @param query Filters and pagination parameters.
2024
+ * @returns The matching medications, with pagination in `meta()`.
2025
+ * @throws {ValidationError} If a filter is rejected.
2026
+ * @throws {ApiError} On any other non-2xx status.
2027
+ * @throws {TransportError} If the request never completed.
2028
+ */
2029
+ async list(query = {}) {
2030
+ return await this.transport.send({
2031
+ service: "sales",
2032
+ method: "GET",
2033
+ path: "/medications/list",
2034
+ query
2035
+ });
2036
+ }
2037
+ };
2038
+
2039
+ // src/resource/opportunities.ts
2040
+ var Opportunities = class extends AbstractResource {
2041
+ /**
2042
+ * @param transport The shared transport.
2043
+ */
2044
+ constructor(transport) {
2045
+ super(transport);
2046
+ }
2047
+ /**
2048
+ * Creates an opportunity.
2049
+ *
2050
+ * Include a `sessions` array of the session identifiers that led here, so the
2051
+ * server can attribute the lead. The rest of the payload is forwarded
2052
+ * unmodified.
2053
+ *
2054
+ * @param data The prospect's details and the sessions to attach.
2055
+ * @returns The created opportunity, including its id.
2056
+ * @throws {ValidationError} If a field is missing or rejected.
2057
+ * @throws {ApiError} On any other non-2xx status.
2058
+ * @throws {TransportError} If the request never completed.
2059
+ */
2060
+ async create(data) {
2061
+ return await this.transport.send({
2062
+ service: "sales",
2063
+ method: "POST",
2064
+ path: "/opportunities/create",
2065
+ body: data
2066
+ });
2067
+ }
2068
+ /**
2069
+ * Fetches one opportunity.
2070
+ *
2071
+ * @param id The opportunity's id.
2072
+ * @returns The opportunity record.
2073
+ * @throws {NotFoundError} If the opportunity does not exist or is out of scope.
2074
+ * @throws {ApiError} On any other non-2xx status.
2075
+ * @throws {TransportError} If the request never completed.
2076
+ */
2077
+ async view(id) {
2078
+ return await this.transport.send({
2079
+ service: "sales",
2080
+ method: "GET",
2081
+ path: "/opportunities/view/{id}",
2082
+ pathParams: { id }
2083
+ });
2084
+ }
2085
+ /**
2086
+ * Updates an opportunity.
2087
+ *
2088
+ * Send only the fields you are changing. Use this to enrich a lead as more
2089
+ * information arrives - a phone number, a later session to attach.
2090
+ *
2091
+ * @param id The opportunity's id.
2092
+ * @param data The fields to change.
2093
+ * @returns The updated opportunity.
2094
+ * @throws {NotFoundError} If the opportunity does not exist.
2095
+ * @throws {ValidationError} If a field is rejected.
2096
+ * @throws {ApiError} On any other non-2xx status.
2097
+ * @throws {TransportError} If the request never completed.
2098
+ */
2099
+ async update(id, data) {
2100
+ return await this.transport.send({
2101
+ service: "sales",
2102
+ method: "PUT",
2103
+ path: "/opportunities/update/{id}",
2104
+ pathParams: { id },
2105
+ body: data
2106
+ });
2107
+ }
2108
+ /**
2109
+ * Changes an opportunity's status.
2110
+ *
2111
+ * Kept separate from {@link Opportunities.update} so a pipeline transition is
2112
+ * explicit in your code and in the server's audit trail. The accepted values
2113
+ * are configured for your organisation; see the API reference in your AsterMD
2114
+ * dashboard.
2115
+ *
2116
+ * @param id The opportunity's id.
2117
+ * @param status The new status value, sent as `opportunity_status`.
2118
+ * @returns The updated opportunity.
2119
+ * @throws {NotFoundError} If the opportunity does not exist.
2120
+ * @throws {ValidationError} If the status is not an accepted value.
2121
+ * @throws {ApiError} On any other non-2xx status.
2122
+ * @throws {TransportError} If the request never completed.
2123
+ */
2124
+ async status(id, status) {
2125
+ return await this.transport.send({
2126
+ service: "sales",
2127
+ method: "PATCH",
2128
+ path: "/opportunities/status/{id}",
2129
+ pathParams: { id },
2130
+ body: { opportunity_status: status }
2131
+ });
2132
+ }
2133
+ };
2134
+
2135
+ // src/resource/patients.ts
2136
+ var Patients = class extends AbstractResource {
2137
+ /**
2138
+ * @param transport The shared transport.
2139
+ */
2140
+ constructor(transport) {
2141
+ super(transport);
2142
+ }
2143
+ /**
2144
+ * Creates a patient record.
2145
+ *
2146
+ * Call this once the prospect has qualified and is proceeding. The payload is
2147
+ * forwarded unmodified, so it carries whatever demographic and contact fields
2148
+ * your organisation's configuration requires.
2149
+ *
2150
+ * @param data The patient's details. See the API reference in your AsterMD
2151
+ * dashboard for the accepted fields.
2152
+ * @returns The created patient; `data()` carries the new record including its id.
2153
+ * @throws {ValidationError} If a field is missing or rejected.
2154
+ * @throws {ApiError} On any other non-2xx status.
2155
+ * @throws {TransportError} If the request never completed.
2156
+ */
2157
+ async create(data) {
2158
+ return await this.transport.send({
2159
+ service: "sales",
2160
+ method: "POST",
2161
+ path: "/patients/create",
2162
+ body: data
2163
+ });
2164
+ }
2165
+ /**
2166
+ * Fetches one patient record.
2167
+ *
2168
+ * Returns the record as your credentials are permitted to see it. Because the
2169
+ * organisation scope lives inside the JWT, an id belonging to another
2170
+ * organisation is indistinguishable from one that never existed - both raise
2171
+ * {@link NotFoundError}.
2172
+ *
2173
+ * @param id The patient's id.
2174
+ * @returns The patient record.
2175
+ * @throws {NotFoundError} If the patient does not exist or is out of scope.
2176
+ * @throws {ApiError} On any other non-2xx status.
2177
+ * @throws {TransportError} If the request never completed.
2178
+ */
2179
+ async view(id) {
2180
+ return await this.transport.send({
2181
+ service: "sales",
2182
+ method: "GET",
2183
+ path: "/patients/view/{id}",
2184
+ pathParams: { id }
2185
+ });
2186
+ }
2187
+ /**
2188
+ * Updates a patient record.
2189
+ *
2190
+ * Send only the fields you are changing; the server merges them into the
2191
+ * existing record rather than replacing it.
2192
+ *
2193
+ * @param id The patient's id.
2194
+ * @param data The fields to change.
2195
+ * @returns The updated patient record.
2196
+ * @throws {NotFoundError} If the patient does not exist.
2197
+ * @throws {ValidationError} If a field is rejected.
2198
+ * @throws {ApiError} On any other non-2xx status.
2199
+ * @throws {TransportError} If the request never completed.
2200
+ */
2201
+ async update(id, data) {
2202
+ return await this.transport.send({
2203
+ service: "sales",
2204
+ method: "PUT",
2205
+ path: "/patients/update/{id}",
2206
+ pathParams: { id },
2207
+ body: data
2208
+ });
2209
+ }
2210
+ /**
2211
+ * Changes a patient's status.
2212
+ *
2213
+ * A narrow call for the one field that drives workflow state, kept separate
2214
+ * from {@link Patients.update} so a status transition is explicit in your code
2215
+ * and in the server's audit trail. The accepted values are configured for your
2216
+ * organisation; see the API reference in your AsterMD dashboard.
2217
+ *
2218
+ * @param id The patient's id.
2219
+ * @param status The new status value.
2220
+ * @returns The updated patient record.
2221
+ * @throws {NotFoundError} If the patient does not exist.
2222
+ * @throws {ValidationError} If the status is not an accepted value.
2223
+ * @throws {ApiError} On any other non-2xx status.
2224
+ * @throws {TransportError} If the request never completed.
2225
+ */
2226
+ async status(id, status) {
2227
+ return await this.transport.send({
2228
+ service: "sales",
2229
+ method: "PATCH",
2230
+ path: "/patients/status/{id}",
2231
+ pathParams: { id },
2232
+ body: { status }
2233
+ });
2234
+ }
2235
+ /**
2236
+ * Submits a patient's health information.
2237
+ *
2238
+ * This is the clinical questionnaire result a clinician reviews, and the most
2239
+ * sensitive payload the SDK carries. Where the server requires explicit patient
2240
+ * confirmation, obtain a token through
2241
+ * {@link Patients.verifyHealthInformationOtp} first and pass it as
2242
+ * `phiVerificationToken`.
2243
+ *
2244
+ * Neither the request nor the response body is ever written to a debug log.
2245
+ *
2246
+ * @param options The health information, and the verification token where one
2247
+ * is required.
2248
+ * @returns The stored submission.
2249
+ * @throws {AuthenticationError} If the verification token is missing or invalid.
2250
+ * @throws {ValidationError} If the payload is rejected.
2251
+ * @throws {ApiError} On any other non-2xx status.
2252
+ * @throws {TransportError} If the request never completed.
2253
+ */
2254
+ async submitHealthInformation(options) {
2255
+ const headers = options.phiVerificationToken !== void 0 ? { "x-phi-verification-token": options.phiVerificationToken } : {};
2256
+ return await this.transport.send({
2257
+ service: "sales",
2258
+ method: "POST",
2259
+ path: "/patients/health-information",
2260
+ body: options.data,
2261
+ headers
2262
+ });
2263
+ }
2264
+ /**
2265
+ * Verifies the one-time code a patient was sent, before health information is
2266
+ * submitted.
2267
+ *
2268
+ * The confirming half of the two-step health-information flow. On success the
2269
+ * response carries the verification token to pass to
2270
+ * {@link Patients.submitHealthInformation}.
2271
+ *
2272
+ * The one-time code is a credential: it is never written to a debug log, and it
2273
+ * should not be written to yours either.
2274
+ *
2275
+ * @param data The patient identifier and the one-time code. See the API
2276
+ * reference in your AsterMD dashboard for the exact fields.
2277
+ * @returns The verification result, carrying the token on success.
2278
+ * @throws {ValidationError} If the code is wrong, expired, or malformed.
2279
+ * @throws {ApiError} On any other non-2xx status.
2280
+ * @throws {TransportError} If the request never completed.
2281
+ */
2282
+ async verifyHealthInformationOtp(data) {
2283
+ return await this.transport.send({
2284
+ service: "sales",
2285
+ method: "POST",
2286
+ path: "/patients/health-information/otp-verification",
2287
+ body: data
2288
+ });
2289
+ }
2290
+ };
2291
+
2292
+ // src/resource/products.ts
2293
+ var Products = class extends AbstractResource {
2294
+ /**
2295
+ * @param transport The shared transport.
2296
+ */
2297
+ constructor(transport) {
2298
+ super(transport);
2299
+ }
2300
+ /**
2301
+ * Lists products, filtered and paginated.
2302
+ *
2303
+ * Pagination details come back in `meta()`. The accepted filters are in the
2304
+ * API reference in your AsterMD dashboard.
2305
+ *
2306
+ * @param query Filters and pagination parameters.
2307
+ * @returns The matching products, with pagination in `meta()`.
2308
+ * @throws {ValidationError} If a filter is rejected.
2309
+ * @throws {ApiError} On any other non-2xx status.
2310
+ * @throws {TransportError} If the request never completed.
2311
+ */
2312
+ async list(query = {}) {
2313
+ return await this.transport.send({
2314
+ service: "sales",
2315
+ method: "GET",
2316
+ path: "/products/list",
2317
+ query
2318
+ });
2319
+ }
2320
+ /**
2321
+ * Fetches one product, including its variants and prices.
2322
+ *
2323
+ * @param id The product's id.
2324
+ * @returns The product record.
2325
+ * @throws {NotFoundError} If the product does not exist or is out of scope.
2326
+ * @throws {ApiError} On any other non-2xx status.
2327
+ * @throws {TransportError} If the request never completed.
2328
+ */
2329
+ async view(id) {
2330
+ return await this.transport.send({
2331
+ service: "sales",
2332
+ method: "GET",
2333
+ path: "/products/view/{id}",
2334
+ pathParams: { id }
2335
+ });
2336
+ }
2337
+ };
2338
+
2339
+ // src/resource/sessions.ts
2340
+ var Sessions = class extends AbstractResource {
2341
+ /**
2342
+ * @param transport The shared transport.
2343
+ */
2344
+ constructor(transport) {
2345
+ super(transport);
2346
+ }
2347
+ /**
2348
+ * Creates a new session and returns its identifier.
2349
+ *
2350
+ * This is the first call of an order flow. The server records an implicit
2351
+ * `visit_page` event alongside the new session, so the funnel begins here.
2352
+ * Inspect `data().session` on the response for the identifier to thread through
2353
+ * every later call.
2354
+ *
2355
+ * @param options Attribution payload and visitor headers. All optional; with
2356
+ * none, an empty object body is sent.
2357
+ * @returns The created session; `data()` holds `{ session }`.
2358
+ * @throws {ValidationError} If the attribution payload is rejected.
2359
+ * @throws {AuthenticationError} If the credentials are refused.
2360
+ * @throws {RateLimitError} If the organisation's request allowance is exceeded.
2361
+ * @throws {ApiError} On any other non-2xx status.
2362
+ * @throws {TransportError} If the request never completed.
2363
+ */
2364
+ async create(options = {}) {
2365
+ const headers = {};
2366
+ if (options.userAgent !== void 0 && options.userAgent !== "") {
2367
+ headers["User-Agent"] = options.userAgent;
2368
+ }
2369
+ if (options.clientIp !== void 0 && options.clientIp !== "") {
2370
+ headers["X-Original-Client-Ip"] = options.clientIp;
2371
+ }
2372
+ return await this.transport.send({
2373
+ service: "sales",
2374
+ method: "POST",
2375
+ path: "/sessions/create",
2376
+ body: options.data ?? {},
2377
+ headers
2378
+ });
2379
+ }
2380
+ /**
2381
+ * Fetches one or more sessions and the events recorded against them.
2382
+ *
2383
+ * Useful for reconstructing a visitor's journey server-side - which forms they
2384
+ * touched, where they stopped. Several identifiers can be fetched in one call,
2385
+ * which is cheaper than looping. Timestamps come back in `tz` when you supply
2386
+ * one, and in the organisation's default zone otherwise.
2387
+ *
2388
+ * @param options The session identifiers to fetch, and an optional time zone.
2389
+ * @returns The sessions and their recorded events.
2390
+ * @throws {NotFoundError} If none of the identifiers resolve.
2391
+ * @throws {ApiError} On any other non-2xx status.
2392
+ * @throws {TransportError} If the request never completed.
2393
+ */
2394
+ async view(options) {
2395
+ const query = {
2396
+ session_ids: options.sessions.join(",")
2397
+ };
2398
+ if (options.tz !== void 0 && options.tz !== "") {
2399
+ query.tz = options.tz;
2400
+ }
2401
+ return await this.transport.send({
2402
+ service: "sales",
2403
+ method: "GET",
2404
+ path: "/sessions/view",
2405
+ query
2406
+ });
2407
+ }
2408
+ /**
2409
+ * Updates the attribution and context recorded against an existing session.
2410
+ *
2411
+ * Call this when information arrives after the session was created - a UTM
2412
+ * parameter only present on a later page, a referrer resolved asynchronously.
2413
+ * The payload is merged server-side rather than replacing what is already
2414
+ * recorded.
2415
+ *
2416
+ * @param session The session identifier to update.
2417
+ * @param data Fields to record. Forwarded unmodified; see the API reference in
2418
+ * your AsterMD dashboard for the recognised keys.
2419
+ * @returns The updated session.
2420
+ * @throws {NotFoundError} If the session does not exist.
2421
+ * @throws {ValidationError} If the payload is rejected.
2422
+ * @throws {ApiError} On any other non-2xx status.
2423
+ * @throws {TransportError} If the request never completed.
2424
+ */
2425
+ async update(session, data = {}) {
2426
+ return await this.transport.send({
2427
+ service: "sales",
2428
+ method: "PUT",
2429
+ path: "/sessions/update/{id}",
2430
+ pathParams: { id: session },
2431
+ body: data
2432
+ });
2433
+ }
2434
+ /**
2435
+ * Deletes a session and the events recorded against it.
2436
+ *
2437
+ * Intended for honouring a visitor's erasure request, not for ordinary
2438
+ * cleanup - sessions age out server-side on their own. Deleting one removes the
2439
+ * attribution trail for anything that referenced it.
2440
+ *
2441
+ * @param session The session identifier to delete.
2442
+ * @returns The deletion confirmation.
2443
+ * @throws {NotFoundError} If the session does not exist.
2444
+ * @throws {ApiError} On any other non-2xx status.
2445
+ * @throws {TransportError} If the request never completed.
2446
+ */
2447
+ async delete(session) {
2448
+ return await this.transport.send({
2449
+ service: "sales",
2450
+ method: "DELETE",
2451
+ path: "/sessions/delete/{id}",
2452
+ pathParams: { id: session }
2453
+ });
2454
+ }
2455
+ };
2456
+
2457
+ // src/resource/shippings.ts
2458
+ var Shippings = class extends AbstractResource {
2459
+ /**
2460
+ * @param transport The shared transport.
2461
+ */
2462
+ constructor(transport) {
2463
+ super(transport);
2464
+ }
2465
+ /**
2466
+ * Lists shipping options, filtered and paginated.
2467
+ *
2468
+ * Pagination details come back in `meta()`. The accepted filters are in the
2469
+ * API reference in your AsterMD dashboard.
2470
+ *
2471
+ * @param query Filters and pagination parameters.
2472
+ * @returns The matching shipping options, with pagination in `meta()`.
2473
+ * @throws {ValidationError} If a filter is rejected.
2474
+ * @throws {ApiError} On any other non-2xx status.
2475
+ * @throws {TransportError} If the request never completed.
2476
+ */
2477
+ async list(query = {}) {
2478
+ return await this.transport.send({
2479
+ service: "sales",
2480
+ method: "GET",
2481
+ path: "/shippings/list",
2482
+ query
2483
+ });
2484
+ }
2485
+ /**
2486
+ * Fetches one shipping option.
2487
+ *
2488
+ * @param id The shipping option's id.
2489
+ * @returns The shipping option record.
2490
+ * @throws {NotFoundError} If the shipping option does not exist or is out of scope.
2491
+ * @throws {ApiError} On any other non-2xx status.
2492
+ * @throws {TransportError} If the request never completed.
2493
+ */
2494
+ async view(id) {
2495
+ return await this.transport.send({
2496
+ service: "sales",
2497
+ method: "GET",
2498
+ path: "/shippings/view/{id}",
2499
+ pathParams: { id }
2500
+ });
2501
+ }
2502
+ };
2503
+
2504
+ // src/resource/teleforms.ts
2505
+ var Teleforms = class extends AbstractResource {
2506
+ /**
2507
+ * @param transport The shared transport.
2508
+ */
2509
+ constructor(transport) {
2510
+ super(transport);
2511
+ }
2512
+ /**
2513
+ * Fetches a teleform definition by its configured identifier.
2514
+ *
2515
+ * This is the call a storefront usually wants: the identifier is the stable
2516
+ * name you chose in your AsterMD dashboard, so it can live in your
2517
+ * configuration and survive the form being edited.
2518
+ *
2519
+ * @param identifier The teleform's configured identifier.
2520
+ * @returns The teleform definition.
2521
+ * @throws {NotFoundError} If no teleform carries that identifier.
2522
+ * @throws {ApiError} On any other non-2xx status.
2523
+ * @throws {TransportError} If the request never completed.
2524
+ */
2525
+ async viewByIdentifier(identifier) {
2526
+ return await this.transport.send({
2527
+ service: "sales",
2528
+ method: "GET",
2529
+ path: "/teleforms/view-url/{form_json_identifier}",
2530
+ pathParams: { form_json_identifier: identifier }
2531
+ });
2532
+ }
2533
+ /**
2534
+ * Fetches a teleform definition by its generated id.
2535
+ *
2536
+ * Use this when you already hold an id from another API response - a product's
2537
+ * assigned teleform, for instance - rather than a configured identifier.
2538
+ *
2539
+ * @param id The teleform's generated id.
2540
+ * @returns The teleform definition.
2541
+ * @throws {NotFoundError} If the teleform does not exist.
2542
+ * @throws {ApiError} On any other non-2xx status.
2543
+ * @throws {TransportError} If the request never completed.
2544
+ */
2545
+ async view(id) {
2546
+ return await this.transport.send({
2547
+ service: "sales",
2548
+ method: "GET",
2549
+ path: "/teleforms/view/{id}",
2550
+ pathParams: { id }
2551
+ });
2552
+ }
2553
+ };
2554
+
2555
+ // src/resource/treatments.ts
2556
+ var Treatments = class extends AbstractResource {
2557
+ /**
2558
+ * @param transport The shared transport.
2559
+ */
2560
+ constructor(transport) {
2561
+ super(transport);
2562
+ }
2563
+ /**
2564
+ * Records a treatment - an order that has been paid for.
2565
+ *
2566
+ * Call this only after your payment flow has settled. Include your own order
2567
+ * identifier as `external_order_id` so the record can be reconciled against
2568
+ * your commerce system later.
2569
+ *
2570
+ * @param data The order: patient, product, your order identifier, and whatever
2571
+ * else your configuration requires.
2572
+ * @returns The created treatment.
2573
+ * @throws {ValidationError} If a field is missing or rejected.
2574
+ * @throws {NotFoundError} If the referenced patient or product does not exist.
2575
+ * @throws {ApiError} On any other non-2xx status.
2576
+ * @throws {TransportError} If the request never completed.
2577
+ */
2578
+ async create(data) {
2579
+ return await this.transport.send({
2580
+ service: "sales",
2581
+ method: "POST",
2582
+ path: "/treatments/create",
2583
+ body: data
2584
+ });
2585
+ }
2586
+ /**
2587
+ * Fetches one treatment, including its clinical status.
2588
+ *
2589
+ * This is how a storefront shows an order's progress - whether a clinician has
2590
+ * reviewed it, whether it has shipped.
2591
+ *
2592
+ * @param id The treatment's id.
2593
+ * @returns The treatment record.
2594
+ * @throws {NotFoundError} If the treatment does not exist or is out of scope.
2595
+ * @throws {ApiError} On any other non-2xx status.
2596
+ * @throws {TransportError} If the request never completed.
2597
+ */
2598
+ async view(id) {
2599
+ return await this.transport.send({
2600
+ service: "sales",
2601
+ method: "GET",
2602
+ path: "/treatments/view/{id}",
2603
+ pathParams: { id }
2604
+ });
2605
+ }
2606
+ /**
2607
+ * Lists treatments, filtered and paginated.
2608
+ *
2609
+ * Useful for an account area showing a patient's order history. Pagination
2610
+ * details come back in `meta()`; the accepted filters are in the API reference
2611
+ * in your AsterMD dashboard.
2612
+ *
2613
+ * @param query Filters and pagination parameters.
2614
+ * @returns The matching treatments, with pagination in `meta()`.
2615
+ * @throws {ValidationError} If a filter is rejected.
2616
+ * @throws {ApiError} On any other non-2xx status.
2617
+ * @throws {TransportError} If the request never completed.
2618
+ */
2619
+ async list(query = {}) {
2620
+ return await this.transport.send({
2621
+ service: "sales",
2622
+ method: "GET",
2623
+ path: "/treatments/list",
2624
+ query
2625
+ });
2626
+ }
2627
+ /**
2628
+ * Reconciles orders placed in your own commerce system against a session.
2629
+ *
2630
+ * The alternative to {@link Treatments.create} for flows where checkout happens
2631
+ * outside your storefront: hand over the session and your order identifiers and
2632
+ * the server creates the treatments and attributes them to that session's
2633
+ * journey. Several orders can be reconciled in one call.
2634
+ *
2635
+ * @param options The session, your order identifiers, and optional attribution.
2636
+ * @returns The created treatments.
2637
+ * @throws {ValidationError} If the session or an order identifier is rejected.
2638
+ * @throws {NotFoundError} If the session does not exist.
2639
+ * @throws {ApiError} On any other non-2xx status.
2640
+ * @throws {TransportError} If the request never completed.
2641
+ */
2642
+ async sync(options) {
2643
+ const body = {
2644
+ session_id: options.session,
2645
+ order_ids: options.orderIds
2646
+ };
2647
+ if (options.utmSource !== void 0) {
2648
+ body.utm_source = options.utmSource;
2649
+ }
2650
+ const headers = options.userAgent !== void 0 && options.userAgent !== "" ? { "User-Agent": options.userAgent } : {};
2651
+ return await this.transport.send({
2652
+ service: "sales",
2653
+ method: "POST",
2654
+ path: "/treatments/sync",
2655
+ body,
2656
+ headers
2657
+ });
2658
+ }
2659
+ };
2660
+
2661
+ // src/resource/verification.ts
2662
+ var Verification = class extends AbstractResource {
2663
+ /**
2664
+ * @param transport The shared transport.
2665
+ */
2666
+ constructor(transport) {
2667
+ super(transport);
2668
+ }
2669
+ /**
2670
+ * Suggests complete addresses for a partial one.
2671
+ *
2672
+ * Built for a type-ahead: call it as the buyer types and render the
2673
+ * suggestions. Debouncing is your side's job - every keystroke here is a round
2674
+ * trip and counts against your request allowance.
2675
+ *
2676
+ * @param search The partial address typed so far.
2677
+ * @returns Candidate addresses from the configured provider.
2678
+ * @throws {ValidationError} If the search term is rejected.
2679
+ * @throws {RateLimitError} If the request allowance is exceeded.
2680
+ * @throws {ApiError} On any other non-2xx status.
2681
+ * @throws {TransportError} If the request never completed.
2682
+ */
2683
+ async autofillAddress(search) {
2684
+ return await this.transport.send({
2685
+ service: "platform",
2686
+ method: "GET",
2687
+ path: "/extensions/address-autofill",
2688
+ query: { search }
2689
+ });
2690
+ }
2691
+ /**
2692
+ * Verifies that an address exists and is deliverable.
2693
+ *
2694
+ * Call this before checkout completes rather than after: a prescription sent to
2695
+ * an undeliverable address is a clinical problem, not just a logistics one. The
2696
+ * response carries the provider's normalised form of the address, which is what
2697
+ * you should store.
2698
+ *
2699
+ * @param address The full address to verify.
2700
+ * @returns The verification result and the normalised address.
2701
+ * @throws {ValidationError} If the address is rejected.
2702
+ * @throws {ApiError} On any other non-2xx status.
2703
+ * @throws {TransportError} If the request never completed.
2704
+ */
2705
+ async verifyAddress(address) {
2706
+ return await this.transport.send({
2707
+ service: "platform",
2708
+ method: "GET",
2709
+ path: "/extensions/address-verify",
2710
+ query: { address }
2711
+ });
2712
+ }
2713
+ /**
2714
+ * Verifies that an email address is real and reachable.
2715
+ *
2716
+ * Catches typos and disposable addresses at the point of entry. Since every
2717
+ * later notification - intake reminders, clinician messages, shipping updates -
2718
+ * goes to this address, a bad one silently costs you the patient.
2719
+ *
2720
+ * @param email The email address to verify.
2721
+ * @returns The verification result.
2722
+ * @throws {ValidationError} If the address is malformed.
2723
+ * @throws {ApiError} On any other non-2xx status.
2724
+ * @throws {TransportError} If the request never completed.
2725
+ */
2726
+ async verifyEmail(email) {
2727
+ return await this.transport.send({
2728
+ service: "platform",
2729
+ method: "GET",
2730
+ path: "/extensions/email-verify",
2731
+ query: { email }
2732
+ });
2733
+ }
2734
+ /**
2735
+ * Runs an identity check against the configured provider.
2736
+ *
2737
+ * The strongest check available, and usually a precondition for clinical
2738
+ * review. Which variant runs is carried by `check`, and each expects a
2739
+ * different payload - see the API reference in your AsterMD dashboard for the
2740
+ * fields each one needs.
2741
+ *
2742
+ * The payload carries PII, and a Social Security Number in the `SsnVerify`
2743
+ * case. It is never written to a debug log, and it should not be written to
2744
+ * yours. The payload cannot override the check you selected.
2745
+ *
2746
+ * @param check Which identity check to run.
2747
+ * @param data The fields that check requires.
2748
+ * @returns The verification result.
2749
+ * @throws {ValidationError} If the payload does not satisfy the chosen check.
2750
+ * @throws {ApiError} On any other non-2xx status.
2751
+ * @throws {TransportError} If the request never completed.
2752
+ */
2753
+ async verifyIdentity(check, data) {
2754
+ return await this.transport.send({
2755
+ service: "platform",
2756
+ method: "POST",
2757
+ path: "/extensions/identity-verify",
2758
+ // Data first, slug last: the caller's payload must not rewrite the check.
2759
+ body: { ...data, slug: check }
2760
+ });
2761
+ }
2762
+ };
2763
+
2764
+ // src/astermd-client.ts
2765
+ var AsterMDClient = class {
2766
+ #config;
2767
+ #transport;
2768
+ #sessions;
2769
+ #intakeSubmissions;
2770
+ #carts;
2771
+ #checkoutEvents;
2772
+ #teleforms;
2773
+ #patients;
2774
+ #opportunities;
2775
+ #treatments;
2776
+ #doctorsNetworks;
2777
+ #channels;
2778
+ #products;
2779
+ #categories;
2780
+ #labTests;
2781
+ #medications;
2782
+ #shippings;
2783
+ #verification;
2784
+ #geo;
2785
+ /**
2786
+ * @param options Credentials, connection settings, and debug configuration.
2787
+ * @throws {TypeError} If the credentials are empty, if `baseHost` carries a
2788
+ * scheme or path, if `timeoutSeconds` is below 1, or if `debug` is on with
2789
+ * neither `debugFile` nor `debugSink`.
2790
+ */
2791
+ constructor(options) {
2792
+ const timeoutSeconds = options.timeoutSeconds ?? 10;
2793
+ this.#config = new Config({
2794
+ clientId: options.clientId,
2795
+ clientSecret: options.clientSecret,
2796
+ baseHost: options.baseHost ?? "api.astermd.com",
2797
+ timeoutSeconds
2798
+ });
2799
+ let http = options.httpClient ?? new FetchHttpClient(timeoutSeconds);
2800
+ if (options.debug === true) {
2801
+ if (options.debugSink === void 0 && options.debugFile === void 0) {
2802
+ throw new TypeError("debug mode requires debugFile or debugSink.");
2803
+ }
2804
+ const timeZone = options.debugTimezone ?? "UTC";
2805
+ const sink = options.debugSink ?? (() => {
2806
+ const fileSink = new DailyFileLogSink({
2807
+ basePath: options.debugFile,
2808
+ retentionDays: options.debugRetentionDays ?? 7,
2809
+ timeZone
2810
+ });
2811
+ return async (entry) => {
2812
+ await fileSink.write(entry);
2813
+ };
2814
+ })();
2815
+ http = new LoggingHttpClient({
2816
+ inner: http,
2817
+ sink,
2818
+ timeZone,
2819
+ redactor: options.debugRedact === false ? null : new LogRedactor()
2820
+ });
2821
+ }
2822
+ const urlBuilder = new UrlBuilder(this.#config.baseHost());
2823
+ const tokenManager = new TokenManager({
2824
+ config: this.#config,
2825
+ httpClient: http,
2826
+ urlBuilder,
2827
+ store: options.tokenStore ?? new InMemoryTokenStore()
2828
+ });
2829
+ this.#transport = new Transport({
2830
+ httpClient: http,
2831
+ urlBuilder,
2832
+ tokenProvider: async () => await tokenManager.bearerToken(),
2833
+ onUnauthorized: async () => await tokenManager.refresh()
2834
+ });
2835
+ }
2836
+ /**
2837
+ * Returns the immutable configuration this client was built with.
2838
+ *
2839
+ * @returns The configuration, including {@link Config.assetUrl}.
2840
+ */
2841
+ config() {
2842
+ return this.#config;
2843
+ }
2844
+ /**
2845
+ * Resolves a relative asset path returned by the API against the AsterMD CDN.
2846
+ *
2847
+ * A convenience for `client.config().assetUrl(path)`. Treat the result as a
2848
+ * fetch URL: download the asset once and serve your own copy rather than
2849
+ * hot-linking.
2850
+ *
2851
+ * @param path Relative asset path as returned by the API.
2852
+ * @returns The fully-qualified CDN URL.
2853
+ */
2854
+ assetUrl(path) {
2855
+ return this.#config.assetUrl(path);
2856
+ }
2857
+ /** Sessions - the thread tying one visitor's journey together. */
2858
+ sessions() {
2859
+ return this.#sessions ??= new Sessions(this.#transport);
2860
+ }
2861
+ /** Intake submissions - pre-qualifying and intake questionnaire answers. */
2862
+ intakeSubmissions() {
2863
+ return this.#intakeSubmissions ??= new IntakeSubmissions(this.#transport);
2864
+ }
2865
+ /** Carts - what the visitor intends to buy. */
2866
+ carts() {
2867
+ return this.#carts ??= new Carts(this.#transport);
2868
+ }
2869
+ /** Checkout events - progress through the checkout funnel. */
2870
+ checkoutEvents() {
2871
+ return this.#checkoutEvents ??= new CheckoutEvents(this.#transport);
2872
+ }
2873
+ /** Teleforms - the questionnaire definitions your storefront renders. */
2874
+ teleforms() {
2875
+ return this.#teleforms ??= new Teleforms(this.#transport);
2876
+ }
2877
+ /** Patients - the person receiving care. Carries PHI. */
2878
+ patients() {
2879
+ return this.#patients ??= new Patients(this.#transport);
2880
+ }
2881
+ /** Opportunities - a named prospect worth following up. */
2882
+ opportunities() {
2883
+ return this.#opportunities ??= new Opportunities(this.#transport);
2884
+ }
2885
+ /** Treatments - the order, once payment has settled. */
2886
+ treatments() {
2887
+ return this.#treatments ??= new Treatments(this.#transport);
2888
+ }
2889
+ /** Doctors' networks - handing a case to the clinicians who review it. */
2890
+ doctorsNetworks() {
2891
+ return this.#doctorsNetworks ??= new DoctorsNetworks(this.#transport);
2892
+ }
2893
+ /** Channels - the storefront's own server-side configuration. */
2894
+ channels() {
2895
+ return this.#channels ??= new Channels(this.#transport);
2896
+ }
2897
+ /** Products - the read-only catalogue. */
2898
+ products() {
2899
+ return this.#products ??= new Products(this.#transport);
2900
+ }
2901
+ /** Categories - how the catalogue is grouped for browsing. */
2902
+ categories() {
2903
+ return this.#categories ??= new Categories(this.#transport);
2904
+ }
2905
+ /** Lab tests - diagnostic panels a product may require. */
2906
+ labTests() {
2907
+ return this.#labTests ??= new LabTests(this.#transport);
2908
+ }
2909
+ /** Medications - the reference list backing "what are you currently taking?". */
2910
+ medications() {
2911
+ return this.#medications ??= new Medications(this.#transport);
2912
+ }
2913
+ /** Shipping options - delivery methods available for an order. */
2914
+ shippings() {
2915
+ return this.#shippings ??= new Shippings(this.#transport);
2916
+ }
2917
+ /** Verification - address, email, and identity checks. */
2918
+ verification() {
2919
+ return this.#verification ??= new Verification(this.#transport);
2920
+ }
2921
+ /** Geo - where a visitor is, and whether you may serve them. */
2922
+ geo() {
2923
+ return this.#geo ??= new Geo(this.#transport);
2924
+ }
2925
+ };
2926
+
2927
+ // src/enum/event.ts
2928
+ var Event = Object.freeze({
2929
+ /** The prospect started the pre-qualifying questionnaire. Pass to `create()`. */
2930
+ PreQualifyingInitiated: "pre_qualifying_initiated",
2931
+ /** The prospect advanced through an intermediate pre-qualifying step. Pass to `update()`. */
2932
+ PreQualifyingInProgress: "pre_qualifying_inprogress",
2933
+ /** The prospect submitted the pre-qualifying form. Pass to `update()`. */
2934
+ PreQualifyingCompleted: "pre_qualifying_completed",
2935
+ /** The prospect started the intake questionnaire. Pass to `create()`. */
2936
+ IntakeInitiated: "intake_initiated",
2937
+ /** The prospect advanced through an intermediate intake step. Pass to `update()`. */
2938
+ IntakeInProgress: "intake_inprogress",
2939
+ /** The prospect submitted the intake form. Pass to `update()`. */
2940
+ IntakeCompleted: "intake_completed"
2941
+ });
2942
+
2943
+ // src/enum/identity-check.ts
2944
+ var IdentityCheck = Object.freeze({
2945
+ /** Cross-check the supplied identity details against the provider's records. */
2946
+ Crosscheck: "crosscheck",
2947
+ /** Confirm the supplied date of birth. */
2948
+ DobVerify: "dob_verify",
2949
+ /** Confirm the supplied Social Security Number. */
2950
+ SsnVerify: "ssn_verify"
2951
+ });
2952
+ var FileTokenStore = class {
2953
+ #path;
2954
+ /**
2955
+ * @param path Path to the JSON file used for token storage. The parent
2956
+ * directory is created on first write if it does not exist.
2957
+ */
2958
+ constructor(path) {
2959
+ this.#path = path;
2960
+ }
2961
+ /**
2962
+ * Reads the cached token from the file.
2963
+ *
2964
+ * @returns The stored token, or `null` when the file is absent, unreadable, or
2965
+ * does not hold a usable token.
2966
+ */
2967
+ async get() {
2968
+ let contents;
2969
+ try {
2970
+ contents = await promises.readFile(this.#path, "utf8");
2971
+ } catch {
2972
+ return null;
2973
+ }
2974
+ if (contents === "") {
2975
+ return null;
2976
+ }
2977
+ let decoded;
2978
+ try {
2979
+ decoded = JSON.parse(contents);
2980
+ } catch {
2981
+ return null;
2982
+ }
2983
+ if (typeof decoded !== "object" || decoded === null) {
2984
+ return null;
2985
+ }
2986
+ const { value, expires_at: expiresAt } = decoded;
2987
+ if (typeof value !== "string" || typeof expiresAt !== "string") {
2988
+ return null;
2989
+ }
2990
+ const parsed = new Date(expiresAt);
2991
+ if (Number.isNaN(parsed.getTime())) {
2992
+ return null;
2993
+ }
2994
+ return new Token(value, parsed);
2995
+ }
2996
+ /**
2997
+ * Writes the token to the backing file, replacing any existing content.
2998
+ *
2999
+ * Persistence is best-effort: a filesystem failure - a read-only or
3000
+ * misconfigured cache directory, a permissions problem - is swallowed rather
3001
+ * than thrown, matching the PHP SDK's `put()`, which returns early on a
3002
+ * `fopen` failure. This method runs on every token acquisition, so failing it
3003
+ * would fail the API call it was only meant to optimise; the cost of a
3004
+ * degraded cache is an extra credential exchange next time, not a broken
3005
+ * request.
3006
+ *
3007
+ * @param token The freshly acquired token to persist.
3008
+ */
3009
+ async put(token) {
3010
+ const directory = path.dirname(this.#path);
3011
+ const temporary = path.join(directory, `.${crypto.randomUUID()}.tmp`);
3012
+ try {
3013
+ await promises.mkdir(directory, { recursive: true, mode: 493 });
3014
+ const contents = JSON.stringify({
3015
+ value: token.value(),
3016
+ expires_at: token.expiresAt().toISOString()
3017
+ });
3018
+ await promises.writeFile(temporary, contents, { mode: 384 });
3019
+ await promises.rename(temporary, this.#path);
3020
+ } catch {
3021
+ await promises.rm(temporary, { force: true }).catch(() => void 0);
3022
+ }
3023
+ }
3024
+ /**
3025
+ * Deletes the backing file so the next {@link FileTokenStore.get} returns
3026
+ * `null`.
3027
+ *
3028
+ * A no-op when the file does not exist, including when another process
3029
+ * removed it first. Best-effort otherwise: any other filesystem failure (for
3030
+ * example a permissions problem) is swallowed rather than thrown, since a
3031
+ * cache that fails to clear only costs a stale-token read on the next call,
3032
+ * which {@link FileTokenStore.get} already tolerates.
3033
+ */
3034
+ async clear() {
3035
+ await promises.rm(this.#path, { force: true }).catch(() => void 0);
3036
+ }
3037
+ };
3038
+
3039
+ // src/presenter/channel-detail.ts
3040
+ var ChannelDetail = class {
3041
+ constructor() {
3042
+ }
3043
+ /**
3044
+ * Flattens a channel-detail payload into a shape a view can render directly.
3045
+ *
3046
+ * Pass `response.data()` from {@link Channels.details}. Malformed or missing
3047
+ * branches degrade to `null` and `[]` rather than raising, because a channel is
3048
+ * configuration a storefront needs at boot: failing the page over one absent
3049
+ * optional field would be the wrong trade.
3050
+ *
3051
+ * @param data The raw `data` object from the channel-detail response.
3052
+ * @returns The flattened view.
3053
+ */
3054
+ static from(data) {
3055
+ return {
3056
+ id: data._id ?? null,
3057
+ name: data.name ?? null,
3058
+ description: data.description ?? null,
3059
+ status: data.status ?? null,
3060
+ type: data.type ?? null,
3061
+ auto_inc_id: data.auto_inc_id ?? null,
3062
+ payment_processor: processor(data.payment_processor),
3063
+ products: toList(data.products).map((entry) => product(toObject(entry)))
3064
+ };
3065
+ }
3066
+ };
3067
+ function processor(value) {
3068
+ if (typeof value !== "object" || value === null) {
3069
+ return null;
3070
+ }
3071
+ const pp = value;
3072
+ return {
3073
+ id: pp._id ?? null,
3074
+ name: pp.name ?? null,
3075
+ provider: pp.provider_category ?? null,
3076
+ type: pp.type ?? null,
3077
+ config: pp.config ?? null
3078
+ };
3079
+ }
3080
+ function product(entry) {
3081
+ const byVariant = /* @__PURE__ */ new Map();
3082
+ for (const raw of toList(entry.product_mappings)) {
3083
+ const pm = toObject(raw);
3084
+ const variantId = pm.variant_id;
3085
+ if (typeof variantId === "string") {
3086
+ byVariant.set(variantId, mapping(pm));
3087
+ }
3088
+ }
3089
+ return coreProduct(toObject(entry.product), byVariant, toList(entry.add_ons));
3090
+ }
3091
+ function coreProduct(p, byVariant, addOns) {
3092
+ const id = typeof p._id === "string" ? p._id : null;
3093
+ const variants = toList(p.variants).map((raw) => {
3094
+ const v = toObject(raw);
3095
+ const variantId = typeof v._id === "string" ? v._id : null;
3096
+ return variant(v, lookup(byVariant, variantId));
3097
+ });
3098
+ const singleRows = toList(p.single);
3099
+ const single = singleRows.length === 0 ? null : singlePricing(toObject(singleRows[0]), lookup(byVariant, id));
3100
+ return {
3101
+ id: p._id ?? null,
3102
+ name: p.name ?? null,
3103
+ type: p.type ?? null,
3104
+ sku: p.sku ?? null,
3105
+ status: p.status ?? null,
3106
+ visibility: p.visibility ?? null,
3107
+ image: p.image ?? null,
3108
+ description_long: p.description_long ?? null,
3109
+ description_short: p.description_short ?? null,
3110
+ restrict_multiple: p.restrict_multiple ?? null,
3111
+ min_buy_qty: p.min_buy_qty ?? null,
3112
+ max_buy_qty: p.max_buy_qty ?? null,
3113
+ stock: p.stock ?? null,
3114
+ categories: refs(p.categories),
3115
+ condition_treated: refs(p.condition_treated),
3116
+ teleforms: refs(p.teleforms)[0] ?? null,
3117
+ labtest: refs(p.labtest),
3118
+ add_ons: addOns.map((addOn) => coreProduct(toObject(addOn), /* @__PURE__ */ new Map(), [])),
3119
+ single,
3120
+ variants
3121
+ };
3122
+ }
3123
+ function variant(v, productMapping) {
3124
+ return {
3125
+ id: v._id ?? null,
3126
+ name: v.name ?? null,
3127
+ sku: v.sku ?? null,
3128
+ price: v.default_price ?? null,
3129
+ intro_price: v.intro_price ?? null,
3130
+ sale_price: v.sale_price ?? null,
3131
+ sale_start: v.sale_start ?? null,
3132
+ sale_end: v.sale_end ?? null,
3133
+ mapping: productMapping
3134
+ };
3135
+ }
3136
+ function singlePricing(row, productMapping) {
3137
+ return {
3138
+ price: row.default_price ?? null,
3139
+ intro_price: row.intro_price ?? null,
3140
+ sale_price: row.sale_price ?? null,
3141
+ sale_start: row.sale_start ?? null,
3142
+ sale_end: row.sale_end ?? null,
3143
+ mapping: productMapping
3144
+ };
3145
+ }
3146
+ function mapping(pm) {
3147
+ const integration = toObject(pm.integration);
3148
+ const out = { type: integration.type ?? null };
3149
+ for (const [key, value] of Object.entries(toObject(pm.mapping))) {
3150
+ if (key !== "type") {
3151
+ out[key] = value;
3152
+ }
3153
+ }
3154
+ return out;
3155
+ }
3156
+ function refs(list) {
3157
+ return toList(list).map((item) => ref(item));
3158
+ }
3159
+ function ref(r) {
3160
+ if (typeof r !== "object" || r === null) {
3161
+ return r;
3162
+ }
3163
+ const raw = r;
3164
+ const out = { id: raw._id ?? null };
3165
+ for (const [key, value] of Object.entries(raw)) {
3166
+ if (key !== "_id") {
3167
+ out[key] = value;
3168
+ }
3169
+ }
3170
+ return out;
3171
+ }
3172
+ function lookup(map, key) {
3173
+ return key === null ? null : map.get(key) ?? null;
3174
+ }
3175
+ function toList(value) {
3176
+ if (Array.isArray(value)) {
3177
+ return value;
3178
+ }
3179
+ return typeof value === "object" && value !== null ? Object.values(value) : [];
3180
+ }
3181
+ function toObject(value) {
3182
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
3183
+ }
3184
+ var ALGORITHM = "aes-128-cbc";
3185
+ var IV_LENGTH = 16;
3186
+ var QueryParamCipher = class {
3187
+ constructor() {
3188
+ }
3189
+ /**
3190
+ * Decrypts a query-parameter token into tracking parameters.
3191
+ *
3192
+ * Call this at your storefront's entry point with the raw token from the URL
3193
+ * and the key from your own configuration. Any failure along the way - bad
3194
+ * encoding, wrong or malformed key, decryption error, non-list payload -
3195
+ * returns an empty object so the caller can fall back to plain parameters.
3196
+ *
3197
+ * @param token The base64url token, `iv(16) || ciphertext`.
3198
+ * @param keyHex 32 hex characters - a 16-byte AES-128 key. Supplied by you;
3199
+ * the SDK ships no default.
3200
+ * @returns The decrypted parameters, or `{}` on any failure.
3201
+ */
3202
+ static decrypt(token, keyHex) {
3203
+ if (token === "" || keyHex === "") {
3204
+ return {};
3205
+ }
3206
+ const key = decodeKey(keyHex);
3207
+ if (key === null) {
3208
+ return {};
3209
+ }
3210
+ let blob;
3211
+ try {
3212
+ blob = Buffer.from(base64UrlToBase64(token), "base64");
3213
+ } catch {
3214
+ return {};
3215
+ }
3216
+ if (blob.byteLength <= IV_LENGTH) {
3217
+ return {};
3218
+ }
3219
+ let json;
3220
+ try {
3221
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, blob.subarray(0, IV_LENGTH));
3222
+ json = Buffer.concat([decipher.update(blob.subarray(IV_LENGTH)), decipher.final()]).toString("utf8");
3223
+ } catch {
3224
+ return {};
3225
+ }
3226
+ let decoded;
3227
+ try {
3228
+ decoded = JSON.parse(json);
3229
+ } catch {
3230
+ return {};
3231
+ }
3232
+ if (!Array.isArray(decoded)) {
3233
+ return {};
3234
+ }
3235
+ const out = {};
3236
+ for (const entry of decoded) {
3237
+ if (typeof entry !== "object" || entry === null) {
3238
+ continue;
3239
+ }
3240
+ const { key: name, value } = entry;
3241
+ if (!isScalar(name)) {
3242
+ continue;
3243
+ }
3244
+ out[phpString(name)] = isScalar(value) ? phpString(value) : "";
3245
+ }
3246
+ return out;
3247
+ }
3248
+ /**
3249
+ * Encrypts tracking parameters into a token.
3250
+ *
3251
+ * Provided mainly for round-trip tests and local tooling. A fresh random IV is
3252
+ * generated on every call and prepended to the ciphertext before encoding, so
3253
+ * the same parameters never produce the same token twice.
3254
+ *
3255
+ * Unlike {@link QueryParamCipher.decrypt}, this is strict: a bad key raises
3256
+ * rather than being swallowed, because a failed encrypt is a programming error
3257
+ * and not untrusted input.
3258
+ *
3259
+ * @param params The parameters to encrypt. Written to the wire as the
3260
+ * `[{ key, value }, …]` list the PHP SDK also reads.
3261
+ * @param keyHex 32 hex characters - a 16-byte AES-128 key.
3262
+ * @returns The base64url token, `iv(16) || ciphertext`.
3263
+ * @throws {TypeError} If `keyHex` is not a valid 16-byte hex key.
3264
+ */
3265
+ static encrypt(params, keyHex) {
3266
+ const key = decodeKey(keyHex);
3267
+ if (key === null) {
3268
+ throw new TypeError("keyHex must be 32 hex chars (a 16-byte AES-128 key).");
3269
+ }
3270
+ const payload = JSON.stringify(Object.entries(params).map(([name, value]) => ({ key: name, value })));
3271
+ const iv = crypto.randomBytes(IV_LENGTH);
3272
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
3273
+ const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
3274
+ return Buffer.concat([iv, ciphertext]).toString("base64url");
3275
+ }
3276
+ };
3277
+ function decodeKey(keyHex) {
3278
+ if (!/^[0-9a-fA-F]{32}$/.test(keyHex)) {
3279
+ return null;
3280
+ }
3281
+ return Buffer.from(keyHex, "hex");
3282
+ }
3283
+ function base64UrlToBase64(value) {
3284
+ const standard = value.replaceAll("-", "+").replaceAll("_", "/");
3285
+ const remainder = standard.length % 4;
3286
+ return remainder === 0 ? standard : standard + "=".repeat(4 - remainder);
3287
+ }
3288
+ function isScalar(value) {
3289
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
3290
+ }
3291
+ function phpString(value) {
3292
+ if (typeof value === "boolean") {
3293
+ return value ? "1" : "";
3294
+ }
3295
+ return String(value);
3296
+ }
3297
+
3298
+ exports.AbstractResource = AbstractResource;
3299
+ exports.ApiError = ApiError;
3300
+ exports.AsterMDClient = AsterMDClient;
3301
+ exports.AsterMDError = AsterMDError;
3302
+ exports.AuthenticationError = AuthenticationError;
3303
+ exports.Carts = Carts;
3304
+ exports.Categories = Categories;
3305
+ exports.ChannelDetail = ChannelDetail;
3306
+ exports.Channels = Channels;
3307
+ exports.CheckoutEvent = CheckoutEvent;
3308
+ exports.CheckoutEvents = CheckoutEvents;
3309
+ exports.Config = Config;
3310
+ exports.DailyFileLogSink = DailyFileLogSink;
3311
+ exports.DoctorsNetworks = DoctorsNetworks;
3312
+ exports.Event = Event;
3313
+ exports.FetchHttpClient = FetchHttpClient;
3314
+ exports.FileTokenStore = FileTokenStore;
3315
+ exports.FileUpload = FileUpload;
3316
+ exports.Geo = Geo;
3317
+ exports.IdentityCheck = IdentityCheck;
3318
+ exports.InMemoryTokenStore = InMemoryTokenStore;
3319
+ exports.IntakeSubmissions = IntakeSubmissions;
3320
+ exports.LabTests = LabTests;
3321
+ exports.Medications = Medications;
3322
+ exports.NotFoundError = NotFoundError;
3323
+ exports.Opportunities = Opportunities;
3324
+ exports.Patients = Patients;
3325
+ exports.Products = Products;
3326
+ exports.QueryParamCipher = QueryParamCipher;
3327
+ exports.RateLimitError = RateLimitError;
3328
+ exports.Response = Response2;
3329
+ exports.Sessions = Sessions;
3330
+ exports.Shippings = Shippings;
3331
+ exports.Teleforms = Teleforms;
3332
+ exports.Token = Token;
3333
+ exports.TransportError = TransportError;
3334
+ exports.Treatments = Treatments;
3335
+ exports.ValidationError = ValidationError;
3336
+ exports.Verification = Verification;
3337
+ //# sourceMappingURL=index.cjs.map
3338
+ //# sourceMappingURL=index.cjs.map