@contentcredits/sdk 2.0.2 → 2.0.4

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/README.md CHANGED
@@ -171,6 +171,14 @@ export function PremiumGate({ apiKey, children }) {
171
171
 
172
172
  ---
173
173
 
174
+ ## Examples
175
+
176
+ | Example | Description |
177
+ |---------|-------------|
178
+ | [`examples/nextjs-blog`](./examples/nextjs-blog) | Next.js 14 (App Router) blog with free and premium articles — the fastest way to see the SDK in a real project |
179
+
180
+ ---
181
+
174
182
  ## Links
175
183
 
176
184
  - [Full documentation](https://docs.contentcredits.com)
@@ -8,10 +8,21 @@ export declare function scrubTokenFromUrl(): void;
8
8
  * Read and store a token that may have been placed in the current URL
9
9
  * (e.g. after a mobile redirect back from the accounts site).
10
10
  * Always scrubs the token from the URL after reading it.
11
+ *
12
+ * If we're running inside a popup window (window.opener exists), we notify
13
+ * the opener via postMessage and close ourselves — this prevents the popup
14
+ * from showing the full article page after a successful login redirect.
11
15
  */
12
16
  export declare function consumeTokenFromUrl(): string | null;
13
17
  /**
14
- * Open a centered auth popup and poll for the token callback.
18
+ * Open a centered auth popup and wait for the token callback.
19
+ *
20
+ * Primary path: listens for a postMessage from the popup page (sent by
21
+ * consumeTokenFromUrl when the accounts redirect lands on our origin).
22
+ *
23
+ * Fallback path: polls popup.location.href every 200 ms for cases where
24
+ * postMessage isn't available (e.g. some mobile browsers, extensions).
25
+ *
15
26
  * Returns a promise that resolves with the token when login completes,
16
27
  * or null if the popup is closed without completing login.
17
28
  */
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Attempt a silent token refresh using the stored refresh token.
3
+ *
4
+ * Uses a raw fetch (not the SDK API client) to avoid:
5
+ * 1. Circular dependency: client → session → client
6
+ * 2. Triggering the client's own 401 → tryRefreshSession loop
7
+ *
8
+ * Returns true if a new access token was obtained and stored.
9
+ *
10
+ * On network error the refresh token is NOT cleared — it may still be
11
+ * valid once connectivity returns. On 401/403 the token is cleared
12
+ * because the server has explicitly rejected it (expired / revoked).
13
+ */
14
+ export declare function tryRefreshSession(apiBaseUrl: string): Promise<boolean>;
@@ -4,3 +4,17 @@ export declare const tokenStorage: {
4
4
  clear(): void;
5
5
  has(): boolean;
6
6
  };
7
+ /**
8
+ * Refresh token storage — localStorage only.
9
+ *
10
+ * Stored on the publisher's domain (first-party storage) so it is never
11
+ * subject to third-party cookie / storage blocking in any browser.
12
+ * Refresh tokens are opaque strings issued by the backend and rotated
13
+ * on every use.
14
+ */
15
+ export declare const refreshTokenStorage: {
16
+ set(token: string): void;
17
+ get(): string | null;
18
+ clear(): void;
19
+ has(): boolean;
20
+ };
@@ -164,15 +164,22 @@ function getUserIdFromToken(token) {
164
164
  }
165
165
 
166
166
  const SESSION_KEY = 'cc_sdk_token';
167
+ const REFRESH_KEY = 'cc_rt';
167
168
  /**
168
- * Two-layer token storage:
169
- * 1. In-memory (primary) — invisible to other scripts, survives page navigations
170
- * within the same JS context but gone on hard reload.
171
- * 2. sessionStorage (secondary) — survives soft reloads, cleared when the tab
172
- * closes, never shared across tabs.
169
+ * Three-layer auth storage:
173
170
  *
174
- * We intentionally never use document.cookie (no HttpOnly = XSS risk) or
175
- * localStorage (persists indefinitely across sessions).
171
+ * ACCESS TOKEN (short-lived JWT, ~15 min)
172
+ * ┌─ Layer 1: in-memory — invisible to other scripts; cleared on page close
173
+ * └─ Layer 2: sessionStorage — survives soft reloads; cleared when tab closes
174
+ *
175
+ * REFRESH TOKEN (long-lived opaque token, ~30 days)
176
+ * └─ Layer 3: localStorage — survives browser close; used to silently
177
+ * re-authenticate on the next visit without showing a popup
178
+ *
179
+ * We intentionally never write to document.cookie (no HttpOnly = XSS risk).
180
+ * The refresh token in localStorage is the accepted industry trade-off:
181
+ * it persists across sessions at the cost of XSS accessibility, mitigated
182
+ * by short-lived access tokens and server-side refresh token rotation.
176
183
  */
177
184
  let memoryToken = null;
178
185
  const tokenStorage = {
@@ -224,6 +231,95 @@ const tokenStorage = {
224
231
  return this.get() !== null;
225
232
  },
226
233
  };
234
+ /**
235
+ * Refresh token storage — localStorage only.
236
+ *
237
+ * Stored on the publisher's domain (first-party storage) so it is never
238
+ * subject to third-party cookie / storage blocking in any browser.
239
+ * Refresh tokens are opaque strings issued by the backend and rotated
240
+ * on every use.
241
+ */
242
+ const refreshTokenStorage = {
243
+ set(token) {
244
+ try {
245
+ localStorage.setItem(REFRESH_KEY, token);
246
+ }
247
+ catch (_a) {
248
+ // localStorage unavailable (private mode with strict settings) — degrade
249
+ // to session-only auth gracefully
250
+ }
251
+ },
252
+ get() {
253
+ try {
254
+ return localStorage.getItem(REFRESH_KEY);
255
+ }
256
+ catch (_a) {
257
+ return null;
258
+ }
259
+ },
260
+ clear() {
261
+ try {
262
+ localStorage.removeItem(REFRESH_KEY);
263
+ }
264
+ catch (_a) {
265
+ // ignore
266
+ }
267
+ },
268
+ has() {
269
+ return this.get() !== null;
270
+ },
271
+ };
272
+
273
+ /**
274
+ * Attempt a silent token refresh using the stored refresh token.
275
+ *
276
+ * Uses a raw fetch (not the SDK API client) to avoid:
277
+ * 1. Circular dependency: client → session → client
278
+ * 2. Triggering the client's own 401 → tryRefreshSession loop
279
+ *
280
+ * Returns true if a new access token was obtained and stored.
281
+ *
282
+ * On network error the refresh token is NOT cleared — it may still be
283
+ * valid once connectivity returns. On 401/403 the token is cleared
284
+ * because the server has explicitly rejected it (expired / revoked).
285
+ */
286
+ async function tryRefreshSession(apiBaseUrl) {
287
+ const rt = refreshTokenStorage.get();
288
+ if (!rt)
289
+ return false;
290
+ const controller = new AbortController();
291
+ const timeoutId = setTimeout(() => controller.abort(), 8000);
292
+ try {
293
+ const resp = await fetch(`${apiBaseUrl}/auth/refresh`, {
294
+ method: 'POST',
295
+ headers: { 'Content-Type': 'application/json' },
296
+ body: JSON.stringify({ refreshToken: rt }),
297
+ credentials: 'omit',
298
+ signal: controller.signal,
299
+ });
300
+ clearTimeout(timeoutId);
301
+ if (!resp.ok) {
302
+ // Server rejected the token — it has expired or been revoked.
303
+ // Clear it so we don't retry on every page load.
304
+ refreshTokenStorage.clear();
305
+ return false;
306
+ }
307
+ const data = (await resp.json());
308
+ if (!(data === null || data === void 0 ? void 0 : data.accessToken) || !(data === null || data === void 0 ? void 0 : data.refreshToken)) {
309
+ refreshTokenStorage.clear();
310
+ return false;
311
+ }
312
+ // Store new access token and rotate the refresh token
313
+ tokenStorage.set(data.accessToken);
314
+ refreshTokenStorage.set(data.refreshToken);
315
+ return true;
316
+ }
317
+ catch (_a) {
318
+ // Network error or timeout — don't clear the refresh token
319
+ clearTimeout(timeoutId);
320
+ return false;
321
+ }
322
+ }
227
323
 
228
324
  const REQUEST_TIMEOUT_MS = 12000;
229
325
  const MAX_RETRIES = 3;
@@ -240,7 +336,8 @@ function shouldRetry(status) {
240
336
  return status >= 500 || status === 429;
241
337
  }
242
338
  function createApiClient(baseUrl, emitter) {
243
- async function request(method, path, body, attempt = 0) {
339
+ async function request(method, path, body, attempt = 0, authRetried = false // true after one silent-refresh attempt, prevents loops
340
+ ) {
244
341
  const url = `${baseUrl}${path}`;
245
342
  const serialisedBody = body ? JSON.stringify(body) : undefined;
246
343
  const key = requestKey(method, url, serialisedBody);
@@ -268,7 +365,18 @@ function createApiClient(baseUrl, emitter) {
268
365
  var _a;
269
366
  clearTimeout(timeoutId);
270
367
  if (response.status === 401) {
368
+ // Try a silent refresh once before giving up.
369
+ // authRetried guard prevents an infinite loop if the refresh
370
+ // endpoint itself returns 401.
371
+ if (!authRetried) {
372
+ const refreshed = await tryRefreshSession(baseUrl);
373
+ if (refreshed) {
374
+ inFlight.delete(key);
375
+ return request(method, path, body, attempt, true);
376
+ }
377
+ }
271
378
  tokenStorage.clear();
379
+ refreshTokenStorage.clear();
272
380
  emitter.emit('auth:logout', {});
273
381
  throw new ApiError(401, 'Unauthorized — session expired');
274
382
  }
@@ -494,16 +602,29 @@ function getPaywallStyles(primaryColor, fontFamily) {
494
602
  pointer-events: none;
495
603
  }
496
604
 
605
+ /* Full-viewport backdrop — centers the overlay and blocks background clicks */
606
+ .cc-paywall-backdrop {
607
+ position: fixed;
608
+ inset: 0;
609
+ background: rgba(0, 0, 0, 0.45);
610
+ display: flex;
611
+ align-items: center;
612
+ justify-content: center;
613
+ padding: 20px;
614
+ pointer-events: all;
615
+ }
616
+
497
617
  .cc-paywall-overlay {
498
618
  background: #fff;
499
619
  border: 1px solid #e5e7eb;
500
620
  border-radius: 12px;
501
621
  padding: 32px 24px;
622
+ width: 100%;
502
623
  max-width: 480px;
503
- margin: 24px auto;
504
624
  text-align: center;
505
625
  font-family: ${fontFamily};
506
- box-shadow: 0 4px 24px rgba(0,0,0,0.08);
626
+ box-shadow: 0 8px 40px rgba(0,0,0,0.18);
627
+ pointer-events: all;
507
628
  }
508
629
 
509
630
  .cc-paywall-overlay h2 {
@@ -1049,9 +1170,14 @@ function createPaywallRenderer(config) {
1049
1170
  const { root: shadowRoot } = createShadowHost(HOST_ID);
1050
1171
  root = shadowRoot;
1051
1172
  injectStyles(root, getPaywallStyles(config.theme.primaryColor, config.theme.fontFamily));
1173
+ // Backdrop: fixed full-viewport flex container — centers the overlay and
1174
+ // blocks interaction with the page behind it (pointer-events: all in CSS).
1175
+ const backdrop = el('div');
1176
+ backdrop.className = 'cc-paywall-backdrop';
1177
+ root.appendChild(backdrop);
1052
1178
  overlay = el('div');
1053
1179
  overlay.className = 'cc-paywall-overlay';
1054
- root.appendChild(overlay);
1180
+ backdrop.appendChild(overlay);
1055
1181
  }
1056
1182
  function render(state, callbacks, meta) {
1057
1183
  var _a, _b, _c;
@@ -1323,7 +1449,7 @@ function scrubTokenFromUrl() {
1323
1449
  try {
1324
1450
  const url = new URL(window.location.href);
1325
1451
  let changed = false;
1326
- ['token', 'cc_token'].forEach(param => {
1452
+ ['token', 'cc_token', 'refresh_token', 'cc_refresh_token'].forEach(param => {
1327
1453
  if (url.searchParams.has(param)) {
1328
1454
  url.searchParams.delete(param);
1329
1455
  changed = true;
@@ -1341,60 +1467,124 @@ function scrubTokenFromUrl() {
1341
1467
  * Read and store a token that may have been placed in the current URL
1342
1468
  * (e.g. after a mobile redirect back from the accounts site).
1343
1469
  * Always scrubs the token from the URL after reading it.
1470
+ *
1471
+ * If we're running inside a popup window (window.opener exists), we notify
1472
+ * the opener via postMessage and close ourselves — this prevents the popup
1473
+ * from showing the full article page after a successful login redirect.
1344
1474
  */
1345
1475
  function consumeTokenFromUrl() {
1346
- var _a;
1476
+ var _a, _b;
1347
1477
  try {
1348
1478
  const url = new URL(window.location.href);
1349
1479
  const token = (_a = url.searchParams.get('token')) !== null && _a !== void 0 ? _a : url.searchParams.get('cc_token');
1480
+ const refreshToken = (_b = url.searchParams.get('refresh_token')) !== null && _b !== void 0 ? _b : url.searchParams.get('cc_refresh_token');
1481
+ // Scrub ALL auth params before doing anything else
1350
1482
  scrubTokenFromUrl();
1483
+ if (refreshToken) {
1484
+ refreshTokenStorage.set(refreshToken);
1485
+ }
1351
1486
  if (token) {
1352
1487
  tokenStorage.set(token);
1488
+ // If we're inside a popup (opened by openAuthPopup), notify the opener
1489
+ // and close instead of rendering the page. This fixes the bug where the
1490
+ // popup shows the blog article after the accounts redirect.
1491
+ if (window.opener && !window.opener.closed) {
1492
+ try {
1493
+ window.opener.postMessage({ type: 'cc_auth_callback', token, refreshToken: refreshToken !== null && refreshToken !== void 0 ? refreshToken : null }, window.location.origin);
1494
+ }
1495
+ catch (_c) {
1496
+ // opener is cross-origin or restricted — fall through, URL polling will handle it
1497
+ }
1498
+ // Brief delay so the opener can process the message before the popup closes
1499
+ setTimeout(() => { try {
1500
+ window.close();
1501
+ }
1502
+ catch ( /* ignore */_a) { /* ignore */ } }, 300);
1503
+ }
1353
1504
  return token;
1354
1505
  }
1355
1506
  }
1356
- catch (_b) {
1507
+ catch (_d) {
1357
1508
  // ignore
1358
1509
  }
1359
1510
  return null;
1360
1511
  }
1361
1512
  /**
1362
- * Open a centered auth popup and poll for the token callback.
1513
+ * Open a centered auth popup and wait for the token callback.
1514
+ *
1515
+ * Primary path: listens for a postMessage from the popup page (sent by
1516
+ * consumeTokenFromUrl when the accounts redirect lands on our origin).
1517
+ *
1518
+ * Fallback path: polls popup.location.href every 200 ms for cases where
1519
+ * postMessage isn't available (e.g. some mobile browsers, extensions).
1520
+ *
1363
1521
  * Returns a promise that resolves with the token when login completes,
1364
1522
  * or null if the popup is closed without completing login.
1365
1523
  */
1366
1524
  function openAuthPopup(authUrl) {
1367
1525
  return new Promise(resolve => {
1368
1526
  let popup = null;
1527
+ let settled = false;
1528
+ function finish(token) {
1529
+ if (settled)
1530
+ return;
1531
+ settled = true;
1532
+ clearInterval(timer);
1533
+ window.removeEventListener('message', onMessage);
1534
+ resolve(token);
1535
+ }
1536
+ // ── Primary: postMessage from popup ───────────────────────────────────
1537
+ function onMessage(event) {
1538
+ var _a;
1539
+ // Only accept messages from our own origin
1540
+ if (event.origin !== window.location.origin)
1541
+ return;
1542
+ if (((_a = event.data) === null || _a === void 0 ? void 0 : _a.type) !== 'cc_auth_callback')
1543
+ return;
1544
+ const token = event.data.token;
1545
+ const refreshToken = event.data.refreshToken;
1546
+ if (!token)
1547
+ return;
1548
+ tokenStorage.set(token);
1549
+ if (refreshToken)
1550
+ refreshTokenStorage.set(refreshToken);
1551
+ try {
1552
+ popup === null || popup === void 0 ? void 0 : popup.close();
1553
+ }
1554
+ catch ( /* ignore */_b) { /* ignore */ }
1555
+ finish(token);
1556
+ }
1557
+ window.addEventListener('message', onMessage);
1369
1558
  try {
1370
1559
  popup = window.open(authUrl, POPUP_NAME, centeredSpecs());
1371
1560
  }
1372
1561
  catch (_a) {
1373
- // popup blocked — fall through to null
1562
+ window.removeEventListener('message', onMessage);
1563
+ resolve(null);
1564
+ return;
1374
1565
  }
1375
- // Popup blocked
1376
1566
  if (!popup || popup.closed) {
1567
+ window.removeEventListener('message', onMessage);
1377
1568
  resolve(null);
1378
1569
  return;
1379
1570
  }
1571
+ // ── Fallback: URL polling ─────────────────────────────────────────────
1380
1572
  const POLL_MS = 200;
1381
1573
  const MAX_WAIT_MS = 5 * 60 * 1000; // 5 minutes
1382
1574
  let elapsed = 0;
1383
1575
  const timer = setInterval(() => {
1384
- var _a;
1576
+ var _a, _b;
1385
1577
  elapsed += POLL_MS;
1386
1578
  if (!popup || popup.closed) {
1387
- clearInterval(timer);
1388
- resolve(tokenStorage.get()); // may have been set just before close
1579
+ finish(tokenStorage.get());
1389
1580
  return;
1390
1581
  }
1391
1582
  if (elapsed > MAX_WAIT_MS) {
1392
- clearInterval(timer);
1393
1583
  try {
1394
1584
  popup.close();
1395
1585
  }
1396
- catch ( /* ignore */_b) { /* ignore */ }
1397
- resolve(null);
1586
+ catch ( /* ignore */_c) { /* ignore */ }
1587
+ finish(null);
1398
1588
  return;
1399
1589
  }
1400
1590
  try {
@@ -1403,18 +1593,20 @@ function openAuthPopup(authUrl) {
1403
1593
  if (popupUrl.includes('/auth/callback') || popupUrl.includes('cc_token=') || popupUrl.includes('token=')) {
1404
1594
  const params = new URLSearchParams(popup.location.search);
1405
1595
  const token = (_a = params.get('token')) !== null && _a !== void 0 ? _a : params.get('cc_token');
1596
+ const refreshToken = (_b = params.get('refresh_token')) !== null && _b !== void 0 ? _b : params.get('cc_refresh_token');
1406
1597
  if (token) {
1407
1598
  tokenStorage.set(token);
1408
- clearInterval(timer);
1599
+ if (refreshToken)
1600
+ refreshTokenStorage.set(refreshToken);
1409
1601
  try {
1410
1602
  popup.close();
1411
1603
  }
1412
- catch ( /* ignore */_c) { /* ignore */ }
1413
- resolve(token);
1604
+ catch ( /* ignore */_d) { /* ignore */ }
1605
+ finish(token);
1414
1606
  }
1415
1607
  }
1416
1608
  }
1417
- catch (_d) {
1609
+ catch (_e) {
1418
1610
  // cross-origin error — popup is on accounts domain, not ours yet; keep polling
1419
1611
  }
1420
1612
  }, POLL_MS);
@@ -2485,8 +2677,15 @@ class ContentCredits {
2485
2677
  }
2486
2678
  // ── Internal start ────────────────────────────────────────────────────────
2487
2679
  async _start() {
2488
- // Handle token that may have arrived in the URL (mobile redirect flow)
2680
+ // 1. Consume any token that arrived in the URL (mobile redirect flow)
2489
2681
  consumeTokenFromUrl();
2682
+ // 2. If no access token in memory/session, attempt a silent refresh.
2683
+ // This runs on every new browser session (after the browser was closed)
2684
+ // and silently re-authenticates the user using their stored refresh token
2685
+ // — no popup, no visible delay, no paywall flash.
2686
+ if (!tokenStorage.has()) {
2687
+ await tryRefreshSession(this.config.apiBaseUrl);
2688
+ }
2490
2689
  this.paywallModule = createPaywall(this.config, this.creditsApi, this.state, this.emitter);
2491
2690
  if (this.config.enableComments) {
2492
2691
  this.commentsModule = createComments(this.config, this.commentsApi, this.emitter);
@@ -2537,7 +2736,7 @@ class ContentCredits {
2537
2736
  }
2538
2737
  /** SDK version string. */
2539
2738
  static get version() {
2540
- return "2.0.0";
2739
+ return "2.0.4";
2541
2740
  }
2542
2741
  }
2543
2742
  // ── Auto-init from script data attributes (CDN usage) ────────────────────────