@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/README.md
CHANGED
|
@@ -206,16 +206,51 @@ const {
|
|
|
206
206
|
|
|
207
207
|
```tsx
|
|
208
208
|
const {
|
|
209
|
-
subscription,
|
|
210
|
-
planCode,
|
|
211
|
-
isActive,
|
|
212
|
-
isTrialing,
|
|
213
|
-
isCanceled,
|
|
214
|
-
|
|
215
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
3082
|
-
if (!
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
3080
|
-
if (!
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
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 };
|