@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 +8 -0
- package/dist/auth/popup.d.ts +12 -1
- package/dist/auth/session.d.ts +14 -0
- package/dist/auth/storage.d.ts +14 -0
- package/dist/content-credits.cjs.js +228 -29
- package/dist/content-credits.cjs.js.map +1 -1
- package/dist/content-credits.esm.js +228 -29
- package/dist/content-credits.esm.js.map +1 -1
- package/dist/content-credits.umd.min.js +1 -1
- package/dist/content-credits.umd.min.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
173
|
-
*
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1505
|
+
catch (_d) {
|
|
1355
1506
|
// ignore
|
|
1356
1507
|
}
|
|
1357
1508
|
return null;
|
|
1358
1509
|
}
|
|
1359
1510
|
/**
|
|
1360
|
-
* Open a centered auth popup and
|
|
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
|
-
|
|
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
|
-
|
|
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 */
|
|
1395
|
-
|
|
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
|
-
|
|
1597
|
+
if (refreshToken)
|
|
1598
|
+
refreshTokenStorage.set(refreshToken);
|
|
1407
1599
|
try {
|
|
1408
1600
|
popup.close();
|
|
1409
1601
|
}
|
|
1410
|
-
catch ( /* ignore */
|
|
1411
|
-
|
|
1602
|
+
catch ( /* ignore */_d) { /* ignore */ }
|
|
1603
|
+
finish(token);
|
|
1412
1604
|
}
|
|
1413
1605
|
}
|
|
1414
1606
|
}
|
|
1415
|
-
catch (
|
|
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
|
-
//
|
|
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.
|
|
2737
|
+
return "2.0.4";
|
|
2539
2738
|
}
|
|
2540
2739
|
}
|
|
2541
2740
|
// ── Auto-init from script data attributes (CDN usage) ────────────────────────
|