@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.
@@ -1,13 +1,13 @@
1
1
  import type { EventEmitter } from '../core/events.js';
2
- export declare function createApiClient(baseUrl: string, emitter: EventEmitter): {
3
- get: <T>(path: string) => Promise<T>;
4
- post: <T>(path: string, body: Record<string, unknown>) => Promise<T>;
5
- put: <T>(path: string, body: Record<string, unknown>) => Promise<T>;
6
- delete: <T>(path: string) => Promise<T>;
7
- };
2
+ export interface ApiClient {
3
+ get<T>(path: string): Promise<T>;
4
+ post<T>(path: string, body: Record<string, unknown>): Promise<T>;
5
+ put<T>(path: string, body: Record<string, unknown>): Promise<T>;
6
+ delete<T>(path: string): Promise<T>;
7
+ }
8
+ export declare function createApiClient(baseUrl: string, emitter: EventEmitter): ApiClient;
8
9
  export declare class ApiError extends Error {
9
10
  readonly status: number;
10
11
  readonly data?: unknown | undefined;
11
12
  constructor(status: number, message: string, data?: unknown | undefined);
12
13
  }
13
- export type ApiClient = ReturnType<typeof createApiClient>;
@@ -1,6 +1,6 @@
1
1
  import type { ApiClient } from './client.js';
2
2
  import type { CommentsResponse, CommentThread, Comment, CommentSortBy } from '../types/index.js';
