@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.
package/dist/vue.mjs CHANGED
@@ -68,7 +68,7 @@ function typeMapForStatus(status) {
68
68
 
69
69
  // src/http.ts
70
70
  var SDK_NAME = "@cross-deck/web";
71
- var SDK_VERSION = "1.0.0";
71
+ var SDK_VERSION = "1.1.0";
72
72
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
73
73
  var DEFAULT_TIMEOUT_MS = 15e3;
74
74
  var HttpClient = class {
@@ -316,70 +316,120 @@ function randomChars(count) {
316
316
  }
317
317
 
318
318
  // src/entitlement-cache.ts
319
+ var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
319
320
  var EntitlementCache = class {
320
- constructor() {
321
- this.active = /* @__PURE__ */ new Set();
321
+ /**
322
+ * @param storage Device storage adapter. When omitted (tests) or
323
+ * a MemoryStorage (strict-consent / no-persistence
324
+ * mode) the cache is session-only — durability is
325
+ * simply absent, never wrong.
326
+ * @param storageKey Full key the persisted blob lives under.
327
+ * @param staleAfterMs Age past which last-known-good is flagged stale
328
+ * even without a failed refresh. Default 24h.
329
+ */
330
+ constructor(storage, storageKey = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
322
331
  this.all = [];
323
332
  this.lastUpdated = 0;
333
+ this.lastRefreshFailedAt = 0;
324
334
  this.listeners = /* @__PURE__ */ new Set();
325
335
  this.listenerErrorCount = 0;
336
+ this.storage = storage;
337
+ this.storageKey = storageKey;
338
+ this.staleAfterMs = staleAfterMs;
339
+ this.hydrate();
326
340
  }
327
- /** Sync read — true iff the entitlement key is currently active. */
341
+ /**
342
+ * Sync read — true iff the entitlement is currently granting access.
343
+ *
344
+ * Served from last-known-good: a stale cache (server unreachable since
345
+ * the last successful fetch) still answers true for a still-valid
346
+ * entitlement. The ONLY thing that turns it false is the entitlement's
347
+ * own expiry (validUntil) — never overall cache staleness.
348
+ */
328
349
  isEntitled(key) {
329
- return this.active.has(key);
350
+ const nowSec = Date.now() / 1e3;
351
+ return this.all.some(
352
+ (e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
353
+ );
330
354
  }
331
355
  /** Full snapshot for callers that need source / validUntil details. */
332
356
  list() {
333
357
  return this.all.slice();
334
358
  }
335
- /** When the cache was last refreshed. 0 means "never". */
359
+ /** When the cache was last refreshed from the server. 0 means "never". */
336
360
  get freshness() {
337
361
  return this.lastUpdated;
338
362
  }
339
363
  /**
340
- * Cumulative count of listener invocations that threw. Listener errors
341
- * are swallowed (a buggy consumer must not crash the SDK) but the
342
- * counter lets diagnostics() surface "you have a broken subscriber"
343
- * without putting the developer in a debug session.
364
+ * Whether the cache is knowingly serving older-than-trustworthy data.
365
+ *
366
+ * True when the most recent refresh ATTEMPT failed (Crossdeck
367
+ * unreachable since the last success the outage case, distinct from
368
+ * a benign idle tab that simply hasn't re-fetched), OR when
369
+ * last-known-good has aged past staleAfterMs.
370
+ *
371
+ * isStale never changes what isEntitled() returns — the cache still
372
+ * serves last-known-good. It exists so the staleness is observable
373
+ * (diagnostics()) instead of an unbounded silent window where a
374
+ * revoked customer holds access with nobody able to see it.
344
375
  */
376
+ get isStale() {
377
+ if (this.lastRefreshFailedAt > this.lastUpdated) return true;
378
+ return this.lastUpdated > 0 && Date.now() - this.lastUpdated > this.staleAfterMs;
379
+ }
380
+ /** Epoch ms of the last failed refresh attempt. 0 if none since the last success. */
381
+ get refreshFailedAt() {
382
+ return this.lastRefreshFailedAt;
383
+ }
345
384
  get listenerErrors() {
346
385
  return this.listenerErrorCount;
347
386
  }
348
387
  /**
349
- * Replace the cache with a fresh server response. The backend already
350
- * filters to active + env-matching, so we don't re-filter — just trust
351
- * what we got.
388
+ * Record that a refresh attempt failed (Crossdeck unreachable / a
389
+ * transient error). The SDK's getEntitlements() calls this in its
390
+ * catch path. It does NOT touch the cached entitlements — last-known-
391
+ * good keeps serving — it only flips isStale so the staleness shows
392
+ * up in diagnostics() rather than being silent.
393
+ */
394
+ markRefreshFailed() {
395
+ this.lastRefreshFailedAt = Date.now();
396
+ }
397
+ /**
398
+ * Replace the cache with a fresh server response and persist it to
399
+ * device storage so it survives a reload / app restart.
352
400
  *
353
- * Fires listeners AFTER the mutation so each listener sees the new state.
401
+ * Called ONLY after a successful server read a failed fetch throws
402
+ * before it reaches here, so last-known-good is preserved through an
403
+ * outage. A success also clears the stale flag.
354
404
  */
355
405
  setFromList(entitlements) {
356
406
  this.all = entitlements.slice();
357
- this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
358
407
  this.lastUpdated = Date.now();
408
+ this.lastRefreshFailedAt = 0;
409
+ this.persist();
359
410
  this.notify();
360
411
  }
361
412
  /**
362
- * Wipe — used on reset() (logout). The SDK forgets everything until
363
- * the next identify + read.
364
- *
365
- * Fires listeners so React/SwiftUI/etc bindings re-render to the
366
- * logged-out state immediately.
413
+ * Wipe — used on reset() (logout) and on an identity switch. Clears
414
+ * BOTH memory and durable storage so a prior user's entitlements can
415
+ * never leak to the next person on this device.
367
416
  */
368
417
  clear() {
369
- this.active.clear();
370
418
  this.all = [];
371
419
  this.lastUpdated = 0;
420
+ this.lastRefreshFailedAt = 0;
421
+ if (this.storage) {
422
+ try {
423
+ this.storage.removeItem(this.storageKey);
424
+ } catch {
425
+ }
426
+ }
372
427
  this.notify();
373
428
  }
374
429
  /**
375
- * Subscribe to cache mutations. Returns an unsubscribe function.
376
- *
377
- * The listener is invoked AFTER setFromList() or clear() with the
378
- * current snapshot. Throwing inside a listener is non-fatal — the
379
- * error is swallowed and subsequent listeners still run.
380
- *
381
- * Used by `@cross-deck/web/react`'s `useEntitlement` hook to
382
- * trigger re-renders when entitlements change.
430
+ * Subscribe to cache mutations. Returns an idempotent unsubscribe fn.
431
+ * The listener fires AFTER setFromList() or clear() with the current
432
+ * snapshot. Used by `@cross-deck/web/react`'s `useEntitlement` hook.
383
433
  */
384
434
  subscribe(listener) {
385
435
  this.listeners.add(listener);
@@ -390,6 +440,39 @@ var EntitlementCache = class {
390
440
  this.listeners.delete(listener);
391
441
  };
392
442
  }
443
+ // ----- Durable persistence -----
444
+ /**
445
+ * Load last-known-good from device storage. Runs once in the
446
+ * constructor, synchronously, so isEntitled() is correct from boot.
447
+ * Any corrupt / unparseable blob degrades silently to an empty cache —
448
+ * boot must never throw.
449
+ */
450
+ hydrate() {
451
+ if (!this.storage) return;
452
+ try {
453
+ const raw = this.storage.getItem(this.storageKey);
454
+ if (!raw) return;
455
+ const parsed = JSON.parse(raw);
456
+ if (parsed && parsed.v === 1 && Array.isArray(parsed.entitlements)) {
457
+ this.all = parsed.entitlements;
458
+ this.lastUpdated = typeof parsed.lastUpdated === "number" ? parsed.lastUpdated : 0;
459
+ }
460
+ } catch {
461
+ }
462
+ }
463
+ /** Write last-known-good to device storage. Best-effort. */
464
+ persist() {
465
+ if (!this.storage) return;
466
+ try {
467
+ const blob = {
468
+ v: 1,
469
+ entitlements: this.all,
470
+ lastUpdated: this.lastUpdated
471
+ };
472
+ this.storage.setItem(this.storageKey, JSON.stringify(blob));
473
+ } catch {
474
+ }
475
+ }
393
476
  notify() {
394
477
  if (this.listeners.size === 0) return;
395
478
  const snapshot = this.all.slice();
@@ -1186,11 +1269,50 @@ function isInsidePasswordField(el) {
1186
1269
  return false;
1187
1270
  }
1188
1271
  function extractText(el) {
1272
+ const clean = (s) => s.replace(/\s+/g, " ").trim();
1273
+ const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
1274
+ if (explicit) {
1275
+ const t = clean(explicit);
1276
+ if (t) return t;
1277
+ }
1189
1278
  const aria = el.getAttribute("aria-label");
1190
- if (aria) return aria.replace(/\s+/g, " ").trim();
1191
- if (el instanceof HTMLInputElement && el.value) return el.value;
1192
- const text = (el.textContent || "").replace(/\s+/g, " ").trim();
1193
- return text;
1279
+ if (aria) {
1280
+ const t = clean(aria);
1281
+ if (t) return t;
1282
+ }
1283
+ const labelledBy = el.getAttribute("aria-labelledby");
1284
+ if (labelledBy && typeof el.ownerDocument?.getElementById === "function") {
1285
+ const parts = [];
1286
+ for (const id of labelledBy.split(/\s+/)) {
1287
+ const ref2 = el.ownerDocument.getElementById(id);
1288
+ const t = ref2?.textContent ? clean(ref2.textContent) : "";
1289
+ if (t) parts.push(t);
1290
+ }
1291
+ if (parts.length > 0) return parts.join(" ");
1292
+ }
1293
+ if (el instanceof HTMLInputElement && el.value) {
1294
+ const t = clean(el.value);
1295
+ if (t) return t;
1296
+ }
1297
+ const text = clean(el.textContent || "");
1298
+ if (text) return text;
1299
+ const title = el.getAttribute("title");
1300
+ if (title) {
1301
+ const t = clean(title);
1302
+ if (t) return t;
1303
+ }
1304
+ const img = el.querySelector("img[alt]");
1305
+ if (img) {
1306
+ const alt = img.getAttribute("alt") ?? "";
1307
+ const t = clean(alt);
1308
+ if (t) return t;
1309
+ }
1310
+ const svgTitle = el.querySelector("svg title");
1311
+ if (svgTitle?.textContent) {
1312
+ const t = clean(svgTitle.textContent);
1313
+ if (t) return t;
1314
+ }
1315
+ return "";
1194
1316
  }
1195
1317
  function trimText(s, cap) {
1196
1318
  if (s.length <= cap) return s;
@@ -1895,13 +2017,22 @@ function isInAppFrame(filename) {
1895
2017
  if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
1896
2018
  return true;
1897
2019
  }
1898
- function fingerprintError(message, frames) {
2020
+ function fingerprintError(message, frames, location) {
1899
2021
  const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
1900
- const key = [
2022
+ const parts = [
1901
2023
  (message || "").slice(0, 200),
1902
2024
  ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
1903
- ].join("|");
1904
- return djb2Hex(key);
2025
+ ];
2026
+ if (inAppFrames.length === 0 && location) {
2027
+ const loc = [
2028
+ location.errorType ?? "",
2029
+ location.filename ?? "",
2030
+ location.lineno ?? "",
2031
+ location.colno ?? ""
2032
+ ].join(":");
2033
+ if (loc !== ":::") parts.push(loc);
2034
+ }
2035
+ return djb2Hex(parts.join("|"));
1905
2036
  }
1906
2037
  function djb2Hex(input) {
1907
2038
  let h = 5381;
@@ -1923,11 +2054,17 @@ var DEFAULT_ERROR_CAPTURE = {
1923
2054
  // Classic browser noise. These aren't application bugs.
1924
2055
  "ResizeObserver loop limit exceeded",
1925
2056
  "ResizeObserver loop completed with undelivered notifications",
1926
- "Non-Error promise rejection captured",
1927
- // Cross-origin script errors that the browser strips no info,
1928
- // no way to act on them, just noise.
1929
- "Script error.",
1930
- "Script error"
2057
+ "Non-Error promise rejection captured"
2058
+ // NOTE: We deliberately do NOT drop cross-origin "Script error."
2059
+ // events here. They used to be silently filtered as noise, but
2060
+ // silent drops are the opposite of "developer sleeps well at
2061
+ // night". We now capture them with a clear label and the
2062
+ // `cross_origin` tag so the dashboard surfaces them as a
2063
+ // distinct, actionable category (the fix is always the same —
2064
+ // add `crossorigin="anonymous"` to the script tag + CORS
2065
+ // headers on the script's origin). Apps that genuinely want
2066
+ // them muted can re-add "Script error" to ignoreErrors via
2067
+ // init config.
1931
2068
  ],
1932
2069
  allowUrls: [],
1933
2070
  denyUrls: [
@@ -2167,61 +2304,84 @@ var ErrorTracker = class {
2167
2304
  // ============================================================
2168
2305
  buildFromErrorEvent(event) {
2169
2306
  const err = event.error;
2170
- const message = event.message || (err instanceof Error ? err.message : "Unknown error");
2307
+ const filename = event.filename || null;
2308
+ const lineno = typeof event.lineno === "number" && event.lineno > 0 ? event.lineno : null;
2309
+ const colno = typeof event.colno === "number" && event.colno > 0 ? event.colno : null;
2310
+ const isCrossOriginStripped = err == null && !filename && lineno == null && (event.message === "Script error." || event.message === "Script error" || !event.message);
2311
+ if (isCrossOriginStripped) {
2312
+ const message2 = "Cross-origin script error (browser hid details \u2014 script needs crossorigin attribute + CORS headers)";
2313
+ return {
2314
+ timestamp: Date.now(),
2315
+ kind: "error.unhandled",
2316
+ level: "error",
2317
+ message: message2,
2318
+ errorType: "ScriptError",
2319
+ frames: [],
2320
+ rawStack: null,
2321
+ filename: null,
2322
+ lineno: null,
2323
+ colno: null,
2324
+ // No location to fingerprint by — all of these will share one
2325
+ // group, which is correct: developer fixes them all with the
2326
+ // same CORS change.
2327
+ fingerprint: fingerprintError(message2, []),
2328
+ breadcrumbs: this.opts.breadcrumbs.snapshot(),
2329
+ context: this.opts.getContext(),
2330
+ tags: { ...this.opts.getTags(), cross_origin: "true" }
2331
+ };
2332
+ }
2333
+ const payload = coerceErrorPayload(err);
2334
+ const message = (payload.message || event.message || "Unknown error").slice(0, 1024);
2171
2335
  const stack = err instanceof Error ? err.stack ?? null : null;
2172
2336
  const frames = parseStack(stack);
2337
+ const errorType = payload.errorType ?? null;
2338
+ const context = payload.extras ? { ...this.opts.getContext(), __error_extras: payload.extras } : this.opts.getContext();
2173
2339
  return {
2174
2340
  timestamp: Date.now(),
2175
2341
  kind: "error.unhandled",
2176
2342
  level: "error",
2177
- message: String(message).slice(0, 1024),
2178
- errorType: err instanceof Error ? err.name : null,
2343
+ message,
2344
+ errorType,
2179
2345
  frames,
2180
2346
  rawStack: stack,
2181
- filename: event.filename || null,
2182
- lineno: typeof event.lineno === "number" ? event.lineno : null,
2183
- colno: typeof event.colno === "number" ? event.colno : null,
2184
- fingerprint: fingerprintError(message, frames),
2347
+ filename,
2348
+ lineno,
2349
+ colno,
2350
+ // Location fallback ensures distinct call sites stay separate
2351
+ // even when the message is generic ("Unknown error",
2352
+ // "[object Object]") and there are no parseable frames.
2353
+ fingerprint: fingerprintError(message, frames, {
2354
+ filename,
2355
+ lineno,
2356
+ colno,
2357
+ errorType
2358
+ }),
2185
2359
  breadcrumbs: this.opts.breadcrumbs.snapshot(),
2186
- context: this.opts.getContext(),
2360
+ context,
2187
2361
  tags: this.opts.getTags()
2188
2362
  };
2189
2363
  }
2190
2364
  buildFromUnknown(err, kind, level) {
2191
- if (err instanceof Error) {
2192
- const frames = parseStack(err.stack);
2193
- return {
2194
- timestamp: Date.now(),
2195
- kind,
2196
- level,
2197
- message: String(err.message).slice(0, 1024),
2198
- errorType: err.name,
2199
- frames,
2200
- rawStack: err.stack ?? null,
2201
- filename: null,
2202
- lineno: null,
2203
- colno: null,
2204
- fingerprint: fingerprintError(err.message, frames),
2205
- breadcrumbs: this.opts.breadcrumbs.snapshot(),
2206
- context: this.opts.getContext(),
2207
- tags: this.opts.getTags()
2208
- };
2209
- }
2210
- const message = safeStringify2(err).slice(0, 1024);
2365
+ const payload = coerceErrorPayload(err);
2366
+ const message = (payload.message || "Unknown error").slice(0, 1024);
2367
+ const stack = err instanceof Error ? err.stack ?? null : null;
2368
+ const frames = parseStack(stack);
2369
+ const errorType = payload.errorType ?? null;
2370
+ const context = payload.extras ? { ...this.opts.getContext(), __error_extras: payload.extras } : this.opts.getContext();
2211
2371
  return {
2212
2372
  timestamp: Date.now(),
2213
2373
  kind,
2214
2374
  level,
2215
2375
  message,
2216
- errorType: null,
2217
- frames: [],
2218
- rawStack: null,
2376
+ errorType,
2377
+ frames,
2378
+ rawStack: stack,
2219
2379
  filename: null,
2220
2380
  lineno: null,
2221
2381
  colno: null,
2222
- fingerprint: fingerprintError(message, []),
2382
+ fingerprint: fingerprintError(message, frames, { errorType }),
2223
2383
  breadcrumbs: this.opts.breadcrumbs.snapshot(),
2224
- context: this.opts.getContext(),
2384
+ context,
2225
2385
  tags: this.opts.getTags()
2226
2386
  };
2227
2387
  }
@@ -2239,7 +2399,10 @@ var ErrorTracker = class {
2239
2399
  filename: info.url,
2240
2400
  lineno: null,
2241
2401
  colno: null,
2242
- fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
2402
+ fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, [], {
2403
+ filename: info.url,
2404
+ errorType: "HTTPError"
2405
+ }),
2243
2406
  breadcrumbs: this.opts.breadcrumbs.snapshot(),
2244
2407
  context: this.opts.getContext(),
2245
2408
  tags: this.opts.getTags(),
@@ -2318,16 +2481,131 @@ var ErrorTracker = class {
2318
2481
  return true;
2319
2482
  }
2320
2483
  };
2321
- function safeStringify2(v) {
2322
- if (v == null) return String(v);
2323
- if (typeof v === "string") return v;
2324
- if (typeof v === "number" || typeof v === "boolean") return String(v);
2484
+ function coerceErrorPayload(v) {
2485
+ if (v === null) return { message: "(thrown: null)", errorType: null, extras: null };
2486
+ if (v === void 0) return { message: "(thrown: undefined)", errorType: null, extras: null };
2487
+ if (typeof v === "string") {
2488
+ return { message: v, errorType: null, extras: null };
2489
+ }
2490
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") {
2491
+ return { message: String(v), errorType: typeof v, extras: null };
2492
+ }
2493
+ if (typeof v === "symbol") {
2494
+ return { message: v.toString(), errorType: "symbol", extras: null };
2495
+ }
2496
+ if (typeof v === "function") {
2497
+ return { message: `(thrown function: ${v.name || "anonymous"})`, errorType: "function", extras: null };
2498
+ }
2499
+ if (v instanceof Error) {
2500
+ const errorType = v.name || v.constructor?.name || "Error";
2501
+ const message = typeof v.message === "string" && v.message.length > 0 ? v.message : safeToString(v) || errorType;
2502
+ const extras = {};
2503
+ const causeChain = collectCauseChain(v);
2504
+ if (causeChain.length > 0) extras.cause = causeChain;
2505
+ for (const key of ["code", "status", "statusCode", "errno", "response", "data", "detail", "details"]) {
2506
+ const val = v[key];
2507
+ if (val !== void 0 && typeof val !== "function") {
2508
+ extras[key] = safeClone(val);
2509
+ }
2510
+ }
2511
+ for (const key of Object.keys(v)) {
2512
+ if (key === "message" || key === "stack" || key === "name" || key === "cause") continue;
2513
+ if (key in extras) continue;
2514
+ const val = v[key];
2515
+ if (typeof val === "function") continue;
2516
+ extras[key] = safeClone(val);
2517
+ }
2518
+ return {
2519
+ message,
2520
+ errorType,
2521
+ extras: Object.keys(extras).length > 0 ? extras : null
2522
+ };
2523
+ }
2524
+ if (typeof Response !== "undefined" && v instanceof Response) {
2525
+ return {
2526
+ message: `HTTP ${v.status} ${v.statusText || ""}${v.url ? ` ${v.url}` : ""}`.trim(),
2527
+ errorType: "Response",
2528
+ extras: { status: v.status, statusText: v.statusText, url: v.url, type: v.type }
2529
+ };
2530
+ }
2531
+ if (typeof v === "object") {
2532
+ const obj = v;
2533
+ const ctorName = obj.constructor && typeof obj.constructor === "function" && obj.constructor.name || null;
2534
+ const ownMessage = typeof obj.message === "string" && obj.message ? obj.message : null;
2535
+ const ownName = typeof obj.name === "string" && obj.name ? obj.name : null;
2536
+ let jsonForm = null;
2537
+ try {
2538
+ const serialised = JSON.stringify(obj);
2539
+ jsonForm = serialised === "{}" ? null : serialised;
2540
+ } catch {
2541
+ jsonForm = null;
2542
+ }
2543
+ const fallbackString = safeToString(obj);
2544
+ const message = ownMessage ?? jsonForm ?? (fallbackString && fallbackString !== "[object Object]" ? fallbackString : null) ?? (ctorName ? `(thrown ${ctorName} with no message)` : "(thrown object with no message)");
2545
+ const errorType = ownName ?? ctorName ?? null;
2546
+ const extras = {};
2547
+ let count = 0;
2548
+ for (const key of Object.keys(obj)) {
2549
+ if (count >= 20) break;
2550
+ if (key === "message" || key === "name") continue;
2551
+ const val = obj[key];
2552
+ if (typeof val === "function") continue;
2553
+ extras[key] = safeClone(val);
2554
+ count++;
2555
+ }
2556
+ return {
2557
+ message,
2558
+ errorType,
2559
+ extras: Object.keys(extras).length > 0 ? extras : null
2560
+ };
2561
+ }
2562
+ return { message: safeToString(v) || "(unstringifiable thrown value)", errorType: null, extras: null };
2563
+ }
2564
+ function collectCauseChain(err) {
2565
+ const out = [];
2566
+ let cur = err.cause;
2567
+ let depth = 0;
2568
+ while (cur != null && depth < 5) {
2569
+ if (cur instanceof Error) {
2570
+ out.push({ name: cur.name || "Error", message: cur.message || "" });
2571
+ cur = cur.cause;
2572
+ } else {
2573
+ out.push({ name: "non-Error", message: safeToString(cur) });
2574
+ cur = null;
2575
+ }
2576
+ depth++;
2577
+ }
2578
+ return out;
2579
+ }
2580
+ function safeToString(v) {
2325
2581
  try {
2326
- return JSON.stringify(v);
2582
+ const s = Object.prototype.toString.call(v);
2583
+ if (s !== "[object Object]") return s;
2584
+ const own = v?.toString;
2585
+ if (typeof own === "function" && own !== Object.prototype.toString) {
2586
+ const r = own.call(v);
2587
+ if (typeof r === "string") return r;
2588
+ }
2589
+ return s;
2327
2590
  } catch {
2328
- return Object.prototype.toString.call(v);
2591
+ return "(throwing toString)";
2329
2592
  }
2330
2593
  }
2594
+ function safeClone(v) {
2595
+ if (v == null) return v;
2596
+ const t = typeof v;
2597
+ if (t === "string" || t === "number" || t === "boolean") return v;
2598
+ if (t === "bigint") return String(v);
2599
+ try {
2600
+ const s = JSON.stringify(v);
2601
+ return s === void 0 ? safeToString(v) : JSON.parse(s);
2602
+ } catch {
2603
+ return safeToString(v);
2604
+ }
2605
+ }
2606
+ function safeStringify2(v) {
2607
+ return coerceErrorPayload(v).message;
2608
+ }
2331
2609
 
2332
2610
  // src/crossdeck.ts
2333
2611
  var CrossdeckClient = class {
@@ -2417,7 +2695,10 @@ var CrossdeckClient = class {
2417
2695
  typeof globalThis.document !== "undefined";
2418
2696
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
2419
2697
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
2420
- const entitlements = new EntitlementCache();
2698
+ const entitlements = new EntitlementCache(
2699
+ effectiveStorage,
2700
+ opts.storagePrefix + "entitlements"
2701
+ );
2421
2702
  const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
2422
2703
  if (persistentEvents) {
2423
2704
  debug.emit(
@@ -2597,6 +2878,10 @@ var CrossdeckClient = class {
2597
2878
  const result = await s.http.request("POST", "/identity/alias", {
2598
2879
  body
2599
2880
  });
2881
+ const priorCdcust = s.identity.crossdeckCustomerId;
2882
+ if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
2883
+ s.entitlements.clear();
2884
+ }
2600
2885
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2601
2886
  s.developerUserId = userId;
2602
2887
  return result;
@@ -2831,11 +3116,17 @@ var CrossdeckClient = class {
2831
3116
  async getEntitlements() {
2832
3117
  const s = this.requireStarted();
2833
3118
  const query = this.identityQueryParams();
2834
- const result = await s.http.request(
2835
- "GET",
2836
- "/entitlements",
2837
- { query }
2838
- );
3119
+ let result;
3120
+ try {
3121
+ result = await s.http.request(
3122
+ "GET",
3123
+ "/entitlements",
3124
+ { query }
3125
+ );
3126
+ } catch (err) {
3127
+ s.entitlements.markRefreshFailed();
3128
+ throw err;
3129
+ }
2839
3130
  if (result.crossdeckCustomerId) {
2840
3131
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
2841
3132
  }
@@ -2843,8 +3134,12 @@ var CrossdeckClient = class {
2843
3134
  return result.data;
2844
3135
  }
2845
3136
  /**
2846
- * Synchronous read from the local cache. Returns false if the cache
2847
- * has never been populated (call getEntitlements first to warm it).
3137
+ * Synchronous read from the durable local cache answers from
3138
+ * last-known-good. The cache hydrates from device storage on boot and
3139
+ * survives a Crossdeck outage, so a returning paying customer reads
3140
+ * true even before the session's first network round-trip. Returns
3141
+ * false only for a genuinely new install that has never completed a
3142
+ * getEntitlements(), or for an entitlement past its own validUntil.
2848
3143
  */
2849
3144
  isEntitled(key) {
2850
3145
  const s = this.requireStarted();
@@ -3120,7 +3415,7 @@ var CrossdeckClient = class {
3120
3415
  sdkVersion: null,
3121
3416
  baseUrl: null,
3122
3417
  clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
3123
- entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
3418
+ entitlements: { count: 0, lastUpdated: 0, stale: false, listenerErrors: 0 },
3124
3419
  events: {
3125
3420
  buffered: 0,
3126
3421
  dropped: 0,
@@ -3149,6 +3444,7 @@ var CrossdeckClient = class {
3149
3444
  entitlements: {
3150
3445
  count: s.entitlements.list().length,
3151
3446
  lastUpdated: s.entitlements.freshness,
3447
+ stale: s.entitlements.isStale,
3152
3448
  listenerErrors: s.entitlements.listenerErrors
3153
3449
  },
3154
3450
  events: s.events.getStats()