@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 CHANGED
@@ -206,16 +206,51 @@ const {
206
206
 
207
207
  ```tsx
208
208
  const {
209
- subscription, // FFIDSubscription | null - 現在のサブスクリプション
210
- planCode, // string | null - プランコード
211
- isActive, // boolean - アクティブ契約
212
- isTrialing, // boolean - トライアル中
213
- isCanceled, // boolean - 解約済み
214
- hasPlan, // (plans: string | string[]) => boolean - プラン確認
215
- hasAccess, // () => boolean - アクセス権確認
209
+ subscription, // FFIDSubscription | null - 現在のサブスクリプション
210
+ planCode, // string | null - プランコード
211
+ isActive, // boolean - DB ステータスが 'active'
212
+ isTrialing, // boolean - トライアル中
213
+ isCanceled, // boolean - 解約済み
214
+ isTrialExpired, // boolean - トライアル期間超過
215
+ effectiveStatus, // EffectiveSubscriptionStatus | null - 意味論的アクセス制御値
216
+ isBlocked, // boolean - blocked / expired / canceled / trial_expired
217
+ isGrace, // boolean - past_due_grace (支払い失敗の猶予期間中)
218
+ hasPlan, // (plans: string | string[]) => boolean - プラン確認
219
+ hasAccess, // () => boolean - アクセス権確認 (active || past_due_grace)
220
+ hasAccessLegacy, // () => boolean - 旧セマンティクス (active || trialing, pre-2.19)
216
221
  } = useSubscription()
217
222
  ```
218
223
 
224
+ `effectiveStatus` は `/api/v1/subscriptions/ext/check` が返す意味論的ステータスと同じ値を取る。詳細は [契約期限切れハンドリング](#契約期限切れハンドリング) を参照。
225
+
226
+ ### useRequireActiveSubscription()
227
+
228
+ 契約が期限切れ/遮断状態のときに自動でリダイレクトするフック。
229
+
230
+ ```tsx
231
+ 'use client'
232
+ import { useRouter } from 'next/navigation'
233
+ import { useRequireActiveSubscription } from '@feelflow/ffid-sdk'
234
+
235
+ function ProtectedShell({ children }: { children: React.ReactNode }) {
236
+ const router = useRouter()
237
+ const { loading } = useRequireActiveSubscription({
238
+ redirectTo: (status) =>
239
+ status === 'canceled' ? '/contract-ended' : '/contract-required',
240
+ onRedirect: (url) => router.replace(url),
241
+ })
242
+
243
+ if (loading) return <FullPageSpinner />
244
+ return <>{children}</>
245
+ }
246
+ ```
247
+
248
+ オプション:
249
+
250
+ - `redirectTo`: `string` または `(status: EffectiveSubscriptionStatus) => string`
251
+ - `allowGrace` (default: `true`): `past_due_grace` を通過させるか
252
+ - `onRedirect` (optional): 独自のリダイレクト関数(未指定時は `window.location.href`)
253
+
219
254
  ### withSubscription()
220
255
 
221
256
  サブスクリプション確認HOC。
@@ -228,6 +263,34 @@ const PremiumFeature = withSubscription(MyComponent, {
228
263
  })
229
264
  ```
230
265
 
266
+ ## 契約期限切れハンドリング
267
+
268
+ 契約が失効したり解約されたとき、外部サービス側で適切にアクセスを遮断し、ユーザーを再契約動線に案内する必要があります。SDK は 3 層構成(トークン検証 / 契約チェック / Webhook 受信)をサポートしています。
269
+
270
+ ### 最小構成(Next.js App Router)
271
+
272
+ ```tsx
273
+ 'use client'
274
+ import { useRouter } from 'next/navigation'
275
+ import { useRequireActiveSubscription } from '@feelflow/ffid-sdk'
276
+
277
+ export default function Layout({ children }: { children: React.ReactNode }) {
278
+ const router = useRouter()
279
+ const { loading, effectiveStatus } = useRequireActiveSubscription({
280
+ redirectTo: '/contract-required',
281
+ onRedirect: (url) => router.replace(url),
282
+ })
283
+
284
+ if (loading) return <Spinner />
285
+ if (effectiveStatus !== 'active' && effectiveStatus !== 'past_due_grace') {
286
+ return null // リダイレクト発火中
287
+ }
288
+ return <>{children}</>
289
+ }
290
+ ```
291
+
292
+ **詳細な実装レシピ**(middleware、Express、UI 分岐、Webhook 受信、fixture API を使ったテスト、`EffectiveSubscriptionStatus` 別の UI 推奨動作まで) → [docs/03-implementation/EXPIRED_CONTRACT_HANDLING.md](https://github.com/feel-flow/feelflow-id-platform/blob/develop/docs/03-implementation/EXPIRED_CONTRACT_HANDLING.md)
293
+
231
294
  ### getProfile() / updateProfile()
232
295
 
233
296
  ログイン中ユーザー自身のプロフィールを取得・更新するメソッド(`createFFIDClient` から呼び出し)。
@@ -105,10 +105,7 @@ function createTokenStore(storageType) {
105
105
  // src/client/oauth-userinfo.ts
106
106
  var SESSION_ELIGIBLE_SUBSCRIPTION_STATUSES = [
107
107
  "trialing",
108
- "active",
109
- "past_due",
110
- "canceled",
111
- "paused"
108
+ "active"
112
109
  ];
113
110
  function isSessionEligibleSubscriptionStatus(value) {
114
111
  return typeof value === "string" && SESSION_ELIGIBLE_SUBSCRIPTION_STATUSES.includes(value);
@@ -810,7 +807,7 @@ function createProfileMethods(deps) {
810
807
  }
811
808
 
812
809
  // src/client/version-check.ts
813
- var SDK_VERSION = "2.17.1";
810
+ var SDK_VERSION = "2.19.0";
814
811
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
815
812
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
816
813
  function sdkHeaders() {
@@ -1373,6 +1370,87 @@ var OAUTH_AUTHORIZE_ENDPOINT = "/api/v1/oauth/authorize";
1373
1370
  var AUTH_LOGOUT_ENDPOINT = "/api/v1/auth/logout";
1374
1371
  var STATE_RANDOM_BYTES = 16;
1375
1372
  var HEX_BASE2 = 16;
1373
+ var REDIRECT_LOOP_KEY = "ffid_sdk_redirect_loop_history";
1374
+ var REDIRECT_LOOP_WINDOW_MS = 6e4;
1375
+ var REDIRECT_LOOP_THRESHOLD = 3;
1376
+ var storageReadFailureLogged = false;
1377
+ var storageWriteFailureLogged = false;
1378
+ function logStorageReadFailure(logger, err) {
1379
+ if (storageReadFailureLogged) return;
1380
+ storageReadFailureLogged = true;
1381
+ logger.warn(
1382
+ "[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",
1383
+ err
1384
+ );
1385
+ }
1386
+ function logStorageWriteFailure(logger, err) {
1387
+ if (storageWriteFailureLogged) return;
1388
+ storageWriteFailureLogged = true;
1389
+ logger.warn(
1390
+ "[FFID SDK] sessionStorage write failed \u2014 redirect loop detection disabled on this browser (fail-open).",
1391
+ err
1392
+ );
1393
+ }
1394
+ function isRedirectLoopHistory(value) {
1395
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1396
+ return Object.values(value).every(
1397
+ (arr) => Array.isArray(arr) && arr.every((t) => typeof t === "number" && Number.isFinite(t))
1398
+ );
1399
+ }
1400
+ function readRedirectLoopHistory(logger) {
1401
+ if (typeof window === "undefined") return {};
1402
+ try {
1403
+ const raw = window.sessionStorage.getItem(REDIRECT_LOOP_KEY);
1404
+ if (raw === null) return {};
1405
+ const parsed = JSON.parse(raw);
1406
+ return isRedirectLoopHistory(parsed) ? parsed : {};
1407
+ } catch (err) {
1408
+ logStorageReadFailure(logger, err);
1409
+ return {};
1410
+ }
1411
+ }
1412
+ function writeRedirectLoopHistory(history, logger) {
1413
+ if (typeof window === "undefined") return;
1414
+ try {
1415
+ window.sessionStorage.setItem(REDIRECT_LOOP_KEY, JSON.stringify(history));
1416
+ } catch (err) {
1417
+ logStorageWriteFailure(logger, err);
1418
+ }
1419
+ }
1420
+ function pruneRedirectLoopHistory(history, now) {
1421
+ const cutoff = now - REDIRECT_LOOP_WINDOW_MS;
1422
+ const pruned = {};
1423
+ for (const [key, timestamps] of Object.entries(history)) {
1424
+ const fresh = timestamps.filter((t) => t > cutoff);
1425
+ if (fresh.length > 0) pruned[key] = fresh;
1426
+ }
1427
+ return pruned;
1428
+ }
1429
+ function getRecentRedirectCount(authorizeKey, now, logger) {
1430
+ const pruned = pruneRedirectLoopHistory(readRedirectLoopHistory(logger), now);
1431
+ writeRedirectLoopHistory(pruned, logger);
1432
+ return pruned[authorizeKey]?.length ?? 0;
1433
+ }
1434
+ function recordRedirectAttempt(authorizeKey, now, logger) {
1435
+ const history = readRedirectLoopHistory(logger);
1436
+ const existing = history[authorizeKey] ?? [];
1437
+ history[authorizeKey] = [...existing, now];
1438
+ writeRedirectLoopHistory(history, logger);
1439
+ }
1440
+ function rollbackLastRedirectAttempt(authorizeKey, logger) {
1441
+ const history = readRedirectLoopHistory(logger);
1442
+ const existing = history[authorizeKey];
1443
+ if (!Array.isArray(existing) || existing.length === 0) return;
1444
+ history[authorizeKey] = existing.slice(0, -1);
1445
+ if (history[authorizeKey].length === 0) delete history[authorizeKey];
1446
+ writeRedirectLoopHistory(history, logger);
1447
+ }
1448
+ function buildAuthorizeKey(baseUrl, clientId, organizationId) {
1449
+ const params = new URLSearchParams({ client_id: clientId });
1450
+ const trimmedOrg = organizationId?.trim();
1451
+ if (trimmedOrg) params.set("organization_id", trimmedOrg);
1452
+ return `${baseUrl}${OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`;
1453
+ }
1376
1454
  function generateRandomState() {
1377
1455
  const array = new Uint8Array(STATE_RANDOM_BYTES);
1378
1456
  crypto.getRandomValues(array);
@@ -1392,6 +1470,23 @@ function createRedirectMethods(deps) {
1392
1470
  logger.warn("SSR \u74B0\u5883\u3067\u306F\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3067\u304D\u307E\u305B\u3093");
1393
1471
  return { success: false, error: "SSR \u74B0\u5883\u3067\u306F\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3067\u304D\u307E\u305B\u3093" };
1394
1472
  }
1473
+ const authorizeKey = buildAuthorizeKey(baseUrl, clientId, options?.organizationId);
1474
+ const now = Date.now();
1475
+ const recentCount = getRecentRedirectCount(authorizeKey, now, logger);
1476
+ if (recentCount >= REDIRECT_LOOP_THRESHOLD) {
1477
+ cleanupVerifierStorage(logger);
1478
+ logger.warn("[FFID SDK] redirect loop detected \u2014 \u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u505C\u6B62\u3057\u307E\u3057\u305F", {
1479
+ authorizeKey,
1480
+ recentCount,
1481
+ windowMs: REDIRECT_LOOP_WINDOW_MS,
1482
+ threshold: REDIRECT_LOOP_THRESHOLD
1483
+ });
1484
+ return {
1485
+ success: false,
1486
+ code: "redirect_loop_detected",
1487
+ 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"
1488
+ };
1489
+ }
1395
1490
  const verifier = generateCodeVerifier();
1396
1491
  storeCodeVerifier(verifier, logger);
1397
1492
  let challenge;
@@ -1422,7 +1517,13 @@ function createRedirectMethods(deps) {
1422
1517
  }
1423
1518
  const authorizeUrl = `${baseUrl}${OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`;
1424
1519
  logger.debug("Redirecting to authorize:", authorizeUrl);
1425
- window.location.href = authorizeUrl;
1520
+ recordRedirectAttempt(authorizeKey, now, logger);
1521
+ try {
1522
+ window.location.href = authorizeUrl;
1523
+ } catch (err) {
1524
+ rollbackLastRedirectAttempt(authorizeKey, logger);
1525
+ throw err;
1526
+ }
1426
1527
  return { success: true };
1427
1528
  }
1428
1529
  async function redirectToLogin() {
@@ -3061,6 +3162,67 @@ function FFIDOrganizationSwitcher({
3061
3162
  ] })
3062
3163
  ] });
3063
3164
  }
3165
+
3166
+ // src/subscriptions/types.ts
3167
+ var PAST_DUE_GRACE_PERIOD_DAYS = 7;
3168
+ var HOURS_PER_DAY = 24;
3169
+ var MINUTES_PER_HOUR = 60;
3170
+ var SECONDS_PER_MINUTE = 60;
3171
+ var MS_PER_SECOND2 = 1e3;
3172
+ var MS_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND2;
3173
+
3174
+ // src/subscriptions/compute-effective-status.ts
3175
+ function isExpired(isoTimestamp, nowMs) {
3176
+ if (!isoTimestamp) return null;
3177
+ const parsed = Date.parse(isoTimestamp);
3178
+ if (Number.isNaN(parsed)) return null;
3179
+ return parsed < nowMs;
3180
+ }
3181
+ function computeEffectiveStatusFromSession(input, nowMs = Date.now()) {
3182
+ const { status, currentPeriodEnd, trialEnd, pastDueSince } = input;
3183
+ switch (status) {
3184
+ case "trialing": {
3185
+ const relevantEnd = trialEnd ?? currentPeriodEnd;
3186
+ const expired = isExpired(relevantEnd, nowMs);
3187
+ return expired === true ? "trial_expired" : "active";
3188
+ }
3189
+ case "active": {
3190
+ const expired = isExpired(currentPeriodEnd, nowMs);
3191
+ return expired === true ? "expired" : "active";
3192
+ }
3193
+ case "past_due": {
3194
+ const baselineIso = pastDueSince ?? currentPeriodEnd;
3195
+ if (!baselineIso) return "blocked";
3196
+ const baselineMs = Date.parse(baselineIso);
3197
+ if (Number.isNaN(baselineMs)) return "blocked";
3198
+ const graceEndMs = baselineMs + PAST_DUE_GRACE_PERIOD_DAYS * MS_PER_DAY;
3199
+ return graceEndMs > nowMs ? "past_due_grace" : "blocked";
3200
+ }
3201
+ case "pending_invoice":
3202
+ return "active";
3203
+ case "paused":
3204
+ case "incomplete":
3205
+ return "active";
3206
+ case "canceled":
3207
+ return "canceled";
3208
+ case "unpaid":
3209
+ case "incomplete_expired":
3210
+ return "blocked";
3211
+ default: {
3212
+ return "blocked";
3213
+ }
3214
+ }
3215
+ }
3216
+ var ACCESS_GRANTING_STATUSES = [
3217
+ "active",
3218
+ "past_due_grace"
3219
+ ];
3220
+ var BLOCKING_STATUSES = [
3221
+ "blocked",
3222
+ "expired",
3223
+ "canceled",
3224
+ "trial_expired"
3225
+ ];
3064
3226
  function useSubscription() {
3065
3227
  const context = useFFIDContext();
3066
3228
  const client = useFFIDClient();
@@ -3078,12 +3240,23 @@ function useSubscription() {
3078
3240
  const isActive = subscription?.status === "active";
3079
3241
  const isTrialing = subscription?.status === "trialing";
3080
3242
  const isCanceled = subscription?.status === "canceled";
3081
- const isTrialExpired = react.useMemo(() => {
3082
- if (!isTrialing) return false;
3083
- const relevantEnd = subscription?.trialEnd ?? subscription?.currentPeriodEnd;
3084
- if (!relevantEnd) return false;
3085
- return new Date(relevantEnd).getTime() < Date.now();
3086
- }, [isTrialing, subscription?.trialEnd, subscription?.currentPeriodEnd]);
3243
+ const effectiveStatus = react.useMemo(() => {
3244
+ if (!subscription) return null;
3245
+ return computeEffectiveStatusFromSession({
3246
+ status: subscription.status,
3247
+ currentPeriodEnd: subscription.currentPeriodEnd,
3248
+ trialEnd: subscription.trialEnd,
3249
+ // TODO(別Issue): FFID backend の session API (`/api/auth/session`) に
3250
+ // `pastDueSince` (ISO timestamp) を追加し、ここで渡す。現状は undefined
3251
+ // のため `computeEffectiveStatusFromSession` 内で currentPeriodEnd を
3252
+ // fallback に使う (Stripe は period end 近辺で past_due にする)。
3253
+ // Canonical な verdict が必要な consumer は `/ext/check` を直接呼ぶ。
3254
+ pastDueSince: void 0
3255
+ });
3256
+ }, [subscription]);
3257
+ const isGrace = effectiveStatus === "past_due_grace";
3258
+ const isBlocked = effectiveStatus !== null && BLOCKING_STATUSES.includes(effectiveStatus);
3259
+ const isTrialExpired = effectiveStatus === "trial_expired";
3087
3260
  const hasPlan = react.useCallback(
3088
3261
  (plans) => {
3089
3262
  if (!planCode) return false;
@@ -3093,6 +3266,10 @@ function useSubscription() {
3093
3266
  [planCode]
3094
3267
  );
3095
3268
  const hasAccess = react.useCallback(() => {
3269
+ if (effectiveStatus === null) return false;
3270
+ return ACCESS_GRANTING_STATUSES.includes(effectiveStatus);
3271
+ }, [effectiveStatus]);
3272
+ const hasAccessLegacy = react.useCallback(() => {
3096
3273
  if (isTrialExpired) return false;
3097
3274
  return isActive || isTrialing;
3098
3275
  }, [isActive, isTrialing, isTrialExpired]);
@@ -3103,8 +3280,12 @@ function useSubscription() {
3103
3280
  isTrialing,
3104
3281
  isTrialExpired,
3105
3282
  isCanceled,
3283
+ effectiveStatus,
3284
+ isBlocked,
3285
+ isGrace,
3106
3286
  hasPlan,
3107
- hasAccess
3287
+ hasAccess,
3288
+ hasAccessLegacy
3108
3289
  };
3109
3290
  }
3110
3291
  function withSubscription(Component, options = {}) {
@@ -4340,6 +4521,7 @@ exports.FFIDUserMenu = FFIDUserMenu;
4340
4521
  exports.FFID_ANNOUNCEMENTS_ERROR_CODES = FFID_ANNOUNCEMENTS_ERROR_CODES;
4341
4522
  exports.FFID_INQUIRY_CATEGORIES = FFID_INQUIRY_CATEGORIES;
4342
4523
  exports.FFID_INQUIRY_CATEGORIES_SITE_2026 = FFID_INQUIRY_CATEGORIES_SITE_2026;
4524
+ exports.computeEffectiveStatusFromSession = computeEffectiveStatusFromSession;
4343
4525
  exports.createFFIDAnnouncementsClient = createFFIDAnnouncementsClient;
4344
4526
  exports.createFFIDClient = createFFIDClient;
4345
4527
  exports.createTokenStore = createTokenStore;
@@ -103,10 +103,7 @@ function createTokenStore(storageType) {
103
103
  // src/client/oauth-userinfo.ts
104
104
  var SESSION_ELIGIBLE_SUBSCRIPTION_STATUSES = [
105
105
  "trialing",
106
- "active",
107
- "past_due",
108
- "canceled",
109
- "paused"
106
+ "active"
110
107
  ];
111
108
  function isSessionEligibleSubscriptionStatus(value) {
112
109
  return typeof value === "string" && SESSION_ELIGIBLE_SUBSCRIPTION_STATUSES.includes(value);
@@ -808,7 +805,7 @@ function createProfileMethods(deps) {
808
805
  }
809
806
 
810
807
  // src/client/version-check.ts
811
- var SDK_VERSION = "2.17.1";
808
+ var SDK_VERSION = "2.19.0";
812
809
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
813
810
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
814
811
  function sdkHeaders() {
@@ -1371,6 +1368,87 @@ var OAUTH_AUTHORIZE_ENDPOINT = "/api/v1/oauth/authorize";
1371
1368
  var AUTH_LOGOUT_ENDPOINT = "/api/v1/auth/logout";
1372
1369
  var STATE_RANDOM_BYTES = 16;
1373
1370
  var HEX_BASE2 = 16;
1371
+ var REDIRECT_LOOP_KEY = "ffid_sdk_redirect_loop_history";
1372
+ var REDIRECT_LOOP_WINDOW_MS = 6e4;
1373
+ var REDIRECT_LOOP_THRESHOLD = 3;
1374
+ var storageReadFailureLogged = false;
1375
+ var storageWriteFailureLogged = false;
1376
+ function logStorageReadFailure(logger, err) {
1377
+ if (storageReadFailureLogged) return;
1378
+ storageReadFailureLogged = true;
1379
+ logger.warn(
1380
+ "[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",
1381
+ err
1382
+ );
1383
+ }
1384
+ function logStorageWriteFailure(logger, err) {
1385
+ if (storageWriteFailureLogged) return;
1386
+ storageWriteFailureLogged = true;
1387
+ logger.warn(
1388
+ "[FFID SDK] sessionStorage write failed \u2014 redirect loop detection disabled on this browser (fail-open).",
1389
+ err
1390
+ );
1391
+ }
1392
+ function isRedirectLoopHistory(value) {
1393
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1394
+ return Object.values(value).every(
1395
+ (arr) => Array.isArray(arr) && arr.every((t) => typeof t === "number" && Number.isFinite(t))
1396
+ );
1397
+ }
1398
+ function readRedirectLoopHistory(logger) {
1399
+ if (typeof window === "undefined") return {};
1400
+ try {
1401
+ const raw = window.sessionStorage.getItem(REDIRECT_LOOP_KEY);
1402
+ if (raw === null) return {};
1403
+ const parsed = JSON.parse(raw);
1404
+ return isRedirectLoopHistory(parsed) ? parsed : {};
1405
+ } catch (err) {
1406
+ logStorageReadFailure(logger, err);
1407
+ return {};
1408
+ }
1409
+ }
1410
+ function writeRedirectLoopHistory(history, logger) {
1411
+ if (typeof window === "undefined") return;
1412
+ try {
1413
+ window.sessionStorage.setItem(REDIRECT_LOOP_KEY, JSON.stringify(history));
1414
+ } catch (err) {
1415
+ logStorageWriteFailure(logger, err);
1416
+ }
1417
+ }
1418
+ function pruneRedirectLoopHistory(history, now) {
1419
+ const cutoff = now - REDIRECT_LOOP_WINDOW_MS;
1420
+ const pruned = {};
1421
+ for (const [key, timestamps] of Object.entries(history)) {
1422
+ const fresh = timestamps.filter((t) => t > cutoff);
1423
+ if (fresh.length > 0) pruned[key] = fresh;
1424
+ }
1425
+ return pruned;
1426
+ }
1427
+ function getRecentRedirectCount(authorizeKey, now, logger) {
1428
+ const pruned = pruneRedirectLoopHistory(readRedirectLoopHistory(logger), now);
1429
+ writeRedirectLoopHistory(pruned, logger);
1430
+ return pruned[authorizeKey]?.length ?? 0;
1431
+ }
1432
+ function recordRedirectAttempt(authorizeKey, now, logger) {
1433
+ const history = readRedirectLoopHistory(logger);
1434
+ const existing = history[authorizeKey] ?? [];
1435
+ history[authorizeKey] = [...existing, now];
1436
+ writeRedirectLoopHistory(history, logger);
1437
+ }
1438
+ function rollbackLastRedirectAttempt(authorizeKey, logger) {
1439
+ const history = readRedirectLoopHistory(logger);
1440
+ const existing = history[authorizeKey];
1441
+ if (!Array.isArray(existing) || existing.length === 0) return;
1442
+ history[authorizeKey] = existing.slice(0, -1);
1443
+ if (history[authorizeKey].length === 0) delete history[authorizeKey];
1444
+ writeRedirectLoopHistory(history, logger);
1445
+ }
1446
+ function buildAuthorizeKey(baseUrl, clientId, organizationId) {
1447
+ const params = new URLSearchParams({ client_id: clientId });
1448
+ const trimmedOrg = organizationId?.trim();
1449
+ if (trimmedOrg) params.set("organization_id", trimmedOrg);
1450
+ return `${baseUrl}${OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`;
1451
+ }
1374
1452
  function generateRandomState() {
1375
1453
  const array = new Uint8Array(STATE_RANDOM_BYTES);
1376
1454
  crypto.getRandomValues(array);
@@ -1390,6 +1468,23 @@ function createRedirectMethods(deps) {
1390
1468
  logger.warn("SSR \u74B0\u5883\u3067\u306F\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3067\u304D\u307E\u305B\u3093");
1391
1469
  return { success: false, error: "SSR \u74B0\u5883\u3067\u306F\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3067\u304D\u307E\u305B\u3093" };
1392
1470
  }
1471
+ const authorizeKey = buildAuthorizeKey(baseUrl, clientId, options?.organizationId);
1472
+ const now = Date.now();
1473
+ const recentCount = getRecentRedirectCount(authorizeKey, now, logger);
1474
+ if (recentCount >= REDIRECT_LOOP_THRESHOLD) {
1475
+ cleanupVerifierStorage(logger);
1476
+ logger.warn("[FFID SDK] redirect loop detected \u2014 \u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u505C\u6B62\u3057\u307E\u3057\u305F", {
1477
+ authorizeKey,
1478
+ recentCount,
1479
+ windowMs: REDIRECT_LOOP_WINDOW_MS,
1480
+ threshold: REDIRECT_LOOP_THRESHOLD
1481
+ });
1482
+ return {
1483
+ success: false,
1484
+ code: "redirect_loop_detected",
1485
+ 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"
1486
+ };
1487
+ }
1393
1488
  const verifier = generateCodeVerifier();
1394
1489
  storeCodeVerifier(verifier, logger);
1395
1490
  let challenge;
@@ -1420,7 +1515,13 @@ function createRedirectMethods(deps) {
1420
1515
  }
1421
1516
  const authorizeUrl = `${baseUrl}${OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`;
1422
1517
  logger.debug("Redirecting to authorize:", authorizeUrl);
1423
- window.location.href = authorizeUrl;
1518
+ recordRedirectAttempt(authorizeKey, now, logger);
1519
+ try {
1520
+ window.location.href = authorizeUrl;
1521
+ } catch (err) {
1522
+ rollbackLastRedirectAttempt(authorizeKey, logger);
1523
+ throw err;
1524
+ }
1424
1525
  return { success: true };
1425
1526
  }
1426
1527
  async function redirectToLogin() {
@@ -3059,6 +3160,67 @@ function FFIDOrganizationSwitcher({
3059
3160
  ] })
3060
3161
  ] });
3061
3162
  }
3163
+
3164
+ // src/subscriptions/types.ts
3165
+ var PAST_DUE_GRACE_PERIOD_DAYS = 7;
3166
+ var HOURS_PER_DAY = 24;
3167
+ var MINUTES_PER_HOUR = 60;
3168
+ var SECONDS_PER_MINUTE = 60;
3169
+ var MS_PER_SECOND2 = 1e3;
3170
+ var MS_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND2;
3171
+
3172
+ // src/subscriptions/compute-effective-status.ts
3173
+ function isExpired(isoTimestamp, nowMs) {
3174
+ if (!isoTimestamp) return null;
3175
+ const parsed = Date.parse(isoTimestamp);
3176
+ if (Number.isNaN(parsed)) return null;
3177
+ return parsed < nowMs;
3178
+ }
3179
+ function computeEffectiveStatusFromSession(input, nowMs = Date.now()) {
3180
+ const { status, currentPeriodEnd, trialEnd, pastDueSince } = input;
3181
+ switch (status) {
3182
+ case "trialing": {
3183
+ const relevantEnd = trialEnd ?? currentPeriodEnd;
3184
+ const expired = isExpired(relevantEnd, nowMs);
3185
+ return expired === true ? "trial_expired" : "active";
3186
+ }
3187
+ case "active": {
3188
+ const expired = isExpired(currentPeriodEnd, nowMs);
3189
+ return expired === true ? "expired" : "active";
3190
+ }
3191
+ case "past_due": {
3192
+ const baselineIso = pastDueSince ?? currentPeriodEnd;
3193
+ if (!baselineIso) return "blocked";
3194
+ const baselineMs = Date.parse(baselineIso);
3195
+ if (Number.isNaN(baselineMs)) return "blocked";
3196
+ const graceEndMs = baselineMs + PAST_DUE_GRACE_PERIOD_DAYS * MS_PER_DAY;
3197
+ return graceEndMs > nowMs ? "past_due_grace" : "blocked";
3198
+ }
3199
+ case "pending_invoice":
3200
+ return "active";
3201
+ case "paused":
3202
+ case "incomplete":
3203
+ return "active";
3204
+ case "canceled":
3205
+ return "canceled";
3206
+ case "unpaid":
3207
+ case "incomplete_expired":
3208
+ return "blocked";
3209
+ default: {
3210
+ return "blocked";
3211
+ }
3212
+ }
3213
+ }
3214
+ var ACCESS_GRANTING_STATUSES = [
3215
+ "active",
3216
+ "past_due_grace"
3217
+ ];
3218
+ var BLOCKING_STATUSES = [
3219
+ "blocked",
3220
+ "expired",
3221
+ "canceled",
3222
+ "trial_expired"
3223
+ ];
3062
3224
  function useSubscription() {
3063
3225
  const context = useFFIDContext();
3064
3226
  const client = useFFIDClient();
@@ -3076,12 +3238,23 @@ function useSubscription() {
3076
3238
  const isActive = subscription?.status === "active";
3077
3239
  const isTrialing = subscription?.status === "trialing";
3078
3240
  const isCanceled = subscription?.status === "canceled";
3079
- const isTrialExpired = useMemo(() => {
3080
- if (!isTrialing) return false;
3081
- const relevantEnd = subscription?.trialEnd ?? subscription?.currentPeriodEnd;
3082
- if (!relevantEnd) return false;
3083
- return new Date(relevantEnd).getTime() < Date.now();
3084
- }, [isTrialing, subscription?.trialEnd, subscription?.currentPeriodEnd]);
3241
+ const effectiveStatus = useMemo(() => {
3242
+ if (!subscription) return null;
3243
+ return computeEffectiveStatusFromSession({
3244
+ status: subscription.status,
3245
+ currentPeriodEnd: subscription.currentPeriodEnd,
3246
+ trialEnd: subscription.trialEnd,
3247
+ // TODO(別Issue): FFID backend の session API (`/api/auth/session`) に
3248
+ // `pastDueSince` (ISO timestamp) を追加し、ここで渡す。現状は undefined
3249
+ // のため `computeEffectiveStatusFromSession` 内で currentPeriodEnd を
3250
+ // fallback に使う (Stripe は period end 近辺で past_due にする)。
3251
+ // Canonical な verdict が必要な consumer は `/ext/check` を直接呼ぶ。
3252
+ pastDueSince: void 0
3253
+ });
3254
+ }, [subscription]);
3255
+ const isGrace = effectiveStatus === "past_due_grace";
3256
+ const isBlocked = effectiveStatus !== null && BLOCKING_STATUSES.includes(effectiveStatus);
3257
+ const isTrialExpired = effectiveStatus === "trial_expired";
3085
3258
  const hasPlan = useCallback(
3086
3259
  (plans) => {
3087
3260
  if (!planCode) return false;
@@ -3091,6 +3264,10 @@ function useSubscription() {
3091
3264
  [planCode]
3092
3265
  );
3093
3266
  const hasAccess = useCallback(() => {
3267
+ if (effectiveStatus === null) return false;
3268
+ return ACCESS_GRANTING_STATUSES.includes(effectiveStatus);
3269
+ }, [effectiveStatus]);
3270
+ const hasAccessLegacy = useCallback(() => {
3094
3271
  if (isTrialExpired) return false;
3095
3272
  return isActive || isTrialing;
3096
3273
  }, [isActive, isTrialing, isTrialExpired]);
@@ -3101,8 +3278,12 @@ function useSubscription() {
3101
3278
  isTrialing,
3102
3279
  isTrialExpired,
3103
3280
  isCanceled,
3281
+ effectiveStatus,
3282
+ isBlocked,
3283
+ isGrace,
3104
3284
  hasPlan,
3105
- hasAccess
3285
+ hasAccess,
3286
+ hasAccessLegacy
3106
3287
  };
3107
3288
  }
3108
3289
  function withSubscription(Component, options = {}) {
@@ -4325,4 +4506,4 @@ function FFIDInquiryForm({
4325
4506
  );
4326
4507
  }
4327
4508
 
4328
- export { DEFAULT_API_BASE_URL, FFIDAnnouncementBadge, FFIDAnnouncementList, FFIDInquiryForm, FFIDLoginButton, FFIDOrganizationSwitcher, FFIDProvider, FFIDSDKError, FFIDSubscriptionBadge, FFIDUserMenu, FFID_ANNOUNCEMENTS_ERROR_CODES, FFID_INQUIRY_CATEGORIES, FFID_INQUIRY_CATEGORIES_SITE_2026, createFFIDAnnouncementsClient, createFFIDClient, createTokenStore, generateCodeChallenge, generateCodeVerifier, isFFIDInquiryCategorySite2026, normalizeRedirectUri, retrieveCodeVerifier, storeCodeVerifier, useFFID, useFFIDAnnouncements, useFFIDContext, useSubscription, withSubscription };
4509
+ export { DEFAULT_API_BASE_URL, FFIDAnnouncementBadge, FFIDAnnouncementList, FFIDInquiryForm, FFIDLoginButton, FFIDOrganizationSwitcher, FFIDProvider, FFIDSDKError, FFIDSubscriptionBadge, FFIDUserMenu, FFID_ANNOUNCEMENTS_ERROR_CODES, FFID_INQUIRY_CATEGORIES, FFID_INQUIRY_CATEGORIES_SITE_2026, computeEffectiveStatusFromSession, createFFIDAnnouncementsClient, createFFIDClient, createTokenStore, generateCodeChallenge, generateCodeVerifier, isFFIDInquiryCategorySite2026, normalizeRedirectUri, retrieveCodeVerifier, storeCodeVerifier, useFFID, useFFIDAnnouncements, useFFIDContext, useSubscription, withSubscription };