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