@cross-deck/node 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,25 @@
1
- export { A as AliasIdentityInput, a as AliasResult, b as AuditDecision, c as AuditEntry, B as Breadcrumb, d as BreadcrumbCategory, e as BreadcrumbLevel, C as CROSSDECK_API_VERSION, f as CapturedError, g as CrossdeckAuthenticationError, h as CrossdeckConfigurationError, i as CrossdeckError, j as CrossdeckErrorPayload, k as CrossdeckErrorType, l as CrossdeckInternalError, m as CrossdeckNetworkError, n as CrossdeckPermissionError, o as CrossdeckRateLimitError, p as CrossdeckServer, q as CrossdeckServerOptions, r as CrossdeckValidationError, D as DEFAULT_BASE_URL, s as DEFAULT_TIMEOUT_MS, t as Diagnostics, E as EntitlementCacheOptions, u as EntitlementMutationResult, v as EntitlementStore, w as EntitlementsListResponse, x as EntitlementsListener, y as Environment, z as ErrorCaptureConfig, F as ErrorLevel, G as EventProperties, H as ForgetResult, I as GrantDuration, J as GrantEntitlementInput, K as GroupMembership, L as HeartbeatResponse, M as HttpRequestInfo, N as HttpResponseInfo, O as HttpRetriesConfig, P as IdentifyOptions, Q as IdentityHints, R as IngestOptions, S as IngestResponse, T as PublicEntitlement, U as PurchaseResult, V as RequestOptions, W as RevokeEntitlementInput, X as RuntimeHost, Y as RuntimeInfo, Z as SDK_NAME, _ as SDK_VERSION, $ as ServerEvent, a0 as StackFrame, a1 as StoredEntitlements, a2 as SyncPurchaseInput, a3 as makeCrossdeckError } from './crossdeck-server-BZVZEuS-.mjs';
1
+ export { A as AliasIdentityInput, a as AliasResult, b as AuditDecision, c as AuditEntry, B as Breadcrumb, d as BreadcrumbCategory, e as BreadcrumbLevel, C as CROSSDECK_API_VERSION, f as CapturedError, g as CrossdeckAuthenticationError, h as CrossdeckConfigurationError, i as CrossdeckError, j as CrossdeckErrorPayload, k as CrossdeckErrorType, l as CrossdeckInternalError, m as CrossdeckNetworkError, n as CrossdeckPermissionError, o as CrossdeckRateLimitError, p as CrossdeckServer, q as CrossdeckServerOptions, r as CrossdeckValidationError, D as DEFAULT_BASE_URL, s as DEFAULT_TIMEOUT_MS, t as Diagnostics, E as EntitlementCacheOptions, u as EntitlementMutationResult, v as EntitlementStore, w as EntitlementsListResponse, x as EntitlementsListener, y as Environment, z as ErrorCaptureConfig, F as ErrorLevel, G as EventProperties, H as ForgetResult, I as GrantDuration, J as GrantEntitlementInput, K as GroupMembership, L as HeartbeatResponse, M as HttpRequestInfo, N as HttpResponseInfo, O as HttpRetriesConfig, P as IdentifyOptions, Q as IdentityHints, R as IngestOptions, S as IngestResponse, T as PublicEntitlement, U as PurchaseResult, V as RequestOptions, W as RevokeEntitlementInput, X as RuntimeHost, Y as RuntimeInfo, Z as ServerEvent, _ as StackFrame, $ as StoredEntitlements, a0 as SyncPurchaseInput, a1 as makeCrossdeckError } from './crossdeck-server-DhnHvUhh.mjs';
2
2
  import 'node:events';
3
3
 