3
- export declare function createCommentsApi(client: ApiClient): {
3
+ export interface CommentsApi {
4
4
  ensureThread(params: {
5
5
  pageUrl: string;
6
6
  hostname: string;
@@ -24,4 +24,5 @@ export declare function createCommentsApi(client: ApiClient): {
24
24
  hasLiked: boolean;
25
25
  };
26
26
  }>;
27
- };
27
+ }
28
+ export declare function createCommentsApi(client: ApiClient): CommentsApi;
@@ -1,6 +1,6 @@
1
1
  import type { ApiClient } from './client.js';
2
2
  import type { CheckAccessResponse, PurchaseResponse } from '../types/index.js';
3
- export declare function createCreditsApi(client: ApiClient): {
3
+ export interface CreditsApi {
4
4
  checkAccess(params: {
5
5
  apiKey: string;
6
6
  postUrl: string;
@@ -13,4 +13,5 @@ export declare function createCreditsApi(client: ApiClient): {
13
13
  postName: string;
14
14
  hostName: string;
15
15
  }): Promise<PurchaseResponse>;
16
- };
16
+ }
17
+ export declare function createCreditsApi(client: ApiClient): CreditsApi;
@@ -1,9 +1,10 @@
1
1
  import type { createCommentsApi } from '../api/comments.js';
2
2
  import type { EventEmitter } from '../core/events.js';
3
3
  import type { ResolvedConfig } from '../types/index.js';
4
- export declare function createComments(config: ResolvedConfig, commentsApi: ReturnType<typeof createCommentsApi>, emitter: EventEmitter): {
5
- init: () => void;
6
- open: () => void;
7
- close: () => void;
8
- destroy: () => void;
9
- };
4
+ export interface CommentsModule {
5
+ init(): void;
6
+ open(): void;
7
+ close(): void;
8
+ destroy(): void;
9
+ }
10
+ export declare function createComments(config: ResolvedConfig, commentsApi: ReturnType<typeof createCommentsApi>, emitter: EventEmitter): CommentsModule;
@@ -1,8 +1,9 @@
1
1
  import type { createCommentsApi } from '../api/comments.js';
2
2
  import type { ResolvedConfig } from '../types/index.js';
3
3
  import type { EventEmitter } from '../core/events.js';
4
- export declare function createCommentPanel(config: ResolvedConfig, commentsApi: ReturnType<typeof createCommentsApi>, emitter: EventEmitter, onClose: () => void): {
5
- openPanel: () => void;
6
- closePanel: () => void;
7
- destroy: () => void;
8
- };
4
+ export interface CommentPanelApi {
5
+ openPanel(): void;
6
+ closePanel(): void;
7
+ destroy(): void;
8
+ }
9
+ export declare function createCommentPanel(config: ResolvedConfig, commentsApi: ReturnType<typeof createCommentsApi>, emitter: EventEmitter, onClose: () => void): CommentPanelApi;
@@ -28,6 +28,15 @@ function resolveConfig(raw) {
28
28
  accountsUrl: "https://accounts.contentcredits.com",
29
29
  paywallTemplate: raw.paywallTemplate,
30
30
  onAccessGranted: raw.onAccessGranted,
31
+ onStateChange: raw.onStateChange,
32
+ onReady: raw.onReady,
33
+ onLoginRequired: raw.onLoginRequired,
34
+ onPurchaseRequired: raw.onPurchaseRequired,
35
+ onInsufficientCredits: raw.onInsufficientCredits,
36
+ onPurchased: raw.onPurchased,
37
+ onUserLogin: raw.onUserLogin,
38
+ onUserLogout: raw.onUserLogout,
39
+ onError: raw.onError,
31
40
  theme: {
32
41
  primaryColor: (_j = (_h = raw.theme) === null || _h === void 0 ? void 0 : _h.primaryColor) !== null && _j !== void 0 ? _j : '#44C678',
33
42
  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",
@@ -177,10 +186,12 @@ const REFRESH_KEY = 'cc_rt';
177
186
  * └─ Layer 3: localStorage — survives browser close; used to silently
178
187
  * re-authenticate on the next visit without showing a popup
179
188
  *
180
- * We intentionally never write to document.cookie (no HttpOnly = XSS risk).
181
- * The refresh token in localStorage is the accepted industry trade-off:
182
- * it persists across sessions at the cost of XSS accessibility, mitigated
183
- * by short-lived access tokens and server-side refresh token rotation.
189
+ * We intentionally never write to document.cookie both localStorage and
190
+ * non-HttpOnly cookies are equally XSS-accessible. The truly safe option
191
+ * (HttpOnly server-set cookie) requires cross-site cookie support which
192
+ * browsers are phasing out for third-party embeds. localStorage is
193
+ * first-party (publisher-domain scoped), never blocked, and the risk is
194
+ * mitigated by short-lived access tokens and server-side refresh token rotation.
184
195
  */
185
196
  let memoryToken = null;
186
197
  const tokenStorage = {
@@ -1223,7 +1234,7 @@ function createPaywallRenderer(config) {
1223
1234
  parent.appendChild(el('p', 'Log in with your Content Credits account to unlock this article.'));
1224
1235
  const btn = el('button', 'Login & Buy with Content Credits');
1225
1236
  btn.className = 'cc-btn cc-btn-primary';
1226
- btn.addEventListener('click', cb.onLogin);
1237
+ btn.addEventListener('click', () => { void cb.onLogin(); });
1227
1238
  parent.appendChild(btn);
1228
1239
  parent.appendChild(poweredBy());
1229
1240
  }
@@ -1237,7 +1248,7 @@ function createPaywallRenderer(config) {
1237
1248
  parent.appendChild(el('p', 'Use your Content Credits balance to instantly access this premium article.'));
1238
1249
  const btn = el('button', credits !== null ? `Buy for ${credits} Credit${credits !== 1 ? 's' : ''}` : 'Buy with Content Credits');
1239
1250
  btn.className = 'cc-btn cc-btn-primary';
1240
- btn.addEventListener('click', cb.onPurchase);
1251
+ btn.addEventListener('click', () => { void cb.onPurchase(); });
1241
1252
  parent.appendChild(btn);
1242
1253
  parent.appendChild(poweredBy());
1243
1254
  }
@@ -1249,7 +1260,7 @@ function createPaywallRenderer(config) {
1249
1260
  parent.appendChild(el('p', detail));
1250
1261
  const btn = el('button', 'Buy More Credits');
1251
1262
  btn.className = 'cc-btn cc-btn-primary';
1252
- btn.addEventListener('click', cb.onBuyMoreCredits);
1263
+ btn.addEventListener('click', () => cb.onBuyMoreCredits());
1253
1264
  parent.appendChild(btn);
1254
1265
  parent.appendChild(poweredBy());
1255
1266
  }
@@ -1423,6 +1434,11 @@ function createExtensionBridge() {
1423
1434
  };
1424
1435
  }
1425
1436
 
1437
+ function isAuthCallback(data) {
1438
+ return (typeof data === 'object' &&
1439
+ data !== null &&
1440
+ data.type === 'cc_auth_callback');
1441
+ }
1426
1442
  const POPUP_NAME = 'ccAuthPopup';
1427
1443
  const POPUP_SPECS = 'scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=600,height=650';
1428
1444
  function centeredSpecs() {
@@ -1483,9 +1499,10 @@ function consumeTokenFromUrl() {
1483
1499
  // If we're inside a popup (opened by openAuthPopup), notify the opener
1484
1500
  // and close instead of rendering the page. This fixes the bug where the
1485
1501
  // popup shows the blog article after the accounts redirect.
1486
- if (window.opener && !window.opener.closed) {
1502
+ const opener = window.opener;
1503
+ if (opener && !opener.closed) {
1487
1504
  try {
1488
- window.opener.postMessage({ type: 'cc_auth_callback', token, refreshToken: refreshToken !== null && refreshToken !== void 0 ? refreshToken : null }, window.location.origin);
1505
+ opener.postMessage({ type: 'cc_auth_callback', token, refreshToken: refreshToken !== null && refreshToken !== void 0 ? refreshToken : null }, window.location.origin);
1489
1506
  }
1490
1507
  catch (_c) {
1491
1508
  // opener is cross-origin or restricted — fall through, URL polling will handle it
@@ -1530,11 +1547,10 @@ function openAuthPopup(authUrl) {
1530
1547
  }
1531
1548
  // ── Primary: postMessage from popup ───────────────────────────────────
1532
1549
  function onMessage(event) {
1533
- var _a;
1534
1550
  // Only accept messages from our own origin
1535
1551
  if (event.origin !== window.location.origin)
1536
1552
  return;
1537
- if (((_a = event.data) === null || _a === void 0 ? void 0 : _a.type) !== 'cc_auth_callback')
1553
+ if (!isAuthCallback(event.data))
1538
1554
  return;
1539
1555
  const token = event.data.token;
1540
1556
  const refreshToken = event.data.refreshToken;
@@ -1546,7 +1562,7 @@ function openAuthPopup(authUrl) {
1546
1562
  try {
1547
1563
  popup === null || popup === void 0 ? void 0 : popup.close();
1548
1564
  }
1549
- catch ( /* ignore */_b) { /* ignore */ }
1565
+ catch ( /* ignore */_a) { /* ignore */ }
1550
1566
  finish(token);
1551
1567
  }
1552
1568
  window.addEventListener('message', onMessage);
@@ -1661,7 +1677,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1661
1677
  }
1662
1678
  // ── Purchase ──────────────────────────────────────────────────────────────
1663
1679
  async function doPurchase() {
1664
- var _a, _b, _c;
1680
+ var _a, _b, _c, _d, _e;
1665
1681
  if (!tokenStorage.has()) {
1666
1682
  await doLogin();
1667
1683
  return;
@@ -1705,14 +1721,18 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1705
1721
  creditBalance: state.get().creditBalance,
1706
1722
  });
1707
1723
  }
1708
- emitter.emit('credits:insufficient', {
1709
- required: (_b = state.get().requiredCredits) !== null && _b !== void 0 ? _b : 0,
1710
- available: (_c = state.get().creditBalance) !== null && _c !== void 0 ? _c : 0,
1711
- });
1724
+ const required = (_b = state.get().requiredCredits) !== null && _b !== void 0 ? _b : 0;
1725
+ const available = (_c = state.get().creditBalance) !== null && _c !== void 0 ? _c : 0;
1726
+ (_d = config.onInsufficientCredits) === null || _d === void 0 ? void 0 : _d.call(config, { required, available });
1727
+ emitter.emit('credits:insufficient', { required, available });
1712
1728
  }
1713
1729
  else {
1714
1730
  if (!config.headless)
1715
1731
  renderer.render('purchase', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1732
+ (_e = config.onPurchaseRequired) === null || _e === void 0 ? void 0 : _e.call(config, {
1733
+ requiredCredits: state.get().requiredCredits,
1734
+ creditBalance: state.get().creditBalance,
1735
+ });
1716
1736
  emitter.emit('error', { message: 'Purchase failed', error: err });
1717
1737
  }
1718
1738
  }
@@ -1722,6 +1742,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1722
1742
  }
1723
1743
  // ── Access Check ──────────────────────────────────────────────────────────
1724
1744
  async function checkAccess() {
1745
+ var _a, _b, _c;
1725
1746
  state.set({ isLoading: true });
1726
1747
  if (!config.headless)
1727
1748
  renderer.render('checking', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
@@ -1735,6 +1756,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1735
1756
  gate.hide();
1736
1757
  renderer.render('login', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1737
1758
  }
1759
+ (_a = config.onLoginRequired) === null || _a === void 0 ? void 0 : _a.call(config);
1738
1760
  emitter.emit('paywall:shown', {});
1739
1761
  return;
1740
1762
  }
@@ -1758,6 +1780,10 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1758
1780
  gate.hide();
1759
1781
  renderer.render('purchase', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1760
1782
  }
1783
+ (_b = config.onPurchaseRequired) === null || _b === void 0 ? void 0 : _b.call(config, {
1784
+ requiredCredits: state.get().requiredCredits,
1785
+ creditBalance: state.get().creditBalance,
1786
+ });
1761
1787
  emitter.emit('paywall:shown', {});
1762
1788
  }
1763
1789
  }
@@ -1767,6 +1793,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1767
1793
  gate.hide();
1768
1794
  renderer.render('login', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1769
1795
  }
1796
+ (_c = config.onLoginRequired) === null || _c === void 0 ? void 0 : _c.call(config);
1770
1797
  if (!(err instanceof ApiError && err.status === 401)) {
1771
1798
  emitter.emit('error', { message: 'Access check failed', error: err });
1772
1799
  }
@@ -1785,7 +1812,7 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1785
1812
  if (extensionAvailable) {
1786
1813
  bridge.attach();
1787
1814
  bridge.onAuthorizationResponse(data => {
1788
- var _a, _b, _c;
1815
+ var _a, _b, _c, _d, _e, _f, _g;
1789
1816
  state.set({
1790
1817
  isLoggedIn: data.isAuthenticated,
1791
1818
  hasAccess: data.doesHaveAccess,
@@ -1799,10 +1826,11 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1799
1826
  gate.hide();
1800
1827
  renderer.render('login', { onLogin: doLogin, onPurchase: doPurchase, onBuyMoreCredits: doBuyMoreCredits });
1801
1828
  }
1829
+ (_c = config.onLoginRequired) === null || _c === void 0 ? void 0 : _c.call(config);
1802
1830
  emitter.emit('paywall:shown', {});
1803
1831
  }
1804
1832
  else if (data.doesHaveAccess) {
1805
- handleAccessGranted(0, (_c = data.creditBalance) !== null && _c !== void 0 ? _c : 0);
1833
+ handleAccessGranted(0, (_d = data.creditBalance) !== null && _d !== void 0 ? _d : 0);
1806
1834
  }
1807
1835
  else {
1808
1836
  if (!config.headless) {
@@ -1812,6 +1840,10 @@ function createPaywall(config, creditsApi, state, emitter, existingGate) {
1812
1840
  creditBalance: data.creditBalance,
1813
1841
  });
1814
1842
  }
1843
+ (_e = config.onPurchaseRequired) === null || _e === void 0 ? void 0 : _e.call(config, {
1844
+ requiredCredits: (_f = data.requiredCredits) !== null && _f !== void 0 ? _f : null,
1845
+ creditBalance: (_g = data.creditBalance) !== null && _g !== void 0 ? _g : null,
1846
+ });
1815
1847
  emitter.emit('paywall:shown', {});
1816
1848
  }
1817
1849
  });
@@ -2597,15 +2629,18 @@ function createCommentPanel(config, commentsApi, emitter, onClose) {
2597
2629
  refreshUser();
2598
2630
  refreshUser();
2599
2631
  // Build panel if not already present
2600
- let backdrop = r.getElementById('cc-panel-backdrop');
2601
- if (!backdrop) {
2602
- backdrop = el('div');
2603
- backdrop.className = 'cc-panel-backdrop';
2604
- backdrop.id = 'cc-panel-backdrop';
2605
- backdrop.addEventListener('click', closePanel);
2606
- r.appendChild(backdrop);
2632
+ let backdropEl = r.getElementById('cc-panel-backdrop');
2633
+ if (!backdropEl) {
2634
+ const newBackdrop = el('div');
2635
+ newBackdrop.className = 'cc-panel-backdrop';
2636
+ newBackdrop.id = 'cc-panel-backdrop';
2637
+ newBackdrop.addEventListener('click', closePanel);
2638
+ r.appendChild(newBackdrop);
2607
2639
  r.appendChild(buildPanel());
2640
+ backdropEl = newBackdrop;
2608
2641
  }
2642
+ // Capture in a const so the rAF callback has a non-nullable reference
2643
+ const backdrop = backdropEl;
2609
2644
  // Animate open
2610
2645
  requestAnimationFrame(() => {
2611
2646
  var _a;
@@ -2705,7 +2740,26 @@ class ContentCredits {
2705
2740
  async _start() {
2706
2741
  // 1. Consume any token that arrived in the URL (mobile redirect flow)
2707
2742
  consumeTokenFromUrl();
2708
- // 2. Hide premium content immediately (synchronous) before any async work.
2743
+ // 2. Wire config-level callbacks so developers don't need separate on() calls.
2744
+ if (this.config.onStateChange) {
2745
+ this.state.subscribe(this.config.onStateChange);
2746
+ }
2747
+ if (this.config.onReady) {
2748
+ this.emitter.on('ready', ({ state }) => this.config.onReady(state));
2749
+ }
2750
+ if (this.config.onPurchased) {
2751
+ this.emitter.on('article:purchased', (payload) => this.config.onPurchased(payload));
2752
+ }
2753
+ if (this.config.onUserLogin) {
2754
+ this.emitter.on('auth:login', ({ user }) => this.config.onUserLogin(user));
2755
+ }
2756
+ if (this.config.onUserLogout) {
2757
+ this.emitter.on('auth:logout', () => this.config.onUserLogout());
2758
+ }
2759
+ if (this.config.onError) {
2760
+ this.emitter.on('error', (payload) => this.config.onError(payload));
2761
+ }
2762
+ // 3. Hide premium content immediately (synchronous) before any async work.
2709
2763
  // This prevents the flash of full article content that would otherwise
2710
2764
  // appear during the token-refresh and access-check network round-trips.
2711
2765
  // Skipped in headless mode — the host app owns all DOM manipulation.
@@ -2715,13 +2769,13 @@ class ContentCredits {
2715
2769
  });
2716
2770
  if (!this.config.headless)
2717
2771
  earlyGate.hide();
2718
- // 3. If no access token in memory/session, attempt a silent refresh.
2772
+ // 4. If no access token in memory/session, attempt a silent refresh.
2719
2773
  // This runs on every new browser session (after the browser was closed)
2720
2774
  // and silently re-authenticates the user using their stored refresh token.
2721
2775
  if (!tokenStorage.has()) {
2722
2776
  await tryRefreshSession(this.config.apiBaseUrl);
2723
2777
  }
2724
- // Pass the pre-created gate so createPaywall reuses the same instance
2778
+ // 5. Pass the pre-created gate so createPaywall reuses the same instance
2725
2779
  // (and its hiddenNodes list) rather than creating a second one.
2726
2780
  this.paywallModule = createPaywall(this.config, this.creditsApi, this.state, this.emitter, earlyGate);
2727
2781
  if (this.config.enableComments) {
@@ -2820,6 +2874,32 @@ class ContentCredits {
2820
2874
  isLoggedIn() {
2821
2875
  return tokenStorage.has();
2822
2876
  }
2877
+ /**
2878
+ * Log the current user out.
2879
+ *
2880
+ * Revokes the refresh token on the server (best-effort), clears all local
2881
+ * auth state, resets SDK state, and emits `auth:logout`.
2882
+ */
2883
+ async logout() {
2884
+ const rt = refreshTokenStorage.get();
2885
+ if (rt) {
2886
+ try {
2887
+ await fetch(`${this.config.apiBaseUrl}/auth/log-out`, {
2888
+ method: 'POST',
2889
+ headers: { 'Content-Type': 'application/json' },
2890
+ body: JSON.stringify({ refreshToken: rt }),
2891
+ credentials: 'omit',
2892
+ });
2893
+ }
2894
+ catch (_a) {
2895
+ // Network error — proceed with local cleanup regardless
2896
+ }
2897
+ }
2898
+ tokenStorage.clear();
2899
+ refreshTokenStorage.clear();
2900
+ this.state.reset();
2901
+ this.emitter.emit('auth:logout', {});
2902
+ }
2823
2903
  /** Tear down the SDK — removes all UI, event listeners, and stored state. */
2824
2904
  destroy() {
2825
2905
  var _a, _b;
@@ -2830,7 +2910,7 @@ class ContentCredits {
2830
2910
  }
2831
2911
  /** SDK version string. */
2832
2912
  static get version() {
2833
- return "2.2.0";
2913
+ return "2.4.0";
2834
2914
  }
2835
2915
  }
2836
2916
  // ── Auto-init from script data attributes (CDN usage) ────────────────────────