@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.
@@ -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.17.1";
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
- window.location.href = authorizeUrl;
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() {
@@ -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 */
@@ -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 */
@@ -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.17.1";
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
- window.location.href = authorizeUrl;
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
- organizationId?: string;
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
- source?: 'user_initiated' | 'stripe_confirmed';
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
- subscriptionId: string;
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
- failureReason?: string;
77
- attemptCount?: number;
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;
@@ -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
- organizationId?: string;
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
- source?: 'user_initiated' | 'stripe_confirmed';
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
- subscriptionId: string;
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
- failureReason?: string;
77
- attemptCount?: number;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feelflow/ffid-sdk",
3
- "version": "2.17.1",
3
+ "version": "2.19.0",
4
4
  "description": "FeelFlow ID Platform SDK for React/Next.js applications",
5
5
  "keywords": [
6
6
  "feelflow",