4
+ /**
5
+ * SDK version constant — generated by `scripts/sync-sdk-versions.mjs`.
6
+ *
7
+ * Single source of truth: the `version` field in this package's
8
+ * package.json. The sync script writes this file so that
9
+ * `SDK_VERSION` is a plain TypeScript literal at runtime — no
10
+ * runtime JSON-import gotcha (Node ESM requires
11
+ * `with { type: "json" }` to import JSON as ESM, and the published
12
+ * dist file would otherwise fail to load).
13
+ *
14
+ * Drift protection: `node scripts/sync-sdk-versions.mjs --check` (the
15
+ * CI gate) flags this file when it falls out of sync with package.json.
16
+ * Bumping `package.json` without re-running the sync script fails CI.
17
+ *
18
+ * Do NOT edit by hand — `node scripts/sync-sdk-versions.mjs`.
19
+ */
20
+ declare const SDK_VERSION = "1.3.1";
21
+ declare const SDK_NAME = "@cross-deck/node";
22
+
4
23
  /**
5
24
  * Machine-readable index of every error code `@cross-deck/node` can
6
25
  * throw, with a short description and a hint on what action to take.
@@ -289,16 +308,24 @@ declare function signWebhookPayload(payload: string, secret: string, timestampSe
289
308
  * name: "checkout.started",
290
309
  * developerUserId: userId,
291
310
  * properties: scrubPiiFromProperties({
292
- * url: req.url, // might contain "/users/wes@…/" — gets [email]
311
+ * url: req.url, // might contain "/users/wes@…/" — gets <email>
293
312
  * lastError: e.message, // might contain card numbers
294
313
  * }),
295
314
  * });
296
315
  */
297
316
  /**
298
317
  * Scrub a single string value: replace email-shaped substrings with
299
- * `[email]` and card-number-shaped substrings with `[card]`. Returns
300
- * the original string (===) when nothing matched, so callers can do
301
- * an identity-check to skip allocating a new event copy.
318
+ * `<email>` and card-number-shaped substrings with `<card>`. Returns
319
+ * the original string (===) when nothing matched.
320
+ *
321
+ * Implementation note: we call `.replace()` unconditionally rather than
322
+ * gating on `.test()`. The /g regexes are module-level so `.test()`
323
+ * carries `lastIndex` state between calls — a prior match leaves
324
+ * `lastIndex` mid-string and the next `.test()` can falsely return
325
+ * false on a string that DOES match. `.replace(/g)` always scans the
326
+ * full string regardless of `lastIndex`, so dropping the test-guard
327
+ * removes the sharp edge at zero cost (when nothing matches, replace
328
+ * returns the same `(===)` string).
302
329
  */
303
330
  declare function scrubPii(value: string): string;
304
331
  /**
@@ -339,7 +366,7 @@ declare function scrubPiiFromProperties(properties: Record<string, unknown>): Re
339
366
  * - `sdk.no_durable_store`
340
367
  * - `sdk.super_property_registered`
341
368
  */
342
- type DebugSignal = "sdk.configured" | "sdk.first_event_sent" | "sdk.invalid_key" | "sdk.no_identity" | "sdk.entitlement_cache_used" | "sdk.entitlement_cache_warm" | "sdk.entitlement_cache_stale" | "sdk.entitlement_store_recovered" | "sdk.no_durable_store" | "sdk.purchase_evidence_sent" | "sdk.environment_mismatch" | "sdk.sensitive_property_warning" | "sdk.property_coerced" | "sdk.flush_retry_scheduled" | "sdk.flush_on_exit_started" | "sdk.flush_on_exit_completed" | "sdk.webhook_verified" | "sdk.runtime_detected" | "sdk.super_property_registered" | "sdk.boot_heartbeat_failed";
369
+ type DebugSignal = "sdk.configured" | "sdk.first_event_sent" | "sdk.invalid_key" | "sdk.no_identity" | "sdk.entitlement_cache_used" | "sdk.entitlement_cache_warm" | "sdk.entitlement_cache_stale" | "sdk.entitlement_store_recovered" | "sdk.no_durable_store" | "sdk.purchase_evidence_sent" | "sdk.environment_mismatch" | "sdk.sensitive_property_warning" | "sdk.property_coerced" | "sdk.flush_retry_scheduled" | "sdk.flush_permanent_failure" | "sdk.flush_on_exit_started" | "sdk.flush_on_exit_completed" | "sdk.webhook_verified" | "sdk.runtime_detected" | "sdk.super_property_registered" | "sdk.boot_heartbeat_failed";
343
370
  interface DebugContext {
344
371
  [key: string]: unknown;
345
372
  }
@@ -348,4 +375,4 @@ interface DebugLogger {
348
375
  emit(signal: DebugSignal, message: string, context?: DebugContext): void;
349
376
  }
350
377
 
