@cross-deck/node 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/crossdeck-server.ts
2
- import { randomUUID } from "crypto";
2
+ import { EventEmitter } from "events";
3
3
 
4
4
  // src/errors.ts
5
5
  var CrossdeckError = class _CrossdeckError extends Error {
@@ -18,7 +18,98 @@ var CrossdeckError = class _CrossdeckError extends Error {
18
18
  this.retryAfterMs = payload.retryAfterMs;
19
19
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
20
20
  }
21
+ /**
22
+ * JSON representation suitable for structured loggers. Without this,
23
+ * `console.log(err)` and most log frameworks (Pino, Winston) emit
24
+ * only `name` + `message` + `stack` — losing `type`, `code`,
25
+ * `requestId`, `status`, `retryAfterMs`. With `toJSON`, calling
26
+ * `JSON.stringify(err)` or passing the error to a logger that
27
+ * serialises via JSON includes the full diagnostic surface.
28
+ *
29
+ * Stripe pattern. Critical for production observability.
30
+ */
31
+ toJSON() {
32
+ return {
33
+ name: this.name,
34
+ message: this.message,
35
+ type: this.type,
36
+ code: this.code,
37
+ requestId: this.requestId,
38
+ status: this.status,
39
+ retryAfterMs: this.retryAfterMs,
40
+ stack: this.stack
41
+ };
42
+ }
43
+ };
44
+ var CrossdeckAuthenticationError = class _CrossdeckAuthenticationError extends CrossdeckError {
45
+ constructor(payload) {
46
+ super(payload);
47
+ this.name = "CrossdeckAuthenticationError";
48
+ Object.setPrototypeOf(this, _CrossdeckAuthenticationError.prototype);
49
+ }
50
+ };
51
+ var CrossdeckPermissionError = class _CrossdeckPermissionError extends CrossdeckError {
52
+ constructor(payload) {
53
+ super(payload);
54
+ this.name = "CrossdeckPermissionError";
55
+ Object.setPrototypeOf(this, _CrossdeckPermissionError.prototype);
56
+ }
57
+ };
58
+ var CrossdeckValidationError = class _CrossdeckValidationError extends CrossdeckError {
59
+ constructor(payload) {
60
+ super(payload);
61
+ this.name = "CrossdeckValidationError";
62
+ Object.setPrototypeOf(this, _CrossdeckValidationError.prototype);
63
+ }
64
+ };
65
+ var CrossdeckRateLimitError = class _CrossdeckRateLimitError extends CrossdeckError {
66
+ constructor(payload) {
67
+ super(payload);
68
+ this.name = "CrossdeckRateLimitError";
69
+ Object.setPrototypeOf(this, _CrossdeckRateLimitError.prototype);
70
+ }
71
+ };
72
+ var CrossdeckNetworkError = class _CrossdeckNetworkError extends CrossdeckError {
73
+ constructor(payload) {
74
+ super(payload);
75
+ this.name = "CrossdeckNetworkError";
76
+ Object.setPrototypeOf(this, _CrossdeckNetworkError.prototype);
77
+ }
78
+ };
79
+ var CrossdeckInternalError = class _CrossdeckInternalError extends CrossdeckError {
80
+ constructor(payload) {
81
+ super(payload);
82
+ this.name = "CrossdeckInternalError";
83
+ Object.setPrototypeOf(this, _CrossdeckInternalError.prototype);
84
+ }
85
+ };
86
+ var CrossdeckConfigurationError = class _CrossdeckConfigurationError extends CrossdeckError {
87
+ constructor(payload) {
88
+ super(payload);
89
+ this.name = "CrossdeckConfigurationError";
90
+ Object.setPrototypeOf(this, _CrossdeckConfigurationError.prototype);
91
+ }
21
92
  };
