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