@cross-deck/web 1.0.1 → 1.1.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/react.cjs CHANGED
@@ -93,7 +93,7 @@ function typeMapForStatus(status) {
93
93
 
94
94
  // src/http.ts
95
95
  var SDK_NAME = "@cross-deck/web";
96
- var SDK_VERSION = "1.0.0";
96
+ var SDK_VERSION = "1.1.0";
97
97
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
98
98
  var DEFAULT_TIMEOUT_MS = 15e3;
99
99
  var HttpClient = class {
@@ -341,70 +341,120 @@ function randomChars(count) {
341
341
  }
342
342
 
343
343
  // src/entitlement-cache.ts
344
+ var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
344
345
  var EntitlementCache = class {
345
- constructor() {
346
- this.active = /* @__PURE__ */ new Set();
346
+ /**
347
+ * @param storage Device storage adapter. When omitted (tests) or
348
+ * a MemoryStorage (strict-consent / no-persistence
349
+ * mode) the cache is session-only — durability is
350
+ * simply absent, never wrong.
351
+ * @param storageKey Full key the persisted blob lives under.
352
+ * @param staleAfterMs Age past which last-known-good is flagged stale
353
+ * even without a failed refresh. Default 24h.
354
+ */
355
+ constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
347
356
  this.all = [];
348
357
  this.lastUpdated = 0;
358
+ this.lastRefreshFailedAt = 0;
349
359
  this.listeners = /* @__PURE__ */ new Set();
350
360
  this.listenerErrorCount = 0;
361
+ this.storage = storage;
362
+ this.storageKey = storageKey;
363
+ this.staleAfterMs = staleAfterMs;
364
+ this.hydrate();
351
365
  }
352
- /** Sync read — true iff the entitlement key is currently active. */
366
+ /**
367
+ * Sync read — true iff the entitlement is currently granting access.
368
+ *
369
+ * Served from last-known-good: a stale cache (server unreachable since
370
+ * the last successful fetch) still answers true for a still-valid
371
+ * entitlement. The ONLY thing that turns it false is the entitlement's
372
+ * own expiry (validUntil) — never overall cache staleness.
373
+ */
353
374
  isEntitled(key) {
354
- return this.active.has(key);
375
+ const nowSec = Date.now() / 1e3;
376
+ return this.all.some(
377
+ (e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
378
+ );
355
379
  }
356
380
  /** Full snapshot for callers that need source / validUntil details. */
357
381
  list() {
358
382
  return this.all.slice();
359
383
  }
360
- /** When the cache was last refreshed. 0 means "never". */
384
+ /** When the cache was last refreshed from the server. 0 means "never". */
361
385
  get freshness() {
362
386
  return this.lastUpdated;
363
387
  }
364
388
  /**
365
- * Cumulative count of listener invocations that threw. Listener errors
366
- * are swallowed (a buggy consumer must not crash the SDK) but the
367
- * counter lets diagnostics() surface "you have a broken subscriber"
368
- * without putting the developer in a debug session.
389
+ * Whether the cache is knowingly serving older-than-trustworthy data.
390
+ *
391
+ * True when the most recent refresh ATTEMPT failed (Crossdeck
392
+ * unreachable since the last success the outage case, distinct from
393
+ * a benign idle tab that simply hasn't re-fetched), OR when
394
+ * last-known-good has aged past staleAfterMs.
395
+ *
396
+ * isStale never changes what isEntitled() returns — the cache still
397
+ * serves last-known-good. It exists so the staleness is observable
398
+ * (diagnostics()) instead of an unbounded silent window where a
399
+ * revoked customer holds access with nobody able to see it.
369
400
  */
401
+ get isStale() {
402
+ if (this.lastRefreshFailedAt > this.lastUpdated) return true;
403
+ return this.lastUpdated > 0 && Date.now() - this.lastUpdated > this.staleAfterMs;
404
+ }
405
+ /** Epoch ms of the last failed refresh attempt. 0 if none since the last success. */
406
+ get refreshFailedAt() {
407
+ return this.lastRefreshFailedAt;
408
+ }
370
409
  get listenerErrors() {
371
410
  return this.listenerErrorCount;
372
411
  }
373
412
  /**
374
- * Replace the cache with a fresh server response. The backend already
375
- * filters to active + env-matching, so we don't re-filter — just trust
376
- * what we got.
413
+ * Record that a refresh attempt failed (Crossdeck unreachable / a
414
+ * transient error). The SDK's getEntitlements() calls this in its
415
+ * catch path. It does NOT touch the cached entitlements — last-known-
416
+ * good keeps serving — it only flips isStale so the staleness shows
417
+ * up in diagnostics() rather than being silent.
418
+ */
419
+ markRefreshFailed() {
420
+ this.lastRefreshFailedAt = Date.now();
421
+ }
422
+ /**
423
+ * Replace the cache with a fresh server response and persist it to
424
+ * device storage so it survives a reload / app restart.
377
425
  *
378
- * Fires listeners AFTER the mutation so each listener sees the new state.
426
+ * Called ONLY after a successful server read a failed fetch throws
427
+ * before it reaches here, so last-known-good is preserved through an
428
+ * outage. A success also clears the stale flag.
379
429
  */
380
430
  setFromList(entitlements) {
381
431
  this.all = entitlements.slice();
382
- this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
383
432
  this.lastUpdated = Date.now();
433
+ this.lastRefreshFailedAt = 0;
434
+ this.persist();
384
435
  this.notify();
385
436
  }
386
437
  /**
387
- * Wipe — used on reset() (logout). The SDK forgets everything until
388
- * the next identify + read.
389
- *
390
- * Fires listeners so React/SwiftUI/etc bindings re-render to the
391
- * logged-out state immediately.
438
+ * Wipe — used on reset() (logout) and on an identity switch. Clears
439
+ * BOTH memory and durable storage so a prior user's entitlements can
440
+ * never leak to the next person on this device.
392
441
  */
393
442
  clear() {
394
- this.active.clear();
395
443
  this.all = [];
396
444
  this.lastUpdated = 0;
445
+ this.lastRefreshFailedAt = 0;
446
+ if (this.storage) {
447
+ try {
448
+ this.storage.removeItem(this.storageKey);
449
+ } catch {
450
+ }
451
+ }
397
452
  this.notify();
398
453
  }
399
454
  /**
400
- * Subscribe to cache mutations. Returns an unsubscribe function.
401
- *
402
- * The listener is invoked AFTER setFromList() or clear() with the
403
- * current snapshot. Throwing inside a listener is non-fatal — the
404
- * error is swallowed and subsequent listeners still run.
405
- *
406
- * Used by `@cross-deck/web/react`'s `useEntitlement` hook to
407
- * trigger re-renders when entitlements change.
455
+ * Subscribe to cache mutations. Returns an idempotent unsubscribe fn.
456
+ * The listener fires AFTER setFromList() or clear() with the current
457
+ * snapshot. Used by `@cross-deck/web/react`'s `useEntitlement` hook.
408
458
  */
409
459
  subscribe(listener) {
410
460
  this.listeners.add(listener);
@@ -415,6 +465,39 @@ var EntitlementCache = class {
415
465
  this.listeners.delete(listener);
416
466
  };
417
467
  }
468
+ // ----- Durable persistence -----
469
+ /**
470
+ * Load last-known-good from device storage. Runs once in the
471
+ * constructor, synchronously, so isEntitled() is correct from boot.
472
+ * Any corrupt / unparseable blob degrades silently to an empty cache —
473
+ * boot must never throw.
474
+ */
475
+ hydrate() {
476
+ if (!this.storage) return;
477
+ try {
478
+ const raw = this.storage.getItem(this.storageKey);
479
+ if (!raw) return;
480
+ const parsed = JSON.parse(raw);
481
+ if (parsed && parsed.v === 1 && Array.isArray(parsed.entitlements)) {
482
+ this.all = parsed.entitlements;
483
+ this.lastUpdated = typeof parsed.lastUpdated === "number" ? parsed.lastUpdated : 0;
484
+ }
485
+ } catch {
486
+ }
487
+ }
488
+ /** Write last-known-good to device storage. Best-effort. */
489
+ persist() {
490
+ if (!this.storage) return;
491
+ try {
492
+ const blob = {
493
+ v: 1,
494
+ entitlements: this.all,
495
+ lastUpdated: this.lastUpdated
496
+ };
497
+ this.storage.setItem(this.storageKey, JSON.stringify(blob));
498
+ } catch {
499
+ }
500
+ }
418
501
  notify() {
419
502
  if (this.listeners.size === 0) return;
420
503
  const snapshot = this.all.slice();
@@ -1211,11 +1294,50 @@ function isInsidePasswordField(el) {
1211
1294
  return false;
1212
1295
  }
1213
1296
  function extractText(el) {
1297
+ const clean = (s) => s.replace(/\s+/g, " ").trim();
1298
+ const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
1299
+ if (explicit) {
1300
+ const t = clean(explicit);
1301
+ if (t) return t;
1302
+ }
1214
1303
  const aria = el.getAttribute("aria-label");
1215
- if (aria) return aria.replace(/\s+/g, " ").trim();
1216
- if (el instanceof HTMLInputElement && el.value) return el.value;
1217
- const text = (el.textContent || "").replace(/\s+/g, " ").trim();
1218
- return text;
1304
+ if (aria) {
1305
+ const t = clean(aria);
1306
+ if (t) return t;
1307
+ }
1308
+ const labelledBy = el.getAttribute("aria-labelledby");
1309
+ if (labelledBy && typeof el.ownerDocument?.getElementById === "function") {
1310
+ const parts = [];
1311
+ for (const id of labelledBy.split(/\s+/)) {
1312
+ const ref = el.ownerDocument.getElementById(id);
1313
+ const t = ref?.textContent ? clean(ref.textContent) : "";
1314
+ if (t) parts.push(t);
1315
+ }
1316
+ if (parts.length > 0) return parts.join(" ");
1317
+ }
1318
+ if (el instanceof HTMLInputElement && el.value) {
1319
+ const t = clean(el.value);
1320
+ if (t) return t;
1321
+ }
1322
+ const text = clean(el.textContent || "");
1323
+ if (text) return text;
1324
+ const title = el.getAttribute("title");
1325
+ if (title) {
1326
+ const t = clean(title);
1327
+ if (t) return t;
1328
+ }
1329
+ const img = el.querySelector("img[alt]");
1330
+ if (img) {
1331
+ const alt = img.getAttribute("alt") ?? "";
1332
+ const t = clean(alt);
1333
+ if (t) return t;
1334
+ }
1335
+ const svgTitle = el.querySelector("svg title");
1336
+ if (svgTitle?.textContent) {
1337
+ const t = clean(svgTitle.textContent);
1338
+ if (t) return t;
1339
+ }
1340
+ return "";
1219
1341
  }
1220
1342
  function trimText(s, cap) {
1221
1343
  if (s.length <= cap) return s;
@@ -1920,13 +2042,22 @@ function isInAppFrame(filename) {
1920
2042
  if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
1921
2043
  return true;
1922
2044
  }
1923
- function fingerprintError(message, frames) {
2045
+ function fingerprintError(message, frames, location) {
1924
2046
  const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
1925
- const key = [
2047
+ const parts = [
1926
2048
  (message || "").slice(0, 200),
1927
2049
  ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
1928
- ].join("|");
1929
- return djb2Hex(key);
2050
+ ];
2051
+ if (inAppFrames.length === 0 && location) {
2052
+ const loc = [
2053
+ location.errorType ?? "",
2054
+ location.filename ?? "",
2055
+ location.lineno ?? "",
2056
+ location.colno ?? ""
2057
+ ].join(":");
2058
+ if (loc !== ":::") parts.push(loc);
2059
+ }
2060
+ return djb2Hex(parts.join("|"));
1930
2061
  }
1931
2062
  function djb2Hex(input) {
1932
2063
  let h = 5381;
@@ -1948,11 +2079,17 @@ var DEFAULT_ERROR_CAPTURE = {
1948
2079
  // Classic browser noise. These aren't application bugs.
1949
2080
  "ResizeObserver loop limit exceeded",
1950
2081
  "ResizeObserver loop completed with undelivered notifications",
1951
- "Non-Error promise rejection captured",
1952
- // Cross-origin script errors that the browser strips no info,
1953
- // no way to act on them, just noise.
1954
- "Script error.",
1955
- "Script error"
2082
+ "Non-Error promise rejection captured"
2083
+ // NOTE: We deliberately do NOT drop cross-origin "Script error."
2084
+ // events here. They used to be silently filtered as noise, but
2085
+ // silent drops are the opposite of "developer sleeps well at
2086
+ // night". We now capture them with a clear label and the
2087
+ // `cross_origin` tag so the dashboard surfaces them as a
2088
+ // distinct, actionable category (the fix is always the same —
2089
+ // add `crossorigin="anonymous"` to the script tag + CORS
2090
+ // headers on the script's origin). Apps that genuinely want
2091
+ // them muted can re-add "Script error" to ignoreErrors via
2092
+ // init config.
1956
2093
  ],
1957
2094
  allowUrls: [],
1958
2095
  denyUrls: [
@@ -2192,61 +2329,84 @@ var ErrorTracker = class {
2192
2329
  // ============================================================
2193
2330
  buildFromErrorEvent(event) {
2194
2331
  const err = event.error;
2195
- const message = event.message || (err instanceof Error ? err.message : "Unknown error");
2332
+ const filename = event.filename || null;
2333
+ const lineno = typeof event.lineno === "number" && event.lineno > 0 ? event.lineno : null;
2334
+ const colno = typeof event.colno === "number" && event.colno > 0 ? event.colno : null;
2335
+ const isCrossOriginStripped = err == null && !filename && lineno == null && (event.message === "Script error." || event.message === "Script error" || !event.message);
2336
+ if (isCrossOriginStripped) {
2337
+ const message2 = "Cross-origin script error (browser hid details \u2014 script needs crossorigin attribute + CORS headers)";
2338
+ return {
2339
+ timestamp: Date.now(),
2340
+ kind: "error.unhandled",
2341
+ level: "error",
2342
+ message: message2,
2343
+ errorType: "ScriptError",
2344
+ frames: [],
2345
+ rawStack: null,
2346
+ filename: null,
2347
+ lineno: null,
2348
+ colno: null,
2349
+ // No location to fingerprint by — all of these will share one
2350
+ // group, which is correct: developer fixes them all with the
2351
+ // same CORS change.
2352
+ fingerprint: fingerprintError(message2, []),
2353
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2354
+ context: this.opts.getContext(),
2355
+ tags: { ...this.opts.getTags(), cross_origin: "true" }
2356
+ };
2357
+ }
2358
+ const payload = coerceErrorPayload(err);
2359
+ const message = (payload.message || event.message || "Unknown error").slice(0, 1024);
2196
2360
  const stack = err instanceof Error ? err.stack ?? null : null;
2197
2361
  const frames = parseStack(stack);
2362
+ const errorType = payload.errorType ?? null;
2363
+ const context = payload.extras ? { ...this.opts.getContext(), __error_extras: payload.extras } : this.opts.getContext();
2198
2364
  return {
2199
2365
  timestamp: Date.now(),
2200
2366
  kind: "error.unhandled",
2201
2367
  level: "error",
2202
- message: String(message).slice(0, 1024),
2203
- errorType: err instanceof Error ? err.name : null,
2368
+ message,
2369
+ errorType,
2204
2370
  frames,
2205
2371
  rawStack: stack,
2206
- filename: event.filename || null,
2207
- lineno: typeof event.lineno === "number" ? event.lineno : null,
2208
- colno: typeof event.colno === "number" ? event.colno : null,
2209
- fingerprint: fingerprintError(message, frames),
2372
+ filename,
2373
+ lineno,
2374
+ colno,
2375
+ // Location fallback ensures distinct call sites stay separate
2376
+ // even when the message is generic ("Unknown error",
2377
+ // "[object Object]") and there are no parseable frames.
2378
+ fingerprint: fingerprintError(message, frames, {
2379
+ filename,
2380
+ lineno,
2381
+ colno,
2382
+ errorType
2383
+ }),
2210
2384
  breadcrumbs: this.opts.breadcrumbs.snapshot(),
2211
- context: this.opts.getContext(),
2385
+ context,
2212
2386
  tags: this.opts.getTags()
2213
2387
  };
2214
2388
  }
2215
2389
  buildFromUnknown(err, kind, level) {
2216
- if (err instanceof Error) {
2217
- const frames = parseStack(err.stack);
2218
- return {
2219
- timestamp: Date.now(),
2220
- kind,
2221
- level,
2222
- message: String(err.message).slice(0, 1024),
2223
- errorType: err.name,
2224
- frames,
2225
- rawStack: err.stack ?? null,
2226
- filename: null,
2227
- lineno: null,
2228
- colno: null,
2229
- fingerprint: fingerprintError(err.message, frames),
2230
- breadcrumbs: this.opts.breadcrumbs.snapshot(),
2231
- context: this.opts.getContext(),
2232
- tags: this.opts.getTags()
2233
- };
2234
- }
2235
- const message = safeStringify2(err).slice(0, 1024);
2390
+ const payload = coerceErrorPayload(err);
2391
+ const message = (payload.message || "Unknown error").slice(0, 1024);
2392
+ const stack = err instanceof Error ? err.stack ?? null : null;
2393
+ const frames = parseStack(stack);
2394
+ const errorType = payload.errorType ?? null;
2395
+ const context = payload.extras ? { ...this.opts.getContext(), __error_extras: payload.extras } : this.opts.getContext();
2236
2396
  return {
2237
2397
  timestamp: Date.now(),
2238
2398
  kind,
2239
2399
  level,
2240
2400
  message,
2241
- errorType: null,
2242
- frames: [],
2243
- rawStack: null,
2401
+ errorType,
2402
+ frames,
2403
+ rawStack: stack,
2244
2404
  filename: null,
2245
2405
  lineno: null,
2246
2406
  colno: null,
2247
- fingerprint: fingerprintError(message, []),
2407
+ fingerprint: fingerprintError(message, frames, { errorType }),
2248
2408
  breadcrumbs: this.opts.breadcrumbs.snapshot(),
2249
- context: this.opts.getContext(),
2409
+ context,
2250
2410
  tags: this.opts.getTags()
2251
2411
  };
2252
2412
  }
@@ -2264,7 +2424,10 @@ var ErrorTracker = class {
2264
2424
  filename: info.url,
2265
2425
  lineno: null,
2266
2426
  colno: null,
2267
- fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
2427
+ fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, [], {
2428
+ filename: info.url,
2429
+ errorType: "HTTPError"
2430
+ }),
2268
2431
  breadcrumbs: this.opts.breadcrumbs.snapshot(),
2269
2432
  context: this.opts.getContext(),
2270
2433
  tags: this.opts.getTags(),
@@ -2343,16 +2506,131 @@ var ErrorTracker = class {
2343
2506
  return true;
2344
2507
  }
2345
2508
  };
2346
- function safeStringify2(v) {
2347
- if (v == null) return String(v);
2348
- if (typeof v === "string") return v;
2349
- if (typeof v === "number" || typeof v === "boolean") return String(v);
2509
+ function coerceErrorPayload(v) {
2510
+ if (v === null) return { message: "(thrown: null)", errorType: null, extras: null };
2511
+ if (v === void 0) return { message: "(thrown: undefined)", errorType: null, extras: null };
2512
+ if (typeof v === "string") {
2513
+ return { message: v, errorType: null, extras: null };
2514
+ }
2515
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") {
2516
+ return { message: String(v), errorType: typeof v, extras: null };
2517
+ }
2518
+ if (typeof v === "symbol") {
2519
+ return { message: v.toString(), errorType: "symbol", extras: null };
2520
+ }
2521
+ if (typeof v === "function") {
2522
+ return { message: `(thrown function: ${v.name || "anonymous"})`, errorType: "function", extras: null };
2523
+ }
2524
+ if (v instanceof Error) {
2525
+ const errorType = v.name || v.constructor?.name || "Error";
2526
+ const message = typeof v.message === "string" && v.message.length > 0 ? v.message : safeToString(v) || errorType;
2527
+ const extras = {};
2528
+ const causeChain = collectCauseChain(v);
2529
+ if (causeChain.length > 0) extras.cause = causeChain;
2530
+ for (const key of ["code", "status", "statusCode", "errno", "response", "data", "detail", "details"]) {
2531
+ const val = v[key];
2532
+ if (val !== void 0 && typeof val !== "function") {
2533
+ extras[key] = safeClone(val);
2534
+ }
2535
+ }
2536
+ for (const key of Object.keys(v)) {
2537
+ if (key === "message" || key === "stack" || key === "name" || key === "cause") continue;
2538
+ if (key in extras) continue;
2539
+ const val = v[key];
2540
+ if (typeof val === "function") continue;
2541
+ extras[key] = safeClone(val);
2542
+ }
2543
+ return {
2544
+ message,
2545
+ errorType,
2546
+ extras: Object.keys(extras).length > 0 ? extras : null
2547
+ };
2548
+ }
2549
+ if (typeof Response !== "undefined" && v instanceof Response) {
2550
+ return {
2551
+ message: `HTTP ${v.status} ${v.statusText || ""}${v.url ? ` ${v.url}` : ""}`.trim(),
2552
+ errorType: "Response",
2553
+ extras: { status: v.status, statusText: v.statusText, url: v.url, type: v.type }
2554
+ };
2555
+ }
2556
+ if (typeof v === "object") {
2557
+ const obj = v;
2558
+ const ctorName = obj.constructor && typeof obj.constructor === "function" && obj.constructor.name || null;
2559
+ const ownMessage = typeof obj.message === "string" && obj.message ? obj.message : null;
2560
+ const ownName = typeof obj.name === "string" && obj.name ? obj.name : null;
2561
+ let jsonForm = null;
2562
+ try {
2563
+ const serialised = JSON.stringify(obj);
2564
+ jsonForm = serialised === "{}" ? null : serialised;
2565
+ } catch {
2566
+ jsonForm = null;
2567
+ }
2568
+ const fallbackString = safeToString(obj);
2569
+ const message = ownMessage ?? jsonForm ?? (fallbackString && fallbackString !== "[object Object]" ? fallbackString : null) ?? (ctorName ? `(thrown ${ctorName} with no message)` : "(thrown object with no message)");
2570
+ const errorType = ownName ?? ctorName ?? null;
2571
+ const extras = {};
2572
+ let count = 0;
2573
+ for (const key of Object.keys(obj)) {
2574
+ if (count >= 20) break;
2575
+ if (key === "message" || key === "name") continue;
2576
+ const val = obj[key];
2577
+ if (typeof val === "function") continue;
2578
+ extras[key] = safeClone(val);
2579
+ count++;
2580
+ }
2581
+ return {
2582
+ message,
2583
+ errorType,
2584
+ extras: Object.keys(extras).length > 0 ? extras : null
2585
+ };
2586
+ }
2587
+ return { message: safeToString(v) || "(unstringifiable thrown value)", errorType: null, extras: null };
2588
+ }
2589
+ function collectCauseChain(err) {
2590
+ const out = [];
2591
+ let cur = err.cause;
2592
+ let depth = 0;
2593
+ while (cur != null && depth < 5) {
2594
+ if (cur instanceof Error) {
2595
+ out.push({ name: cur.name || "Error", message: cur.message || "" });
2596
+ cur = cur.cause;
2597
+ } else {
2598
+ out.push({ name: "non-Error", message: safeToString(cur) });
2599
+ cur = null;
2600
+ }
2601
+ depth++;
2602
+ }
2603
+ return out;
2604
+ }
2605
+ function safeToString(v) {
2350
2606
  try {
2351
- return JSON.stringify(v);
2607
+ const s = Object.prototype.toString.call(v);
2608
+ if (s !== "[object Object]") return s;
2609
+ const own = v?.toString;
2610
+ if (typeof own === "function" && own !== Object.prototype.toString) {
2611
+ const r = own.call(v);
2612
+ if (typeof r === "string") return r;
2613
+ }
2614
+ return s;
2352
2615
  } catch {
2353
- return Object.prototype.toString.call(v);
2616
+ return "(throwing toString)";
2354
2617
  }
2355
2618
  }
2619
+ function safeClone(v) {
2620
+ if (v == null) return v;
2621
+ const t = typeof v;
2622
+ if (t === "string" || t === "number" || t === "boolean") return v;
2623
+ if (t === "bigint") return String(v);
2624
+ try {
2625
+ const s = JSON.stringify(v);
2626
+ return s === void 0 ? safeToString(v) : JSON.parse(s);
2627
+ } catch {
2628
+ return safeToString(v);
2629
+ }
2630
+ }
2631
+ function safeStringify2(v) {
2632
+ return coerceErrorPayload(v).message;
2633
+ }
2356
2634
 
2357
2635
  // src/crossdeck.ts
2358
2636
  var CrossdeckClient = class {
@@ -2442,7 +2720,10 @@ var CrossdeckClient = class {
2442
2720
  typeof globalThis.document !== "undefined";
2443
2721
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
2444
2722
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
2445
- const entitlements = new EntitlementCache();
2723
+ const entitlements = new EntitlementCache(
2724
+ effectiveStorage,
2725
+ opts.storagePrefix + "entitlements"
2726
+ );
2446
2727
  const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
2447
2728
  if (persistentEvents) {
2448
2729
  debug.emit(
@@ -2622,6 +2903,10 @@ var CrossdeckClient = class {
2622
2903
  const result = await s.http.request("POST", "/identity/alias", {
2623
2904
  body
2624
2905
  });
2906
+ const priorCdcust = s.identity.crossdeckCustomerId;
2907
+ if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
2908
+ s.entitlements.clear();
2909
+ }
2625
2910
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2626
2911
  s.developerUserId = userId;
2627
2912
  return result;
@@ -2856,11 +3141,17 @@ var CrossdeckClient = class {
2856
3141
  async getEntitlements() {
2857
3142
  const s = this.requireStarted();
2858
3143
  const query = this.identityQueryParams();
2859
- const result = await s.http.request(
2860
- "GET",
2861
- "/entitlements",
2862
- { query }
2863
- );
3144
+ let result;
3145
+ try {
3146
+ result = await s.http.request(
3147
+ "GET",
3148
+ "/entitlements",
3149
+ { query }
3150
+ );
3151
+ } catch (err) {
3152
+ s.entitlements.markRefreshFailed();
3153
+ throw err;
3154
+ }
2864
3155
  if (result.crossdeckCustomerId) {
2865
3156
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2866
3157
  }
@@ -2868,8 +3159,12 @@ var CrossdeckClient = class {
2868
3159
  return result.data;
2869
3160
  }
2870
3161
  /**
2871
- * Synchronous read from the local cache. Returns false if the cache
2872
- * has never been populated (call getEntitlements first to warm it).
3162
+ * Synchronous read from the durable local cache answers from
3163
+ * last-known-good. The cache hydrates from device storage on boot and
3164
+ * survives a Crossdeck outage, so a returning paying customer reads
3165
+ * true even before the session's first network round-trip. Returns
3166
+ * false only for a genuinely new install that has never completed a
3167
+ * getEntitlements(), or for an entitlement past its own validUntil.
2873
3168
  */
2874
3169
  isEntitled(key) {
2875
3170
  const s = this.requireStarted();
@@ -3145,7 +3440,7 @@ var CrossdeckClient = class {
3145
3440
  sdkVersion: null,
3146
3441
  baseUrl: null,
3147
3442
  clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
3148
- entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
3443
+ entitlements: { count: 0, lastUpdated: 0, stale: false, listenerErrors: 0 },
3149
3444
  events: {
3150
3445
  buffered: 0,
3151
3446
  dropped: 0,
@@ -3174,6 +3469,7 @@ var CrossdeckClient = class {
3174
3469
  entitlements: {
3175
3470
  count: s.entitlements.list().length,
3176
3471
  lastUpdated: s.entitlements.freshness,
3472
+ stale: s.entitlements.isStale,
3177
3473
  listenerErrors: s.entitlements.listenerErrors
3178
3474
  },
3179
3475
  events: s.events.getStats()