93
+ function makeCrossdeckError(payload) {
94
+ switch (payload.type) {
95
+ case "authentication_error":
96
+ return new CrossdeckAuthenticationError(payload);
97
+ case "permission_error":
98
+ return new CrossdeckPermissionError(payload);
99
+ case "invalid_request_error":
100
+ return new CrossdeckValidationError(payload);
101
+ case "rate_limit_error":
102
+ return new CrossdeckRateLimitError(payload);
103
+ case "network_error":
104
+ return new CrossdeckNetworkError(payload);
105
+ case "internal_error":
106
+ return new CrossdeckInternalError(payload);
107
+ case "configuration_error":
108
+ return new CrossdeckConfigurationError(payload);
109
+ default:
110
+ return new CrossdeckError(payload);
111
+ }
112
+ }
22
113
  async function crossdeckErrorFromResponse(res) {
23
114
  const requestId = res.headers.get("x-request-id") ?? void 0;
24
115
  const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
@@ -30,7 +121,7 @@ async function crossdeckErrorFromResponse(res) {
30
121
  }
31
122
  const envelope = body?.error;
32
123
  if (envelope && typeof envelope.type === "string" && typeof envelope.code === "string") {
33
- return new CrossdeckError({
124
+ return makeCrossdeckError({
34
125
  type: envelope.type,
35
126
  code: envelope.code,
36
127
  message: envelope.message ?? `HTTP ${res.status}`,
@@ -39,7 +130,7 @@ async function crossdeckErrorFromResponse(res) {
39
130
  retryAfterMs
40
131
  });
41
132
  }
42
- return new CrossdeckError({
133
+ return makeCrossdeckError({
43
134
  type: typeMapForStatus(res.status),
44
135
  code: `http_${res.status}`,
45
136
  message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
@@ -227,66 +318,193 @@ function byteLength(s) {
227
318
 
228
319
  // src/http.ts
229
320
  var SDK_NAME = "@cross-deck/node";
230
- var SDK_VERSION = "0.1.0";
321
+ var SDK_VERSION = "1.0.0";
231
322
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
232
323
  var DEFAULT_TIMEOUT_MS = 15e3;
324
+ var CROSSDECK_API_VERSION = "2025-01-01";
325
+ var DEFAULT_GET_RETRY_ATTEMPTS = 3;
326
+ var DEFAULT_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 500, 502, 503, 504]);
233
327
  var HttpClient = class {
234
328
  constructor(config) {
235
329
  this.config = config;
330
+ this.userAgent = buildUserAgent(config.sdkVersion, config.runtimeToken);
236
331
  }
237
332
  config;
333
+ userAgent;
238
334
  async request(method, path, options = {}) {
239
335
  const url = this.buildUrl(path, options.query);
240
- const headers = {
241
- Authorization: `Bearer ${this.config.secretKey}`,
242
- "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
243
- Accept: "application/json"
244
- };
245
- if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
246
- let bodyInit;
247
- if (options.body !== void 0) {
248
- headers["Content-Type"] = "application/json";
249
- bodyInit = serializeRequestBody(options.body);
336
+ if (this.config.testMode === true) {
337
+ return this.synthesizeTestModeResponse(method, path, url, options);
338
+ }
339
+ const headers = this.buildHeaders(options);
340
+ const bodyInit = this.buildBody(headers, options.body);
341
+ const retryCfg = options.retries ?? this.config.httpRetries ?? {};
342
+ const maxAttempts = method === "GET" ? retryCfg.maxAttempts ?? DEFAULT_GET_RETRY_ATTEMPTS : 1;
343
+ const retryableStatuses = retryCfg.retryableStatuses ? new Set(retryCfg.retryableStatuses) : DEFAULT_RETRYABLE_STATUSES;
344
+ let lastError = null;
345
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
346
+ const reqInfo = {
347
+ method,
348
+ url,
349
+ headers,
350
+ bodyPreview: typeof bodyInit === "string" ? bodyInit : void 0,
351
+ attempt
352
+ };
353
+ try {
354
+ this.config.onRequest?.(reqInfo);
355
+ } catch {
356
+ }
357
+ const start = Date.now();
358
+ let response = null;
359
+ let networkError = null;
360
+ try {
361
+ response = await this.dispatch(url, method, headers, bodyInit, options);
362
+ } catch (err) {
363
+ networkError = err;
364
+ }
365
+ const durationMs = Date.now() - start;
366
+ if (response) {
367
+ try {
368
+ this.config.onResponse?.({
369
+ method,
370
+ url,
371
+ status: response.status,
372
+ durationMs,
373
+ attempt,
374
+ testMode: false
375
+ });
376
+ } catch {
377
+ }
378
+ }
379
+ if (networkError !== null) {
380
+ lastError = this.translateNetworkError(networkError, path, options);
381
+ if (method === "GET" && attempt < maxAttempts) {
382
+ await sleepWithJitter(attempt);
383
+ continue;
384
+ }
385
+ throw lastError;
386
+ }
387
+ if (response && !response.ok) {
388
+ const err = await crossdeckErrorFromResponse(response);
389
+ if (method === "GET" && retryableStatuses.has(response.status) && attempt < maxAttempts) {
390
+ lastError = err;
391
+ await sleepForRetry(err, attempt);
392
+ continue;
393
+ }
394
+ throw err;
395
+ }
396
+ if (response.status === 204) return void 0;
397
+ try {
398
+ return await response.json();
399
+ } catch {
400
+ throw makeCrossdeckError({
401
+ type: "internal_error",
402
+ code: "invalid_json_response",
403
+ message: "Server returned a 2xx with an unparseable body.",
404
+ requestId: response.headers.get("x-request-id") ?? void 0,
405
+ status: response.status
406
+ });
407
+ }
250
408
  }
409
+ throw lastError ?? makeCrossdeckError({
410
+ type: "internal_error",
411
+ code: "retry_exhausted",
412
+ message: `GET ${path} exhausted ${maxAttempts} attempts.`
413
+ });
414
+ }
415
+ /**
416
+ * Issue a single fetch invocation. Composes the per-request timeout
417
+ * with the caller-supplied AbortSignal — whichever fires first wins.
418
+ */
419
+ async dispatch(url, method, headers, bodyInit, options) {
251
420
  const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
252
- const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
421
+ const supportsAbort = typeof AbortController !== "undefined";
422
+ const controller = supportsAbort && effectiveTimeout > 0 ? new AbortController() : null;
423
+ let externalAbortHandler = null;
424
+ if (controller && options.signal) {
425
+ if (options.signal.aborted) {
426
+ controller.abort();
427
+ } else {
428
+ externalAbortHandler = () => controller.abort();
429
+ options.signal.addEventListener("abort", externalAbortHandler, { once: true });
430
+ }
431
+ }
253
432
  let timeoutHandle = null;
254
433
  if (controller && effectiveTimeout > 0) {
255
434
  timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
256
435
  }
257
- let response;
258
436
  try {
259
- response = await fetch(url, {
437
+ return await fetch(url, {
260
438
  method,
261
439
  headers,
262
440
  body: bodyInit,
263
- signal: controller?.signal
264
- });
265
- } catch (err) {
266
- const aborted = controller?.signal?.aborted === true;
267
- throw new CrossdeckError({
268
- type: "network_error",
269
- code: aborted ? "request_timeout" : "fetch_failed",
270
- message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
441
+ signal: controller?.signal ?? options.signal
271
442
  });
272
443
  } finally {
273
444
  if (timeoutHandle !== null) clearTimeout(timeoutHandle);
445
+ if (externalAbortHandler && options.signal) {
446
+ try {
447
+ options.signal.removeEventListener("abort", externalAbortHandler);
448
+ } catch {
449
+ }
450
+ }
274
451
  }
275
- if (!response.ok) {
276
- throw await crossdeckErrorFromResponse(response);
452
+ }
453
+ /** Build the request headers. Same across attempts so caches can dedupe. */
454
+ buildHeaders(options) {
455
+ const headers = {
456
+ Authorization: `Bearer ${this.config.secretKey}`,
457
+ "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
458
+ "Crossdeck-Api-Version": CROSSDECK_API_VERSION,
459
+ "User-Agent": this.userAgent,
460
+ Accept: "application/json"
461
+ };
462
+ if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
463
+ if (options.body !== void 0) {
464
+ headers["Content-Type"] = "application/json";
277
465
  }
278
- if (response.status === 204) return void 0;
466
+ return headers;
467
+ }
468
+ buildBody(headers, body) {
469
+ if (body === void 0) return void 0;
470
+ void headers;
471
+ return serializeRequestBody(body);
472
+ }
473
+ /** Translate a thrown fetch error or abort into a typed `CrossdeckError`. */
474
+ translateNetworkError(err, path, options) {
475
+ const callerAborted = options.signal?.aborted === true || err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message));
476
+ const callerInitiated = options.signal?.aborted === true;
477
+ return makeCrossdeckError({
478
+ type: "network_error",
479
+ code: callerAborted ? callerInitiated ? "request_aborted" : "request_timeout" : "fetch_failed",
480
+ message: callerAborted ? callerInitiated ? `Request to ${path} aborted by caller AbortSignal.` : `Request to ${path} aborted after ${options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` : err instanceof Error ? err.message : "fetch failed"
481
+ });
482
+ }
483
+ /** Synthesise a benign success-shaped response for `testMode: true`. */
484
+ synthesizeTestModeResponse(method, path, url, options) {
279
485
  try {
280
- return await response.json();
486
+ this.config.onRequest?.({
487
+ method,
488
+ url,
489
+ headers: this.buildHeaders(options),
490
+ bodyPreview: options.body !== void 0 ? safeStringify2(options.body) : void 0,
491
+ attempt: 1
492
+ });
281
493
  } catch {
282
- throw new CrossdeckError({
283
- type: "internal_error",
284
- code: "invalid_json_response",
285
- message: "Server returned a 2xx with an unparseable body.",
286
- requestId: response.headers.get("x-request-id") ?? void 0,
287
- status: response.status
494
+ }
495
+ const synth = synthForPath(path);
496
+ try {
497
+ this.config.onResponse?.({
498
+ method,
499
+ url,
500
+ status: 200,
501
+ durationMs: 0,
502
+ attempt: 1,
503
+ testMode: true
288
504
  });
505
+ } catch {
289
506
  }
507
+ return synth;
290
508
  }
291
509
  buildUrl(path, query) {
292
510
  const base = this.config.baseUrl.replace(/\/+$/, "");
@@ -303,6 +521,134 @@ var HttpClient = class {
303
521
  return url;
304
522
  }
305
523
  };
524
+ function buildUserAgent(sdkVersion, override) {
525
+ if (override) return `${SDK_NAME}/${sdkVersion} ${override}`;
526
+ const nodeVersion = typeof process !== "undefined" && process.versions ? process.versions.node : "unknown";
527
+ const osPlatform2 = typeof process !== "undefined" && process.platform ? process.platform : "unknown";
528
+ return `${SDK_NAME}/${sdkVersion} node/${nodeVersion} ${osPlatform2}`;
529
+ }
530
+ async function sleepWithJitter(attempt) {
531
+ const ceiling = Math.min(2e3, 50 * Math.pow(2, attempt - 1));
532
+ const delay = Math.round(ceiling * Math.random());
533
+ await new Promise((resolve) => {
534
+ const t = setTimeout(resolve, delay);
535
+ if (typeof t.unref === "function") {
536
+ try {
537
+ t.unref();
538
+ } catch {
539
+ }
540
+ }
541
+ });
542
+ }
543
+ async function sleepForRetry(err, attempt) {
544
+ if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) {
545
+ await new Promise((resolve) => {
546
+ const t = setTimeout(resolve, err.retryAfterMs);
547
+ if (typeof t.unref === "function") {
548
+ try {
549
+ t.unref();
550
+ } catch {
551
+ }
552
+ }
553
+ });
554
+ return;
555
+ }
556
+ await sleepWithJitter(attempt);
557
+ }
558
+ function synthForPath(path) {
559
+ if (path.startsWith("/sdk/heartbeat")) {
560
+ return {
561
+ object: "heartbeat",
562
+ ok: true,
563
+ projectId: "proj_test_mode",
564
+ appId: "app_test_mode",
565
+ platform: "node",
566
+ env: "sandbox",
567
+ serverTime: Date.now()
568
+ };
569
+ }
570
+ if (path.startsWith("/identity/alias")) {
571
+ return {
572
+ object: "alias_result",
573
+ crossdeckCustomerId: "cdcust_test_mode",
574
+ linked: [],
575
+ mergePending: false,
576
+ env: "sandbox"
577
+ };
578
+ }
579
+ if (path.startsWith("/identity/forget")) {
580
+ return {
581
+ object: "forgot",
582
+ crossdeckCustomerId: null,
583
+ queuedAt: Date.now(),
584
+ env: "sandbox"
585
+ };
586
+ }
587
+ if (path.includes("/entitlements")) {
588
+ return {
589
+ object: "list",
590
+ data: [],
591
+ crossdeckCustomerId: "cdcust_test_mode",
592
+ env: "sandbox"
593
+ };
594
+ }
595
+ if (path.startsWith("/events")) {
596
+ return {
597
+ object: "list",
598
+ received: 0,
599
+ env: "sandbox"
600
+ };
601
+ }
602
+ if (path.includes("/purchases/sync")) {
603
+ return {
604
+ object: "purchase_result",
605
+ crossdeckCustomerId: "cdcust_test_mode",
606
+ env: "sandbox",
607
+ entitlements: []
608
+ };
609
+ }
610
+ if (path.includes("/grant") || path.includes("/revoke")) {
611
+ return {
612
+ object: "entitlement_mutation",
613
+ action: path.includes("/grant") ? "grant" : "revoke",
614
+ crossdeckCustomerId: "cdcust_test_mode",
615
+ entitlement: {
616
+ object: "entitlement",
617
+ key: "pro",
618
+ isActive: path.includes("/grant"),
619
+ validUntil: null,
620
+ source: { rail: "manual", productId: "manual", subscriptionId: "manual:test_mode" },
621
+ updatedAt: Date.now()
622
+ },
623
+ env: "sandbox"
624
+ };
625
+ }
626
+ if (path.startsWith("/server/audit/")) {
627
+ return {
628
+ object: "audit_entry",
629
+ data: {
630
+ eventId: "audit_test_mode",
631
+ rail: "manual",
632
+ env: "sandbox",
633
+ eventType: "test_mode",
634
+ projectId: "proj_test_mode",
635
+ decision: "applied",
636
+ signatureVerified: true,
637
+ reconciledWithProvider: false,
638
+ rawEventReceivedAt: Date.now(),
639
+ processedAt: Date.now()
640
+ }
641
+ };
642
+ }
643
+ return {};
644
+ }
645
+ function safeStringify2(v) {
646
+ try {
647
+ return JSON.stringify(v) ?? "";
648
+ } catch {
649
+ return "[unserialisable]";
650
+ }
651
+ }
306
652
  function serializeRequestBody(body) {
307
653
  try {
308
654
  const direct = JSON.stringify(body);
@@ -329,164 +675,2337 @@ function serializeRequestBody(body) {
329
675
  });
330
676
  }
331
677
 
332
- // src/crossdeck-server.ts
333
- var CrossdeckServer = class {
334
- http;
335
- sdkVersion;
336
- appId;
337
- constructor(options) {
338
- if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
339
- throw new CrossdeckError({
340
- type: "configuration_error",
341
- code: "invalid_secret_key",
342
- message: "CrossdeckServer requires a secret key starting with cd_sk_."
343
- });
344
- }
345
- this.sdkVersion = options.sdkVersion ?? SDK_VERSION;
346
- this.appId = options.appId;
347
- this.http = new HttpClient({
348
- secretKey: options.secretKey,
349
- baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
350
- sdkVersion: this.sdkVersion,
351
- timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS
352
- });
678
+ // src/retry-policy.ts
679
+ var DEFAULT_BASE = 1e3;
680
+ var DEFAULT_MAX = 6e4;
681
+ var DEFAULT_FACTOR = 2;
682
+ var DEFAULT_WARN = 8;
683
+ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
684
+ const base = options.baseMs ?? DEFAULT_BASE;
685
+ const max = options.maxMs ?? DEFAULT_MAX;
686
+ const factor = options.factor ?? DEFAULT_FACTOR;
687
+ const safeAttempts = Math.min(attempts, 30);
688
+ const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
689
+ const jittered = ceiling * random();
690
+ if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
691
+ return Math.min(max, retryAfterMs);
353
692
  }
354
- async identify(userId, anonymousId, options) {
355
- return this.aliasIdentity({ userId, anonymousId, ...options });
693
+ return Math.max(0, Math.round(jittered));
694
+ }
695
+ var RetryPolicy = class {
696
+ constructor(options = {}) {
697
+ this.options = options;
356
698
  }
357
- async aliasIdentity(input) {
358
- if (!input.userId) {
359
- throw new CrossdeckError({
360
- type: "invalid_request_error",
361
- code: "missing_user_id",
362
- message: "aliasIdentity requires a non-empty userId."
363
- });
699
+ options;
700
+ attempts = 0;
701
+ /** How many consecutive failures since the last success. */
702
+ get consecutiveFailures() {
703
+ return this.attempts;
704
+ }
705
+ /** Whether we've crossed the failuresBeforeWarn threshold. */
706
+ get isWarning() {
707
+ return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
708
+ }
709
+ /** Schedule-time delay for the NEXT retry. Increments the counter. */
710
+ nextDelay(retryAfterMs, random = Math.random) {
711
+ const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
712
+ this.attempts += 1;
713
+ return delay;
714
+ }
715
+ /** Mark a successful flush — reset the counter. */
716
+ recordSuccess() {
717
+ this.attempts = 0;
718
+ }
719
+ };
720
+
721
+ // src/_rand.ts
722
+ var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
723
+ function randomChars(count) {
724
+ const out = [];
725
+ const cryptoApi = globalThis.crypto;
726
+ if (cryptoApi?.getRandomValues) {
727
+ const buf = new Uint8Array(count);
728
+ cryptoApi.getRandomValues(buf);
729
+ for (let i = 0; i < count; i++) {
730
+ out.push(ALPHABET[buf[i] % ALPHABET.length] ?? "0");
364
731
  }
365
- if (!input.anonymousId) {
366
- throw new CrossdeckError({
367
- type: "invalid_request_error",
368
- code: "missing_anonymous_id",
369
- message: "aliasIdentity requires a non-empty anonymousId."
370
- });
732
+ } else {
733
+ for (let i = 0; i < count; i++) {
734
+ out.push(ALPHABET[Math.floor(Math.random() * ALPHABET.length)] ?? "0");
371
735
  }
372
- const traits = sanitizePropertyBag(input.traits, "traits");
373
- const body = {
374
- userId: input.userId,
375
- anonymousId: input.anonymousId
376
- };
377
- if (input.email) body.email = input.email;
378
- if (traits && Object.keys(traits).length > 0) body.traits = traits;
379
- return this.http.request("POST", "/identity/alias", { body });
380
736
  }
381
- async forget(hints) {
382
- const body = this.identityPayload(hints);
383
- return this.http.request("POST", "/identity/forget", { body });
737
+ return out.join("");
738
+ }
739
+ function mintId(prefix, randLen = 10) {
740
+ return `${prefix}_${Date.now().toString(36)}${randomChars(randLen)}`;
741
+ }
742
+
743
+ // src/event-queue.ts
744
+ var HARD_BUFFER_CAP = 1e3;
745
+ var EventQueue = class {
746
+ constructor(cfg) {
747
+ this.cfg = cfg;
748
+ this.retry = new RetryPolicy(cfg.retry ?? {});
384
749
  }
385
- async getEntitlements(hints) {
386
- return this.http.request("GET", "/entitlements", {
387
- query: this.identityPayload(hints)
388
- });
750
+ cfg;
751
+ buffer = [];
752
+ dropped = 0;
753
+ inFlight = 0;
754
+ lastFlushAt = 0;
755
+ lastError = null;
756
+ cancelTimer = null;
757
+ firstFlushFired = false;
758
+ nextRetryAt = null;
759
+ retry;
760
+ /**
761
+ * Stable Idempotency-Key for the current in-flight batch. Minted
762
+ * lazily inside `flush()` when no key is pending. Reused across
763
+ * retries of the same logical batch so the backend's idempotency
764
+ * layer can short-circuit duplicates (Stripe pattern). Reset to
765
+ * `null` after a successful flush.
766
+ */
767
+ pendingBatchId = null;
768
+ /**
769
+ * In-flight events that have been spliced from the buffer for the
770
+ * current batch but haven't yet been confirmed (success or final
771
+ * failure). On a retry-driven flush, we re-use this batch alongside
772
+ * `pendingBatchId` instead of re-splicing. New events that arrive
773
+ * during in-flight are buffered separately and join the next batch
774
+ * AFTER this one settles.
775
+ */
776
+ pendingBatch = null;
777
+ enqueue(event) {
778
+ this.buffer.push(event);
779
+ if (this.buffer.length > HARD_BUFFER_CAP) {
780
+ const overflow = this.buffer.length - HARD_BUFFER_CAP;
781
+ this.buffer.splice(0, overflow);
782
+ this.dropped += overflow;
783
+ this.cfg.onDrop?.(overflow);
784
+ }
785
+ this.cfg.onBufferChange?.(this.buffer.length);
786
+ if (this.buffer.length >= this.cfg.batchSize) {
787
+ void this.flush();
788
+ } else {
789
+ this.scheduleIdleFlush();
790
+ }
389
791
  }
390
- async getCustomerEntitlements(customerId) {
391
- if (!customerId) {
392
- throw new CrossdeckError({
393
- type: "invalid_request_error",
394
- code: "missing_customer_id",
395
- message: "getCustomerEntitlements requires a customerId."
792
+ /**
793
+ * Flush the buffer to /v1/events. Resolves when the network call
794
+ * completes (success or failure). On failure, events stay in the
795
+ * `pendingBatch` slot for the next scheduled retry — the SAME batch
796
+ * with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
797
+ *
798
+ * The `pendingBatch` slot guarantees retry semantics:
799
+ * - First call: splices buffer → pendingBatch + mints batchId.
800
+ * - On 5xx / network failure: pendingBatch stays; scheduler fires
801
+ * `flush()` again later, which re-uses pendingBatch + the same
802
+ * batchId.
803
+ * - On success: pendingBatch + batchId cleared; subsequent calls
804
+ * splice the buffer again with a fresh batchId.
805
+ *
806
+ * New events that arrive during an in-flight batch land in `buffer`
807
+ * (separate from `pendingBatch`) and ship on the next batch after
808
+ * this one settles. Strict ordering preserved.
809
+ */
810
+ async flush() {
811
+ let batch;
812
+ let batchId;
813
+ if (this.pendingBatch !== null && this.pendingBatchId !== null) {
814
+ batch = this.pendingBatch;
815
+ batchId = this.pendingBatchId;
816
+ } else {
817
+ if (this.buffer.length === 0) return null;
818
+ batch = this.buffer.splice(0);
819
+ batchId = mintId("batch");
820
+ this.pendingBatch = batch;
821
+ this.pendingBatchId = batchId;
822
+ this.inFlight += batch.length;
823
+ this.cfg.onBufferChange?.(this.buffer.length);
824
+ }
825
+ this.cancelTimerIfSet();
826
+ this.nextRetryAt = null;
827
+ try {
828
+ const env = this.cfg.envelope();
829
+ const body = {
830
+ events: batch,
831
+ sdk: env.sdk
832
+ };
833
+ if (env.appId) body.appId = env.appId;
834
+ const result = await this.cfg.http.request("POST", "/events", {
835
+ body,
836
+ idempotencyKey: batchId
396
837
  });
838
+ this.lastFlushAt = Date.now();
839
+ this.lastError = null;
840
+ this.inFlight -= batch.length;
841
+ this.pendingBatch = null;
842
+ this.pendingBatchId = null;
843
+ this.retry.recordSuccess();
844
+ if (!this.firstFlushFired) {
845
+ this.firstFlushFired = true;
846
+ this.cfg.onFirstFlushSuccess?.();
847
+ }
848
+ return result;
849
+ } catch (err) {
850
+ const message = err instanceof Error ? err.message : String(err);
851
+ this.lastError = message;
852
+ const retryAfterMs = extractRetryAfterMs(err);
853
+ const delay = this.retry.nextDelay(retryAfterMs);
854
+ this.scheduleRetry(delay);
855
+ this.cfg.onRetryScheduled?.({
856
+ delayMs: delay,
857
+ consecutiveFailures: this.retry.consecutiveFailures,
858
+ retryAfterMs,
859
+ lastError: message
860
+ });
861
+ return null;
397
862
  }
398
- return this.http.request(
399
- "GET",
400
- `/server/customers/${encodeURIComponent(customerId)}/entitlements`
401
- );
402
863
  }
403
- async track(event, options = {}) {
404
- return this.ingest([event], options);
864
+ /** Cancel any pending timer and clear in-memory state. */
865
+ reset() {
866
+ this.cancelTimerIfSet();
867
+ this.nextRetryAt = null;
868
+ this.buffer = [];
869
+ this.pendingBatch = null;
870
+ this.pendingBatchId = null;
871
+ this.dropped = 0;
872
+ this.inFlight = 0;
873
+ this.lastError = null;
874
+ this.retry.recordSuccess();
875
+ this.cfg.onBufferChange?.(0);
405
876
  }
406
- async ingest(events, options = {}) {
407
- if (!Array.isArray(events) || events.length === 0) {
408
- throw new CrossdeckError({
409
- type: "invalid_request_error",
410
- code: "missing_events",
411
- message: "ingest requires at least one event."
412
- });
413
- }
414
- const normalized = events.map((event) => this.normalizeEvent(event));
415
- const body = {
416
- events: normalized,
417
- sdk: { name: SDK_NAME, version: this.sdkVersion }
877
+ getStats() {
878
+ return {
879
+ // `buffered` counts events waiting for their FIRST flush. The
880
+ // in-flight pendingBatch (retrying) is tracked separately via
881
+ // `inFlight` — surfacing both lets diagnostics show "we have
882
+ // events stuck retrying" distinct from "new events arriving".
883
+ buffered: this.buffer.length,
884
+ dropped: this.dropped,
885
+ inFlight: this.inFlight,
886
+ lastFlushAt: this.lastFlushAt,
887
+ lastError: this.lastError,
888
+ consecutiveFailures: this.retry.consecutiveFailures,
889
+ nextRetryAt: this.nextRetryAt
418
890
  };
419
- if (this.appId) body.appId = this.appId;
420
- return this.http.request("POST", "/events", {
421
- body,
422
- idempotencyKey: options.idempotencyKey ?? this.mintBatchId()
423
- });
424
891
  }
425
- async syncPurchases(input) {
426
- if (!input.signedTransactionInfo) {
427
- throw new CrossdeckError({
428
- type: "invalid_request_error",
429
- code: "missing_signed_transaction_info",
430
- message: "syncPurchases requires a signedTransactionInfo string."
431
- });
432
- }
433
- return this.http.request("POST", "/purchases/sync", {
434
- body: { rail: input.rail ?? "apple", ...input }
435
- });
892
+ /**
893
+ * The Idempotency-Key of the in-flight pending batch (if any).
894
+ * Exposed for testing the Stripe-style reuse contract. Production
895
+ * callers don't need this.
896
+ */
897
+ get pendingIdempotencyKey() {
898
+ return this.pendingBatchId;
436
899
  }
437
- async grantEntitlement(input) {
438
- if (!input.customerId) {
439
- throw new CrossdeckError({
440
- type: "invalid_request_error",
441
- code: "missing_customer_id",
442
- message: "grantEntitlement requires a customerId."
443
- });
900
+ // ---------- internal scheduling ----------
901
+ scheduleIdleFlush() {
902
+ this.cancelTimerIfSet();
903
+ const sched = this.cfg.scheduler ?? defaultScheduler;
904
+ this.cancelTimer = sched(() => {
905
+ void this.flush();
906
+ }, this.cfg.intervalMs);
907
+ }
908
+ scheduleRetry(delayMs) {
909
+ this.cancelTimerIfSet();
910
+ this.nextRetryAt = Date.now() + delayMs;
911
+ const sched = this.cfg.scheduler ?? defaultScheduler;
912
+ this.cancelTimer = sched(() => {
913
+ void this.flush();
914
+ }, delayMs);
915
+ }
916
+ cancelTimerIfSet() {
917
+ if (this.cancelTimer) {
918
+ this.cancelTimer();
919
+ this.cancelTimer = null;
444
920
  }
445
- return this.http.request(
446
- "POST",
447
- `/server/customers/${encodeURIComponent(input.customerId)}/grant`,
448
- {
449
- body: {
450
- entitlementKey: input.entitlementKey,
451
- duration: input.duration,
452
- reason: input.reason
453
- }
454
- }
455
- );
456
921
  }
457
- async revokeEntitlement(input) {
458
- if (!input.customerId) {
459
- throw new CrossdeckError({
460
- type: "invalid_request_error",
461
- code: "missing_customer_id",
462
- message: "revokeEntitlement requires a customerId."
463
- });
922
+ };
923
+ function extractRetryAfterMs(err) {
924
+ if (err && typeof err === "object" && "retryAfterMs" in err) {
925
+ const v = err.retryAfterMs;
926
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
927
+ }
928
+ return void 0;
929
+ }
930
+ function defaultScheduler(fn, ms) {
931
+ const id = setTimeout(fn, ms);
932
+ if (typeof id.unref === "function") {
933
+ try {
934
+ id.unref();
935
+ } catch {
464
936
  }
465
- return this.http.request(
466
- "POST",
467
- `/server/customers/${encodeURIComponent(input.customerId)}/revoke`,
468
- {
469
- body: {
470
- entitlementKey: input.entitlementKey,
471
- reason: input.reason
472
- }
473
- }
474
- );
475
937
  }
476
- async getAuditEntry(eventId) {
477
- if (!eventId) {
478
- throw new CrossdeckError({
479
- type: "invalid_request_error",
480
- code: "missing_event_id",
481
- message: "getAuditEntry requires an eventId."
482
- });
938
+ return () => clearTimeout(id);
939
+ }
940
+
941
+ // src/breadcrumbs.ts
942
+ var BreadcrumbBuffer = class {
943
+ constructor(maxSize = 50) {
944
+ this.maxSize = maxSize;
945
+ }
946
+ maxSize;
947
+ items = [];
948
+ add(crumb) {
949
+ this.items.push(crumb);
950
+ if (this.items.length > this.maxSize) {
951
+ this.items.shift();
952
+ }
953
+ }
954
+ /** Defensive copy — caller can read freely without mutating buffer state. */
955
+ snapshot() {
956
+ return this.items.slice();
957
+ }
958
+ clear() {
959
+ this.items = [];
960
+ }
961
+ get size() {
962
+ return this.items.length;
963
+ }
964
+ };
965
+
966
+ // src/stack-parser.ts
967
+ function parseStack(stack) {
968
+ if (!stack || typeof stack !== "string") return [];
969
+ const lines = stack.split("\n");
970
+ const frames = [];
971
+ for (const line of lines) {
972
+ const trimmed = line.trim();
973
+ if (!trimmed) continue;
974
+ const frame = parseLine(trimmed);
975
+ if (frame) frames.push(frame);
976
+ }
977
+ return frames;
978
+ }
979
+ function parseLine(line) {
980
+ let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
981
+ if (m) {
982
+ return buildFrame({
983
+ function: m[1],
984
+ filename: m[2],
985
+ lineno: parseInt(m[3], 10),
986
+ colno: parseInt(m[4], 10),
987
+ raw: line
988
+ });
989
+ }
990
+ m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
991
+ if (m) {
992
+ return buildFrame({
993
+ function: "?",
994
+ filename: m[1],
995
+ lineno: parseInt(m[2], 10),
996
+ colno: parseInt(m[3], 10),
997
+ raw: line
998
+ });
999
+ }
1000
+ m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
1001
+ if (m) {
1002
+ return buildFrame({
1003
+ function: m[1] || "?",
1004
+ filename: m[2],
1005
+ lineno: parseInt(m[3], 10),
1006
+ colno: parseInt(m[4], 10),
1007
+ raw: line
1008
+ });
1009
+ }
1010
+ if (/^\w*Error/.test(line) || !line.includes(":")) {
1011
+ return null;
1012
+ }
1013
+ return {
1014
+ function: "?",
1015
+ filename: "",
1016
+ lineno: 0,
1017
+ colno: 0,
1018
+ in_app: true,
1019
+ raw: line
1020
+ };
1021
+ }
1022
+ function buildFrame(input) {
1023
+ return {
1024
+ function: input.function || "?",
1025
+ filename: input.filename,
1026
+ lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
1027
+ colno: Number.isFinite(input.colno) ? input.colno : 0,
1028
+ in_app: isInAppFrame(input.filename),
1029
+ raw: input.raw
1030
+ };
1031
+ }
1032
+ function isInAppFrame(filename) {
1033
+ if (!filename) return true;
1034
+ if (/@cross-deck[\\/]node/.test(filename)) return false;
1035
+ if (/[\\/]node_modules[\\/]/.test(filename)) return false;
1036
+ if (/^node:/.test(filename)) return false;
1037
+ if (/^internal[\\/]/.test(filename)) return false;
1038
+ return true;
1039
+ }
1040
+ function fingerprintError(message, frames) {
1041
+ const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
1042
+ const key = [
1043
+ (message || "").slice(0, 200),
1044
+ ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
1045
+ ].join("|");
1046
+ return djb2Hex(key);
1047
+ }
1048
+ function djb2Hex(input) {
1049
+ let h = 5381;
1050
+ for (let i = 0; i < input.length; i++) {
1051
+ h = (h << 5) + h + input.charCodeAt(i) | 0;
1052
+ }
1053
+ return (h >>> 0).toString(16).padStart(8, "0");
1054
+ }
1055
+
1056
+ // src/error-capture.ts
1057
+ var DEFAULT_ERROR_CAPTURE = {
1058
+ enabled: true,
1059
+ onUncaughtException: true,
1060
+ onUnhandledRejection: true,
1061
+ wrapFetch: true,
1062
+ captureConsole: false,
1063
+ ignoreErrors: [],
1064
+ allowPaths: [],
1065
+ denyPaths: [
1066
+ // SDK self-skip — caught by stack-parser's `isInAppFrame` too,
1067
+ // but defensive here in case a future change to the heuristic
1068
+ // misses one of these paths.
1069
+ /[\\/]node_modules[\\/]@cross-deck[\\/]node[\\/]/
1070
+ ],
1071
+ sampleRate: 1,
1072
+ maxPerFingerprintPerMinute: 5,
1073
+ maxPerSession: 100
1074
+ };
1075
+ var MAX_FINGERPRINTS_TRACKED = 4096;
1076
+ var FINGERPRINT_WINDOW_MS = 6e4;
1077
+ var ErrorTracker = class {
1078
+ constructor(opts) {
1079
+ this.opts = opts;
1080
+ }
1081
+ opts;
1082
+ installed = false;
1083
+ cleanups = [];
1084
+ _reporting = false;
1085
+ sessionCount = 0;
1086
+ fingerprintWindow = /* @__PURE__ */ new Map();
1087
+ install() {
1088
+ if (this.installed) return;
1089
+ if (!this.opts.config.enabled) return;
1090
+ if (this.opts.config.onUncaughtException) this.installUncaughtExceptionHandler();
1091
+ if (this.opts.config.onUnhandledRejection) this.installUnhandledRejectionHandler();
1092
+ if (this.opts.config.wrapFetch) this.installFetchWrap();
1093
+ if (this.opts.config.captureConsole) this.installConsoleWrap();
1094
+ this.installed = true;
1095
+ }
1096
+ uninstall() {
1097
+ for (const fn of this.cleanups.splice(0)) {
1098
+ try {
1099
+ fn();
1100
+ } catch {
1101
+ }
1102
+ }
1103
+ this.installed = false;
1104
+ }
1105
+ /**
1106
+ * Manual API. Either an Error instance or any unknown value (we
1107
+ * coerce). Returns silently — never throws, even if the SDK isn't
1108
+ * initialised.
1109
+ */
1110
+ captureError(error, options) {
1111
+ if (!this.opts.isConsented()) return;
1112
+ try {
1113
+ const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
1114
+ if (options?.context) captured.context = { ...captured.context, ...options.context };
1115
+ if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
1116
+ this.maybeReport(captured);
1117
+ } catch {
1118
+ }
1119
+ }
1120
+ /**
1121
+ * Capture a non-error event as an issue. For "we hit a soft-warning
1122
+ * code path" / "deprecated API used" kinds of signals. Pairs with
1123
+ * Sentry's captureMessage().
1124
+ */
1125
+ captureMessage(message, level = "info") {
1126
+ if (!this.opts.isConsented()) return;
1127
+ try {
1128
+ const captured = {
1129
+ timestamp: Date.now(),
1130
+ kind: "error.message",
1131
+ level,
1132
+ message,
1133
+ errorType: null,
1134
+ frames: [],
1135
+ rawStack: null,
1136
+ fingerprint: fingerprintError(message, []),
1137
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
1138
+ context: this.opts.getContext(),
1139
+ tags: this.opts.getTags()
1140
+ };
1141
+ this.maybeReport(captured);
1142
+ } catch {
1143
+ }
1144
+ }
1145
+ /** Inspection hook — total reports captured this process lifetime. */
1146
+ get reportedCount() {
1147
+ return this.sessionCount;
1148
+ }
1149
+ /** Inspection hook — number of distinct fingerprints inside the rate-limit window. */
1150
+ get fingerprintsTracked() {
1151
+ return this.fingerprintWindow.size;
1152
+ }
1153
+ /** Inspection hook — whether global handlers are installed. */
1154
+ get handlersInstalled() {
1155
+ return this.installed;
1156
+ }
1157
+ // ============================================================
1158
+ // Listener installation — Node hooks
1159
+ // ============================================================
1160
+ installUncaughtExceptionHandler() {
1161
+ const handler = (err) => {
1162
+ if (this._reporting) return;
1163
+ if (!this.opts.isConsented()) return;
1164
+ try {
1165
+ this._reporting = true;
1166
+ const captured = this.buildFromUnknown(err, "error.unhandled", "error");
1167
+ this.maybeReport(captured);
1168
+ } catch {
1169
+ } finally {
1170
+ this._reporting = false;
1171
+ }
1172
+ };
1173
+ process.on("uncaughtException", handler);
1174
+ this.cleanups.push(() => process.off("uncaughtException", handler));
1175
+ }
1176
+ installUnhandledRejectionHandler() {
1177
+ const handler = (reason) => {
1178
+ if (this._reporting) return;
1179
+ if (!this.opts.isConsented()) return;
1180
+ try {
1181
+ this._reporting = true;
1182
+ const captured = this.buildFromUnknown(reason, "error.unhandledrejection", "error");
1183
+ this.maybeReport(captured);
1184
+ } catch {
1185
+ } finally {
1186
+ this._reporting = false;
1187
+ }
1188
+ };
1189
+ process.on("unhandledRejection", handler);
1190
+ this.cleanups.push(() => process.off("unhandledRejection", handler));
1191
+ }
1192
+ /**
1193
+ * Wrap `globalThis.fetch` so failed HTTP requests get auto-captured.
1194
+ * We do NOT call 4xx an "error" (those are often expected — auth
1195
+ * required, validation failed). Only 5xx + network failures fire.
1196
+ *
1197
+ * Node 18+ exposes `fetch` natively on `globalThis`. We tolerate
1198
+ * its absence (some sandboxed runtimes / patched globals) by
1199
+ * skipping the wrap rather than throwing.
1200
+ */
1201
+ installFetchWrap() {
1202
+ const origFetch = globalThis.fetch;
1203
+ if (typeof origFetch !== "function") return;
1204
+ const tracker = this;
1205
+ const wrapped = async (...args) => {
1206
+ const input = args[0];
1207
+ const init = args[1] ?? {};
1208
+ const url = typeof input === "string" ? input : input?.url ?? "";
1209
+ const method = (init.method || "GET").toUpperCase();
1210
+ const start = Date.now();
1211
+ tracker.opts.breadcrumbs.add({
1212
+ timestamp: start,
1213
+ category: "http",
1214
+ message: `${method} ${url}`,
1215
+ data: { url, method }
1216
+ });
1217
+ try {
1218
+ const response = await origFetch(...args);
1219
+ if (response.status >= 500 && tracker.opts.isConsented()) {
1220
+ if (!url.includes("api.cross-deck.com")) {
1221
+ tracker.captureHttp({
1222
+ url,
1223
+ method,
1224
+ status: response.status,
1225
+ statusText: response.statusText
1226
+ });
1227
+ }
1228
+ }
1229
+ return response;
1230
+ } catch (err) {
1231
+ if (tracker.opts.isConsented() && !url.includes("api.cross-deck.com")) {
1232
+ tracker.captureHttp({
1233
+ url,
1234
+ method,
1235
+ status: 0,
1236
+ statusText: err instanceof Error ? err.message : "network error"
1237
+ });
1238
+ }
1239
+ throw err;
1240
+ }
1241
+ };
1242
+ globalThis.fetch = wrapped;
1243
+ this.cleanups.push(() => {
1244
+ if (globalThis.fetch === wrapped) globalThis.fetch = origFetch;
1245
+ });
1246
+ }
1247
+ installConsoleWrap() {
1248
+ const orig = console.error.bind(console);
1249
+ const tracker = this;
1250
+ console.error = (...args) => {
1251
+ try {
1252
+ if (tracker.opts.isConsented()) {
1253
+ tracker.captureMessage(args.map((a) => safeStringify3(a)).join(" "), "error");
1254
+ }
1255
+ } catch {
1256
+ }
1257
+ return orig(...args);
1258
+ };
1259
+ this.cleanups.push(() => {
1260
+ console.error = orig;
1261
+ });
1262
+ }
1263
+ // ============================================================
1264
+ // Builders
1265
+ // ============================================================
1266
+ /**
1267
+ * Build a `CapturedError` from any value. Handles:
1268
+ * - Error instances (the common case) — parses `err.stack` into
1269
+ * frames, fingerprints over message + top in-app frames.
1270
+ * - Non-Error rejections (promise rejected with a string / number
1271
+ * / plain object) — coerces via `safeStringify`, no frames.
1272
+ *
1273
+ * Verbatim port of web's `buildFromUnknown` — the logic is
1274
+ * runtime-agnostic.
1275
+ */
1276
+ buildFromUnknown(err, kind, level) {
1277
+ if (err instanceof Error) {
1278
+ const frames = parseStack(err.stack);
1279
+ return {
1280
+ timestamp: Date.now(),
1281
+ kind,
1282
+ level,
1283
+ message: String(err.message).slice(0, 1024),
1284
+ errorType: err.name,
1285
+ frames,
1286
+ rawStack: err.stack ?? null,
1287
+ fingerprint: fingerprintError(err.message, frames),
1288
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
1289
+ context: this.opts.getContext(),
1290
+ tags: this.opts.getTags()
1291
+ };
1292
+ }
1293
+ const message = safeStringify3(err).slice(0, 1024);
1294
+ return {
1295
+ timestamp: Date.now(),
1296
+ kind,
1297
+ level,
1298
+ message,
1299
+ errorType: null,
1300
+ frames: [],
1301
+ rawStack: null,
1302
+ fingerprint: fingerprintError(message, []),
1303
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
1304
+ context: this.opts.getContext(),
1305
+ tags: this.opts.getTags()
1306
+ };
1307
+ }
1308
+ captureHttp(info) {
1309
+ try {
1310
+ const message = `HTTP ${info.status} ${info.method} ${info.url}`;
1311
+ const captured = {
1312
+ timestamp: Date.now(),
1313
+ kind: "error.http",
1314
+ level: "error",
1315
+ message,
1316
+ errorType: "HTTPError",
1317
+ frames: [],
1318
+ rawStack: null,
1319
+ fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
1320
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
1321
+ context: this.opts.getContext(),
1322
+ tags: this.opts.getTags(),
1323
+ http: info
1324
+ };
1325
+ this.maybeReport(captured);
1326
+ } catch {
1327
+ }
1328
+ }
1329
+ // ============================================================
1330
+ // Reporting pipeline — filter / sample / rate-limit / send
1331
+ // ============================================================
1332
+ maybeReport(err) {
1333
+ if (this.sessionCount >= this.opts.config.maxPerSession) return;
1334
+ if (this.shouldIgnore(err)) return;
1335
+ if (!this.passesPathGate(err)) return;
1336
+ if (!this.passesSample(err)) return;
1337
+ if (!this.passesRateLimit(err)) return;
1338
+ let finalErr = err;
1339
+ if (this.opts.beforeSend) {
1340
+ try {
1341
+ finalErr = this.opts.beforeSend(err);
1342
+ } catch {
1343
+ finalErr = err;
1344
+ }
1345
+ if (!finalErr) return;
1346
+ }
1347
+ this.sessionCount += 1;
1348
+ try {
1349
+ this.opts.report(finalErr);
1350
+ } catch {
1351
+ }
1352
+ }
1353
+ shouldIgnore(err) {
1354
+ for (const pat of this.opts.config.ignoreErrors) {
1355
+ if (typeof pat === "string" && err.message.includes(pat)) return true;
1356
+ if (pat instanceof RegExp && pat.test(err.message)) return true;
1357
+ }
1358
+ return false;
1359
+ }
1360
+ passesPathGate(err) {
1361
+ const topFrame = err.frames.find((f) => f.filename) ?? null;
1362
+ const path = topFrame?.filename ?? "";
1363
+ if (!path) return true;
1364
+ for (const pat of this.opts.config.denyPaths) {
1365
+ if (typeof pat === "string" && path.includes(pat)) return false;
1366
+ if (pat instanceof RegExp && pat.test(path)) return false;
1367
+ }
1368
+ if (this.opts.config.allowPaths.length > 0) {
1369
+ for (const pat of this.opts.config.allowPaths) {
1370
+ if (typeof pat === "string" && path.includes(pat)) return true;
1371
+ if (pat instanceof RegExp && pat.test(path)) return true;
1372
+ }
1373
+ return false;
1374
+ }
1375
+ return true;
1376
+ }
1377
+ passesSample(err) {
1378
+ if (this.opts.config.sampleRate >= 1) return true;
1379
+ if (this.opts.config.sampleRate <= 0) return false;
1380
+ const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
1381
+ return hashByte / 255 < this.opts.config.sampleRate;
1382
+ }
1383
+ passesRateLimit(err) {
1384
+ const now = Date.now();
1385
+ const max = this.opts.config.maxPerFingerprintPerMinute;
1386
+ const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
1387
+ const fresh = arr.filter((t) => now - t < FINGERPRINT_WINDOW_MS);
1388
+ if (fresh.length >= max) {
1389
+ this.fingerprintWindow.set(err.fingerprint, fresh);
1390
+ return false;
1391
+ }
1392
+ fresh.push(now);
1393
+ this.fingerprintWindow.set(err.fingerprint, fresh);
1394
+ this.maybePruneFingerprintWindow(now);
1395
+ return true;
1396
+ }
1397
+ /**
1398
+ * Bound the fingerprint Map's memory footprint. Runs opportunistically
1399
+ * — only when the Map exceeds `MAX_FINGERPRINTS_TRACKED`. First pass:
1400
+ * delete entries whose ENTIRE window is stale (no live timestamps
1401
+ * inside the 60s window). Second pass (if still over): FIFO-evict
1402
+ * the oldest entries by Map insertion order until we're under the
1403
+ * cap. Defends against a long-running process with high-cardinality
1404
+ * fingerprints leaking memory forever.
1405
+ */
1406
+ maybePruneFingerprintWindow(now) {
1407
+ if (this.fingerprintWindow.size <= MAX_FINGERPRINTS_TRACKED) return;
1408
+ for (const [fp, timestamps] of this.fingerprintWindow) {
1409
+ const hasLive = timestamps.some((t) => now - t < FINGERPRINT_WINDOW_MS);
1410
+ if (!hasLive) this.fingerprintWindow.delete(fp);
1411
+ }
1412
+ if (this.fingerprintWindow.size <= MAX_FINGERPRINTS_TRACKED) return;
1413
+ const overflow = this.fingerprintWindow.size - MAX_FINGERPRINTS_TRACKED;
1414
+ let dropped = 0;
1415
+ for (const fp of this.fingerprintWindow.keys()) {
1416
+ if (dropped >= overflow) break;
1417
+ this.fingerprintWindow.delete(fp);
1418
+ dropped += 1;
1419
+ }
1420
+ }
1421
+ };
1422
+ function safeStringify3(v) {
1423
+ if (v == null) return String(v);
1424
+ if (typeof v === "string") return v;
1425
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
1426
+ try {
1427
+ return JSON.stringify(v);
1428
+ } catch {
1429
+ return Object.prototype.toString.call(v);
1430
+ }
1431
+ }
1432
+
1433
+ // src/runtime-info.ts
1434
+ import { hostname as osHostname, platform as osPlatform, release as osRelease } from "os";
1435
+ var cached = null;
1436
+ function collectRuntimeInfo(options = {}) {
1437
+ if (cached) return cached;
1438
+ cached = detect(options);
1439
+ return cached;
1440
+ }
1441
+ function detect(options) {
1442
+ const env = typeof process !== "undefined" && process.env ? process.env : {};
1443
+ const detected = detectHost(env);
1444
+ return Object.freeze({
1445
+ nodeVersion: typeof process !== "undefined" && process.versions ? process.versions.node : "unknown",
1446
+ platform: safePlatform(),
1447
+ platformRelease: safeRelease(),
1448
+ hostname: safeHostname(),
1449
+ host: detected.host,
1450
+ region: detected.region,
1451
+ serviceName: options.serviceName ?? detected.serviceName,
1452
+ serviceVersion: options.serviceVersion ?? detected.serviceVersion,
1453
+ instanceId: detected.instanceId,
1454
+ appVersion: options.appVersion ?? null
1455
+ });
1456
+ }
1457
+ function detectHost(env) {
1458
+ const pid = safePid();
1459
+ if (env.AWS_LAMBDA_FUNCTION_NAME) {
1460
+ return {
1461
+ host: "aws-lambda",
1462
+ region: env.AWS_REGION ?? null,
1463
+ serviceName: env.AWS_LAMBDA_FUNCTION_NAME,
1464
+ serviceVersion: env.AWS_LAMBDA_FUNCTION_VERSION ?? null,
1465
+ instanceId: env.AWS_LAMBDA_LOG_STREAM_NAME ?? pid
1466
+ };
1467
+ }
1468
+ if (env.FUNCTIONS_WORKER_RUNTIME && env.WEBSITE_INSTANCE_ID) {
1469
+ return {
1470
+ host: "azure-functions",
1471
+ region: env.REGION_NAME ?? env.WEBSITE_LOCATION ?? null,
1472
+ serviceName: env.WEBSITE_SITE_NAME ?? null,
1473
+ serviceVersion: env.WEBSITE_BUILD_ID ?? null,
1474
+ instanceId: env.WEBSITE_INSTANCE_ID
1475
+ };
1476
+ }
1477
+ if (env.GAE_APPLICATION) {
1478
+ return {
1479
+ host: "google-app-engine",
1480
+ region: env.GAE_REGION ?? env.GOOGLE_CLOUD_REGION ?? null,
1481
+ serviceName: env.GAE_SERVICE ?? null,
1482
+ serviceVersion: env.GAE_VERSION ?? null,
1483
+ instanceId: env.GAE_INSTANCE ?? pid
1484
+ };
1485
+ }
1486
+ if (env.K_SERVICE && env.K_REVISION) {
1487
+ const isFirebase = Boolean(env.FIREBASE_CONFIG || env.GCLOUD_PROJECT);
1488
+ return {
1489
+ host: isFirebase ? "firebase-functions-v2" : "cloud-run",
1490
+ region: env.FUNCTION_REGION ?? env.GOOGLE_CLOUD_REGION ?? null,
1491
+ serviceName: env.K_SERVICE,
1492
+ serviceVersion: env.K_REVISION,
1493
+ instanceId: `${env.K_REVISION}:${pid}`
1494
+ };
1495
+ }
1496
+ if (env.FUNCTION_NAME && env.FUNCTION_REGION) {
1497
+ return {
1498
+ host: "firebase-functions-v1",
1499
+ region: env.FUNCTION_REGION,
1500
+ serviceName: env.FUNCTION_NAME,
1501
+ serviceVersion: env.X_GOOGLE_FUNCTION_VERSION ?? null,
1502
+ instanceId: pid
1503
+ };
1504
+ }
1505
+ if (env.VERCEL === "1") {
1506
+ return {
1507
+ host: "vercel",
1508
+ region: env.VERCEL_REGION ?? null,
1509
+ serviceName: env.VERCEL_URL ?? null,
1510
+ serviceVersion: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null,
1511
+ instanceId: pid
1512
+ };
1513
+ }
1514
+ if (env.NETLIFY === "true" || env.NETLIFY_BUILD_BASE) {
1515
+ return {
1516
+ host: "netlify",
1517
+ region: env.AWS_REGION ?? null,
1518
+ serviceName: env.SITE_NAME ?? env.SITE_ID ?? null,
1519
+ serviceVersion: env.COMMIT_REF?.slice(0, 7) ?? null,
1520
+ instanceId: pid
1521
+ };
1522
+ }
1523
+ if (env.DYNO) {
1524
+ return {
1525
+ host: "heroku",
1526
+ region: null,
1527
+ serviceName: env.HEROKU_APP_NAME ?? null,
1528
+ serviceVersion: env.HEROKU_RELEASE_VERSION ?? env.HEROKU_SLUG_COMMIT?.slice(0, 7) ?? null,
1529
+ instanceId: env.DYNO
1530
+ };
1531
+ }
1532
+ if (env.RENDER === "true" || env.RENDER_INSTANCE_ID) {
1533
+ return {
1534
+ host: "render",
1535
+ region: env.RENDER_SERVICE_REGION ?? null,
1536
+ serviceName: env.RENDER_SERVICE_NAME ?? null,
1537
+ serviceVersion: env.RENDER_GIT_COMMIT?.slice(0, 7) ?? null,
1538
+ instanceId: env.RENDER_INSTANCE_ID ?? pid
1539
+ };
1540
+ }
1541
+ if (env.RAILWAY_ENVIRONMENT) {
1542
+ return {
1543
+ host: "railway",
1544
+ region: env.RAILWAY_REGION ?? null,
1545
+ serviceName: env.RAILWAY_SERVICE_NAME ?? null,
1546
+ serviceVersion: env.RAILWAY_GIT_COMMIT_SHA?.slice(0, 7) ?? null,
1547
+ instanceId: env.RAILWAY_REPLICA_ID ?? pid
1548
+ };
1549
+ }
1550
+ if (env.FLY_APP_NAME) {
1551
+ return {
1552
+ host: "fly",
1553
+ region: env.FLY_REGION ?? null,
1554
+ serviceName: env.FLY_APP_NAME,
1555
+ serviceVersion: env.FLY_IMAGE_REF?.slice(-7) ?? null,
1556
+ instanceId: env.FLY_ALLOC_ID ?? pid
1557
+ };
1558
+ }
1559
+ if (env.KUBERNETES_SERVICE_HOST) {
1560
+ return {
1561
+ host: "kubernetes",
1562
+ region: null,
1563
+ serviceName: env.POD_NAME ?? env.HOSTNAME ?? null,
1564
+ serviceVersion: null,
1565
+ instanceId: env.POD_NAME ?? env.HOSTNAME ?? pid
1566
+ };
1567
+ }
1568
+ return {
1569
+ host: "node",
1570
+ region: null,
1571
+ serviceName: null,
1572
+ serviceVersion: null,
1573
+ instanceId: pid
1574
+ };
1575
+ }
1576
+ function safeHostname() {
1577
+ try {
1578
+ return osHostname();
1579
+ } catch {
1580
+ return "unknown";
1581
+ }
1582
+ }
1583
+ function safePlatform() {
1584
+ try {
1585
+ return osPlatform();
1586
+ } catch {
1587
+ return "unknown";
1588
+ }
1589
+ }
1590
+ function safeRelease() {
1591
+ try {
1592
+ return osRelease();
1593
+ } catch {
1594
+ return "unknown";
1595
+ }
1596
+ }
1597
+ function safePid() {
1598
+ try {
1599
+ return typeof process !== "undefined" && process.pid ? String(process.pid) : "0";
1600
+ } catch {
1601
+ return "0";
1602
+ }
1603
+ }
1604
+ function runtimeInfoToProperties(info) {
1605
+ const out = {
1606
+ "runtime.nodeVersion": info.nodeVersion,
1607
+ "runtime.platform": info.platform,
1608
+ "runtime.platformRelease": info.platformRelease,
1609
+ "runtime.hostname": info.hostname,
1610
+ "runtime.host": info.host
1611
+ };
1612
+ if (info.region) out["runtime.region"] = info.region;
1613
+ if (info.serviceName) out["runtime.serviceName"] = info.serviceName;
1614
+ if (info.serviceVersion) out["runtime.serviceVersion"] = info.serviceVersion;
1615
+ if (info.instanceId) out["runtime.instanceId"] = info.instanceId;
1616
+ if (info.appVersion) out.appVersion = info.appVersion;
1617
+ return out;
1618
+ }
1619
+
1620
+ // src/flush-on-exit.ts
1621
+ var SIGNALS = ["SIGTERM", "SIGINT"];
1622
+ var DEFAULT_TIMEOUT_MS2 = 2e3;
1623
+ var FlushOnExit = class {
1624
+ constructor(options) {
1625
+ this.options = options;
1626
+ }
1627
+ options;
1628
+ installed = false;
1629
+ draining = false;
1630
+ drained = false;
1631
+ beforeExitHandler = null;
1632
+ signalHandlers = {};
1633
+ /**
1634
+ * Install handlers for `beforeExit` + `SIGTERM` + `SIGINT`. Idempotent —
1635
+ * calling twice does NOT register duplicate handlers.
1636
+ */
1637
+ install() {
1638
+ if (this.installed) return;
1639
+ this.installed = true;
1640
+ this.beforeExitHandler = () => {
1641
+ void this.runDrain("beforeExit");
1642
+ };
1643
+ process.on("beforeExit", this.beforeExitHandler);
1644
+ for (const sig of SIGNALS) {
1645
+ const handler = () => {
1646
+ void this.runDrainAndExit(sig);
1647
+ };
1648
+ this.signalHandlers[sig] = handler;
1649
+ process.on(sig, handler);
1650
+ }
1651
+ }
1652
+ /**
1653
+ * Remove all handlers. Tests + custom-lifecycle callers only.
1654
+ */
1655
+ uninstall() {
1656
+ if (!this.installed) return;
1657
+ this.installed = false;
1658
+ if (this.beforeExitHandler) {
1659
+ process.off("beforeExit", this.beforeExitHandler);
1660
+ this.beforeExitHandler = null;
1661
+ }
1662
+ for (const sig of SIGNALS) {
1663
+ const handler = this.signalHandlers[sig];
1664
+ if (handler) {
1665
+ process.off(sig, handler);
1666
+ delete this.signalHandlers[sig];
1667
+ }
1668
+ }
1669
+ }
1670
+ /**
1671
+ * Force-drain immediately (without waiting for an exit signal).
1672
+ * Used by `wrapLambdaHandler` / `wrapFunction` — Lambda freezes the
1673
+ * process between invocations, so we drain at the END of each
1674
+ * invocation rather than waiting for SIGTERM.
1675
+ */
1676
+ async drainNow() {
1677
+ return this.runDrain("manual");
1678
+ }
1679
+ /** True if the drain has already completed (one-shot lifecycle). */
1680
+ get hasDrained() {
1681
+ return this.drained;
1682
+ }
1683
+ /** True if a drain is in flight. */
1684
+ get isDraining() {
1685
+ return this.draining;
1686
+ }
1687
+ // ---------- internals ----------
1688
+ async runDrain(_reason) {
1689
+ if (this.drained || this.draining) return;
1690
+ this.draining = true;
1691
+ this.options.onStart?.();
1692
+ const start = Date.now();
1693
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
1694
+ let timedOut = false;
1695
+ let drainError = null;
1696
+ await new Promise((resolve) => {
1697
+ let settled = false;
1698
+ const timer = setTimeout(() => {
1699
+ if (settled) return;
1700
+ settled = true;
1701
+ timedOut = true;
1702
+ resolve();
1703
+ }, timeoutMs);
1704
+ if (typeof timer.unref === "function") {
1705
+ try {
1706
+ timer.unref();
1707
+ } catch {
1708
+ }
1709
+ }
1710
+ let drainPromise;
1711
+ try {
1712
+ drainPromise = this.options.drain();
1713
+ } catch (syncErr) {
1714
+ if (settled) return;
1715
+ settled = true;
1716
+ drainError = syncErr;
1717
+ clearTimeout(timer);
1718
+ resolve();
1719
+ return;
1720
+ }
1721
+ drainPromise.then(
1722
+ () => {
1723
+ if (settled) return;
1724
+ settled = true;
1725
+ clearTimeout(timer);
1726
+ resolve();
1727
+ },
1728
+ (err) => {
1729
+ if (settled) return;
1730
+ settled = true;
1731
+ drainError = err;
1732
+ clearTimeout(timer);
1733
+ resolve();
1734
+ }
1735
+ );
1736
+ });
1737
+ if (drainError !== null) {
1738
+ try {
1739
+ this.options.onError?.(drainError);
1740
+ } catch {
1741
+ }
1742
+ }
1743
+ this.draining = false;
1744
+ this.drained = true;
1745
+ this.options.onComplete?.({
1746
+ durationMs: Date.now() - start,
1747
+ timedOut
1748
+ });
1749
+ }
1750
+ /**
1751
+ * Drain in response to a termination signal. After the drain
1752
+ * completes, we re-raise the signal so the process exits with the
1753
+ * correct exit code — `kill -TERM <pid>` should still terminate
1754
+ * the process even though we installed a handler that "consumed" it.
1755
+ *
1756
+ * Detail: Node attaches a default SIGTERM handler that exits the
1757
+ * process. The MOMENT we register our own handler with `process.on`,
1758
+ * the default is removed. So we have to re-raise to mimic the
1759
+ * default behaviour after our drain completes.
1760
+ */
1761
+ async runDrainAndExit(sig) {
1762
+ await this.runDrain(sig);
1763
+ const handler = this.signalHandlers[sig];
1764
+ if (handler) {
1765
+ process.off(sig, handler);
1766
+ delete this.signalHandlers[sig];
1767
+ }
1768
+ try {
1769
+ process.kill(process.pid, sig);
1770
+ } catch {
1771
+ const code = sig === "SIGTERM" ? 143 : 130;
1772
+ process.exit(code);
1773
+ }
1774
+ }
1775
+ };
1776
+
1777
+ // src/super-properties.ts
1778
+ var SuperPropertyStore = class {
1779
+ superProps = {};
1780
+ groups = {};
1781
+ /**
1782
+ * Merge new keys into the super-property bag. Returns a snapshot
1783
+ * of the resulting bag. Values that are `null` are deleted
1784
+ * (Mixpanel's explicit "stop tracking this key" idiom).
1785
+ */
1786
+ register(props) {
1787
+ for (const [k, v] of Object.entries(props)) {
1788
+ if (v === null) {
1789
+ delete this.superProps[k];
1790
+ } else if (v !== void 0) {
1791
+ this.superProps[k] = v;
1792
+ }
1793
+ }
1794
+ return { ...this.superProps };
1795
+ }
1796
+ /** Remove a single super-property key. Idempotent. */
1797
+ unregister(key) {
1798
+ if (key in this.superProps) {
1799
+ delete this.superProps[key];
1800
+ }
1801
+ }
1802
+ /** Defensive snapshot of the current super-property bag. */
1803
+ getSuperProperties() {
1804
+ return { ...this.superProps };
1805
+ }
1806
+ /**
1807
+ * Set a group membership. Passing `id: null` clears the membership
1808
+ * for that type — the SDK stops attaching it to events.
1809
+ */
1810
+ setGroup(type, id, traits) {
1811
+ if (id === null) {
1812
+ delete this.groups[type];
1813
+ } else {
1814
+ this.groups[type] = traits !== void 0 ? { id, traits } : { id };
1815
+ }
1816
+ }
1817
+ /**
1818
+ * Defensive snapshot of the current groups map, keyed by group type.
1819
+ * The `traits` sub-object is the most-recent traits payload passed
1820
+ * to `setGroup` for that type.
1821
+ */
1822
+ getGroups() {
1823
+ const out = {};
1824
+ for (const [type, membership] of Object.entries(this.groups)) {
1825
+ out[type] = {
1826
+ id: membership.id,
1827
+ ...membership.traits ? { traits: { ...membership.traits } } : {}
1828
+ };
1829
+ }
1830
+ return out;
1831
+ }
1832
+ /**
1833
+ * Flat `{ type: id }` projection used for event-attachment. Stable
1834
+ * for fast every-event merge — we don't JSON-clone on each
1835
+ * `track()` call (hot path).
1836
+ */
1837
+ getGroupIds() {
1838
+ const out = {};
1839
+ for (const [type, info] of Object.entries(this.groups)) {
1840
+ out[type] = info.id;
1841
+ }
1842
+ return out;
1843
+ }
1844
+ /** Wipe both bags. Called by `server.shutdown()`. */
1845
+ clear() {
1846
+ this.superProps = {};
1847
+ this.groups = {};
1848
+ }
1849
+ };
1850
+
1851
+ // src/entitlement-cache.ts
1852
+ var DEFAULT_MAX_CUSTOMERS = 1e4;
1853
+ var EntitlementCache = class {
1854
+ ttlMs;
1855
+ maxCustomers;
1856
+ byCustomer = /* @__PURE__ */ new Map();
1857
+ listeners = /* @__PURE__ */ new Set();
1858
+ listenerErrorCount = 0;
1859
+ evicted = 0;
1860
+ constructor(options = {}) {
1861
+ this.ttlMs = options.ttlMs ?? 6e4;
1862
+ this.maxCustomers = options.maxCustomers ?? DEFAULT_MAX_CUSTOMERS;
1863
+ }
1864
+ /**
1865
+ * Synchronous lookup. Returns `true` iff the customer has the
1866
+ * entitlement AND the cache entry is fresh (within `ttlMs`).
1867
+ * Returns `false` otherwise (no entry / expired / key not active).
1868
+ */
1869
+ isEntitled(customerId, key) {
1870
+ const entry = this.byCustomer.get(customerId);
1871
+ if (!entry) return false;
1872
+ if (Date.now() > entry.expiresAt) return false;
1873
+ return entry.active.has(key);
1874
+ }
1875
+ /**
1876
+ * Full snapshot for callers that need source / validUntil. Returns
1877
+ * `[]` when the customer has no cached entry or the entry has
1878
+ * expired.
1879
+ */
1880
+ list(customerId) {
1881
+ const entry = this.byCustomer.get(customerId);
1882
+ if (!entry) return [];
1883
+ if (Date.now() > entry.expiresAt) return [];
1884
+ return entry.all.slice();
1885
+ }
1886
+ /**
1887
+ * Whether a fresh entry exists for the customer. Useful for
1888
+ * deciding whether to warm before a hot path.
1889
+ */
1890
+ isFresh(customerId) {
1891
+ const entry = this.byCustomer.get(customerId);
1892
+ return Boolean(entry && Date.now() <= entry.expiresAt);
1893
+ }
1894
+ /**
1895
+ * Replace (or insert) the cache entry for a customer. Sets the
1896
+ * `expiresAt` to `now + ttlMs`. Re-inserting an existing customerId
1897
+ * "touches" it — the entry moves to the end of insertion order
1898
+ * (Map semantics) so it's treated as most-recently-used for LRU
1899
+ * eviction. Fires listeners.
1900
+ */
1901
+ setForCustomer(customerId, entitlements) {
1902
+ const now = Date.now();
1903
+ this.byCustomer.delete(customerId);
1904
+ this.byCustomer.set(customerId, {
1905
+ all: entitlements.slice(),
1906
+ active: new Set(entitlements.filter((e) => e.isActive).map((e) => e.key)),
1907
+ expiresAt: now + this.ttlMs,
1908
+ populatedAt: now
1909
+ });
1910
+ while (this.byCustomer.size > this.maxCustomers) {
1911
+ const oldestKey = this.byCustomer.keys().next().value;
1912
+ if (oldestKey === void 0) break;
1913
+ this.byCustomer.delete(oldestKey);
1914
+ this.evicted += 1;
1915
+ }
1916
+ this.notify(customerId, entitlements);
1917
+ }
1918
+ /**
1919
+ * Drop a single customer's entry. Fires listeners with an empty
1920
+ * list so subscribers know that customer's cache is gone.
1921
+ */
1922
+ clearCustomer(customerId) {
1923
+ if (!this.byCustomer.delete(customerId)) return;
1924
+ this.notify(customerId, []);
1925
+ }
1926
+ /** Wipe the whole cache. Fires listeners for each customer that had a cached entry. */
1927
+ clear() {
1928
+ const customers = [...this.byCustomer.keys()];
1929
+ this.byCustomer.clear();
1930
+ for (const id of customers) this.notify(id, []);
1931
+ }
1932
+ /**
1933
+ * Subscribe to cache mutations. Returns an unsubscribe function.
1934
+ * Listener is invoked AFTER each `setForCustomer` / `clearCustomer`
1935
+ * / `clear` call with the affected customer ID + fresh entitlements
1936
+ * snapshot. NOT fired on TTL expiry (passive eviction is not a
1937
+ * mutation by design).
1938
+ *
1939
+ * Listener errors are swallowed — a buggy consumer must not crash
1940
+ * the SDK or other listeners. The error count is surfaced via
1941
+ * `listenerErrors`.
1942
+ *
1943
+ * Returned unsubscribe is idempotent.
1944
+ */
1945
+ subscribe(listener) {
1946
+ this.listeners.add(listener);
1947
+ let unsubscribed = false;
1948
+ return () => {
1949
+ if (unsubscribed) return;
1950
+ unsubscribed = true;
1951
+ this.listeners.delete(listener);
1952
+ };
1953
+ }
1954
+ // ---------- diagnostics ----------
1955
+ /** Total customers currently cached (counts expired entries too — eviction is lazy). */
1956
+ get customerCount() {
1957
+ return this.byCustomer.size;
1958
+ }
1959
+ /** Most-recent populatedAt across all entries, or 0 if cache empty. */
1960
+ get lastUpdated() {
1961
+ let max = 0;
1962
+ for (const entry of this.byCustomer.values()) {
1963
+ if (entry.populatedAt > max) max = entry.populatedAt;
1964
+ }
1965
+ return max;
1966
+ }
1967
+ /** Configured TTL in ms. */
1968
+ get ttl() {
1969
+ return this.ttlMs;
1970
+ }
1971
+ /** Cumulative count of listener invocations that threw. Surfaced in `diagnostics()`. */
1972
+ get listenerErrors() {
1973
+ return this.listenerErrorCount;
1974
+ }
1975
+ /** Cumulative count of entries evicted by the max-customers cap. */
1976
+ get evictedCount() {
1977
+ return this.evicted;
1978
+ }
1979
+ /** Configured max-customers cap. */
1980
+ get maxSize() {
1981
+ return this.maxCustomers;
1982
+ }
1983
+ // ---------- internals ----------
1984
+ notify(customerId, snapshot) {
1985
+ if (this.listeners.size === 0) return;
1986
+ const snap = snapshot.slice();
1987
+ const listenersSnapshot = [...this.listeners];
1988
+ for (const listener of listenersSnapshot) {
1989
+ try {
1990
+ listener(customerId, snap);
1991
+ } catch {
1992
+ this.listenerErrorCount += 1;
1993
+ }
1994
+ }
1995
+ }
1996
+ };
1997
+
1998
+ // src/debug.ts
1999
+ var SENSITIVE_KEY_PATTERNS = [
2000
+ /^email$/i,
2001
+ /^password$/i,
2002
+ /^token$/i,
2003
+ /^secret$/i,
2004
+ /^card$/i,
2005
+ /^phone$/i,
2006
+ /password/i,
2007
+ /credit_?card/i
2008
+ ];
2009
+ function findSensitivePropertyKeys(properties) {
2010
+ if (!properties) return [];
2011
+ const hits = [];
2012
+ for (const k of Object.keys(properties)) {
2013
+ if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(k))) hits.push(k);
2014
+ }
2015
+ return hits;
2016
+ }
2017
+ var ONCE_SIGNALS = /* @__PURE__ */ new Set([
2018
+ "sdk.configured",
2019
+ "sdk.first_event_sent",
2020
+ "sdk.environment_mismatch",
2021
+ "sdk.runtime_detected"
2022
+ ]);
2023
+ var ConsoleDebugLogger = class {
2024
+ enabled = false;
2025
+ seen = /* @__PURE__ */ new Set();
2026
+ emit(signal, message, context) {
2027
+ if (!this.enabled) return;
2028
+ if (ONCE_SIGNALS.has(signal)) {
2029
+ if (this.seen.has(signal)) return;
2030
+ this.seen.add(signal);
2031
+ }
2032
+ const ctx = context ? ` ${safeJson(context)}` : "";
2033
+ console.info(`[crossdeck:${signal}] ${message}${ctx}`);
2034
+ }
2035
+ };
2036
+ var NullDebugLogger = class {
2037
+ enabled = false;
2038
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
2039
+ emit(_signal, _message, _context) {
2040
+ }
2041
+ };
2042
+ function safeJson(obj) {
2043
+ try {
2044
+ return JSON.stringify(obj);
2045
+ } catch {
2046
+ return "[unserialisable context]";
2047
+ }
2048
+ }
2049
+
2050
+ // src/crossdeck-server.ts
2051
+ var CrossdeckServer = class extends EventEmitter {
2052
+ http;
2053
+ sdkVersion;
2054
+ baseUrl;
2055
+ appId;
2056
+ env;
2057
+ secretKeyPrefix;
2058
+ /**
2059
+ * Process-stable pseudo-anonymous ID. Used as the default identity
2060
+ * for `track()` / `captureError()` calls where the caller doesn't
2061
+ * supply one (e.g. an `uncaughtException` handler has no per-request
2062
+ * context). Stable for the SDK instance's lifetime so events from
2063
+ * the same process correlate.
2064
+ */
2065
+ processAnonymousId;
2066
+ runtime;
2067
+ runtimeProperties;
2068
+ breadcrumbs;
2069
+ eventQueue;
2070
+ errorTracker;
2071
+ flushOnExit;
2072
+ superProps;
2073
+ entitlementCache;
2074
+ debug;
2075
+ /**
2076
+ * Alias map — `developerUserId` / `anonymousId` → canonical
2077
+ * `crossdeckCustomerId`. Populated by `getEntitlements()` so a
2078
+ * subsequent `isEntitled({ userId }, "pro")` resolves to the same
2079
+ * cache entry the prior `getEntitlements({ userId })` populated.
2080
+ *
2081
+ * Bounded by `MAX_CUSTOMER_ID_ALIASES` (matches the entitlement
2082
+ * cache's default max-customers for symmetry — if the underlying
2083
+ * cache entry was evicted, a stale alias is dead weight anyway).
2084
+ * Long-running multi-tenant servers handling a long tail of customers
2085
+ * are the failure mode this bound defends against.
2086
+ */
2087
+ customerIdAliases = /* @__PURE__ */ new Map();
2088
+ /** Mutable error-state — modified by setTag / setContext / setErrorBeforeSend. */
2089
+ errorContext = {};
2090
+ errorTags = {};
2091
+ errorBeforeSend = null;
2092
+ constructor(options) {
2093
+ super();
2094
+ if (!options.secretKey || !options.secretKey.startsWith("cd_sk_")) {
2095
+ throw new CrossdeckError({
2096
+ type: "configuration_error",
2097
+ code: "invalid_secret_key",
2098
+ message: "CrossdeckServer requires a secret key starting with cd_sk_."
2099
+ });
2100
+ }
2101
+ this.sdkVersion = options.sdkVersion ?? SDK_VERSION;
2102
+ this.appId = options.appId;
2103
+ this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
2104
+ this.env = inferEnvFromKey(options.secretKey);
2105
+ this.secretKeyPrefix = maskSecretKey(options.secretKey);
2106
+ this.http = new HttpClient({
2107
+ secretKey: options.secretKey,
2108
+ baseUrl: this.baseUrl,
2109
+ sdkVersion: this.sdkVersion,
2110
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
2111
+ testMode: options.testMode,
2112
+ onRequest: options.onRequest,
2113
+ onResponse: options.onResponse,
2114
+ httpRetries: options.httpRetries,
2115
+ runtimeToken: options.runtimeToken
2116
+ });
2117
+ this.processAnonymousId = mintId("anon_node");
2118
+ this.runtime = collectRuntimeInfo({
2119
+ serviceName: options.serviceName,
2120
+ serviceVersion: options.serviceVersion,
2121
+ appVersion: options.appVersion
2122
+ });
2123
+ this.runtimeProperties = runtimeInfoToProperties(this.runtime);
2124
+ this.breadcrumbs = new BreadcrumbBuffer(options.breadcrumbsMaxSize ?? 50);
2125
+ this.superProps = new SuperPropertyStore();
2126
+ this.entitlementCache = new EntitlementCache({
2127
+ ttlMs: options.entitlementCacheTtlMs ?? 6e4
2128
+ });
2129
+ this.debug = options.debug === true ? new ConsoleDebugLogger() : new NullDebugLogger();
2130
+ if (options.debug === true) this.debug.enabled = true;
2131
+ this.debug.emit(
2132
+ "sdk.configured",
2133
+ `Crossdeck server SDK connected. env=${this.env}, host=${this.runtime?.host ?? "node"}`,
2134
+ {
2135
+ env: this.env,
2136
+ sdkVersion: this.sdkVersion,
2137
+ secretKeyPrefix: this.secretKeyPrefix
2138
+ }
2139
+ );
2140
+ this.eventQueue = new EventQueue({
2141
+ http: this.http,
2142
+ batchSize: options.eventFlushBatchSize ?? 20,
2143
+ intervalMs: options.eventFlushIntervalMs ?? 1500,
2144
+ envelope: () => ({
2145
+ appId: this.appId,
2146
+ sdk: { name: SDK_NAME, version: this.sdkVersion }
2147
+ }),
2148
+ onDrop: (count) => {
2149
+ this.emit("queue.dropped", { count });
2150
+ },
2151
+ onBufferChange: (size) => {
2152
+ this.emit("queue.buffer_changed", { size });
2153
+ },
2154
+ onRetryScheduled: (info) => {
2155
+ this.emit("queue.flush_failed", {
2156
+ error: info.lastError,
2157
+ attempt: info.consecutiveFailures,
2158
+ nextRetryMs: info.delayMs
2159
+ });
2160
+ },
2161
+ onFirstFlushSuccess: () => {
2162
+ this.debug.emit("sdk.first_event_sent", "First batch landed.");
2163
+ }
2164
+ });
2165
+ if (options.errorCapture === false) {
2166
+ this.errorTracker = null;
2167
+ } else {
2168
+ const config = options.errorCapture && typeof options.errorCapture === "object" ? { ...DEFAULT_ERROR_CAPTURE, ...options.errorCapture } : { ...DEFAULT_ERROR_CAPTURE };
2169
+ this.errorTracker = new ErrorTracker({
2170
+ config,
2171
+ breadcrumbs: this.breadcrumbs,
2172
+ report: (err) => this.reportCapturedError(err),
2173
+ getContext: () => ({ ...this.errorContext }),
2174
+ getTags: () => ({ ...this.errorTags }),
2175
+ beforeSend: null,
2176
+ // wired via setErrorBeforeSend; ErrorTracker reads it through the live ref below
2177
+ isConsented: () => true
2178
+ });
2179
+ const trackerOpts = this.errorTracker.opts;
2180
+ Object.defineProperty(trackerOpts, "beforeSend", {
2181
+ get: () => this.errorBeforeSend,
2182
+ configurable: true
2183
+ });
2184
+ this.errorTracker.install();
2185
+ }
2186
+ if (options.flushOnExit === false) {
2187
+ this.flushOnExit = null;
2188
+ } else {
2189
+ this.flushOnExit = new FlushOnExit({
2190
+ drain: () => this.eventQueue.flush().then(() => void 0),
2191
+ timeoutMs: options.flushOnExitTimeoutMs
2192
+ });
2193
+ this.flushOnExit.install();
2194
+ }
2195
+ }
2196
+ // ============================================================
2197
+ // Identity — direct HTTP (transactional, not telemetry)
2198
+ // ============================================================
2199
+ async identify(userId, anonymousId, options) {
2200
+ const { signal, timeoutMs, ...identifyOpts } = options ?? {};
2201
+ return this.aliasIdentity(
2202
+ { userId, anonymousId, ...identifyOpts },
2203
+ { signal, timeoutMs }
2204
+ );
2205
+ }
2206
+ async aliasIdentity(input, options) {
2207
+ if (!input.userId) {
2208
+ throw new CrossdeckError({
2209
+ type: "invalid_request_error",
2210
+ code: "missing_user_id",
2211
+ message: "aliasIdentity requires a non-empty userId."
2212
+ });
2213
+ }
2214
+ if (!input.anonymousId) {
2215
+ throw new CrossdeckError({
2216
+ type: "invalid_request_error",
2217
+ code: "missing_anonymous_id",
2218
+ message: "aliasIdentity requires a non-empty anonymousId."
2219
+ });
2220
+ }
2221
+ const traits = sanitizePropertyBag(input.traits, "traits");
2222
+ const body = {
2223
+ userId: input.userId,
2224
+ anonymousId: input.anonymousId
2225
+ };
2226
+ if (input.email) body.email = input.email;
2227
+ if (traits && Object.keys(traits).length > 0) body.traits = traits;
2228
+ return this.http.request("POST", "/identity/alias", {
2229
+ body,
2230
+ signal: options?.signal,
2231
+ timeoutMs: options?.timeoutMs
2232
+ });
2233
+ }
2234
+ async forget(hints, options) {
2235
+ const body = this.identityPayload(hints);
2236
+ return this.http.request("POST", "/identity/forget", {
2237
+ body,
2238
+ signal: options?.signal,
2239
+ timeoutMs: options?.timeoutMs
2240
+ });
2241
+ }
2242
+ // ============================================================
2243
+ // Entitlements — direct HTTP + TTL cache (v1.0.0+)
2244
+ //
2245
+ // `getEntitlements()` POSTs over the wire and populates the cache
2246
+ // under the response's canonical `crossdeckCustomerId`. Any
2247
+ // `userId` / `anonymousId` supplied as a hint is recorded as an
2248
+ // alias so a subsequent `isEntitled({ userId }, "pro")` resolves
2249
+ // to the same cache entry.
2250
+ // ============================================================
2251
+ async getEntitlements(hints, options) {
2252
+ const response = await this.http.request("GET", "/entitlements", {
2253
+ query: this.identityPayload(hints),
2254
+ signal: options?.signal,
2255
+ timeoutMs: options?.timeoutMs
2256
+ });
2257
+ this.populateEntitlementCache(hints, response);
2258
+ return response;
2259
+ }
2260
+ async getCustomerEntitlements(customerId, options) {
2261
+ if (!customerId) {
2262
+ throw new CrossdeckError({
2263
+ type: "invalid_request_error",
2264
+ code: "missing_customer_id",
2265
+ message: "getCustomerEntitlements requires a customerId."
2266
+ });
2267
+ }
2268
+ const response = await this.http.request(
2269
+ "GET",
2270
+ `/server/customers/${encodeURIComponent(customerId)}/entitlements`,
2271
+ {
2272
+ signal: options?.signal,
2273
+ timeoutMs: options?.timeoutMs
2274
+ }
2275
+ );
2276
+ this.populateEntitlementCache({ customerId }, response);
2277
+ return response;
2278
+ }
2279
+ /**
2280
+ * Synchronous entitlement check. Returns `true` iff the customer
2281
+ * has the entitlement AND the cache entry is fresh (within
2282
+ * `entitlementCacheTtlMs`, default 60s). Returns `false` when the
2283
+ * cache is cold or expired.
2284
+ *
2285
+ * The hint can be any combination of `customerId` / `userId` /
2286
+ * `anonymousId`. After `getEntitlements({ userId })` populates the
2287
+ * cache, subsequent `isEntitled({ userId }, "pro")` calls within
2288
+ * TTL are memory reads (no HTTP). The "warm cache" pattern that
2289
+ * makes hot-path entitlement gates cheap.
2290
+ *
2291
+ * await server.getEntitlements({ userId }); // warm
2292
+ * if (server.isEntitled({ userId }, "pro")) { // synchronous
2293
+ * // ...
2294
+ * }
2295
+ *
2296
+ * Caller is responsible for re-warming after TTL elapses. The cache
2297
+ * does NOT auto-refresh on read (would block the hot path).
2298
+ */
2299
+ isEntitled(hint, key) {
2300
+ const customerId = this.resolveCacheCustomerId(hint);
2301
+ if (!customerId) return false;
2302
+ const result = this.entitlementCache.isEntitled(customerId, key);
2303
+ if (result) {
2304
+ this.debug.emit("sdk.entitlement_cache_used", `Cache hit for ${customerId}/${key}.`);
2305
+ }
2306
+ return result;
2307
+ }
2308
+ /**
2309
+ * Snapshot of the customer's cached entitlements. Returns `[]` when
2310
+ * the cache is cold or expired. Same hint resolution as
2311
+ * `isEntitled()`.
2312
+ */
2313
+ listEntitlements(hint) {
2314
+ const customerId = this.resolveCacheCustomerId(hint);
2315
+ if (!customerId) return [];
2316
+ return this.entitlementCache.list(customerId);
2317
+ }
2318
+ /**
2319
+ * Subscribe to entitlement-cache mutations. Listener fires after
2320
+ * `getEntitlements()` populates the cache or `shutdown()` clears
2321
+ * it. Returns an idempotent unsubscribe function.
2322
+ *
2323
+ * Used by callers that want to react to entitlement changes (e.g.
2324
+ * a websocket layer notifying connected clients of plan upgrades).
2325
+ * Listener errors are swallowed — surfaced via
2326
+ * `diagnostics().entitlements.listenerErrors`.
2327
+ */
2328
+ onEntitlementsChange(listener) {
2329
+ return this.entitlementCache.subscribe(listener);
2330
+ }
2331
+ // ============================================================
2332
+ // Events — queue-based track(); immediate ingest() for bulk imports
2333
+ // ============================================================
2334
+ /**
2335
+ * Queue an event for batched delivery. Returns synchronously — the
2336
+ * HTTP round-trip happens in the background.
2337
+ *
2338
+ * Behaviour parity with `@cross-deck/web`'s `track()`:
2339
+ * - Synchronous return, void.
2340
+ * - Throws sync on `missing_event_name`.
2341
+ * - Property bag sanitised through `validateEventProperties`.
2342
+ * - Runtime info (`runtime.*`) auto-merged into every event's
2343
+ * properties. Caller-supplied properties win on key collision.
2344
+ * - Breadcrumb auto-emitted (unless the name starts with `error.`,
2345
+ * which would cause a cycle).
2346
+ *
2347
+ * Differences from `@cross-deck/web`:
2348
+ * - Single-argument signature `track(event)` instead of
2349
+ * `track(name, properties)` — the Node wire shape needs the full
2350
+ * `ServerEvent` (identity hint, optional level + tags + categoryTags).
2351
+ * - Auto-fills `anonymousId` with `this.processAnonymousId` when no
2352
+ * identity hint is supplied. A captureError from
2353
+ * `uncaughtException` has no per-request context; without the
2354
+ * auto-fill, the event would be rejected at queue enqueue.
2355
+ */
2356
+ track(event) {
2357
+ if (!event.name) {
2358
+ throw new CrossdeckError({
2359
+ type: "invalid_request_error",
2360
+ code: "missing_event_name",
2361
+ message: "track(event) requires a non-empty event.name."
2362
+ });
2363
+ }
2364
+ const sanitized = sanitizePropertyBag(event.properties, "event properties") ?? {};
2365
+ if (this.debug.enabled) {
2366
+ const flagged = findSensitivePropertyKeys(sanitized);
2367
+ if (flagged.length > 0) {
2368
+ this.debug.emit(
2369
+ "sdk.sensitive_property_warning",
2370
+ `Event "${event.name}" has potentially sensitive property names: ${flagged.join(", ")}. Crossdeck is privacy-first \u2014 avoid sending PII unless intentional.`,
2371
+ { eventName: event.name, flagged }
2372
+ );
2373
+ }
2374
+ }
2375
+ const properties = {
2376
+ ...this.runtimeProperties,
2377
+ ...this.superProps.getSuperProperties(),
2378
+ ...sanitized
2379
+ };
2380
+ const groupIds = this.superProps.getGroupIds();
2381
+ if (Object.keys(groupIds).length > 0 && properties.$groups === void 0) {
2382
+ properties.$groups = groupIds;
2383
+ }
2384
+ const identity = this.resolveIdentity(event);
2385
+ const queued = {
2386
+ eventId: event.eventId ?? mintId("evt", 8),
2387
+ name: event.name,
2388
+ timestamp: event.timestamp ?? Date.now(),
2389
+ properties,
2390
+ ...identity
2391
+ };
2392
+ if (event.level !== void 0) queued.level = event.level;
2393
+ if (event.tags !== void 0) queued.tags = event.tags;
2394
+ if (event.categoryTags !== void 0) queued.categoryTags = event.categoryTags;
2395
+ this.eventQueue.enqueue(queued);
2396
+ if (!event.name.startsWith("error.")) {
2397
+ this.breadcrumbs.add({
2398
+ timestamp: queued.timestamp,
2399
+ category: categoryFor(event.name),
2400
+ message: event.name,
2401
+ data: sanitized
2402
+ });
2403
+ }
2404
+ }
2405
+ /**
2406
+ * Immediate POST of one or more events. For bulk imports / replay
2407
+ * scenarios where the caller wants synchronous confirmation. Bypasses
2408
+ * the queue — no batching, no auto-fill of identity, no
2409
+ * runtime-enrichment.
2410
+ *
2411
+ * Use `track()` for the standard fire-and-forget telemetry path.
2412
+ * Use `ingest()` when you need:
2413
+ * - The IngestResponse synchronously.
2414
+ * - Strict per-event identity validation (no auto-fill).
2415
+ * - Caller-controlled idempotency key.
2416
+ */
2417
+ async ingest(events, options = {}) {
2418
+ if (!Array.isArray(events) || events.length === 0) {
2419
+ throw new CrossdeckError({
2420
+ type: "invalid_request_error",
2421
+ code: "missing_events",
2422
+ message: "ingest requires at least one event."
2423
+ });
2424
+ }
2425
+ const normalized = events.map((event) => this.normalizeIngestEvent(event));
2426
+ const body = {
2427
+ events: normalized,
2428
+ sdk: { name: SDK_NAME, version: this.sdkVersion }
2429
+ };
2430
+ if (this.appId) body.appId = this.appId;
2431
+ return this.http.request("POST", "/events", {
2432
+ body,
2433
+ idempotencyKey: options.idempotencyKey ?? mintId("batch"),
2434
+ signal: options.signal,
2435
+ timeoutMs: options.timeoutMs
2436
+ });
2437
+ }
2438
+ /**
2439
+ * Validate the secret key against the Crossdeck API and return the
2440
+ * resolved project + app metadata. Useful at boot to fail fast on a
2441
+ * misconfigured deployment — without this, a wrong secret key only
2442
+ * surfaces on the first event flush attempt, which may be minutes
2443
+ * after process start.
2444
+ *
2445
+ * const { projectId, appId, env, serverTime } = await server.heartbeat();
2446
+ *
2447
+ * Throws `CrossdeckError` on:
2448
+ * - `authentication_error` — secret key invalid / revoked
2449
+ * - `network_error` — couldn't reach the backend
2450
+ * - `request_timeout` — backend slow / unreachable
2451
+ *
2452
+ * Side effect: success records `(serverTime, clientTime)` for clock-
2453
+ * skew detection in `diagnostics().clock` (Phase 2 — not yet exposed
2454
+ * in this SDK release but the data is captured).
2455
+ *
2456
+ * Not auto-called. Caller decides whether the trade-off (one extra
2457
+ * boot request + ~50ms p50 latency) is worth the early-failure
2458
+ * signal. For serverless cold-starts, it usually is — cheap
2459
+ * compared to the cost of a silent broken secret in production.
2460
+ */
2461
+ async heartbeat(options) {
2462
+ const result = await this.http.request("GET", "/sdk/heartbeat", {
2463
+ signal: options?.signal,
2464
+ timeoutMs: options?.timeoutMs
2465
+ });
2466
+ return result;
2467
+ }
2468
+ /**
2469
+ * Drain the event queue. Resolves when the in-flight batch completes
2470
+ * (success or failure). On failure, events stay queued for the next
2471
+ * scheduled retry — the resolved promise does NOT throw.
2472
+ *
2473
+ * Typical callers:
2474
+ * - End of a Lambda handler: `await server.flush()` before return
2475
+ * so events land before the platform freezes the process.
2476
+ * - Express server shutdown: `await server.flush()` inside the
2477
+ * SIGTERM handler.
2478
+ * - Tests: drain between assertions.
2479
+ *
2480
+ * Idempotent — flush on an empty queue is a no-op.
2481
+ */
2482
+ async flush() {
2483
+ await this.eventQueue.flush();
2484
+ }
2485
+ async syncPurchases(input, options) {
2486
+ if (!input.signedTransactionInfo) {
2487
+ throw new CrossdeckError({
2488
+ type: "invalid_request_error",
2489
+ code: "missing_signed_transaction_info",
2490
+ message: "syncPurchases requires a signedTransactionInfo string."
2491
+ });
2492
+ }
2493
+ return this.http.request("POST", "/purchases/sync", {
2494
+ body: { rail: input.rail ?? "apple", ...input },
2495
+ signal: options?.signal,
2496
+ timeoutMs: options?.timeoutMs
2497
+ });
2498
+ }
2499
+ // ============================================================
2500
+ // Manual entitlement controls + audit — direct HTTP
2501
+ // ============================================================
2502
+ async grantEntitlement(input, options) {
2503
+ if (!input.customerId) {
2504
+ throw new CrossdeckError({
2505
+ type: "invalid_request_error",
2506
+ code: "missing_customer_id",
2507
+ message: "grantEntitlement requires a customerId."
2508
+ });
2509
+ }
2510
+ return this.http.request(
2511
+ "POST",
2512
+ `/server/customers/${encodeURIComponent(input.customerId)}/grant`,
2513
+ {
2514
+ body: {
2515
+ entitlementKey: input.entitlementKey,
2516
+ duration: input.duration,
2517
+ reason: input.reason
2518
+ },
2519
+ signal: options?.signal,
2520
+ timeoutMs: options?.timeoutMs
2521
+ }
2522
+ );
2523
+ }
2524
+ /**
2525
+ * Grant multiple entitlements in one logical call. Backend lacks a
2526
+ * bulk endpoint today, so this is a client-side fan-out — each
2527
+ * grant fires a separate request. Results return as a
2528
+ * settled-promise array so partial failures don't drop the rest:
2529
+ * the caller decides how to handle each `{ ok, value }` /
2530
+ * `{ ok: false, error }` entry.
2531
+ *
2532
+ * Use for ops sweeps (e.g. "grant the entire `pro` tier a one-time
2533
+ * `pro_q1_bonus` entitlement"). The bounded concurrency (default
2534
+ * `maxConcurrency: 5`) avoids hammering the backend; the rate-
2535
+ * limit policy on the server still kicks in if needed.
2536
+ */
2537
+ async bulkGrantEntitlement(grants, options) {
2538
+ return runBulkOperation(
2539
+ grants,
2540
+ options?.maxConcurrency ?? 5,
2541
+ (input) => this.grantEntitlement(input, { signal: options?.signal, timeoutMs: options?.timeoutMs })
2542
+ );
2543
+ }
2544
+ async revokeEntitlement(input, options) {
2545
+ if (!input.customerId) {
2546
+ throw new CrossdeckError({
2547
+ type: "invalid_request_error",
2548
+ code: "missing_customer_id",
2549
+ message: "revokeEntitlement requires a customerId."
2550
+ });
2551
+ }
2552
+ return this.http.request(
2553
+ "POST",
2554
+ `/server/customers/${encodeURIComponent(input.customerId)}/revoke`,
2555
+ {
2556
+ body: {
2557
+ entitlementKey: input.entitlementKey,
2558
+ reason: input.reason
2559
+ },
2560
+ signal: options?.signal,
2561
+ timeoutMs: options?.timeoutMs
2562
+ }
2563
+ );
2564
+ }
2565
+ /**
2566
+ * Revoke multiple entitlements in one logical call. Same
2567
+ * settled-array contract as `bulkGrantEntitlement` — see that
2568
+ * doc for behaviour notes.
2569
+ */
2570
+ async bulkRevokeEntitlement(revokes, options) {
2571
+ return runBulkOperation(
2572
+ revokes,
2573
+ options?.maxConcurrency ?? 5,
2574
+ (input) => this.revokeEntitlement(input, { signal: options?.signal, timeoutMs: options?.timeoutMs })
2575
+ );
2576
+ }
2577
+ async getAuditEntry(eventId, options) {
2578
+ if (!eventId) {
2579
+ throw new CrossdeckError({
2580
+ type: "invalid_request_error",
2581
+ code: "missing_event_id",
2582
+ message: "getAuditEntry requires an eventId."
2583
+ });
483
2584
  }
484
2585
  const result = await this.http.request(
485
2586
  "GET",
486
- `/server/audit/${encodeURIComponent(eventId)}`
2587
+ `/server/audit/${encodeURIComponent(eventId)}`,
2588
+ {
2589
+ signal: options?.signal,
2590
+ timeoutMs: options?.timeoutMs
2591
+ }
487
2592
  );
488
2593
  return result.data;
489
2594
  }
2595
+ // ============================================================
2596
+ // USP 1 — Error capture public surface
2597
+ // ============================================================
2598
+ /**
2599
+ * Manually capture an error from a try/catch block.
2600
+ *
2601
+ * try { … } catch (err) {
2602
+ * server.captureError(err, { context: { jobId }, tags: { flow: "checkout" } });
2603
+ * }
2604
+ *
2605
+ * The error ships through the same event queue analytics rides on
2606
+ * (retried, idempotent, runtime-enriched). Returns silently — never
2607
+ * throws, even if error capture is disabled.
2608
+ */
2609
+ captureError(error, options) {
2610
+ if (!this.errorTracker) return;
2611
+ this.errorTracker.captureError(error, options);
2612
+ }
2613
+ /**
2614
+ * Capture a non-error signal as an issue. Sentry's `captureMessage`
2615
+ * pattern — for "we hit the deprecated code path" / "soft-warning
2616
+ * triggered" signals where there's no Error to throw.
2617
+ */
2618
+ captureMessage(message, level = "info") {
2619
+ if (!this.errorTracker) return;
2620
+ this.errorTracker.captureMessage(message, level);
2621
+ }
2622
+ /**
2623
+ * Attach a tag to every subsequent error report. Sentry pattern.
2624
+ * Tags are flat string key/value (queryable in the dashboard);
2625
+ * use `setContext()` for structured blobs.
2626
+ */
2627
+ setTag(key, value) {
2628
+ this.errorTags[key] = value;
2629
+ }
2630
+ /** Bulk-set tags. Merges with existing tags. */
2631
+ setTags(tags) {
2632
+ Object.assign(this.errorTags, tags);
2633
+ }
2634
+ /**
2635
+ * Attach a structured context blob to every subsequent error report.
2636
+ * Unlike tags (flat key/value), context is a named bag of arbitrary
2637
+ * JSON-serialisable data.
2638
+ *
2639
+ * server.setContext("cart", { items: 3, total: 42.99 });
2640
+ */
2641
+ setContext(name, data) {
2642
+ this.errorContext[name] = data;
2643
+ }
2644
+ /**
2645
+ * Add a custom breadcrumb to the rolling buffer. The last 50
2646
+ * breadcrumbs are attached to every subsequent error report —
2647
+ * "what was the request doing right before things broke."
2648
+ */
2649
+ addBreadcrumb(crumb) {
2650
+ this.breadcrumbs.add(crumb);
2651
+ }
2652
+ /**
2653
+ * Install a pre-send hook for errors. Return null to drop the report,
2654
+ * or a modified `CapturedError` to scrub fields. Sentry's
2655
+ * `beforeSend` pattern — the only place to add app-specific PII
2656
+ * redaction (auth tokens in URLs, etc.) before the report leaves the
2657
+ * process.
2658
+ *
2659
+ * The hook is called LAST, after rate-limit + sampling + path gates
2660
+ * already passed. A throwing hook falls back to the original error.
2661
+ */
2662
+ setErrorBeforeSend(hook) {
2663
+ this.errorBeforeSend = hook;
2664
+ }
2665
+ // ============================================================
2666
+ // USP 2 — Super-properties + groups (Mixpanel pattern)
2667
+ // ============================================================
2668
+ /**
2669
+ * Register super-properties — every subsequent event carries these
2670
+ * keys on its `properties` bag automatically. Mixpanel pattern.
2671
+ *
2672
+ * server.register({ tenant: "acme", plan: "pro" });
2673
+ * server.track({ name: "paywall_shown", developerUserId: userId });
2674
+ * // ^ event carries `tenant` + `plan` in properties
2675
+ *
2676
+ * Values that are `null` are deleted (the explicit "stop tracking
2677
+ * this key" idiom). Sanitised through `validateEventProperties` so
2678
+ * a `{ avatar: <Buffer> }` payload can't poison the queue.
2679
+ *
2680
+ * Returns a defensive snapshot of the resulting bag.
2681
+ *
2682
+ * **Multi-tenant servers — read carefully.** Super-properties are
2683
+ * PROCESS-SCOPED. In a single Node process handling requests for
2684
+ * many tenants (the common multi-tenant SaaS shape), calling
2685
+ * `server.register({ tenant: "acme" })` taints EVERY subsequent
2686
+ * event from that process — including ones serving tenant "beta".
2687
+ * That's almost never what you want.
2688
+ *
2689
+ * The right pattern for per-request properties is to pass them on
2690
+ * the `track()` call itself:
2691
+ *
2692
+ * server.track({
2693
+ * name: "paywall_shown",
2694
+ * developerUserId: req.user.id,
2695
+ * properties: { tenant: req.tenantId, plan: req.user.plan },
2696
+ * });
2697
+ *
2698
+ * Reserve `register()` for properties that genuinely apply to every
2699
+ * event from this process — e.g. service version, region, build
2700
+ * commit. For those, `runtime-info` already provides
2701
+ * `runtime.serviceVersion` etc. automatically.
2702
+ */
2703
+ register(properties) {
2704
+ const validation = validateEventProperties(properties);
2705
+ const result = this.superProps.register(validation.properties);
2706
+ this.debug.emit(
2707
+ "sdk.super_property_registered",
2708
+ `Super-properties updated. ${Object.keys(validation.properties).length} key(s) merged.`
2709
+ );
2710
+ return result;
2711
+ }
2712
+ /** Remove a single super-property key. Idempotent. */
2713
+ unregister(key) {
2714
+ this.superProps.unregister(key);
2715
+ }
2716
+ /** Snapshot of the current super-property bag. */
2717
+ getSuperProperties() {
2718
+ return this.superProps.getSuperProperties();
2719
+ }
2720
+ /**
2721
+ * Associate the current SDK instance with a group (org, team,
2722
+ * account, plan). Mixpanel / Segment Group Analytics pattern.
2723
+ *
2724
+ * server.group("org", "acme_inc");
2725
+ * server.group("team", "design", { headcount: 12 });
2726
+ *
2727
+ * Once set, every subsequent event carries `$groups.<type>: id` on
2728
+ * its `properties` bag, enabling B2B dashboard pivots. Pass
2729
+ * `id: null` to clear a group membership.
2730
+ *
2731
+ * Group traits are sanitised through `validateEventProperties`.
2732
+ */
2733
+ group(type, id, traits) {
2734
+ if (!type) {
2735
+ throw new CrossdeckError({
2736
+ type: "invalid_request_error",
2737
+ code: "missing_group_type",
2738
+ message: "group(type, id) requires a non-empty type."
2739
+ });
2740
+ }
2741
+ const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
2742
+ this.superProps.setGroup(type, id, sanitisedTraits);
2743
+ }
2744
+ /** Snapshot of current group memberships keyed by type. */
2745
+ getGroups() {
2746
+ return this.superProps.getGroups();
2747
+ }
2748
+ // ============================================================
2749
+ // Diagnostics — for debugging + the dashboard's heartbeat row
2750
+ // ============================================================
2751
+ diagnostics() {
2752
+ return {
2753
+ sdkVersion: this.sdkVersion,
2754
+ baseUrl: this.baseUrl,
2755
+ secretKeyPrefix: this.secretKeyPrefix,
2756
+ env: this.env,
2757
+ runtime: {
2758
+ nodeVersion: this.runtime.nodeVersion,
2759
+ platform: this.runtime.platform,
2760
+ hostname: this.runtime.hostname,
2761
+ host: this.runtime.host,
2762
+ region: this.runtime.region,
2763
+ serviceName: this.runtime.serviceName,
2764
+ serviceVersion: this.runtime.serviceVersion,
2765
+ instanceId: this.runtime.instanceId
2766
+ },
2767
+ entitlements: {
2768
+ count: this.entitlementCache.customerCount,
2769
+ lastUpdated: this.entitlementCache.lastUpdated,
2770
+ ttlMs: this.entitlementCache.ttl,
2771
+ listenerErrors: this.entitlementCache.listenerErrors
2772
+ },
2773
+ events: this.eventQueue.getStats(),
2774
+ errors: {
2775
+ sessionCount: this.errorTracker?.reportedCount ?? 0,
2776
+ fingerprintsTracked: this.errorTracker?.fingerprintsTracked ?? 0,
2777
+ handlersInstalled: this.errorTracker?.handlersInstalled ?? false
2778
+ }
2779
+ };
2780
+ }
2781
+ /**
2782
+ * Tear down handlers and clear in-memory state. Tests + custom
2783
+ * lifecycle callers only. Production code should rely on
2784
+ * `flush-on-exit` instead.
2785
+ */
2786
+ shutdown(reason = "shutdown") {
2787
+ this.emit("sdk.shutdown", { reason });
2788
+ this.errorTracker?.uninstall();
2789
+ this.flushOnExit?.uninstall();
2790
+ this.eventQueue.reset();
2791
+ this.breadcrumbs.clear();
2792
+ this.superProps.clear();
2793
+ this.entitlementCache.clear();
2794
+ this.customerIdAliases.clear();
2795
+ this.errorContext = {};
2796
+ this.errorTags = {};
2797
+ this.errorBeforeSend = null;
2798
+ this.removeAllListeners();
2799
+ }
2800
+ on(event, listener) {
2801
+ return super.on(event, listener);
2802
+ }
2803
+ once(event, listener) {
2804
+ return super.once(event, listener);
2805
+ }
2806
+ off(event, listener) {
2807
+ return super.off(event, listener);
2808
+ }
2809
+ emit(event, ...args) {
2810
+ return super.emit(event, ...args);
2811
+ }
2812
+ // ============================================================
2813
+ // Health + readiness + lifecycle
2814
+ // ============================================================
2815
+ /**
2816
+ * Synchronous readiness check — "is the SDK in a state where it
2817
+ * should accept new traffic?". Used by Kubernetes readiness probes
2818
+ * and backpressure-aware callers.
2819
+ *
2820
+ * Returns `false` if:
2821
+ * - The event queue is in a sustained retry storm
2822
+ * (`consecutiveFailures >= 5`).
2823
+ * - The event queue's buffered count is at >= 80% of HARD_BUFFER_CAP.
2824
+ *
2825
+ * Otherwise `true`. The default isn't "perfectly healthy" — the
2826
+ * SDK is happy to enqueue events even during transient flush
2827
+ * failures because the queue's retry path handles them. Only
2828
+ * sustained failure flips this to `false`.
2829
+ */
2830
+ isReady() {
2831
+ const stats = this.eventQueue.getStats();
2832
+ if (stats.consecutiveFailures >= 5) return false;
2833
+ if (stats.buffered >= 800) return false;
2834
+ return true;
2835
+ }
2836
+ /**
2837
+ * Async wait until `isReady()` returns true OR the timeout elapses.
2838
+ * Resolves `true` on ready, `false` on timeout. Polls every 50ms by
2839
+ * default — backpressure for callers writing high-volume servers.
2840
+ *
2841
+ * if (!(await server.awaitReady(2000))) {
2842
+ * // shed load — SDK is in a retry storm, don't queue more
2843
+ * }
2844
+ */
2845
+ async awaitReady(timeoutMs = 5e3, pollIntervalMs = 50) {
2846
+ if (this.isReady()) return true;
2847
+ const start = Date.now();
2848
+ return new Promise((resolve) => {
2849
+ const tick = () => {
2850
+ if (this.isReady()) {
2851
+ resolve(true);
2852
+ return;
2853
+ }
2854
+ if (Date.now() - start >= timeoutMs) {
2855
+ resolve(false);
2856
+ return;
2857
+ }
2858
+ const t = setTimeout(tick, pollIntervalMs);
2859
+ if (typeof t.unref === "function") {
2860
+ try {
2861
+ t.unref();
2862
+ } catch {
2863
+ }
2864
+ }
2865
+ };
2866
+ tick();
2867
+ });
2868
+ }
2869
+ /**
2870
+ * Snapshot for Kubernetes liveness + readiness probes. `healthy`
2871
+ * stays true unless the SDK is in a catastrophic state (which
2872
+ * currently can't happen without crashing the process). `ready`
2873
+ * matches `isReady()`.
2874
+ *
2875
+ * app.get("/healthz", (_req, res) => {
2876
+ * const h = server.getHealth();
2877
+ * res.status(h.healthy ? 200 : 503).json(h);
2878
+ * });
2879
+ */
2880
+ getHealth() {
2881
+ const stats = this.eventQueue.getStats();
2882
+ return {
2883
+ ready: this.isReady(),
2884
+ healthy: true,
2885
+ bufferedEvents: stats.buffered,
2886
+ inFlight: stats.inFlight,
2887
+ consecutiveFailures: stats.consecutiveFailures,
2888
+ lastFlushAt: stats.lastFlushAt,
2889
+ lastError: stats.lastError,
2890
+ errorHandlersInstalled: this.errorTracker?.handlersInstalled ?? false
2891
+ };
2892
+ }
2893
+ /**
2894
+ * Sync disposal hook — runs when a `using` declaration exits scope
2895
+ * (TC39 explicit-resource-management, Node 20+ / TS 5.2+).
2896
+ *
2897
+ * using server = new CrossdeckServer({ ... });
2898
+ * // ... use server ...
2899
+ * // at end of block, server[Symbol.dispose]() runs automatically
2900
+ *
2901
+ * `Symbol.dispose` is synchronous so we can't await `flush()` here
2902
+ * — for that, use `await using` + `[Symbol.asyncDispose]()`. This
2903
+ * sync variant just calls `shutdown()` (handler cleanup +
2904
+ * in-memory state wipe).
2905
+ */
2906
+ [Symbol.dispose]() {
2907
+ this.shutdown("dispose");
2908
+ }
2909
+ /**
2910
+ * Async disposal hook — runs when an `await using` declaration
2911
+ * exits scope. Awaits `flush()` THEN runs `shutdown()`. Use this
2912
+ * variant when the caller needs the queue drained before exit
2913
+ * (the common case for serverless handlers).
2914
+ *
2915
+ * await using server = new CrossdeckServer({ ... });
2916
+ */
2917
+ async [Symbol.asyncDispose]() {
2918
+ try {
2919
+ await this.flush();
2920
+ } catch {
2921
+ }
2922
+ this.shutdown("asyncDispose");
2923
+ }
2924
+ // ============================================================
2925
+ reportCapturedError(captured) {
2926
+ try {
2927
+ this.emit("error.captured", {
2928
+ fingerprint: captured.fingerprint,
2929
+ kind: captured.kind,
2930
+ message: captured.message
2931
+ });
2932
+ } catch {
2933
+ }
2934
+ const properties = {
2935
+ fingerprint: captured.fingerprint,
2936
+ level: captured.level,
2937
+ errorType: captured.errorType,
2938
+ message: captured.message,
2939
+ stack: captured.rawStack ?? void 0,
2940
+ frames: captured.frames,
2941
+ tags: captured.tags,
2942
+ context: captured.context,
2943
+ breadcrumbs: captured.breadcrumbs,
2944
+ http: captured.http
2945
+ };
2946
+ for (const k of Object.keys(properties)) {
2947
+ if (properties[k] === void 0) delete properties[k];
2948
+ }
2949
+ this.track({
2950
+ name: captured.kind,
2951
+ timestamp: captured.timestamp,
2952
+ properties,
2953
+ level: captured.level,
2954
+ tags: captured.tags
2955
+ });
2956
+ }
2957
+ /**
2958
+ * Populate the entitlement cache from a fresh server response.
2959
+ * Records aliases so `userId` / `anonymousId` hints supplied to
2960
+ * `getEntitlements()` resolve to the same cache entry on subsequent
2961
+ * `isEntitled({ userId }, ...)` calls.
2962
+ *
2963
+ * Bounds the alias map at MAX_CUSTOMER_ID_ALIASES — once full, the
2964
+ * oldest aliases (by insertion order) are evicted FIFO. Symmetric
2965
+ * with the entitlement cache's max-customers cap.
2966
+ */
2967
+ populateEntitlementCache(hints, response) {
2968
+ const customerId = response.crossdeckCustomerId;
2969
+ if (!customerId) return;
2970
+ this.entitlementCache.setForCustomer(customerId, response.data);
2971
+ if (hints.userId) this.touchAlias(hints.userId, customerId);
2972
+ if (hints.anonymousId) this.touchAlias(hints.anonymousId, customerId);
2973
+ this.debug.emit(
2974
+ "sdk.entitlement_cache_warm",
2975
+ `Entitlement cache warmed for ${customerId} (${response.data.length} entitlement(s)).`
2976
+ );
2977
+ try {
2978
+ this.emit("entitlements.warmed", {
2979
+ customerId,
2980
+ count: response.data.length
2981
+ });
2982
+ } catch {
2983
+ }
2984
+ }
2985
+ touchAlias(alias, customerId) {
2986
+ this.customerIdAliases.delete(alias);
2987
+ this.customerIdAliases.set(alias, customerId);
2988
+ while (this.customerIdAliases.size > MAX_CUSTOMER_ID_ALIASES) {
2989
+ const oldest = this.customerIdAliases.keys().next().value;
2990
+ if (oldest === void 0) break;
2991
+ this.customerIdAliases.delete(oldest);
2992
+ }
2993
+ }
2994
+ /**
2995
+ * Resolve any hint shape (canonical customerId / userId hint /
2996
+ * anonymousId hint / raw string) to a `crossdeckCustomerId` if we
2997
+ * have a cache entry for it.
2998
+ */
2999
+ resolveCacheCustomerId(hint) {
3000
+ if (typeof hint === "string") {
3001
+ if (this.entitlementCache.isFresh(hint)) return hint;
3002
+ return this.customerIdAliases.get(hint) ?? null;
3003
+ }
3004
+ if (hint.customerId) return hint.customerId;
3005
+ if (hint.userId) return this.customerIdAliases.get(hint.userId) ?? null;
3006
+ if (hint.anonymousId) return this.customerIdAliases.get(hint.anonymousId) ?? null;
3007
+ return null;
3008
+ }
490
3009
  identityPayload(hints) {
491
3010
  const payload = {};
492
3011
  if (typeof hints.customerId === "string" && hints.customerId) {
@@ -507,7 +3026,27 @@ var CrossdeckServer = class {
507
3026
  }
508
3027
  return payload;
509
3028
  }
510
- normalizeEvent(event) {
3029
+ /**
3030
+ * Resolve event identity. Caller-supplied wins; falls back to
3031
+ * `processAnonymousId` so events from `captureError` /
3032
+ * uncaughtException always have at least one identity hint.
3033
+ */
3034
+ resolveIdentity(event) {
3035
+ const out = {};
3036
+ if (event.developerUserId) out.developerUserId = event.developerUserId;
3037
+ if (event.anonymousId) out.anonymousId = event.anonymousId;
3038
+ if (event.crossdeckCustomerId) out.crossdeckCustomerId = event.crossdeckCustomerId;
3039
+ if (!out.developerUserId && !out.anonymousId && !out.crossdeckCustomerId) {
3040
+ out.anonymousId = this.processAnonymousId;
3041
+ }
3042
+ return out;
3043
+ }
3044
+ /**
3045
+ * Strict normalisation for `ingest()` — no auto-fill of identity,
3046
+ * caller must supply at least one hint per event. Matches v0.1.0
3047
+ * behaviour for backward compatibility with bulk-import callers.
3048
+ */
3049
+ normalizeIngestEvent(event) {
511
3050
  if (!event.name) {
512
3051
  throw new CrossdeckError({
513
3052
  type: "invalid_request_error",
@@ -527,18 +3066,10 @@ var CrossdeckServer = class {
527
3066
  return {
528
3067
  ...event,
529
3068
  properties,
530
- eventId: event.eventId ?? this.mintEventId(),
3069
+ eventId: event.eventId ?? mintId("evt", 8),
531
3070
  timestamp: event.timestamp ?? Date.now()
532
3071
  };
533
3072
  }
534
- mintEventId() {
535
- const ts = Date.now().toString(36);
536
- return `evt_${ts}${randomUUID().replace(/-/g, "").slice(0, 8)}`;
537
- }
538
- mintBatchId() {
539
- const ts = Date.now().toString(36);
540
- return `batch_${ts}${randomUUID().replace(/-/g, "").slice(0, 8)}`;
541
- }
542
3073
  };
543
3074
  function sanitizePropertyBag(input, fieldName) {
544
3075
  if (input === void 0) return void 0;
@@ -552,12 +3083,344 @@ function sanitizePropertyBag(input, fieldName) {
552
3083
  });
553
3084
  }
554
3085
  }
3086
+ function categoryFor(name) {
3087
+ if (name.startsWith("page.") || name.startsWith("navigation.")) return "navigation";
3088
+ if (name.startsWith("element.") || name.startsWith("ui.click")) return "ui.click";
3089
+ if (name.startsWith("http.") || name === "request.handled") return "http";
3090
+ return "custom";
3091
+ }
3092
+ var MAX_CUSTOMER_ID_ALIASES = 1e4;
3093
+ function inferEnvFromKey(secretKey) {
3094
+ return secretKey.startsWith("cd_sk_live_") ? "production" : "sandbox";
3095
+ }
3096
+ async function runBulkOperation(inputs, maxConcurrency, op) {
3097
+ const results = new Array(inputs.length);
3098
+ let cursor = 0;
3099
+ const workers = new Array(Math.min(maxConcurrency, Math.max(1, inputs.length))).fill(0).map(async () => {
3100
+ while (true) {
3101
+ const index = cursor++;
3102
+ if (index >= inputs.length) return;
3103
+ const input = inputs[index];
3104
+ try {
3105
+ const value = await op(input);
3106
+ results[index] = { input, ok: true, value };
3107
+ } catch (err) {
3108
+ results[index] = {
3109
+ input,
3110
+ ok: false,
3111
+ error: err instanceof CrossdeckError ? err : new CrossdeckError({
3112
+ type: "internal_error",
3113
+ code: "bulk_operation_failed",
3114
+ message: err instanceof Error ? err.message : String(err)
3115
+ })
3116
+ };
3117
+ }
3118
+ }
3119
+ });
3120
+ await Promise.all(workers);
3121
+ return results;
3122
+ }
3123
+ function maskSecretKey(secretKey) {
3124
+ const m = secretKey.match(/^cd_sk_(test|live)_/);
3125
+ const prefix = m ? m[0] : secretKey.slice(0, 11);
3126
+ const tail = secretKey.length > prefix.length + 4 ? secretKey.slice(-4) : "";
3127
+ return `${prefix}****${tail}`;
3128
+ }
3129
+
3130
+ // src/error-codes.ts
3131
+ var _CROSSDECK_ERROR_CODES = Object.freeze([
3132
+ // ----- Configuration -----
3133
+ {
3134
+ code: "invalid_secret_key",
3135
+ type: "configuration_error",
3136
+ description: "The secret key passed to new CrossdeckServer({ secretKey }) doesn't start with cd_sk_.",
3137
+ resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page. Server SDKs use cd_sk_test_\u2026 (sandbox) or cd_sk_live_\u2026 (production). Never ship this key to a browser.",
3138
+ retryable: false
3139
+ },
3140
+ // ----- Argument validation -----
3141
+ {
3142
+ code: "missing_user_id",
3143
+ type: "invalid_request_error",
3144
+ description: "identify() / aliasIdentity() called with an empty userId.",
3145
+ resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
3146
+ retryable: false
3147
+ },
3148
+ {
3149
+ code: "missing_anonymous_id",
3150
+ type: "invalid_request_error",
3151
+ description: "aliasIdentity() called with an empty anonymousId.",
3152
+ resolution: "Pass the anonymousId originally minted by the web SDK on this user's device.",
3153
+ retryable: false
3154
+ },
3155
+ {
3156
+ code: "missing_customer_id",
3157
+ type: "invalid_request_error",
3158
+ description: "An operation that requires a Crossdeck customer ID was called with an empty value.",
3159
+ resolution: "Pass the customerId returned from a prior identify() / getEntitlements() call.",
3160
+ retryable: false
3161
+ },
3162
+ {
3163
+ code: "missing_identity",
3164
+ type: "invalid_request_error",
3165
+ description: "An ingest / forget / entitlements call received no identity hints.",
3166
+ resolution: "Pass at least one of customerId, userId, or anonymousId on the call (or per-event for ingest).",
3167
+ retryable: false
3168
+ },
3169
+ {
3170
+ code: "missing_event_name",
3171
+ type: "invalid_request_error",
3172
+ description: "track() / ingest() received an event without a name.",
3173
+ resolution: "Pass a non-empty string as the event name. The wire shape is { name, properties? }.",
3174
+ retryable: false
3175
+ },
3176
+ {
3177
+ code: "missing_events",
3178
+ type: "invalid_request_error",
3179
+ description: "ingest() received an empty array.",
3180
+ resolution: "Pass at least one event. Use server.track(event) to send a single event.",
3181
+ retryable: false
3182
+ },
3183
+ {
3184
+ code: "missing_event_id",
3185
+ type: "invalid_request_error",
3186
+ description: "getAuditEntry() called with an empty eventId.",
3187
+ resolution: "Pass the eventId from the audit row you want to inspect.",
3188
+ retryable: false
3189
+ },
3190
+ {
3191
+ code: "missing_signed_transaction_info",
3192
+ type: "invalid_request_error",
3193
+ description: "syncPurchases() called without StoreKit 2 signed transaction info.",
3194
+ resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
3195
+ retryable: false
3196
+ },
3197
+ {
3198
+ code: "missing_group_type",
3199
+ type: "invalid_request_error",
3200
+ description: "group(type, id) called with an empty type.",
3201
+ resolution: 'Pass a non-empty group type (e.g. "org", "team", "plan") as the first argument.',
3202
+ retryable: false
3203
+ },
3204
+ {
3205
+ code: "serialization_failed",
3206
+ type: "invalid_request_error",
3207
+ description: "An event payload or trait bag could not be JSON-serialised even after sanitisation.",
3208
+ resolution: "Inspect the payload for non-JSON-friendly values (functions, symbols, deeply circular refs). The SDK's validator drops these by default, so this usually means a bug \u2014 file an issue with the payload shape.",
3209
+ retryable: false
3210
+ },
3211
+ // ----- Network / transport -----
3212
+ {
3213
+ code: "fetch_failed",
3214
+ type: "network_error",
3215
+ description: "The underlying fetch() call failed (typically a network outage, DNS, or refused connection).",
3216
+ resolution: "Check the host's outbound network. The SDK retries automatically with exponential backoff + jitter for queued events.",
3217
+ retryable: true
3218
+ },
3219
+ {
3220
+ code: "request_timeout",
3221
+ type: "network_error",
3222
+ description: "A request was aborted after the configured timeoutMs (default 15s).",
3223
+ resolution: "Check the host's network. Increase timeoutMs in CrossdeckServer options if you're on a known-slow link.",
3224
+ retryable: true
3225
+ },
3226
+ {
3227
+ code: "invalid_json_response",
3228
+ type: "internal_error",
3229
+ description: "The server returned a 2xx with an unparseable body.",
3230
+ resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
3231
+ retryable: true
3232
+ },
3233
+ // ----- Lifecycle (Node-specific) -----
3234
+ {
3235
+ code: "flush_on_exit_failed",
3236
+ type: "internal_error",
3237
+ description: "The on-exit drain (beforeExit / SIGTERM / SIGINT) did not complete before flushOnExitTimeoutMs.",
3238
+ resolution: "Increase flushOnExitTimeoutMs in CrossdeckServer options. Default is 2000ms; serverless runtimes typically allow 5-10s before SIGKILL. If events are dropping silently in production, raise this.",
3239
+ retryable: false
3240
+ },
3241
+ // ----- Webhook verification (Node-specific) -----
3242
+ {
3243
+ code: "webhook_invalid_signature",
3244
+ type: "authentication_error",
3245
+ description: "The webhook signature header did not verify against the supplied secret.",
3246
+ resolution: "Confirm the secret matches the one in your Crossdeck dashboard \u2192 Webhooks page. If the request is genuinely from Crossdeck, the secret is wrong, stale, or recently rotated.",
3247
+ retryable: false
3248
+ },
3249
+ {
3250
+ code: "webhook_replay_window_exceeded",
3251
+ type: "authentication_error",
3252
+ description: "The webhook timestamp is older than the replay-tolerance window (default 5 minutes).",
3253
+ resolution: "The webhook is either replayed or your receiving clock is wildly skewed. Verify NTP on the receiving host. Increase replayToleranceMs only if you accept the replay-attack risk.",
3254
+ retryable: false
3255
+ },
3256
+ {
3257
+ code: "webhook_missing_secret",
3258
+ type: "configuration_error",
3259
+ description: "verifyWebhookSignature() was called without a signing secret.",
3260
+ resolution: "Pass the secret from your Crossdeck dashboard \u2192 Webhooks page. Never hardcode in source \u2014 read from an env var.",
3261
+ retryable: false
3262
+ }
3263
+ ]);
3264
+ function isCrossdeckErrorCode(code) {
3265
+ return _CROSSDECK_ERROR_CODES.some((e) => e.code === code);
3266
+ }
3267
+ var CROSSDECK_ERROR_CODES = _CROSSDECK_ERROR_CODES;
3268
+ function getErrorCode(code) {
3269
+ return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
3270
+ }
3271
+
3272
+ // src/webhooks.ts
3273
+ import { createHmac, timingSafeEqual } from "crypto";
3274
+ var DEFAULT_REPLAY_TOLERANCE_MS = 5 * 60 * 1e3;
3275
+ function verifyWebhookSignature(payload, signatureHeader, secret, options = {}) {
3276
+ const secrets = normaliseSecrets(secret);
3277
+ if (secrets.length === 0) {
3278
+ throw new CrossdeckError({
3279
+ type: "configuration_error",
3280
+ code: "webhook_missing_secret",
3281
+ message: "verifyWebhookSignature requires a non-empty secret. Read it from process.env.CROSSDECK_WEBHOOK_SECRET \u2014 never hardcode in source."
3282
+ });
3283
+ }
3284
+ const header = normaliseHeader(signatureHeader);
3285
+ const parsed = parseSignatureHeader(header);
3286
+ if (!parsed) {
3287
+ throw new CrossdeckError({
3288
+ type: "authentication_error",
3289
+ code: "webhook_invalid_signature",
3290
+ message: "Webhook signature header is missing or malformed. Expected 'Crossdeck-Signature: t=<unix>,v1=<hex>'."
3291
+ });
3292
+ }
3293
+ const tolerance = options.replayToleranceMs ?? DEFAULT_REPLAY_TOLERANCE_MS;
3294
+ if (tolerance > 0) {
3295
+ const now = (options.now ?? Date.now)();
3296
+ const timestampMs = parsed.timestampSec * 1e3;
3297
+ const drift = Math.abs(now - timestampMs);
3298
+ if (drift > tolerance) {
3299
+ throw new CrossdeckError({
3300
+ type: "authentication_error",
3301
+ code: "webhook_replay_window_exceeded",
3302
+ message: `Webhook timestamp is ${drift}ms outside the ${tolerance}ms replay-tolerance window. Either the request is replayed or the receiving clock is skewed \u2014 verify NTP on the host.`
3303
+ });
3304
+ }
3305
+ }
3306
+ const signedPayload = `${parsed.timestampSec}.${payload}`;
3307
+ const expectedBuf = Buffer.from(parsed.signature, "hex");
3308
+ if (expectedBuf.length === 0) {
3309
+ throw new CrossdeckError({
3310
+ type: "authentication_error",
3311
+ code: "webhook_invalid_signature",
3312
+ message: "Webhook signature is not a valid hex string."
3313
+ });
3314
+ }
3315
+ const anyMatch = secrets.some((s) => {
3316
+ const computed = createHmac("sha256", s).update(signedPayload).digest();
3317
+ return computed.length === expectedBuf.length && timingSafeEqual(computed, expectedBuf);
3318
+ });
3319
+ if (!anyMatch) {
3320
+ throw new CrossdeckError({
3321
+ type: "authentication_error",
3322
+ code: "webhook_invalid_signature",
3323
+ message: "Webhook signature did not verify. Confirm the secret matches your Crossdeck dashboard \u2192 Webhooks page (and that you're not on a stale rotation)."
3324
+ });
3325
+ }
3326
+ try {
3327
+ return JSON.parse(payload);
3328
+ } catch {
3329
+ throw new CrossdeckError({
3330
+ type: "authentication_error",
3331
+ code: "webhook_invalid_signature",
3332
+ message: "Webhook signature verified but the payload is not valid JSON. Either the payload was tampered with after signing, or the webhook source is misconfigured."
3333
+ });
3334
+ }
3335
+ }
3336
+ function signWebhookPayload(payload, secret, timestampSec) {
3337
+ return createHmac("sha256", secret).update(`${timestampSec}.${payload}`).digest("hex");
3338
+ }
3339
+ function parseSignatureHeader(header) {
3340
+ if (!header) return null;
3341
+ let timestampSec = null;
3342
+ let signature = null;
3343
+ for (const part of header.split(",")) {
3344
+ const eqIdx = part.indexOf("=");
3345
+ if (eqIdx <= 0) continue;
3346
+ const key = part.slice(0, eqIdx).trim();
3347
+ const value = part.slice(eqIdx + 1).trim();
3348
+ if (key === "t") {
3349
+ const n = Number(value);
3350
+ if (Number.isFinite(n) && n > 0) timestampSec = Math.floor(n);
3351
+ } else if (key === "v1") {
3352
+ if (/^[0-9a-fA-F]+$/.test(value)) signature = value.toLowerCase();
3353
+ }
3354
+ }
3355
+ if (timestampSec === null || signature === null) return null;
3356
+ return { timestampSec, signature };
3357
+ }
3358
+ function normaliseHeader(input) {
3359
+ if (input === void 0) return null;
3360
+ if (Array.isArray(input)) return input[0] ?? null;
3361
+ return input;
3362
+ }
3363
+ function normaliseSecrets(input) {
3364
+ if (input === void 0 || input === null) return [];
3365
+ const arr = Array.isArray(input) ? input : [input];
3366
+ return arr.filter((s) => typeof s === "string" && s.length > 0);
3367
+ }
3368
+
3369
+ // src/consent.ts
3370
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
3371
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
3372
+ var REPLACEMENT_EMAIL = "[email]";
3373
+ var REPLACEMENT_CARD = "[card]";
3374
+ function scrubPii(value) {
3375
+ if (!value) return value;
3376
+ let out = value;
3377
+ if (EMAIL_PATTERN.test(out)) {
3378
+ out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
3379
+ }
3380
+ EMAIL_PATTERN.lastIndex = 0;
3381
+ if (CARD_PATTERN.test(out)) {
3382
+ out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
3383
+ }
3384
+ CARD_PATTERN.lastIndex = 0;
3385
+ return out;
3386
+ }
3387
+ function scrubPiiFromProperties(properties) {
3388
+ const out = {};
3389
+ for (const k of Object.keys(properties)) {
3390
+ out[k] = scrubValue(properties[k]);
3391
+ }
3392
+ return out;
3393
+ }
3394
+ function scrubValue(v) {
3395
+ if (typeof v === "string") return scrubPii(v);
3396
+ if (Array.isArray(v)) return v.map(scrubValue);
3397
+ if (v && typeof v === "object" && v.constructor === Object) {
3398
+ return scrubPiiFromProperties(v);
3399
+ }
3400
+ return v;
3401
+ }
555
3402
  export {
3403
+ CROSSDECK_API_VERSION,
3404
+ CROSSDECK_ERROR_CODES,
3405
+ CrossdeckAuthenticationError,
3406
+ CrossdeckConfigurationError,
556
3407
  CrossdeckError,
3408
+ CrossdeckInternalError,
3409
+ CrossdeckNetworkError,
3410
+ CrossdeckPermissionError,
3411
+ CrossdeckRateLimitError,
557
3412
  CrossdeckServer,
3413
+ CrossdeckValidationError,
558
3414
  DEFAULT_BASE_URL,
559
3415
  DEFAULT_TIMEOUT_MS,
560
3416
  SDK_NAME,
561
- SDK_VERSION
3417
+ SDK_VERSION,
3418
+ getErrorCode,
3419
+ isCrossdeckErrorCode,
3420
+ makeCrossdeckError,
3421
+ scrubPii,
3422
+ scrubPiiFromProperties,
3423
+ signWebhookPayload,
3424
+ verifyWebhookSignature
562
3425
  };
563
3426
  //# sourceMappingURL=index.mjs.map