@feelflow/ffid-sdk 2.17.1 → 2.19.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.
- package/README.md +70 -7
- package/dist/{chunk-DERFBYBZ.cjs → chunk-BBXUZS4U.cjs} +195 -13
- package/dist/{chunk-FGTRPNSW.js → chunk-SXYB5QM3.js} +195 -14
- package/dist/components/index.cjs +8 -8
- package/dist/components/index.d.cts +1 -1
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.js +1 -1
- package/dist/{index-Cv1qXIl1.d.cts → index-0D2vYSLq.d.cts} +107 -3
- package/dist/{index-Cv1qXIl1.d.ts → index-0D2vYSLq.d.ts} +107 -3
- package/dist/index.cjs +62 -28
- package/dist/index.d.cts +181 -11
- package/dist/index.d.ts +181 -11
- package/dist/index.js +33 -4
- package/dist/server/index.cjs +120 -6
- package/dist/server/index.d.cts +20 -1
- package/dist/server/index.d.ts +20 -1
- package/dist/server/index.js +120 -6
- package/dist/webhooks/index.d.cts +71 -5
- package/dist/webhooks/index.d.ts +71 -5
- package/package.json +1 -1
package/dist/server/index.cjs
CHANGED
|
@@ -101,10 +101,7 @@ function createTokenStore(storageType) {
|
|
|
101
101
|
// src/client/oauth-userinfo.ts
|
|
102
102
|
var SESSION_ELIGIBLE_SUBSCRIPTION_STATUSES = [
|
|
103
103
|
"trialing",
|
|
104
|
-
"active"
|
|
105
|
-
"past_due",
|
|
106
|
-
"canceled",
|
|
107
|
-
"paused"
|
|
104
|
+
"active"
|
|
108
105
|
];
|
|
109
106
|
function isSessionEligibleSubscriptionStatus(value) {
|
|
110
107
|
return typeof value === "string" && SESSION_ELIGIBLE_SUBSCRIPTION_STATUSES.includes(value);
|
|
@@ -806,7 +803,7 @@ function createProfileMethods(deps) {
|
|
|
806
803
|
}
|
|
807
804
|
|
|
808
805
|
// src/client/version-check.ts
|
|
809
|
-
var SDK_VERSION = "2.
|
|
806
|
+
var SDK_VERSION = "2.19.0";
|
|
810
807
|
var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
|
|
811
808
|
var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
|
|
812
809
|
function sdkHeaders() {
|
|
@@ -1303,6 +1300,19 @@ function storeCodeVerifier(verifier, logger) {
|
|
|
1303
1300
|
}
|
|
1304
1301
|
return sessionStored || localStored;
|
|
1305
1302
|
}
|
|
1303
|
+
function cleanupVerifierStorage(logger) {
|
|
1304
|
+
try {
|
|
1305
|
+
window.sessionStorage.removeItem(VERIFIER_STORAGE_KEY);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
logger?.warn("retrieveCodeVerifier: sessionStorage \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
window.localStorage.removeItem(VERIFIER_FALLBACK_STORAGE_KEY);
|
|
1311
|
+
window.localStorage.removeItem(VERIFIER_FALLBACK_TIMESTAMP_KEY);
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
logger?.warn("retrieveCodeVerifier: localStorage \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1306
1316
|
function base64UrlEncode(buffer) {
|
|
1307
1317
|
const bytes = new Uint8Array(buffer);
|
|
1308
1318
|
let binary = "";
|
|
@@ -1317,6 +1327,87 @@ var OAUTH_AUTHORIZE_ENDPOINT = "/api/v1/oauth/authorize";
|
|
|
1317
1327
|
var AUTH_LOGOUT_ENDPOINT = "/api/v1/auth/logout";
|
|
1318
1328
|
var STATE_RANDOM_BYTES = 16;
|
|
1319
1329
|
var HEX_BASE2 = 16;
|
|
1330
|
+
var REDIRECT_LOOP_KEY = "ffid_sdk_redirect_loop_history";
|
|
1331
|
+
var REDIRECT_LOOP_WINDOW_MS = 6e4;
|
|
1332
|
+
var REDIRECT_LOOP_THRESHOLD = 3;
|
|
1333
|
+
var storageReadFailureLogged = false;
|
|
1334
|
+
var storageWriteFailureLogged = false;
|
|
1335
|
+
function logStorageReadFailure(logger, err) {
|
|
1336
|
+
if (storageReadFailureLogged) return;
|
|
1337
|
+
storageReadFailureLogged = true;
|
|
1338
|
+
logger.warn(
|
|
1339
|
+
"[FFID SDK] sessionStorage read failed \u2014 redirect loop detection disabled on this browser (fail-open). iOS WebKit private mode / eviction \u304C\u5178\u578B\u8981\u56E0",
|
|
1340
|
+
err
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
function logStorageWriteFailure(logger, err) {
|
|
1344
|
+
if (storageWriteFailureLogged) return;
|
|
1345
|
+
storageWriteFailureLogged = true;
|
|
1346
|
+
logger.warn(
|
|
1347
|
+
"[FFID SDK] sessionStorage write failed \u2014 redirect loop detection disabled on this browser (fail-open).",
|
|
1348
|
+
err
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
function isRedirectLoopHistory(value) {
|
|
1352
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1353
|
+
return Object.values(value).every(
|
|
1354
|
+
(arr) => Array.isArray(arr) && arr.every((t) => typeof t === "number" && Number.isFinite(t))
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
function readRedirectLoopHistory(logger) {
|
|
1358
|
+
if (typeof window === "undefined") return {};
|
|
1359
|
+
try {
|
|
1360
|
+
const raw = window.sessionStorage.getItem(REDIRECT_LOOP_KEY);
|
|
1361
|
+
if (raw === null) return {};
|
|
1362
|
+
const parsed = JSON.parse(raw);
|
|
1363
|
+
return isRedirectLoopHistory(parsed) ? parsed : {};
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
logStorageReadFailure(logger, err);
|
|
1366
|
+
return {};
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
function writeRedirectLoopHistory(history, logger) {
|
|
1370
|
+
if (typeof window === "undefined") return;
|
|
1371
|
+
try {
|
|
1372
|
+
window.sessionStorage.setItem(REDIRECT_LOOP_KEY, JSON.stringify(history));
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
logStorageWriteFailure(logger, err);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function pruneRedirectLoopHistory(history, now) {
|
|
1378
|
+
const cutoff = now - REDIRECT_LOOP_WINDOW_MS;
|
|
1379
|
+
const pruned = {};
|
|
1380
|
+
for (const [key, timestamps] of Object.entries(history)) {
|
|
1381
|
+
const fresh = timestamps.filter((t) => t > cutoff);
|
|
1382
|
+
if (fresh.length > 0) pruned[key] = fresh;
|
|
1383
|
+
}
|
|
1384
|
+
return pruned;
|
|
1385
|
+
}
|
|
1386
|
+
function getRecentRedirectCount(authorizeKey, now, logger) {
|
|
1387
|
+
const pruned = pruneRedirectLoopHistory(readRedirectLoopHistory(logger), now);
|
|
1388
|
+
writeRedirectLoopHistory(pruned, logger);
|
|
1389
|
+
return pruned[authorizeKey]?.length ?? 0;
|
|
1390
|
+
}
|
|
1391
|
+
function recordRedirectAttempt(authorizeKey, now, logger) {
|
|
1392
|
+
const history = readRedirectLoopHistory(logger);
|
|
1393
|
+
const existing = history[authorizeKey] ?? [];
|
|
1394
|
+
history[authorizeKey] = [...existing, now];
|
|
1395
|
+
writeRedirectLoopHistory(history, logger);
|
|
1396
|
+
}
|
|
1397
|
+
function rollbackLastRedirectAttempt(authorizeKey, logger) {
|
|
1398
|
+
const history = readRedirectLoopHistory(logger);
|
|
1399
|
+
const existing = history[authorizeKey];
|
|
1400
|
+
if (!Array.isArray(existing) || existing.length === 0) return;
|
|
1401
|
+
history[authorizeKey] = existing.slice(0, -1);
|
|
1402
|
+
if (history[authorizeKey].length === 0) delete history[authorizeKey];
|
|
1403
|
+
writeRedirectLoopHistory(history, logger);
|
|
1404
|
+
}
|
|
1405
|
+
function buildAuthorizeKey(baseUrl, clientId, organizationId) {
|
|
1406
|
+
const params = new URLSearchParams({ client_id: clientId });
|
|
1407
|
+
const trimmedOrg = organizationId?.trim();
|
|
1408
|
+
if (trimmedOrg) params.set("organization_id", trimmedOrg);
|
|
1409
|
+
return `${baseUrl}${OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
|
1410
|
+
}
|
|
1320
1411
|
function generateRandomState() {
|
|
1321
1412
|
const array = new Uint8Array(STATE_RANDOM_BYTES);
|
|
1322
1413
|
crypto.getRandomValues(array);
|
|
@@ -1336,6 +1427,23 @@ function createRedirectMethods(deps) {
|
|
|
1336
1427
|
logger.warn("SSR \u74B0\u5883\u3067\u306F\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3067\u304D\u307E\u305B\u3093");
|
|
1337
1428
|
return { success: false, error: "SSR \u74B0\u5883\u3067\u306F\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3067\u304D\u307E\u305B\u3093" };
|
|
1338
1429
|
}
|
|
1430
|
+
const authorizeKey = buildAuthorizeKey(baseUrl, clientId, options?.organizationId);
|
|
1431
|
+
const now = Date.now();
|
|
1432
|
+
const recentCount = getRecentRedirectCount(authorizeKey, now, logger);
|
|
1433
|
+
if (recentCount >= REDIRECT_LOOP_THRESHOLD) {
|
|
1434
|
+
cleanupVerifierStorage(logger);
|
|
1435
|
+
logger.warn("[FFID SDK] redirect loop detected \u2014 \u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u505C\u6B62\u3057\u307E\u3057\u305F", {
|
|
1436
|
+
authorizeKey,
|
|
1437
|
+
recentCount,
|
|
1438
|
+
windowMs: REDIRECT_LOOP_WINDOW_MS,
|
|
1439
|
+
threshold: REDIRECT_LOOP_THRESHOLD
|
|
1440
|
+
});
|
|
1441
|
+
return {
|
|
1442
|
+
success: false,
|
|
1443
|
+
code: "redirect_loop_detected",
|
|
1444
|
+
error: "\u77ED\u6642\u9593\u306B\u540C\u3058\u8A8D\u53EF\u30A8\u30F3\u30C9\u30DD\u30A4\u30F3\u30C8\u3078\u306E\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u304C\u7E70\u308A\u8FD4\u3055\u308C\u305F\u305F\u3081\u3001\u30EB\u30FC\u30D7\u691C\u51FA\u306B\u3088\u308A\u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u505C\u6B62\u3057\u307E\u3057\u305F"
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1339
1447
|
const verifier = generateCodeVerifier();
|
|
1340
1448
|
storeCodeVerifier(verifier, logger);
|
|
1341
1449
|
let challenge;
|
|
@@ -1366,7 +1474,13 @@ function createRedirectMethods(deps) {
|
|
|
1366
1474
|
}
|
|
1367
1475
|
const authorizeUrl = `${baseUrl}${OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
|
1368
1476
|
logger.debug("Redirecting to authorize:", authorizeUrl);
|
|
1369
|
-
|
|
1477
|
+
recordRedirectAttempt(authorizeKey, now, logger);
|
|
1478
|
+
try {
|
|
1479
|
+
window.location.href = authorizeUrl;
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
rollbackLastRedirectAttempt(authorizeKey, logger);
|
|
1482
|
+
throw err;
|
|
1483
|
+
}
|
|
1370
1484
|
return { success: true };
|
|
1371
1485
|
}
|
|
1372
1486
|
async function redirectToLogin() {
|
package/dist/server/index.d.cts
CHANGED
|
@@ -891,17 +891,36 @@ interface FFIDUpdateUserProfileRequest {
|
|
|
891
891
|
/** Arbitrary user preferences bag (null clears the column; reads return {}) */
|
|
892
892
|
preferences?: Record<string, unknown> | null;
|
|
893
893
|
}
|
|
894
|
+
/**
|
|
895
|
+
* Discriminant for redirect failures that callers need to handle
|
|
896
|
+
* programmatically (vs. logging the human-readable `error` string).
|
|
897
|
+
*
|
|
898
|
+
* - `'redirect_loop_detected'`: `redirectToAuthorize()` detected that the
|
|
899
|
+
* same authorize URL (keyed by `baseUrl + client_id + organization_id`)
|
|
900
|
+
* has been fired **3 times within 60 seconds**. Caller should surface a
|
|
901
|
+
* manual "再度ログインする" UI rather than retry automatically (#2406 / #2411).
|
|
902
|
+
*
|
|
903
|
+
* SDK 2.18.0 only ships `'redirect_loop_detected'`. Other failure paths
|
|
904
|
+
* (SSR environment, PKCE generation failure, empty organizationId) currently
|
|
905
|
+
* return `error` without a `code`. New codes will be added in future minor
|
|
906
|
+
* versions — treat this union as forward-extensible and do NOT exhaustively
|
|
907
|
+
* `switch` over it without a `default` branch for consumer code that must
|
|
908
|
+
* stay compatible across SDK upgrades.
|
|
909
|
+
*/
|
|
910
|
+
type FFIDRedirectErrorCode = 'redirect_loop_detected';
|
|
894
911
|
/**
|
|
895
912
|
* Result of a redirect operation (redirectToLogin / redirectToAuthorize / redirectToLogout)
|
|
896
913
|
*
|
|
897
914
|
* Structured return type so callers can inspect failure reasons
|
|
898
|
-
* instead of receiving a bare `false`.
|
|
915
|
+
* instead of receiving a bare `false`. When `code` is set, branch on it
|
|
916
|
+
* for programmatic handling; otherwise log `error` for humans.
|
|
899
917
|
*/
|
|
900
918
|
type FFIDRedirectResult = {
|
|
901
919
|
success: true;
|
|
902
920
|
} | {
|
|
903
921
|
success: false;
|
|
904
922
|
error: string;
|
|
923
|
+
code?: FFIDRedirectErrorCode;
|
|
905
924
|
};
|
|
906
925
|
|
|
907
926
|
/** OTP / magic link methods - sendOtp / verifyOtp */
|
package/dist/server/index.d.ts
CHANGED
|
@@ -891,17 +891,36 @@ interface FFIDUpdateUserProfileRequest {
|
|
|
891
891
|
/** Arbitrary user preferences bag (null clears the column; reads return {}) */
|
|
892
892
|
preferences?: Record<string, unknown> | null;
|
|
893
893
|
}
|
|
894
|
+
/**
|
|
895
|
+
* Discriminant for redirect failures that callers need to handle
|
|
896
|
+
* programmatically (vs. logging the human-readable `error` string).
|
|
897
|
+
*
|
|
898
|
+
* - `'redirect_loop_detected'`: `redirectToAuthorize()` detected that the
|
|
899
|
+
* same authorize URL (keyed by `baseUrl + client_id + organization_id`)
|
|
900
|
+
* has been fired **3 times within 60 seconds**. Caller should surface a
|
|
901
|
+
* manual "再度ログインする" UI rather than retry automatically (#2406 / #2411).
|
|
902
|
+
*
|
|
903
|
+
* SDK 2.18.0 only ships `'redirect_loop_detected'`. Other failure paths
|
|
904
|
+
* (SSR environment, PKCE generation failure, empty organizationId) currently
|
|
905
|
+
* return `error` without a `code`. New codes will be added in future minor
|
|
906
|
+
* versions — treat this union as forward-extensible and do NOT exhaustively
|
|
907
|
+
* `switch` over it without a `default` branch for consumer code that must
|
|
908
|
+
* stay compatible across SDK upgrades.
|
|
909
|
+
*/
|
|
910
|
+
type FFIDRedirectErrorCode = 'redirect_loop_detected';
|
|
894
911
|
/**
|
|
895
912
|
* Result of a redirect operation (redirectToLogin / redirectToAuthorize / redirectToLogout)
|
|
896
913
|
*
|
|
897
914
|
* Structured return type so callers can inspect failure reasons
|
|
898
|
-
* instead of receiving a bare `false`.
|
|
915
|
+
* instead of receiving a bare `false`. When `code` is set, branch on it
|
|
916
|
+
* for programmatic handling; otherwise log `error` for humans.
|
|
899
917
|
*/
|
|
900
918
|
type FFIDRedirectResult = {
|
|
901
919
|
success: true;
|
|
902
920
|
} | {
|
|
903
921
|
success: false;
|
|
904
922
|
error: string;
|
|
923
|
+
code?: FFIDRedirectErrorCode;
|
|
905
924
|
};
|
|
906
925
|
|
|
907
926
|
/** OTP / magic link methods - sendOtp / verifyOtp */
|
package/dist/server/index.js
CHANGED
|
@@ -100,10 +100,7 @@ function createTokenStore(storageType) {
|
|
|
100
100
|
// src/client/oauth-userinfo.ts
|
|
101
101
|
var SESSION_ELIGIBLE_SUBSCRIPTION_STATUSES = [
|
|
102
102
|
"trialing",
|
|
103
|
-
"active"
|
|
104
|
-
"past_due",
|
|
105
|
-
"canceled",
|
|
106
|
-
"paused"
|
|
103
|
+
"active"
|
|
107
104
|
];
|
|
108
105
|
function isSessionEligibleSubscriptionStatus(value) {
|
|
109
106
|
return typeof value === "string" && SESSION_ELIGIBLE_SUBSCRIPTION_STATUSES.includes(value);
|
|
@@ -805,7 +802,7 @@ function createProfileMethods(deps) {
|
|
|
805
802
|
}
|
|
806
803
|
|
|
807
804
|
// src/client/version-check.ts
|
|
808
|
-
var SDK_VERSION = "2.
|
|
805
|
+
var SDK_VERSION = "2.19.0";
|
|
809
806
|
var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
|
|
810
807
|
var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
|
|
811
808
|
function sdkHeaders() {
|
|
@@ -1302,6 +1299,19 @@ function storeCodeVerifier(verifier, logger) {
|
|
|
1302
1299
|
}
|
|
1303
1300
|
return sessionStored || localStored;
|
|
1304
1301
|
}
|
|
1302
|
+
function cleanupVerifierStorage(logger) {
|
|
1303
|
+
try {
|
|
1304
|
+
window.sessionStorage.removeItem(VERIFIER_STORAGE_KEY);
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
logger?.warn("retrieveCodeVerifier: sessionStorage \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
window.localStorage.removeItem(VERIFIER_FALLBACK_STORAGE_KEY);
|
|
1310
|
+
window.localStorage.removeItem(VERIFIER_FALLBACK_TIMESTAMP_KEY);
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
logger?.warn("retrieveCodeVerifier: localStorage \u306E\u30AF\u30EA\u30FC\u30F3\u30A2\u30C3\u30D7\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1305
1315
|
function base64UrlEncode(buffer) {
|
|
1306
1316
|
const bytes = new Uint8Array(buffer);
|
|
1307
1317
|
let binary = "";
|
|
@@ -1316,6 +1326,87 @@ var OAUTH_AUTHORIZE_ENDPOINT = "/api/v1/oauth/authorize";
|
|
|
1316
1326
|
var AUTH_LOGOUT_ENDPOINT = "/api/v1/auth/logout";
|
|
1317
1327
|
var STATE_RANDOM_BYTES = 16;
|
|
1318
1328
|
var HEX_BASE2 = 16;
|
|
1329
|
+
var REDIRECT_LOOP_KEY = "ffid_sdk_redirect_loop_history";
|
|
1330
|
+
var REDIRECT_LOOP_WINDOW_MS = 6e4;
|
|
1331
|
+
var REDIRECT_LOOP_THRESHOLD = 3;
|
|
1332
|
+
var storageReadFailureLogged = false;
|
|
1333
|
+
var storageWriteFailureLogged = false;
|
|
1334
|
+
function logStorageReadFailure(logger, err) {
|
|
1335
|
+
if (storageReadFailureLogged) return;
|
|
1336
|
+
storageReadFailureLogged = true;
|
|
1337
|
+
logger.warn(
|
|
1338
|
+
"[FFID SDK] sessionStorage read failed \u2014 redirect loop detection disabled on this browser (fail-open). iOS WebKit private mode / eviction \u304C\u5178\u578B\u8981\u56E0",
|
|
1339
|
+
err
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
function logStorageWriteFailure(logger, err) {
|
|
1343
|
+
if (storageWriteFailureLogged) return;
|
|
1344
|
+
storageWriteFailureLogged = true;
|
|
1345
|
+
logger.warn(
|
|
1346
|
+
"[FFID SDK] sessionStorage write failed \u2014 redirect loop detection disabled on this browser (fail-open).",
|
|
1347
|
+
err
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
function isRedirectLoopHistory(value) {
|
|
1351
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1352
|
+
return Object.values(value).every(
|
|
1353
|
+
(arr) => Array.isArray(arr) && arr.every((t) => typeof t === "number" && Number.isFinite(t))
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
function readRedirectLoopHistory(logger) {
|
|
1357
|
+
if (typeof window === "undefined") return {};
|
|
1358
|
+
try {
|
|
1359
|
+
const raw = window.sessionStorage.getItem(REDIRECT_LOOP_KEY);
|
|
1360
|
+
if (raw === null) return {};
|
|
1361
|
+
const parsed = JSON.parse(raw);
|
|
1362
|
+
return isRedirectLoopHistory(parsed) ? parsed : {};
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
logStorageReadFailure(logger, err);
|
|
1365
|
+
return {};
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
function writeRedirectLoopHistory(history, logger) {
|
|
1369
|
+
if (typeof window === "undefined") return;
|
|
1370
|
+
try {
|
|
1371
|
+
window.sessionStorage.setItem(REDIRECT_LOOP_KEY, JSON.stringify(history));
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
logStorageWriteFailure(logger, err);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
function pruneRedirectLoopHistory(history, now) {
|
|
1377
|
+
const cutoff = now - REDIRECT_LOOP_WINDOW_MS;
|
|
1378
|
+
const pruned = {};
|
|
1379
|
+
for (const [key, timestamps] of Object.entries(history)) {
|
|
1380
|
+
const fresh = timestamps.filter((t) => t > cutoff);
|
|
1381
|
+
if (fresh.length > 0) pruned[key] = fresh;
|
|
1382
|
+
}
|
|
1383
|
+
return pruned;
|
|
1384
|
+
}
|
|
1385
|
+
function getRecentRedirectCount(authorizeKey, now, logger) {
|
|
1386
|
+
const pruned = pruneRedirectLoopHistory(readRedirectLoopHistory(logger), now);
|
|
1387
|
+
writeRedirectLoopHistory(pruned, logger);
|
|
1388
|
+
return pruned[authorizeKey]?.length ?? 0;
|
|
1389
|
+
}
|
|
1390
|
+
function recordRedirectAttempt(authorizeKey, now, logger) {
|
|
1391
|
+
const history = readRedirectLoopHistory(logger);
|
|
1392
|
+
const existing = history[authorizeKey] ?? [];
|
|
1393
|
+
history[authorizeKey] = [...existing, now];
|
|
1394
|
+
writeRedirectLoopHistory(history, logger);
|
|
1395
|
+
}
|
|
1396
|
+
function rollbackLastRedirectAttempt(authorizeKey, logger) {
|
|
1397
|
+
const history = readRedirectLoopHistory(logger);
|
|
1398
|
+
const existing = history[authorizeKey];
|
|
1399
|
+
if (!Array.isArray(existing) || existing.length === 0) return;
|
|
1400
|
+
history[authorizeKey] = existing.slice(0, -1);
|
|
1401
|
+
if (history[authorizeKey].length === 0) delete history[authorizeKey];
|
|
1402
|
+
writeRedirectLoopHistory(history, logger);
|
|
1403
|
+
}
|
|
1404
|
+
function buildAuthorizeKey(baseUrl, clientId, organizationId) {
|
|
1405
|
+
const params = new URLSearchParams({ client_id: clientId });
|
|
1406
|
+
const trimmedOrg = organizationId?.trim();
|
|
1407
|
+
if (trimmedOrg) params.set("organization_id", trimmedOrg);
|
|
1408
|
+
return `${baseUrl}${OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
|
1409
|
+
}
|
|
1319
1410
|
function generateRandomState() {
|
|
1320
1411
|
const array = new Uint8Array(STATE_RANDOM_BYTES);
|
|
1321
1412
|
crypto.getRandomValues(array);
|
|
@@ -1335,6 +1426,23 @@ function createRedirectMethods(deps) {
|
|
|
1335
1426
|
logger.warn("SSR \u74B0\u5883\u3067\u306F\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3067\u304D\u307E\u305B\u3093");
|
|
1336
1427
|
return { success: false, error: "SSR \u74B0\u5883\u3067\u306F\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3067\u304D\u307E\u305B\u3093" };
|
|
1337
1428
|
}
|
|
1429
|
+
const authorizeKey = buildAuthorizeKey(baseUrl, clientId, options?.organizationId);
|
|
1430
|
+
const now = Date.now();
|
|
1431
|
+
const recentCount = getRecentRedirectCount(authorizeKey, now, logger);
|
|
1432
|
+
if (recentCount >= REDIRECT_LOOP_THRESHOLD) {
|
|
1433
|
+
cleanupVerifierStorage(logger);
|
|
1434
|
+
logger.warn("[FFID SDK] redirect loop detected \u2014 \u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u505C\u6B62\u3057\u307E\u3057\u305F", {
|
|
1435
|
+
authorizeKey,
|
|
1436
|
+
recentCount,
|
|
1437
|
+
windowMs: REDIRECT_LOOP_WINDOW_MS,
|
|
1438
|
+
threshold: REDIRECT_LOOP_THRESHOLD
|
|
1439
|
+
});
|
|
1440
|
+
return {
|
|
1441
|
+
success: false,
|
|
1442
|
+
code: "redirect_loop_detected",
|
|
1443
|
+
error: "\u77ED\u6642\u9593\u306B\u540C\u3058\u8A8D\u53EF\u30A8\u30F3\u30C9\u30DD\u30A4\u30F3\u30C8\u3078\u306E\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u304C\u7E70\u308A\u8FD4\u3055\u308C\u305F\u305F\u3081\u3001\u30EB\u30FC\u30D7\u691C\u51FA\u306B\u3088\u308A\u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u505C\u6B62\u3057\u307E\u3057\u305F"
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1338
1446
|
const verifier = generateCodeVerifier();
|
|
1339
1447
|
storeCodeVerifier(verifier, logger);
|
|
1340
1448
|
let challenge;
|
|
@@ -1365,7 +1473,13 @@ function createRedirectMethods(deps) {
|
|
|
1365
1473
|
}
|
|
1366
1474
|
const authorizeUrl = `${baseUrl}${OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
|
1367
1475
|
logger.debug("Redirecting to authorize:", authorizeUrl);
|
|
1368
|
-
|
|
1476
|
+
recordRedirectAttempt(authorizeKey, now, logger);
|
|
1477
|
+
try {
|
|
1478
|
+
window.location.href = authorizeUrl;
|
|
1479
|
+
} catch (err) {
|
|
1480
|
+
rollbackLastRedirectAttempt(authorizeKey, logger);
|
|
1481
|
+
throw err;
|
|
1482
|
+
}
|
|
1369
1483
|
return { success: true };
|
|
1370
1484
|
}
|
|
1371
1485
|
async function redirectToLogin() {
|
|
@@ -59,11 +59,33 @@ interface FFIDSubscriptionUpdatedPayload {
|
|
|
59
59
|
status?: string;
|
|
60
60
|
}
|
|
61
61
|
interface FFIDSubscriptionCanceledPayload {
|
|
62
|
+
/** FFID subscription UUID. */
|
|
62
63
|
subscriptionId: string;
|
|
63
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Organization UUID that owned the subscription. Always emitted by the FFID
|
|
66
|
+
* server (see `src/lib/webhooks/types.ts > SubscriptionCanceledPayload`).
|
|
67
|
+
*/
|
|
68
|
+
organizationId: string;
|
|
69
|
+
/**
|
|
70
|
+
* Stripe subscription id (`sub_...`). `null` when the subscription was
|
|
71
|
+
* cancelled before Stripe provisioning (e.g. free plans / manual cancels).
|
|
72
|
+
* Always present on the wire.
|
|
73
|
+
*/
|
|
74
|
+
stripeSubscriptionId: string | null;
|
|
64
75
|
reason?: string;
|
|
65
76
|
cancelAt?: string;
|
|
66
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Origin of the cancellation. Always emitted by the FFID server — required
|
|
79
|
+
* by the wire shape in `SubscriptionCanceledPayload`.
|
|
80
|
+
*/
|
|
81
|
+
source: 'user_initiated' | 'stripe_confirmed' | 'payment_failure_auto_cancel';
|
|
82
|
+
/** Whether this cancellation can be resumed via resubscribe flow. */
|
|
83
|
+
reactivatable?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* ISO timestamp at which access was actually lost. May differ from `cancelAt`
|
|
86
|
+
* when Stripe runs out the paid period before emitting the deletion event.
|
|
87
|
+
*/
|
|
88
|
+
expiredAt?: string;
|
|
67
89
|
}
|
|
68
90
|
interface FFIDSubscriptionTrialEndingPayload {
|
|
69
91
|
subscriptionId: string;
|
|
@@ -71,10 +93,54 @@ interface FFIDSubscriptionTrialEndingPayload {
|
|
|
71
93
|
trialEndDate: string;
|
|
72
94
|
}
|
|
73
95
|
interface FFIDSubscriptionPaymentFailedPayload {
|
|
74
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Stripe invoice id (`in_...`) whose payment failed. Always emitted by the
|
|
98
|
+
* FFID server (see `src/lib/webhooks/types.ts > SubscriptionPaymentFailedPayload`).
|
|
99
|
+
*/
|
|
100
|
+
stripeInvoiceId: string;
|
|
101
|
+
/**
|
|
102
|
+
* Stripe subscription id (`sub_...`) tied to the failed invoice, or `null`
|
|
103
|
+
* when the invoice was not linked to a subscription. Always emitted by the
|
|
104
|
+
* FFID server.
|
|
105
|
+
*/
|
|
106
|
+
stripeSubscriptionId: string | null;
|
|
107
|
+
/**
|
|
108
|
+
* FFID subscription UUID resolved from `stripeSubscriptionId`.
|
|
109
|
+
*
|
|
110
|
+
* Optional because the server only spreads this field when it could
|
|
111
|
+
* correlate the Stripe subscription to an FFID row. Consumers should check
|
|
112
|
+
* {@link correlationUnavailable} before assuming a lookup failure is a
|
|
113
|
+
* data-quality issue rather than a legitimate unlinked payload.
|
|
114
|
+
*/
|
|
115
|
+
subscriptionId?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Organization UUID that owns the subscription.
|
|
118
|
+
*
|
|
119
|
+
* Optional for the same reason as `subscriptionId` (conditionally emitted
|
|
120
|
+
* when correlation succeeded).
|
|
121
|
+
*/
|
|
75
122
|
organizationId?: string;
|
|
76
|
-
|
|
77
|
-
|
|
123
|
+
/**
|
|
124
|
+
* 'warning' during Stripe dunning retries — keep access, surface UI banner.
|
|
125
|
+
* 'blocking' once retries are exhausted or the grace period has passed — cut off access.
|
|
126
|
+
*/
|
|
127
|
+
severity?: 'warning' | 'blocking';
|
|
128
|
+
/**
|
|
129
|
+
* ISO timestamp at which the grace period ends and severity flips to 'blocking'.
|
|
130
|
+
* `null` means already blocking.
|
|
131
|
+
*/
|
|
132
|
+
graceUntil?: string | null;
|
|
133
|
+
/**
|
|
134
|
+
* `true` when the FFID server could not resolve `stripeSubscriptionId` to an
|
|
135
|
+
* FFID subscription row (e.g. race with a not-yet-synced sub, or invoice
|
|
136
|
+
* without a linked subscription). In that case both `subscriptionId` and
|
|
137
|
+
* `organizationId` will be absent. Consumers should treat the event as a
|
|
138
|
+
* best-effort signal and avoid hard failures when this flag is set.
|
|
139
|
+
*
|
|
140
|
+
* Added alongside backend change (#2444) so SDK consumers can distinguish
|
|
141
|
+
* "backend has no mapping yet" from "payload shape regression".
|
|
142
|
+
*/
|
|
143
|
+
correlationUnavailable?: boolean;
|
|
78
144
|
}
|
|
79
145
|
interface FFIDUserCreatedPayload {
|
|
80
146
|
userId: string;
|
package/dist/webhooks/index.d.ts
CHANGED
|
@@ -59,11 +59,33 @@ interface FFIDSubscriptionUpdatedPayload {
|
|
|
59
59
|
status?: string;
|
|
60
60
|
}
|
|
61
61
|
interface FFIDSubscriptionCanceledPayload {
|
|
62
|
+
/** FFID subscription UUID. */
|
|
62
63
|
subscriptionId: string;
|
|
63
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Organization UUID that owned the subscription. Always emitted by the FFID
|
|
66
|
+
* server (see `src/lib/webhooks/types.ts > SubscriptionCanceledPayload`).
|
|
67
|
+
*/
|
|
68
|
+
organizationId: string;
|
|
69
|
+
/**
|
|
70
|
+
* Stripe subscription id (`sub_...`). `null` when the subscription was
|
|
71
|
+
* cancelled before Stripe provisioning (e.g. free plans / manual cancels).
|
|
72
|
+
* Always present on the wire.
|
|
73
|
+
*/
|
|
74
|
+
stripeSubscriptionId: string | null;
|
|
64
75
|
reason?: string;
|
|
65
76
|
cancelAt?: string;
|
|
66
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Origin of the cancellation. Always emitted by the FFID server — required
|
|
79
|
+
* by the wire shape in `SubscriptionCanceledPayload`.
|
|
80
|
+
*/
|
|
81
|
+
source: 'user_initiated' | 'stripe_confirmed' | 'payment_failure_auto_cancel';
|
|
82
|
+
/** Whether this cancellation can be resumed via resubscribe flow. */
|
|
83
|
+
reactivatable?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* ISO timestamp at which access was actually lost. May differ from `cancelAt`
|
|
86
|
+
* when Stripe runs out the paid period before emitting the deletion event.
|
|
87
|
+
*/
|
|
88
|
+
expiredAt?: string;
|
|
67
89
|
}
|
|
68
90
|
interface FFIDSubscriptionTrialEndingPayload {
|
|
69
91
|
subscriptionId: string;
|
|
@@ -71,10 +93,54 @@ interface FFIDSubscriptionTrialEndingPayload {
|
|
|
71
93
|
trialEndDate: string;
|
|
72
94
|
}
|
|
73
95
|
interface FFIDSubscriptionPaymentFailedPayload {
|
|
74
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Stripe invoice id (`in_...`) whose payment failed. Always emitted by the
|
|
98
|
+
* FFID server (see `src/lib/webhooks/types.ts > SubscriptionPaymentFailedPayload`).
|
|
99
|
+
*/
|
|
100
|
+
stripeInvoiceId: string;
|
|
101
|
+
/**
|
|
102
|
+
* Stripe subscription id (`sub_...`) tied to the failed invoice, or `null`
|
|
103
|
+
* when the invoice was not linked to a subscription. Always emitted by the
|
|
104
|
+
* FFID server.
|
|
105
|
+
*/
|
|
106
|
+
stripeSubscriptionId: string | null;
|
|
107
|
+
/**
|
|
108
|
+
* FFID subscription UUID resolved from `stripeSubscriptionId`.
|
|
109
|
+
*
|
|
110
|
+
* Optional because the server only spreads this field when it could
|
|
111
|
+
* correlate the Stripe subscription to an FFID row. Consumers should check
|
|
112
|
+
* {@link correlationUnavailable} before assuming a lookup failure is a
|
|
113
|
+
* data-quality issue rather than a legitimate unlinked payload.
|
|
114
|
+
*/
|
|
115
|
+
subscriptionId?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Organization UUID that owns the subscription.
|
|
118
|
+
*
|
|
119
|
+
* Optional for the same reason as `subscriptionId` (conditionally emitted
|
|
120
|
+
* when correlation succeeded).
|
|
121
|
+
*/
|
|
75
122
|
organizationId?: string;
|
|
76
|
-
|
|
77
|
-
|
|
123
|
+
/**
|
|
124
|
+
* 'warning' during Stripe dunning retries — keep access, surface UI banner.
|
|
125
|
+
* 'blocking' once retries are exhausted or the grace period has passed — cut off access.
|
|
126
|
+
*/
|
|
127
|
+
severity?: 'warning' | 'blocking';
|
|
128
|
+
/**
|
|
129
|
+
* ISO timestamp at which the grace period ends and severity flips to 'blocking'.
|
|
130
|
+
* `null` means already blocking.
|
|
131
|
+
*/
|
|
132
|
+
graceUntil?: string | null;
|
|
133
|
+
/**
|
|
134
|
+
* `true` when the FFID server could not resolve `stripeSubscriptionId` to an
|
|
135
|
+
* FFID subscription row (e.g. race with a not-yet-synced sub, or invoice
|
|
136
|
+
* without a linked subscription). In that case both `subscriptionId` and
|
|
137
|
+
* `organizationId` will be absent. Consumers should treat the event as a
|
|
138
|
+
* best-effort signal and avoid hard failures when this flag is set.
|
|
139
|
+
*
|
|
140
|
+
* Added alongside backend change (#2444) so SDK consumers can distinguish
|
|
141
|
+
* "backend has no mapping yet" from "payload shape regression".
|
|
142
|
+
*/
|
|
143
|
+
correlationUnavailable?: boolean;
|
|
78
144
|
}
|
|
79
145
|
interface FFIDUserCreatedPayload {
|
|
80
146
|
userId: string;
|