@contentcredits/sdk 2.2.0 → 2.4.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.
@@ -26,6 +26,15 @@ function resolveConfig(raw) {
26
26
  accountsUrl: "https://accounts.contentcredits.com",
27
27
  paywallTemplate: raw.paywallTemplate,
28
28
  onAccessGranted: raw.onAccessGranted,
29
+ onStateChange: raw.onStateChange,
30
+ onReady: raw.onReady,
31
+ onLoginRequired: raw.onLoginRequired,
32
+ onPurchaseRequired: raw.onPurchaseRequired,
33
+ onInsufficientCredits: raw.onInsufficientCredits,
34
+ onPurchased: raw.onPurchased,
35
+ onUserLogin: raw.onUserLogin,
36
+ onUserLogout: raw.onUserLogout,
37
+ onError: raw.onError,
29
38
  theme: {
30
39
  primaryColor: (_j = (_h = raw.theme) === null || _h === void 0 ? void 0 : _h.primaryColor) !== null && _j !== void 0 ? _j : '#44C678',
31
40
  fontFamily: (_l = (_k = raw.theme) === null || _k === void 0 ? void 0 : _k.fontFamily) !== null && _l !== void 0 ? _l : "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
@@ -175,10 +184,12 @@ const REFRESH_KEY = 'cc_rt';
175
184
  * └─ Layer 3: localStorage — survives browser close; used to silently
176
185
  * re-authenticate on the next visit without showing a popup
177
186
  *
178
- * We intentionally never write to document.cookie (no HttpOnly = XSS risk).
179
- * The refresh token in localStorage is the accepted industry trade-off:
180
- * it persists across sessions at the cost of XSS accessibility, mitigated
181
- * by short-lived access tokens and server-side refresh token rotation.
187
+ * We intentionally never write to document.cookie both localStorage and
188
+ * non-HttpOnly cookies are equally XSS-accessible. The truly safe option
189
+ * (HttpOnly server-set cookie) requires cross-site cookie support which
190
+ * browsers are phasing out for third-party embeds. localStorage is
191
+ * first-party (publisher-domain scoped), never blocked, and the risk is
192
+ * mitigated by short-lived access tokens and server-side refresh token rotation.
182
193
  */
183
194
  let memoryToken = null;
184
195
  const tokenStorage = {
@@ -1221,7 +1232,7 @@ function createPaywallRenderer(config) {
1221
1232
  parent.appendChild(el('p', 'Log in with your Content Credits account to unlock this article.'));
1222
1233
  const btn = el('button', 'Login & Buy with Content Credits');
1223
1234
  btn.className = 'cc-btn cc-btn-primary';
1224
- btn.addEventListener('click', cb.onLogin);
1235
+ btn.addEventListener('click', () => { void cb.onLogin(); });
1225
1236
  parent.appendChild(btn);
1226
1237
  parent.appendChild(poweredBy());
1227
1238
  }
@@ -1235,7 +1246,7 @@ function createPaywallRenderer(config) {
1235
1246
  parent.appendChild(el('p', 'Use your Content Credits balance to instantly access this premium article.'));
1236
1247
  const btn = el('button', credits !== null ? `Buy for ${credits} Credit${credits !== 1 ? 's' : ''}` : 'Buy with Content Credits');
1237
1248
  btn.className = 'cc-btn cc-btn-primary';
1238
- btn.addEventListener('click', cb.onPurchase);
1249
+ btn.addEventListener('click', () => { void cb.onPurchase(); });
1239
1250
  parent.appendChild(btn);
1240
1251
  parent.appendChild(poweredBy());
1241
1252
  }
@@ -1247,7 +1258,7 @@ function createPaywallRenderer(config) {
1247
1258
  parent.appendChild(el('p', detail));
1248
1259
  const btn = el('button', 'Buy More Credits');
1249
1260
  btn.className = 'cc-btn cc-btn-primary';
1250
- btn.addEventListener('click', cb.onBuyMoreCredits);
1261
+ btn.addEventListener('click', () => cb.onBuyMoreCredits());
1251
1262
  parent.appendChild(btn);
1252
1263
  parent.appendChild(poweredBy());
1253
1264
  }
@@ -1421,6 +1432,11 @@ function createExtensionBridge() {
1421
1432
  };
1422
1433
  }
1423
1434
 
1435
+ function isAuthCallback(data) {
1436
+ return (typeof data === 'object' &&
1437
+ data !== null &&
1438
+ data.type === 'cc_auth_callback');
1439
+ }
1424
1440
  const POPUP_NAME = 'ccAuthPopup';
1425
1441
  const POPUP_SPECS = 'scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=600,height=650';
1426
1442
  function centeredSpecs() {
@@ -1481,9 +1497,10 @@ function consumeTokenFromUrl() {
1481
1497
  // If we're inside a popup (opened by openAuthPopup), notify the opener
1482
1498
  // and close instead of rendering the page. This fixes the bug where the
1483
1499
  // popup shows the blog article after the accounts redirect.
1484
- if (window.opener && !window.opener.closed) {
1500
+ const opener = window.opener;
1501
+ if (opener && !opener.closed) {
1485
1502
  try {
1486
- window.opener.postMessage({ type: 'cc_auth_callback', token, refreshToken: refreshToken !== null && refreshToken !== void 0 ? refreshToken : null }, window.location.origin);
1503
+ opener.postMessage({ type: 'cc_auth_callback', token, refreshToken: refreshToken !== null && refreshToken !== void 0 ? refreshToken : null }, window.location.origin);
1487
1504
  }
1488
1505
  catch (_c) {
1489
1506
  // opener is cross-origin or restricted — fall through, URL polling will handle it
@@ -1528,11 +1545,10 @@ function openAuthPopup(authUrl) {
1528
1545
  }
1529
1546
  // ── Primary: postMessage from popup ───────────────────────────────────
1530
1547
  function onMessage(event) {
1531
- var _a;
1532
1548
  // Only accept messages from our own origin
1533
1549
  if (event.origin !== window.location.origin)
1534
1550
  return;
1535
- if (((_a = event.data) === null || _a === void 0 ? void 0 : _a.type) !== 'cc_auth_callback')
1551
+ if (!isAuthCallback(event.data))
1536
1552
  return;
1537
1553
  const token = event.data.token;
1538
1554
  const refreshToken = event.data.refreshToken;
@@ -1544,7 +1560,7 @@ function openAuthPopup(authUrl) {
1544
1560
  try {
1545
1561
  popup === null || popup === void 0 ? void 0 : popup.close();
1546
1562
  }
1547
- catch ( /* ignore */_b) { /* ignore */ }
1563
+ catch ( /* ignore */_a) { /* ignore */ }
1548
1564
  finish(token);
1549
1565
  }
1550
1566
  window.addEventListener('message', onMessage);
@@ -1659,7 +1675,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1659
1675
  }
1660
1676
  // ── Purchase ──────────────────────────────────────────────────────────────
1661
1677
  async function doPurchase() {
1662
- var _a, _b, _c;
1678
+ var _a, _b, _c, _d, _e;
1663
1679
  if (!tokenStorage.has()) {
1664
1680
  await doLogin();
1665
1681
  return;
@@ -1703,14 +1719,18 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1703
1719
  creditBalance: state.get().creditBalance,
1704
1720
  });
1705
1721
  }
1706
- emitter.emit('credits:insufficient', {
1707
- required: (_b = state.get().requiredCredits) !== null && _b !== void 0 ? _b : 0,
1708
- available: (_c = state.get().creditBalance) !== null && _c !== void 0 ? _c : 0,
1709
- });
1722
+ const required = (_b = state.get().requiredCredits) !== null && _b !== void 0 ? _b : 0;
1723
+ const available = (_c = state.get().creditBalance) !== null && _c !== void 0 ? _c : 0;
1724
+ (_d = config.onInsufficientCredits) === null || _d === void 0 ? void 0 : _d.call(config, { required, available });
1725
+ emitter.emit('credits:insufficient', { required, available });
1710
1726
  }
1711
1727
  else {
1712
1728
  if (!config.headless)
1713
1729
  renderer.render('purchase', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1730
+ (_e = config.onPurchaseRequired) === null || _e === void 0 ? void 0 : _e.call(config, {
1731
+ requiredCredits: state.get().requiredCredits,
1732
+ creditBalance: state.get().creditBalance,
1733
+ });
1714
1734
  emitter.emit('error', { message: 'Purchase failed', error: err });
1715
1735
  }
1716
1736
  }
@@ -1720,6 +1740,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1720
1740
  }
1721
1741
  // ── Access Check ──────────────────────────────────────────────────────────
1722
1742
  async function checkAccess() {
1743
+ var _a, _b, _c;
1723
1744
  state.set({ isLoading: true });
1724
1745
  if (!config.headless)
1725
1746
  renderer.render('checking', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
@@ -1733,6 +1754,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1733
1754
  gate.hide();
1734
1755
  renderer.render('login', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1735
1756
  }
1757
+ (_a = config.onLoginRequired) === null || _a === void 0 ? void 0 : _a.call(config);
1736
1758
  emitter.emit('paywall:shown', {});
1737
1759
  return;
1738
1760
  }
@@ -1756,6 +1778,10 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1756
1778
  gate.hide();
1757
1779
  renderer.render('purchase', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1758
1780
  }
1781
+ (_b = config.onPurchaseRequired) === null || _b === void 0 ? void 0 : _b.call(config, {
1782
+ requiredCredits: state.get().requiredCredits,
1783
+ creditBalance: state.get().creditBalance,
1784
+ });
1759
1785
  emitter.emit('paywall:shown', {});
1760
1786
  }
1761
1787
  }
@@ -1765,6 +1791,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1765
1791
  gate.hide();
1766
1792
  renderer.render('login', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1767
1793
  }
1794
+ (_c = config.onLoginRequired) === null || _c === void 0 ? void 0 : _c.call(config);
1768
1795
  if (!(err instanceof ApiError && err.status === 401)) {
1769
1796
  emitter.emit('error', { message: 'Access check failed', error: err });
1770
1797
  }
@@ -1783,7 +1810,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1783
1810
  if (extensionAvailable) {
1784
1811
  bridge.attach();
1785
1812
  bridge.onAuthorizationResponse(data => {
1786
- var _a, _b, _c;
1813
+ var _a, _b, _c, _d, _e, _f, _g;
1787
1814
  state.set({
1788
1815
  isLoggedIn: data.isAuthenticated,
1789
1816
  hasAccess: data.doesHaveAccess,
@@ -1797,10 +1824,11 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1797
1824
  gate.hide();
1798
1825
  renderer.render('login', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1799
1826
  }
1827
+ (_c = config.onLoginRequired) === null || _c === void 0 ? void 0 : _c.call(config);
1800
1828
  emitter.emit('paywall:shown', {});
1801
1829
  }
1802
1830
  else if (data.doesHaveAccess) {
1803
- handleAccessGranted(0, (_c = data.creditBalance) !== null && _c !== void 0 ? _c : 0);
1831
+ handleAccessGranted(0, (_d = data.creditBalance) !== null && _d !== void 0 ? _d : 0);
1804
1832
  }
1805
1833
  else {
1806
1834
  if (!config.headless) {
@@ -1810,6 +1838,10 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1810
1838
  creditBalance: data.creditBalance,
1811
1839
  });
1812
1840
  }
1841
+ (_e = config.onPurchaseRequired) === null || _e === void 0 ? void 0 : _e.call(config, {
1842
+ requiredCredits: (_f = data.requiredCredits) !== null && _f !== void 0 ? _f : null,
1843
+ creditBalance: (_g = data.creditBalance) !== null && _g !== void 0 ? _g : null,
1844
+ });
1813
1845
  emitter.emit('paywall:shown', {});
1814
1846
  }
1815
1847
  });
@@ -2595,15 +2627,18 @@ function createCommentPanel(config, commentsApi, emitter, onClose) {
2595
2627
  refreshUser();
2596
2628
  refreshUser();
2597
2629
  // Build panel if not already present
2598
- let backdrop = r.getElementById('cc-panel-backdrop');
2599
- if (!backdrop) {
2600
- backdrop = el('div');
2601
- backdrop.className = 'cc-panel-backdrop';
2602
- backdrop.id = 'cc-panel-backdrop';
2603
- backdrop.addEventListener('click', closePanel);
2604
- r.appendChild(backdrop);
2630
+ let backdropEl = r.getElementById('cc-panel-backdrop');
2631
+ if (!backdropEl) {
2632
+ const newBackdrop = el('div');
2633
+ newBackdrop.className = 'cc-panel-backdrop';
2634
+ newBackdrop.id = 'cc-panel-backdrop';
2635
+ newBackdrop.addEventListener('click', closePanel);
2636
+ r.appendChild(newBackdrop);
2605
2637
  r.appendChild(buildPanel());
2638
+ backdropEl = newBackdrop;
2606
2639
  }
2640
+ // Capture in a const so the rAF callback has a non-nullable reference
2641
+ const backdrop = backdropEl;
2607
2642
  // Animate open
2608
2643
  requestAnimationFrame(() => {
2609
2644
  var _a;
@@ -2703,7 +2738,26 @@ class ContentCredits {
2703
2738
  async _start() {
2704
2739
  // 1. Consume any token that arrived in the URL (mobile redirect flow)
2705
2740
  consumeTokenFromUrl();
2706
- // 2. Hide premium content immediately (synchronous) before any async work.
2741
+ // 2. Wire config-level callbacks so developers don't need separate on() calls.
2742
+ if (this.config.onStateChange) {
2743
+ this.state.subscribe(this.config.onStateChange);
2744
+ }
2745
+ if (this.config.onReady) {
2746
+ this.emitter.on('ready', ({ state }) => this.config.onReady(state));
2747
+ }
2748
+ if (this.config.onPurchased) {
2749
+ this.emitter.on('article:purchased', (payload) => this.config.onPurchased(payload));
2750
+ }
2751
+ if (this.config.onUserLogin) {
2752
+ this.emitter.on('auth:login', ({ user }) => this.config.onUserLogin(user));
2753
+ }
2754
+ if (this.config.onUserLogout) {
2755
+ this.emitter.on('auth:logout', () => this.config.onUserLogout());
2756
+ }
2757
+ if (this.config.onError) {
2758
+ this.emitter.on('error', (payload) => this.config.onError(payload));
2759
+ }
2760
+ // 3. Hide premium content immediately (synchronous) before any async work.
2707
2761
  // This prevents the flash of full article content that would otherwise
2708
2762
  // appear during the token-refresh and access-check network round-trips.
2709
2763
  // Skipped in headless mode — the host app owns all DOM manipulation.
@@ -2713,13 +2767,13 @@ class ContentCredits {
2713
2767
  });
2714
2768
  if (!this.config.headless)
2715
2769
  earlyGate.hide();
2716
- // 3. If no access token in memory/session, attempt a silent refresh.
2770
+ // 4. If no access token in memory/session, attempt a silent refresh.
2717
2771
  // This runs on every new browser session (after the browser was closed)
2718
2772
  // and silently re-authenticates the user using their stored refresh token.
2719
2773
  if (!tokenStorage.has()) {
2720
2774
  await tryRefreshSession(this.config.apiBaseUrl);
2721
2775
  }
2722
- // Pass the pre-created gate so createPaywall reuses the same instance
2776
+ // 5. Pass the pre-created gate so createPaywall reuses the same instance
2723
2777
  // (and its hiddenNodes list) rather than creating a second one.
2724
2778
  this.paywallModule = createPaywall(this.config, this.creditsApi, this.state, this.emitter, earlyGate);
2725
2779
  if (this.config.enableComments) {
@@ -2818,6 +2872,32 @@ class ContentCredits {
2818
2872
  isLoggedIn() {
2819
2873
  return tokenStorage.has();
2820
2874
  }
2875
+ /**
2876
+ * Log the current user out.
2877
+ *
2878
+ * Revokes the refresh token on the server (best-effort), clears all local
2879
+ * auth state, resets SDK state, and emits `auth:logout`.
2880
+ */
2881
+ async logout() {
2882
+ const rt = refreshTokenStorage.get();
2883
+ if (rt) {
2884
+ try {
2885
+ await fetch(`${this.config.apiBaseUrl}/auth/log-out`, {
2886
+ method: 'POST',
2887
+ headers: { 'Content-Type': 'application/json' },
2888
+ body: JSON.stringify({ refreshToken: rt }),
2889
+ credentials: 'omit',
2890
+ });
2891
+ }
2892
+ catch (_a) {
2893
+ // Network error — proceed with local cleanup regardless
2894
+ }
2895
+ }
2896
+ tokenStorage.clear();
2897
+ refreshTokenStorage.clear();
2898
+ this.state.reset();
2899
+ this.emitter.emit('auth:logout', {});
2900
+ }
2821
2901
  /** Tear down the SDK — removes all UI, event listeners, and stored state. */
2822
2902
  destroy() {
2823
2903
  var _a, _b;
@@ -2828,7 +2908,7 @@ class ContentCredits {
2828
2908
  }
2829
2909
  /** SDK version string. */
2830
2910
  static get version() {
2831
- return "2.2.0";
2911
+ return "2.4.0";
2832
2912
  }
2833
2913
  }
2834
2914
  // ── Auto-init from script data attributes (CDN usage) ────────────────────────