351
- export { CROSSDECK_ERROR_CODES, type CrossdeckErrorCode, type DebugContext, type DebugLogger, type DebugSignal, type ErrorCodeEntry, type VerifyWebhookOptions, getErrorCode, isCrossdeckErrorCode, scrubPii, scrubPiiFromProperties, signWebhookPayload, verifyWebhookSignature };
378
+ export { CROSSDECK_ERROR_CODES, type CrossdeckErrorCode, type DebugContext, type DebugLogger, type DebugSignal, type ErrorCodeEntry, SDK_NAME, SDK_VERSION, type VerifyWebhookOptions, getErrorCode, isCrossdeckErrorCode, scrubPii, scrubPiiFromProperties, signWebhookPayload, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,25 @@
1
- export { A as AliasIdentityInput, a as AliasResult, b as AuditDecision, c as AuditEntry, B as Breadcrumb, d as BreadcrumbCategory, e as BreadcrumbLevel, C as CROSSDECK_API_VERSION, f as CapturedError, g as CrossdeckAuthenticationError, h as CrossdeckConfigurationError, i as CrossdeckError, j as CrossdeckErrorPayload, k as CrossdeckErrorType, l as CrossdeckInternalError, m as CrossdeckNetworkError, n as CrossdeckPermissionError, o as CrossdeckRateLimitError, p as CrossdeckServer, q as CrossdeckServerOptions, r as CrossdeckValidationError, D as DEFAULT_BASE_URL, s as DEFAULT_TIMEOUT_MS, t as Diagnostics, E as EntitlementCacheOptions, u as EntitlementMutationResult, v as EntitlementStore, w as EntitlementsListResponse, x as EntitlementsListener, y as Environment, z as ErrorCaptureConfig, F as ErrorLevel, G as EventProperties, H as ForgetResult, I as GrantDuration, J as GrantEntitlementInput, K as GroupMembership, L as HeartbeatResponse, M as HttpRequestInfo, N as HttpResponseInfo, O as HttpRetriesConfig, P as IdentifyOptions, Q as IdentityHints, R as IngestOptions, S as IngestResponse, T as PublicEntitlement, U as PurchaseResult, V as RequestOptions, W as RevokeEntitlementInput, X as RuntimeHost, Y as RuntimeInfo, Z as SDK_NAME, _ as SDK_VERSION, $ as ServerEvent, a0 as StackFrame, a1 as StoredEntitlements, a2 as SyncPurchaseInput, a3 as makeCrossdeckError } from './crossdeck-server-BZVZEuS-.js';
1
+ export { A as AliasIdentityInput, a as AliasResult, b as AuditDecision, c as AuditEntry, B as Breadcrumb, d as BreadcrumbCategory, e as BreadcrumbLevel, C as CROSSDECK_API_VERSION, f as CapturedError, g as CrossdeckAuthenticationError, h as CrossdeckConfigurationError, i as CrossdeckError, j as CrossdeckErrorPayload, k as CrossdeckErrorType, l as CrossdeckInternalError, m as CrossdeckNetworkError, n as CrossdeckPermissionError, o as CrossdeckRateLimitError, p as CrossdeckServer, q as CrossdeckServerOptions, r as CrossdeckValidationError, D as DEFAULT_BASE_URL, s as DEFAULT_TIMEOUT_MS, t as Diagnostics, E as EntitlementCacheOptions, u as EntitlementMutationResult, v as EntitlementStore, w as EntitlementsListResponse, x as EntitlementsListener, y as Environment, z as ErrorCaptureConfig, F as ErrorLevel, G as EventProperties, H as ForgetResult, I as GrantDuration, J as GrantEntitlementInput, K as GroupMembership, L as HeartbeatResponse, M as HttpRequestInfo, N as HttpResponseInfo, O as HttpRetriesConfig, P as IdentifyOptions, Q as IdentityHints, R as IngestOptions, S as IngestResponse, T as PublicEntitlement, U as PurchaseResult, V as RequestOptions, W as RevokeEntitlementInput, X as RuntimeHost, Y as RuntimeInfo, Z as ServerEvent, _ as StackFrame, $ as StoredEntitlements, a0 as SyncPurchaseInput, a1 as makeCrossdeckError } from './crossdeck-server-DhnHvUhh.js';
2
2
  import 'node:events';
3
3
 
4
+ /**
5
+ * SDK version constant — generated by `scripts/sync-sdk-versions.mjs`.
6
+ *
7
+ * Single source of truth: the `version` field in this package's
8
+ * package.json. The sync script writes this file so that
9
+ * `SDK_VERSION` is a plain TypeScript literal at runtime — no
10
+ * runtime JSON-import gotcha (Node ESM requires
11
+ * `with { type: "json" }` to import JSON as ESM, and the published
12
+ * dist file would otherwise fail to load).
13
+ *
14
+ * Drift protection: `node scripts/sync-sdk-versions.mjs --check` (the
15
+ * CI gate) flags this file when it falls out of sync with package.json.
16
+ * Bumping `package.json` without re-running the sync script fails CI.
17
+ *
18
+ * Do NOT edit by hand — `node scripts/sync-sdk-versions.mjs`.
19
+ */
20
+ declare const SDK_VERSION = "1.3.1";
21
+ declare const SDK_NAME = "@cross-deck/node";
22
+
4
23
  /**
5
24
  * Machine-readable index of every error code `@cross-deck/node` can
6
25
  * throw, with a short description and a hint on what action to take.
@@ -289,16 +308,24 @@ declare function signWebhookPayload(payload: string, secret: string, timestampSe
289
308
  * name: "checkout.started",
290
309
  * developerUserId: userId,
291
310
  * properties: scrubPiiFromProperties({
292
- * url: req.url, // might contain "/users/wes@…/" — gets [email]
311
+ * url: req.url, // might contain "/users/wes@…/" — gets <email>
293
312
  * lastError: e.message, // might contain card numbers
294
313
  * }),
295
314
  * });
296
315
  */
297
316
  /**
298
317
  * Scrub a single string value: replace email-shaped substrings with
299
- * `[email]` and card-number-shaped substrings with `[card]`. Returns
300
- * the original string (===) when nothing matched, so callers can do
301
- * an identity-check to skip allocating a new event copy.
318
+ * `<email>` and card-number-shaped substrings with `<card>`. Returns
319
+ * the original string (===) when nothing matched.
320
+ *
321
+ * Implementation note: we call `.replace()` unconditionally rather than
322
+ * gating on `.test()`. The /g regexes are module-level so `.test()`
323
+ * carries `lastIndex` state between calls — a prior match leaves
324
+ * `lastIndex` mid-string and the next `.test()` can falsely return
325
+ * false on a string that DOES match. `.replace(/g)` always scans the
326
+ * full string regardless of `lastIndex`, so dropping the test-guard
327
+ * removes the sharp edge at zero cost (when nothing matches, replace
328
+ * returns the same `(===)` string).
302
329
  */
303
330
  declare function scrubPii(value: string): string;
304
331
  /**
@@ -339,7 +366,7 @@ declare function scrubPiiFromProperties(properties: Record<string, unknown>): Re
339
366
  * - `sdk.no_durable_store`
340
367
  * - `sdk.super_property_registered`
341
368
  */
342
- type DebugSignal = "sdk.configured" | "sdk.first_event_sent" | "sdk.invalid_key" | "sdk.no_identity" | "sdk.entitlement_cache_used" | "sdk.entitlement_cache_warm" | "sdk.entitlement_cache_stale" | "sdk.entitlement_store_recovered" | "sdk.no_durable_store" | "sdk.purchase_evidence_sent" | "sdk.environment_mismatch" | "sdk.sensitive_property_warning" | "sdk.property_coerced" | "sdk.flush_retry_scheduled" | "sdk.flush_on_exit_started" | "sdk.flush_on_exit_completed" | "sdk.webhook_verified" | "sdk.runtime_detected" | "sdk.super_property_registered" | "sdk.boot_heartbeat_failed";
369
+ type DebugSignal = "sdk.configured" | "sdk.first_event_sent" | "sdk.invalid_key" | "sdk.no_identity" | "sdk.entitlement_cache_used" | "sdk.entitlement_cache_warm" | "sdk.entitlement_cache_stale" | "sdk.entitlement_store_recovered" | "sdk.no_durable_store" | "sdk.purchase_evidence_sent" | "sdk.environment_mismatch" | "sdk.sensitive_property_warning" | "sdk.property_coerced" | "sdk.flush_retry_scheduled" | "sdk.flush_permanent_failure" | "sdk.flush_on_exit_started" | "sdk.flush_on_exit_completed" | "sdk.webhook_verified" | "sdk.runtime_detected" | "sdk.super_property_registered" | "sdk.boot_heartbeat_failed";
343
370
  interface DebugContext {
344
371
  [key: string]: unknown;
345
372
  }
@@ -348,4 +375,4 @@ interface DebugLogger {
348
375
  emit(signal: DebugSignal, message: string, context?: DebugContext): void;
349
376
  }
350
377
 
351
- export { CROSSDECK_ERROR_CODES, type CrossdeckErrorCode, type DebugContext, type DebugLogger, type DebugSignal, type ErrorCodeEntry, type VerifyWebhookOptions, getErrorCode, isCrossdeckErrorCode, scrubPii, scrubPiiFromProperties, signWebhookPayload, verifyWebhookSignature };
378
+ export { CROSSDECK_ERROR_CODES, type CrossdeckErrorCode, type DebugContext, type DebugLogger, type DebugSignal, type ErrorCodeEntry, SDK_NAME, SDK_VERSION, type VerifyWebhookOptions, getErrorCode, isCrossdeckErrorCode, scrubPii, scrubPiiFromProperties, signWebhookPayload, verifyWebhookSignature };
package/dist/index.mjs CHANGED
@@ -316,9 +316,11 @@ function byteLength(s) {
316
316
  return s.length * 4;
317
317
  }
318
318
 
319
- // src/http.ts
319
+ // src/_version.ts
320
+ var SDK_VERSION = "1.3.1";
320
321
  var SDK_NAME = "@cross-deck/node";
321
- var SDK_VERSION = "1.2.0";
322
+
323
+ // src/http.ts
322
324
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
323
325
  var DEFAULT_TIMEOUT_MS = 15e3;
324
326
  var CROSSDECK_API_VERSION = "2025-01-01";
@@ -831,6 +833,7 @@ var EventQueue = class {
831
833
  sdk: env.sdk
832
834
  };
833
835
  if (env.appId) body.appId = env.appId;
836
+ if (env.environment) body.environment = env.environment;
834
837
  const result = await this.cfg.http.request("POST", "/events", {
835
838
  body,
836
839
  idempotencyKey: batchId
@@ -849,6 +852,20 @@ var EventQueue = class {
849
852
  } catch (err) {
850
853
  const message = err instanceof Error ? err.message : String(err);
851
854
  this.lastError = message;
855
+ if (isPermanent4xx(err)) {
856
+ const droppedCount = batch.length;
857
+ this.pendingBatch = null;
858
+ this.pendingBatchId = null;
859
+ this.inFlight -= droppedCount;
860
+ this.dropped += droppedCount;
861
+ this.cfg.onDrop?.(droppedCount);
862
+ this.cfg.onPermanentFailure?.({
863
+ status: err.status ?? 0,
864
+ droppedCount,
865
+ lastError: message
866
+ });
867
+ return null;
868
+ }
852
869
  const retryAfterMs = extractRetryAfterMs(err);
853
870
  const delay = this.retry.nextDelay(retryAfterMs);
854
871
  this.scheduleRetry(delay);
@@ -927,6 +944,14 @@ function extractRetryAfterMs(err) {
927
944
  }
928
945
  return void 0;
929
946
  }
947
+ function isPermanent4xx(err) {
948
+ if (!err || typeof err !== "object") return false;
949
+ const status = err.status;
950
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
951
+ if (status < 400 || status >= 500) return false;
952
+ if (status === 408 || status === 429) return false;
953
+ return true;
954
+ }
930
955
  function defaultScheduler(fn, ms) {
931
956
  const id = setTimeout(fn, ms);
932
957
  if (typeof id.unref === "function") {
@@ -1216,16 +1241,18 @@ var ErrorTracker = class {
1216
1241
  const url = typeof input === "string" ? input : input?.url ?? "";
1217
1242
  const method = (init.method || "GET").toUpperCase();
1218
1243
  const start = Date.now();
1219
- tracker.opts.breadcrumbs.add({
1220
- timestamp: start,
1221
- category: "http",
1222
- message: `${method} ${url}`,
1223
- data: { url, method }
1224
- });
1244
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
1245
+ tracker.opts.breadcrumbs.add({
1246
+ timestamp: start,
1247
+ category: "http",
1248
+ message: `${method} ${url}`,
1249
+ data: { url, method }
1250
+ });
1251
+ }
1225
1252
  try {
1226
1253
  const response = await origFetch(...args);
1227
1254
  if (response.status >= 500 && tracker.opts.isConsented()) {
1228
- if (!url.includes("api.cross-deck.com")) {
1255
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
1229
1256
  tracker.captureHttp({
1230
1257
  url,
1231
1258
  method,
@@ -1343,9 +1370,10 @@ var ErrorTracker = class {
1343
1370
  if (!this.passesSample(err)) return;
1344
1371
  if (!this.passesRateLimit(err)) return;
1345
1372
  let finalErr = err;
1346
- if (this.opts.beforeSend) {
1373
+ const hook = this.opts.beforeSend?.();
1374
+ if (hook) {
1347
1375
  try {
1348
- finalErr = this.opts.beforeSend(err);
1376
+ finalErr = hook(err);
1349
1377
  } catch {
1350
1378
  finalErr = err;
1351
1379
  }
@@ -1571,6 +1599,22 @@ function safeClone(v) {
1571
1599
  function safeStringify3(v) {
1572
1600
  return coerceErrorPayload(v).message;
1573
1601
  }
1602
+ function extractSelfHostname(baseUrl) {
1603
+ if (!baseUrl || typeof baseUrl !== "string") return null;
1604
+ try {
1605
+ return new URL(baseUrl).hostname.toLowerCase();
1606
+ } catch {
1607
+ return null;
1608
+ }
1609
+ }
1610
+ function isSelfRequest(requestUrl, selfHostname) {
1611
+ if (!selfHostname || !requestUrl) return false;
1612
+ try {
1613
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
1614
+ } catch {
1615
+ return false;
1616
+ }
1617
+ }
1574
1618
 
1575
1619
  // src/runtime-info.ts
1576
1620
  import { hostname as osHostname, platform as osPlatform, release as osRelease } from "os";
@@ -2465,6 +2509,11 @@ var CrossdeckServer = class extends EventEmitter {
2465
2509
  intervalMs: options.eventFlushIntervalMs ?? 1500,
2466
2510
  envelope: () => ({
2467
2511
  appId: this.appId,
2512
+ // Ship env on every batch so the backend can cross-check
2513
+ // against the API-key-derived env and reject mismatches
2514
+ // loudly (env_mismatch). Web has always done this; node now
2515
+ // matches so defence-in-depth is symmetric across SDKs.
2516
+ environment: this.env,
2468
2517
  sdk: { name: SDK_NAME, version: this.sdkVersion }
2469
2518
  }),
2470
2519
  onDrop: (count) => {
@@ -2480,6 +2529,20 @@ var CrossdeckServer = class extends EventEmitter {
2480
2529
  nextRetryMs: info.delayMs
2481
2530
  });
2482
2531
  },
2532
+ onPermanentFailure: (info) => {
2533
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your secret key + app config.`;
2534
+ console.error(headline);
2535
+ this.debug.emit(
2536
+ "sdk.flush_permanent_failure",
2537
+ headline,
2538
+ { ...info }
2539
+ );
2540
+ this.emit("queue.permanent_failure", {
2541
+ status: info.status,
2542
+ droppedCount: info.droppedCount,
2543
+ error: info.lastError
2544
+ });
2545
+ },
2483
2546
  onFirstFlushSuccess: () => {
2484
2547
  this.debug.emit("sdk.first_event_sent", "First batch landed.");
2485
2548
  }
@@ -2494,14 +2557,20 @@ var CrossdeckServer = class extends EventEmitter {
2494
2557
  report: (err) => this.reportCapturedError(err),
2495
2558
  getContext: () => ({ ...this.errorContext }),
2496
2559
  getTags: () => ({ ...this.errorTags }),
2497
- beforeSend: null,
2498
- // wired via setErrorBeforeSend; ErrorTracker reads it through the live ref below
2499
- isConsented: () => true
2500
- });
2501
- const trackerOpts = this.errorTracker.opts;
2502
- Object.defineProperty(trackerOpts, "beforeSend", {
2503
- get: () => this.errorBeforeSend,
2504
- configurable: true
2560
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
2561
+ // `this.errorBeforeSend` after init() and the tracker MUST pick
2562
+ // up the new hook on the next error. Pre-fix we worked around
2563
+ // a captured-by-value field with `Object.defineProperty` on the
2564
+ // tracker's private opts; the contract is now a real getter so
2565
+ // we just hand it the closure and the hack is gone.
2566
+ beforeSend: () => this.errorBeforeSend,
2567
+ isConsented: () => true,
2568
+ // Derived from the configured baseUrl at construction time.
2569
+ // Used by the fetch wrapper to skip captureHttp on Crossdeck's
2570
+ // own requests — pre-fix the skip was hardcoded to
2571
+ // `api.cross-deck.com` and broke for customers on staging /
2572
+ // regional / self-hosted base URLs (recursive capture loop).
2573
+ selfHostname: extractSelfHostname(this.baseUrl)
2505
2574
  });
2506
2575
  this.errorTracker.install();
2507
2576
  }
@@ -2514,6 +2583,7 @@ var CrossdeckServer = class extends EventEmitter {
2514
2583
  });
2515
2584
  this.flushOnExit.install();
2516
2585
  }
2586
+ this.emitDurabilityWarning();
2517
2587
  if (options.testMode !== true && options.bootHeartbeat !== false) {
2518
2588
  setImmediate(() => {
2519
2589
  void this.heartbeat().catch((err) => {
@@ -2523,25 +2593,16 @@ var CrossdeckServer = class extends EventEmitter {
2523
2593
  { message: err instanceof Error ? err.message : String(err) }
2524
2594
  );
2525
2595
  });
2526
- this.emitBootTelemetry();
2596
+ this.emitBootTelemetryEvent();
2527
2597
  });
2528
2598
  }
2529
2599
  }
2530
2600
  /**
2531
- * Emit the one-time `sdk.boot` telemetry event and, when the runtime
2532
- * is serverless with no `entitlementStore`, the honest "no cold-start
2533
- * durability" warning.
2534
- *
2535
- * Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
2536
- * carries no request body, so it cannot transport a structured
2537
- * `durability` fact. The event pipeline can — every `track()` event
2538
- * lands as an aggregatable document the backend can query, so
2539
- * Crossdeck can compute fleet-wide "% serverless-with-no-durable-
2540
- * store" from `sdk.boot` events (denominator = all `sdk.boot`,
2541
- * numerator = those with `durability.coldStartDurable === false`).
2542
- * The event rides the existing batched + retried + idempotent queue
2543
- * and is drained by flush-on-exit, so it survives a serverless
2544
- * teardown — it is NOT a local-only debug log.
2601
+ * Emit the honest "no cold-start durability" warning when the runtime
2602
+ * is serverless AND no `entitlementStore` is wired. Local-only debug
2603
+ * signal — no network call, no phone-home. Safe to fire from the
2604
+ * constructor before `setImmediate` because there is no I/O on this
2605
+ * path.
2545
2606
  *
2546
2607
  * `isServerless` AND no store is the gap: a cold start begins with an
2547
2608
  * empty in-memory cache and a brief Crossdeck outage in that window
@@ -2549,14 +2610,15 @@ var CrossdeckServer = class extends EventEmitter {
2549
2610
  * unavoidable without a store — so the SDK STATES it (a
2550
2611
  * `sdk.no_durable_store` debug warning) rather than hiding it.
2551
2612
  *
2552
- * Called once, from the deferred boot block so it inherits the
2553
- * `testMode` / `bootHeartbeat:false` opt-outs and never fires before
2554
- * the constructor returns.
2613
+ * Audit P1 #9: this used to live INSIDE `emitBootTelemetry()` which
2614
+ * itself sat inside the `bootHeartbeat` gate, so any developer who
2615
+ * set `bootHeartbeat: false` silently disabled the entire reason
2616
+ * `entitlementStore` exists. Now split: warning fires
2617
+ * unconditionally; the boot phone-home stays gated.
2555
2618
  */
2556
- emitBootTelemetry() {
2619
+ emitDurabilityWarning() {
2557
2620
  const isServerless = this.runtime.isServerless;
2558
2621
  const hasStore = this.entitlementStore !== null;
2559
- const coldStartDurable = hasStore || !isServerless;
2560
2622
  if (isServerless && !hasStore) {
2561
2623
  this.debug.emit(
2562
2624
  "sdk.no_durable_store",
@@ -2564,6 +2626,26 @@ var CrossdeckServer = class extends EventEmitter {
2564
2626
  { host: this.runtime.host, isServerless, durableStore: false }
2565
2627
  );
2566
2628
  }
2629
+ }
2630
+ /**
2631
+ * Emit the one-time `sdk.boot` telemetry event — the aggregatable
2632
+ * fact the backend pivots on (compute fleet-wide
2633
+ * "% serverless-with-no-durable-store"). Rides the batched + retried
2634
+ * + idempotent queue and is drained by flush-on-exit, so it survives
2635
+ * a serverless teardown.
2636
+ *
2637
+ * Why a `track()` event and not the heartbeat: `GET /v1/sdk/heartbeat`
2638
+ * carries no request body, so it cannot transport a structured
2639
+ * `durability` fact.
2640
+ *
2641
+ * Gated by `bootHeartbeat` (and `testMode`) because it IS a phone-
2642
+ * home — the unconditional surface is `emitDurabilityWarning()`,
2643
+ * which has no network call.
2644
+ */
2645
+ emitBootTelemetryEvent() {
2646
+ const isServerless = this.runtime.isServerless;
2647
+ const hasStore = this.entitlementStore !== null;
2648
+ const coldStartDurable = hasStore || !isServerless;
2567
2649
  try {
2568
2650
  this.track({
2569
2651
  name: "sdk.boot",
@@ -2875,7 +2957,13 @@ var CrossdeckServer = class extends EventEmitter {
2875
2957
  const normalized = events.map((event) => this.normalizeIngestEvent(event));
2876
2958
  const body = {
2877
2959
  events: normalized,
2878
- sdk: { name: SDK_NAME, version: this.sdkVersion }
2960
+ sdk: { name: SDK_NAME, version: this.sdkVersion },
2961
+ // Match the queue's batch envelope (see event-queue.ts) — backend
2962
+ // cross-checks `environment` against the API-key-derived env and
2963
+ // rejects mismatches loudly (env_mismatch). Pre-fix this direct
2964
+ // ingest path skipped env, so a "live key, env: sandbox"
2965
+ // misconfig fell through silently for the bulk-import path.
2966
+ environment: this.env
2879
2967
  };
2880
2968
  if (this.appId) body.appId = this.appId;
2881
2969
  return this.http.request("POST", "/events", {
@@ -2940,8 +3028,9 @@ var CrossdeckServer = class extends EventEmitter {
2940
3028
  message: "syncPurchases requires a signedTransactionInfo string."
2941
3029
  });
2942
3030
  }
3031
+ const rail = input.rail ?? "apple";
2943
3032
  return this.http.request("POST", "/purchases/sync", {
2944
- body: { rail: input.rail ?? "apple", ...input },
3033
+ body: { ...input, rail },
2945
3034
  signal: options?.signal,
2946
3035
  timeoutMs: options?.timeoutMs
2947
3036
  });
@@ -3536,10 +3625,21 @@ var CrossdeckServer = class extends EventEmitter {
3536
3625
  * Resolve any hint shape (canonical customerId / userId hint /
3537
3626
  * anonymousId hint / raw string) to a `crossdeckCustomerId` if we
3538
3627
  * have a cache entry for it.
3628
+ *
3629
+ * String overload is STRICT on the canonical-id shape. Pre-fix
3630
+ * `isFresh(raw)` treated any string with a cache entry as a valid
3631
+ * canonical id — if tenant A's userId happened to collide with
3632
+ * tenant B's crossdeckCustomerId, A's call would resolve to B's
3633
+ * cached entitlements. Bounded by the `cdcust_` prefix convention
3634
+ * (which both SDKs and the backend mint, see
3635
+ * backend/src/lib/customers.ts) — anything else is treated purely
3636
+ * as an alias lookup, never as a canonical id. Audit P1 #19.
3539
3637
  */
3540
3638
  resolveCacheCustomerId(hint) {
3541
3639
  if (typeof hint === "string") {
3542
- if (this.entitlementCache.isFresh(hint)) return hint;
3640
+ if (hint.startsWith("cdcust_") && this.entitlementCache.isFresh(hint)) {
3641
+ return hint;
3642
+ }
3543
3643
  return this.customerIdAliases.get(hint) ?? null;
3544
3644
  }
3545
3645
  if (hint.customerId) return hint.customerId;
@@ -3915,20 +4015,11 @@ function normaliseSecrets(input) {
3915
4015
  // src/consent.ts
3916
4016
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
3917
4017
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
3918
- var REPLACEMENT_EMAIL = "[email]";
3919
- var REPLACEMENT_CARD = "[card]";
4018
+ var REPLACEMENT_EMAIL = "<email>";
4019
+ var REPLACEMENT_CARD = "<card>";
3920
4020
  function scrubPii(value) {
3921
4021
  if (!value) return value;
3922
- let out = value;
3923
- if (EMAIL_PATTERN.test(out)) {
3924
- out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
3925
- }
3926
- EMAIL_PATTERN.lastIndex = 0;
3927
- if (CARD_PATTERN.test(out)) {
3928
- out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
3929
- }
3930
- CARD_PATTERN.lastIndex = 0;
3931
- return out;
4022
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
3932
4023
  }
3933
4024
  function scrubPiiFromProperties(properties) {
3934
4025
  const out = {};