@geniee-inc/sfa-kit 1.17.0 → 1.18.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/GUIDE.md +1 -1
- package/package.json +1 -1
- package/src/index.d.ts +1 -1
- package/src/lib/auth/resolve-api-base.d.ts +6 -4
- package/src/lib/auth/resolve-api-base.js +24 -5
- package/src/lib/auth/useEmbedSession.d.ts +2 -1
- package/src/lib/auth/useEmbedSession.js +196 -26
- package/src/lib/provider/SfaClient.d.ts +13 -0
- package/src/lib/provider/SfaClient.js +25 -8
- package/src/lib/provider/SfaProvider.d.ts +2 -2
- package/src/lib/provider/SfaProvider.js +1 -1
- package/src/lib/provider/types.d.ts +12 -1
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
package/GUIDE.md
CHANGED
|
@@ -128,7 +128,7 @@ Every page uses `SfaLoginGate` as the outermost wrapper. It auto-detects the hos
|
|
|
128
128
|
|
|
129
129
|
### Props
|
|
130
130
|
|
|
131
|
-
- **`apiBase`** (optional) — Overrides the auto-detected Keystone API endpoint. When omitted, the endpoint is resolved from a hostname: the embedding SFA/CRM host's when the page runs inside the iframe, the page's own when it runs standalone.
|
|
131
|
+
- **`apiBase`** (optional) — Overrides the auto-detected Keystone API endpoint. When omitted, the endpoint is resolved from a hostname: the embedding SFA/CRM host's when the page runs inside the iframe, the page's own when it runs standalone. `dev09` (`dev09.chikyu.mobi` and `dev09.waf.amplify.axiom.chikyu.mobi`) has a Keystone of its own and uses `https://keystone-api-dev09.chikyu.mobi`. The other non-production environments (`stg.chikyu.mobi`, `dev01`-`dev08`, `devdc`, `deva0`, `dev-docker`, `dev.chikyu.local`, `localhost`, `127.0.0.1`, `[::1]`, and every Amplify host of the new frontend under `*.amplify.axiom.chikyu.mobi`: PR previews plus the `devNN.waf.` / `stg.waf.` record-detail hosts) use `https://keystone-api-staging.chikyu.mobi`; everything else (`app.chikyu.net`, any hostname not in that list) uses `https://keystone-api-production.chikyu.mobi`. Production is the fallback on purpose so a page that reaches a customer never silently talks to staging.
|
|
132
132
|
- **`trustedOrigin`** (optional) — The origin of the SFA/CRM host page (e.g. `"https://app.chikyu.net"`). When provided and the page runs inside an iframe, `SfaLoginGate` uses the postMessage code-exchange flow (`POST /v1/auth/exchange`) instead of a login form. Injected automatically by Keystone's content endpoint at serve time.
|
|
133
133
|
- **`defaultOrganizationId`** (optional) — The org ID to auto-select when the user's account belongs to multiple organizations. **Set this to the org that generated the page's metadata** (the org used in Step 0 schema discovery). Without this prop, multi-org accounts auto-select the first available org, which may not be the intended one. Use an env var, not a literal: `defaultOrganizationId={Number(process.env.NEXT_PUBLIC_DEFAULT_ORG_ID)}`.
|
|
134
134
|
- **`objectKey`** (optional) — The object this page is built for (e.g. `"companies"`). The standalone DevToolbar shows it in the page-info tooltip and in the record ID placeholder. Pass it at **both** levels; omit it only if the page genuinely has no object.
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export { ErrorBoundary } from './lib/components/ErrorBoundary';
|
|
|
34
34
|
export { EmptyState } from './lib/components/EmptyState';
|
|
35
35
|
export { FetchErrorState } from './lib/components/FetchErrorState';
|
|
36
36
|
export { ErrorSnackbar } from './lib/components/ErrorSnackbar';
|
|
37
|
-
export type { SfaAuth, SfaApiMode, SfaClientConfig, SfaRecord, SfaListResponse, SfaListParams, SfaFieldValue, SfaFieldKind, SfaFieldValueMapping, FieldGroupSpec, FieldGroupLayout, SfaFieldDescriptor, SfaFieldOption, SfaRelationDescriptor, SfaObjectDetail, SfaObjectSummary, SfaObjectCatalog, SfaObjectPermissions, SfaObjectOrigin, SfaRelationshipKind, SfaCardinality, SfaDescribeResult, SfaFieldTypeDescriptor, SfaFieldTypeCatalog, SfaFilterOperator, SfaCreateOptions, SfaFieldError, SfaBulkRowStatus, SfaBulkRowResult, SfaBulkRowError, SfaBulkUpdateRow, SfaBulkDeleteRow, } from './lib/provider/types';
|
|
37
|
+
export type { SfaAuth, SfaApiMode, SfaAuthErrorHandler, SfaClientConfig, SfaRecord, SfaListResponse, SfaListParams, SfaFieldValue, SfaFieldKind, SfaFieldValueMapping, FieldGroupSpec, FieldGroupLayout, SfaFieldDescriptor, SfaFieldOption, SfaRelationDescriptor, SfaObjectDetail, SfaObjectSummary, SfaObjectCatalog, SfaObjectPermissions, SfaObjectOrigin, SfaRelationshipKind, SfaCardinality, SfaDescribeResult, SfaFieldTypeDescriptor, SfaFieldTypeCatalog, SfaFilterOperator, SfaCreateOptions, SfaFieldError, SfaBulkRowStatus, SfaBulkRowResult, SfaBulkRowError, SfaBulkUpdateRow, SfaBulkDeleteRow, } from './lib/provider/types';
|
|
38
38
|
export { SFA_BULK_MAX_ROWS } from './lib/provider/SfaClient';
|
|
39
39
|
export type { SfaSession, AuthResult, OrganizationChoice, } from './lib/auth/types';
|
|
40
40
|
export { buildRecordDetailUrl, resolveHostOrigin, } from './lib/navigation/record-detail-url';
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
export declare const KEYSTONE_PRODUCTION_API_BASE = "https://keystone-api-production.chikyu.mobi";
|
|
2
2
|
export declare const KEYSTONE_STAGING_API_BASE = "https://keystone-api-staging.chikyu.mobi";
|
|
3
|
+
export declare const KEYSTONE_DEV09_API_BASE = "https://keystone-api-dev09.chikyu.mobi";
|
|
3
4
|
/**
|
|
4
5
|
* @description Resolves the Keystone API base URL from an origin.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* no origin at all —
|
|
7
|
+
* `dev09` has a keystone of its own. Every other non-production chikyu
|
|
8
|
+
* environment — `stg.chikyu.mobi`, the rest of the dev tier, local development,
|
|
9
|
+
* and Amplify PR previews — uses the staging Keystone API. Everything else —
|
|
10
|
+
* `app.chikyu.net`, an origin this table does not know, or no origin at all —
|
|
11
|
+
* uses production.
|
|
10
12
|
*
|
|
11
13
|
* Production is the fallback on purpose: a page that reaches a real customer
|
|
12
14
|
* must never silently talk to staging. A developer on an unrecognised host
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export const KEYSTONE_PRODUCTION_API_BASE = 'https://keystone-api-production.chikyu.mobi';
|
|
2
2
|
export const KEYSTONE_STAGING_API_BASE = 'https://keystone-api-staging.chikyu.mobi';
|
|
3
|
-
|
|
3
|
+
export const KEYSTONE_DEV09_API_BASE = 'https://keystone-api-dev09.chikyu.mobi';
|
|
4
|
+
/**
|
|
5
|
+
* Dev-tier chikyu (legacy frontend) hosts: dev01-dev08, devdc, deva0, and the
|
|
6
|
+
* like. `dev09` matches this too but is taken by `DEV09_HOSTNAMES` first.
|
|
7
|
+
*/
|
|
4
8
|
const DEV_HOST_PATTERN = /^dev\w+\.chikyu\.mobi$/;
|
|
5
9
|
/**
|
|
6
10
|
* Amplify-hosted new frontend. Covers PR previews
|
|
@@ -21,13 +25,26 @@ const LOCAL_HOSTNAMES = new Set([
|
|
|
21
25
|
]);
|
|
22
26
|
/** Shared pre-production chikyu environments. */
|
|
23
27
|
const STAGING_HOSTNAMES = new Set(['stg.chikyu.mobi']);
|
|
28
|
+
/**
|
|
29
|
+
* dev09 has a keystone of its own. One keystone delegates to exactly one chikyu
|
|
30
|
+
* environment, so the staging keystone follows `stg` and this one follows
|
|
31
|
+
* `dev09`, the JapanAI Studio integration environment. Both of dev09's frontends
|
|
32
|
+
* are listed: the legacy chikyu host and the Amplify host that frames the page.
|
|
33
|
+
* Exact hostnames, because `DEV_HOST_PATTERN` and `AMPLIFY_PATTERN` below would
|
|
34
|
+
* otherwise claim them for staging.
|
|
35
|
+
*/
|
|
36
|
+
const DEV09_HOSTNAMES = new Set([
|
|
37
|
+
'dev09.chikyu.mobi',
|
|
38
|
+
'dev09.waf.amplify.axiom.chikyu.mobi',
|
|
39
|
+
]);
|
|
24
40
|
/**
|
|
25
41
|
* @description Resolves the Keystone API base URL from an origin.
|
|
26
42
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* no origin at all —
|
|
43
|
+
* `dev09` has a keystone of its own. Every other non-production chikyu
|
|
44
|
+
* environment — `stg.chikyu.mobi`, the rest of the dev tier, local development,
|
|
45
|
+
* and Amplify PR previews — uses the staging Keystone API. Everything else —
|
|
46
|
+
* `app.chikyu.net`, an origin this table does not know, or no origin at all —
|
|
47
|
+
* uses production.
|
|
31
48
|
*
|
|
32
49
|
* Production is the fallback on purpose: a page that reaches a real customer
|
|
33
50
|
* must never silently talk to staging. A developer on an unrecognised host
|
|
@@ -41,6 +58,8 @@ export function resolveApiBase(origin) {
|
|
|
41
58
|
const hostname = hostnameOf(origin);
|
|
42
59
|
if (hostname == null)
|
|
43
60
|
return KEYSTONE_PRODUCTION_API_BASE;
|
|
61
|
+
if (DEV09_HOSTNAMES.has(hostname))
|
|
62
|
+
return KEYSTONE_DEV09_API_BASE;
|
|
44
63
|
const isNonProduction = STAGING_HOSTNAMES.has(hostname) ||
|
|
45
64
|
LOCAL_HOSTNAMES.has(hostname) ||
|
|
46
65
|
DEV_HOST_PATTERN.test(hostname) ||
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SfaAuthErrorHandler } from '../provider/types';
|
|
1
2
|
import type { EmbedContext } from './embed-protocol';
|
|
2
3
|
import type { SfaSession } from './types';
|
|
3
4
|
export type EmbedPhase = {
|
|
@@ -28,7 +29,7 @@ export type EmbedPhase = {
|
|
|
28
29
|
export interface EmbedSessionState {
|
|
29
30
|
readonly phase: EmbedPhase;
|
|
30
31
|
/** Passed to SfaProvider so a 401 triggers the code-request fallback. */
|
|
31
|
-
readonly handleAuthError:
|
|
32
|
+
readonly handleAuthError: SfaAuthErrorHandler;
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* @description Keeps an embedded page authenticated over the postMessage
|
|
@@ -21,12 +21,23 @@ const CODE_TIMEOUT_SECONDS = 15;
|
|
|
21
21
|
/** Consecutive failed attempts to obtain a usable session before giving up. */
|
|
22
22
|
const MAX_RECOVERY_ATTEMPTS = 4;
|
|
23
23
|
/**
|
|
24
|
-
* A
|
|
25
|
-
* recovery run. Without it, a token Keystone happily mints but the API
|
|
26
|
-
* rejecting loops forever, because every exchange succeeds and resets
|
|
24
|
+
* A rejection landing within this window of the run's start belongs to the
|
|
25
|
+
* same recovery run. Without it, a token Keystone happily mints but the API
|
|
26
|
+
* keeps rejecting loops forever, because every exchange succeeds and resets
|
|
27
|
+
* the retry counters.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: only rejections of the token currently held are counted — a reply
|
|
30
|
+
* about a token already replaced is dropped before it gets here. The window
|
|
31
|
+
* therefore bounds a genuine loop, and no longer trips on the stragglers of a
|
|
32
|
+
* session that is recovering perfectly well.
|
|
33
|
+
*
|
|
34
|
+
* NOTE: a backstop for a fast loop, not a precise detector. Separating a loop
|
|
35
|
+
* from an endpoint answering 401 where it means 403 needs to know whether any
|
|
36
|
+
* request *succeeded* with the replacement token, which nothing reports — the
|
|
37
|
+
* client only calls back on failure.
|
|
27
38
|
*/
|
|
28
39
|
const AUTH_ERROR_WINDOW_SECONDS = 30;
|
|
29
|
-
/**
|
|
40
|
+
/** Rejections of the current token allowed inside AUTH_ERROR_WINDOW_SECONDS. */
|
|
30
41
|
const MAX_AUTH_ERROR_CYCLES = 3;
|
|
31
42
|
/** An exchange still unsettled after this long is treated as abandoned. */
|
|
32
43
|
const STALE_EXCHANGE_SECONDS = 20;
|
|
@@ -84,6 +95,8 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
84
95
|
const [phase, setPhase] = useState({ step: 'waiting_for_code' });
|
|
85
96
|
const refreshTimerRef = useRef(null);
|
|
86
97
|
const retryTimerRef = useRef(null);
|
|
98
|
+
/** ホストから最初のコードが届くのを待つタイマー。届いた時点で止める。 */
|
|
99
|
+
const codeTimeoutRef = useRef(null);
|
|
87
100
|
const exchangingRef = useRef(false);
|
|
88
101
|
const exchangeStartedAtRef = useRef(0);
|
|
89
102
|
/** 追い越された交換の結果で新しいセッションを上書きしないための世代番号。 */
|
|
@@ -99,15 +112,23 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
99
112
|
/** 連鎖の起点。最後の 1 件ではなく最初の 1 件から窓を測る。 */
|
|
100
113
|
const authErrorRunStartedAtRef = useRef(0);
|
|
101
114
|
/**
|
|
102
|
-
* 401
|
|
103
|
-
*
|
|
115
|
+
* 直近に交換できたトークン。401 がどのトークンに対するものかの照合に使う。
|
|
116
|
+
* 拒否として受理した時点で null にする。SfaClient は 401 を返した
|
|
117
|
+
* リクエストごとに onAuthError を呼ぶので、同じトークンの並行分は以降
|
|
118
|
+
* 照合に失敗して落ちる。
|
|
104
119
|
*/
|
|
105
|
-
const
|
|
120
|
+
const currentTokenRef = useRef(null);
|
|
106
121
|
const hasEverAuthenticatedRef = useRef(false);
|
|
107
122
|
/** 同じ復帰の試行でコンソールに書いたかどうか。交換成功で false に戻す。 */
|
|
108
123
|
const hasLoggedExchangeFailureRef = useRef(false);
|
|
109
124
|
/** 打ち切り済み。以降は再要求も画面遷移もしない。 */
|
|
110
125
|
const isSettledRef = useRef(false);
|
|
126
|
+
/**
|
|
127
|
+
* 後片付け済み。SfaClient は解体後も生き続け、飛んでいたリクエストが 401 で
|
|
128
|
+
* 返ると onAuthError を呼ぶ。isSettledRef では代用できない(依存変更による
|
|
129
|
+
* 再実行でも立ってしまい、生きているセッションまで止まる)。
|
|
130
|
+
*/
|
|
131
|
+
const isTornDownRef = useRef(false);
|
|
111
132
|
const clearRefreshTimer = useCallback(() => {
|
|
112
133
|
if (refreshTimerRef.current != null) {
|
|
113
134
|
clearTimeout(refreshTimerRef.current);
|
|
@@ -120,6 +141,12 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
120
141
|
retryTimerRef.current = null;
|
|
121
142
|
}
|
|
122
143
|
}, []);
|
|
144
|
+
const clearCodeTimeout = useCallback(() => {
|
|
145
|
+
if (codeTimeoutRef.current != null) {
|
|
146
|
+
clearTimeout(codeTimeoutRef.current);
|
|
147
|
+
codeTimeoutRef.current = null;
|
|
148
|
+
}
|
|
149
|
+
}, []);
|
|
123
150
|
const isTokenUsable = useCallback(() => {
|
|
124
151
|
const session = sessionRef.current;
|
|
125
152
|
return session != null && session.expiresAt > Date.now();
|
|
@@ -127,13 +154,17 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
127
154
|
/** 打ち切って画面を差し替える。手元に使えるものが何も残っていないとき。 */
|
|
128
155
|
const settle = useCallback((next) => {
|
|
129
156
|
isSettledRef.current = true;
|
|
157
|
+
// 待ち時間のタイマーを止める。終了画面が書き換わるのを防いでいるのは
|
|
158
|
+
// ハンドラ側の waiting_for_code 判定のほうで、ここは残り 15 秒を
|
|
159
|
+
// 空回りさせないための後片付け
|
|
160
|
+
clearCodeTimeout();
|
|
130
161
|
// 進行中の交換を無効化する。これが無いと、打ち切った後に解決した交換が
|
|
131
162
|
// authenticated を書き戻し、二度と操作できない画面が残る。
|
|
132
163
|
exchangeGenerationRef.current += 1;
|
|
133
164
|
clearRefreshTimer();
|
|
134
165
|
clearRetryTimer();
|
|
135
166
|
setPhase(next);
|
|
136
|
-
}, [clearRefreshTimer, clearRetryTimer]);
|
|
167
|
+
}, [clearCodeTimeout, clearRefreshTimer, clearRetryTimer]);
|
|
137
168
|
/**
|
|
138
169
|
* 状態画面は子ツリーがまだ描画されていないときだけ出す。認証済みの表示中は
|
|
139
170
|
* phase を動かさず、ページの状態をそのまま残す。打ち切り後も動かさない。
|
|
@@ -154,9 +185,21 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
154
185
|
? current
|
|
155
186
|
: { step: 'exchanging' });
|
|
156
187
|
}, []);
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
// NOTE: 宛先は ref から読む。trustedOrigin を直接閉じ込めると、張り済みの
|
|
189
|
+
// タイマーが差し替え前の宛先へ送り続ける。あわせて sendCodeRequest の同一性が
|
|
190
|
+
// 安定し、これを使うコールバック(armCodeRetry / requestCode / scheduleRefresh)
|
|
191
|
+
// も張り直しの対象から外れる。
|
|
192
|
+
//
|
|
193
|
+
// NOTE: 代入はレンダー中ではなく effect で行う。並行レンダリングでは破棄される
|
|
194
|
+
// レンダーがあり、その値が残ると、一度も有効になっていない宛先へ送ることになる。
|
|
195
|
+
// useRef の初期値があるので、最初の effect までの窓が空になることはない。
|
|
196
|
+
const trustedOriginRef = useRef(trustedOrigin);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
trustedOriginRef.current = trustedOrigin;
|
|
159
199
|
}, [trustedOrigin]);
|
|
200
|
+
const sendCodeRequest = useCallback(() => {
|
|
201
|
+
sendToHost({ type: 'sfa-kit:code-request' }, trustedOriginRef.current);
|
|
202
|
+
}, []);
|
|
160
203
|
/** 打ち切り後にトークンが切れた時点で next に落とすためのタイマー。 */
|
|
161
204
|
const scheduleExpiryCutoff = useCallback((next) => {
|
|
162
205
|
clearRefreshTimer();
|
|
@@ -243,6 +286,10 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
243
286
|
}, delaySeconds * 1000);
|
|
244
287
|
}, [clearRefreshTimer, requestCode]);
|
|
245
288
|
const handleCodeReceived = useCallback((code, context) => __awaiter(this, void 0, void 0, function* () {
|
|
289
|
+
// コードが届いた時点でホストは手順に対応している。取りこぼした 1 通でも
|
|
290
|
+
// それは変わらないので、打ち切り済みかどうかの判定より先に止める。
|
|
291
|
+
// settle() 側と同じく、表示を守っているのはハンドラ側の判定のほう。
|
|
292
|
+
clearCodeTimeout();
|
|
246
293
|
if (isSettledRef.current)
|
|
247
294
|
return;
|
|
248
295
|
const startedAt = Date.now();
|
|
@@ -265,7 +312,7 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
265
312
|
if (exchangeGenerationRef.current !== generation)
|
|
266
313
|
return;
|
|
267
314
|
recoveryAttemptsRef.current = 0;
|
|
268
|
-
|
|
315
|
+
currentTokenRef.current = session.accessToken;
|
|
269
316
|
hasLoggedExchangeFailureRef.current = false;
|
|
270
317
|
hasEverAuthenticatedRef.current = true;
|
|
271
318
|
sessionRef.current = session;
|
|
@@ -317,6 +364,7 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
317
364
|
}
|
|
318
365
|
}), [
|
|
319
366
|
apiBase,
|
|
367
|
+
clearCodeTimeout,
|
|
320
368
|
scheduleRefresh,
|
|
321
369
|
armCodeRetry,
|
|
322
370
|
clearRetryTimer,
|
|
@@ -325,12 +373,37 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
325
373
|
showStatusIfNotMounted,
|
|
326
374
|
showExchangingIfNotMounted,
|
|
327
375
|
]);
|
|
328
|
-
const handleAuthError = useCallback(() => {
|
|
329
|
-
|
|
376
|
+
const handleAuthError = useCallback((rejectedToken) => {
|
|
377
|
+
// 解体後の 401 で復帰を始めない。もう無い画面のために使い捨てコードを
|
|
378
|
+
// ホストへ要求し続けることになる
|
|
379
|
+
if (isTornDownRef.current || isSettledRef.current)
|
|
330
380
|
return;
|
|
331
|
-
|
|
381
|
+
// 認証なしで飛んだリクエストの 401。埋め込みでは SfaProvider に
|
|
382
|
+
// トークンが渡ってから子ツリーを描き、exchangeCode も空のトークンを
|
|
383
|
+
// 弾くので、この経路は発生しない。発生するようになったら落とすのでは
|
|
384
|
+
// なく本物の拒否として扱うこと — さもないと、全部のリクエストが 401 に
|
|
385
|
+
// なる画面が認証済みの見た目のまま残る。
|
|
386
|
+
//
|
|
387
|
+
// NOTE: 下の照合に任せず明示的に落とす。任せると「undefined は string と
|
|
388
|
+
// 一致しない」という偶然が唯一の根拠になり、currentTokenRef の型を
|
|
389
|
+
// string | undefined に揃えるリファクタが、型エラーを出さないまま
|
|
390
|
+
// この分岐を反転させる。
|
|
391
|
+
if (rejectedToken == null)
|
|
332
392
|
return;
|
|
333
|
-
|
|
393
|
+
// 差し替え済みのトークンについての返事は捨てる。ホストへコードを
|
|
394
|
+
// 要求し直しても、そのリクエストはもう存在しないトークンのものなので
|
|
395
|
+
// 何も直らず、動いているセッションを巻き添えにするだけになる。
|
|
396
|
+
// 復帰中(currentTokenRef が null)の分もここで落ちる。
|
|
397
|
+
//
|
|
398
|
+
// 並行リクエストの重複もここで落ちる。最初の 1 件で currentTokenRef を
|
|
399
|
+
// null にするため、同じトークンの 2 件目以降は照合に失敗する。
|
|
400
|
+
//
|
|
401
|
+
// NOTE: 文字列の一致で見分けられるのは、Keystone が 1 回ごとに異なる
|
|
402
|
+
// トークンを出すから(jti に uuid4 が入る)。同じ値が再発行されると、
|
|
403
|
+
// 差し替え後に届いた遅れ分が新しいトークンの拒否と区別できなくなる。
|
|
404
|
+
if (rejectedToken !== currentTokenRef.current)
|
|
405
|
+
return;
|
|
406
|
+
currentTokenRef.current = null;
|
|
334
407
|
const now = Date.now();
|
|
335
408
|
// 窓は連鎖の起点から測る。直近の 1 件から測ると、正常に復帰している
|
|
336
409
|
// 25 秒間隔の 401 でも窓が滑り続けて打ち切りに達してしまう。
|
|
@@ -340,13 +413,18 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
340
413
|
authErrorRunStartedAtRef.current = now;
|
|
341
414
|
}
|
|
342
415
|
authErrorCyclesRef.current += 1;
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
416
|
+
// ここに達するのは、いま持っているトークンが実際に拒否された場合だけ。
|
|
417
|
+
// それでも上限を置くのは、Keystone が発行するそばから API に拒否される
|
|
418
|
+
// 状態を止めるため。交換自体は成功し続けるので、見張り側の試行回数は
|
|
419
|
+
// 毎回 0 に戻り、上限が無ければ永久に回り続ける。
|
|
347
420
|
if (authErrorCyclesRef.current > MAX_AUTH_ERROR_CYCLES) {
|
|
348
|
-
//
|
|
349
|
-
//
|
|
421
|
+
// 表示は保ち、コードの取り直しだけをやめて、失効した時点でセッション
|
|
422
|
+
// 切れとして扱う。
|
|
423
|
+
//
|
|
424
|
+
// NOTE: ここまで来たトークンは期限内だが拒否されている(照合を通った
|
|
425
|
+
// ので、遅れ着信ではない)。つまり残す画面ではリクエストが通らない。
|
|
426
|
+
// それでも畳まないのは、入力中の内容を失わせないため。どちらを採るかは
|
|
427
|
+
// 挙動の選択なので、変えるなら SFAJAI-202 の判断ごと見直すこと。
|
|
350
428
|
//
|
|
351
429
|
// 据え置きにする必要がある。ホストは要求を受けてから非同期にコードを
|
|
352
430
|
// 発行するので、打ち切った後に以前の要求への応答が届く。交換を続けて
|
|
@@ -367,6 +445,61 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
367
445
|
requestCode();
|
|
368
446
|
showStatusIfNotMounted({ step: 'waiting_for_code' });
|
|
369
447
|
}, [requestCode, clearRefreshTimer, stopRetrying, showStatusIfNotMounted]);
|
|
448
|
+
/** 直前に使っていた認証先。同一性の変化と、値の変化を見分けるために持つ。 */
|
|
449
|
+
const previousApiBaseRef = useRef(apiBase);
|
|
450
|
+
/**
|
|
451
|
+
* @description 認証先が変わっていれば、旧 API を前提にした状態をすべて捨てて
|
|
452
|
+
* やり直す。変わっていなければ何もしない。
|
|
453
|
+
*
|
|
454
|
+
* 判定を内側に持つのは、依存の同一性が変わっただけの張り直しで走らせない
|
|
455
|
+
* ため。やり直す理由は認証先が変わったことであって、effect が走り直した
|
|
456
|
+
* ことではない。
|
|
457
|
+
*
|
|
458
|
+
* 交換を始めた時点の apiBase が、その結果が通用する認証先を決める。返ってくる
|
|
459
|
+
* トークンは旧 API が発行したものなので採用できない。採用すると SfaProvider が
|
|
460
|
+
* 新しい API へ旧トークンを送り続けることになる。
|
|
461
|
+
*
|
|
462
|
+
* NOTE: 世代を進めるだけでは足りない。世代がずれると handleCodeReceived の
|
|
463
|
+
* finally が exchangingRef を戻さず、張り直されたメッセージ effect が送る ready
|
|
464
|
+
* への返事まで「交換中」と見なされて STALE_EXCHANGE_SECONDS の間無視される。
|
|
465
|
+
*
|
|
466
|
+
* NOTE: isSettledRef は戻さない。打ち切り済みの画面は認証先を変えても復帰しない。
|
|
467
|
+
* 復帰させるなら settle() が消したタイマー群も張り直す必要があり、終了画面を
|
|
468
|
+
* 出したまま裏で再試行が走る状態を招く。再読み込みを復帰手段とする。
|
|
469
|
+
*/
|
|
470
|
+
const restartIfAuthTargetChanged = useCallback((nextApiBase) => {
|
|
471
|
+
if (previousApiBaseRef.current === nextApiBase)
|
|
472
|
+
return;
|
|
473
|
+
previousApiBaseRef.current = nextApiBase;
|
|
474
|
+
exchangeGenerationRef.current += 1;
|
|
475
|
+
exchangingRef.current = false;
|
|
476
|
+
// 旧 API のトークンは新しい認証先では通らない。保持判定から外す
|
|
477
|
+
sessionRef.current = null;
|
|
478
|
+
// NOTE: これ以降、旧トークンについての 401 は照合に失敗して捨てられる。
|
|
479
|
+
// 旧トークンで復帰を始めても新しい認証先では通らないので意味がない。
|
|
480
|
+
// 復帰はメッセージ effect が送り直す ready と、下で張る見張りが担う。
|
|
481
|
+
currentTokenRef.current = null;
|
|
482
|
+
// 認証先ごとに試行回数を数え直す。引き継ぐと上限の手前から始まることになり、
|
|
483
|
+
// armCodeRetry は上限判定を送信より先に行うため、新しい認証先へ
|
|
484
|
+
// code-request を 1 通も送らないまま打ち切られる。
|
|
485
|
+
//
|
|
486
|
+
// NOTE: 上限に達するには同じ認証先が 1 分ほど続く必要がある。それより短い
|
|
487
|
+
// 間隔で認証先が変わり続ける限り打ち切りには達しない。apiBase は
|
|
488
|
+
// resolveApiBase の純粋な結果で、ページの寿命の間は変わらない前提。
|
|
489
|
+
recoveryAttemptsRef.current = 0;
|
|
490
|
+
authErrorCyclesRef.current = 0;
|
|
491
|
+
authErrorRunStartedAtRef.current = 0;
|
|
492
|
+
// ready はメッセージ effect が送り直す。ここでは応答が無かったときの
|
|
493
|
+
// 見張りだけ張る
|
|
494
|
+
armCodeRetryRef.current();
|
|
495
|
+
// NOTE: 先回り更新のタイマーはここで消さない。refreshTimerRef は打ち切り後の
|
|
496
|
+
// 失効タイマー(scheduleExpiryCutoff)も兼ねており、消すと「セッション期限切れ」
|
|
497
|
+
// へ落ちなくなる。旧セッションの期限で残っても、交換が成功した時点で
|
|
498
|
+
// scheduleRefresh が張り替えるため実害はない。
|
|
499
|
+
}, []);
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
restartIfAuthTargetChanged(apiBase);
|
|
502
|
+
}, [apiBase, restartIfAuthTargetChanged]);
|
|
370
503
|
useEffect(() => {
|
|
371
504
|
const handleMessage = (event) => {
|
|
372
505
|
var _a;
|
|
@@ -382,7 +515,16 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
382
515
|
// If the host never sends a code (e.g. mintSfaKitExchangeCode is not
|
|
383
516
|
// implemented or the host page doesn't support the embed protocol),
|
|
384
517
|
// show an error instead of hanging on "Connecting..." forever.
|
|
385
|
-
|
|
518
|
+
//
|
|
519
|
+
// NOTE: settle では閉じない。ホストが遅いだけなら、期限を過ぎて届いた
|
|
520
|
+
// コードでそのまま復帰できる。打ち切ってしまうと、有効なコードが数秒後に
|
|
521
|
+
// 来ているのに再読み込みするまで死んだままになる。
|
|
522
|
+
//
|
|
523
|
+
// 待ち状態のときだけ出す。この判定が effect の張り直しも兼ねる — 既に
|
|
524
|
+
// 認証済み・交換中・別の理由で打ち切り済みなら、どれも
|
|
525
|
+
// waiting_for_code ではないので、より正確な表示を上書きしない。
|
|
526
|
+
codeTimeoutRef.current = setTimeout(() => {
|
|
527
|
+
codeTimeoutRef.current = null;
|
|
386
528
|
setPhase(current => {
|
|
387
529
|
if (current.step !== 'waiting_for_code')
|
|
388
530
|
return current;
|
|
@@ -393,12 +535,40 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
393
535
|
};
|
|
394
536
|
});
|
|
395
537
|
}, CODE_TIMEOUT_SECONDS * 1000);
|
|
538
|
+
// NOTE: 消すのはこの effect が張ったコード待ちタイマーだけ。更新・再試行の
|
|
539
|
+
// タイマーはこの effect の持ち物ではなく、フックが載っている間ずっと生きる。
|
|
540
|
+
// ここで消すと、認証済みのまま apiBase や trustedOrigin が変わっただけで
|
|
541
|
+
// 先回り更新が失われ、誰も張り直さないままトークンが期限切れになる。
|
|
396
542
|
return () => {
|
|
397
543
|
window.removeEventListener('message', handleMessage);
|
|
398
|
-
|
|
399
|
-
clearRefreshTimer();
|
|
400
|
-
clearRetryTimer();
|
|
544
|
+
clearCodeTimeout();
|
|
401
545
|
};
|
|
402
|
-
}, [trustedOrigin, handleCodeReceived,
|
|
546
|
+
}, [trustedOrigin, handleCodeReceived, clearCodeTimeout]);
|
|
547
|
+
// NOTE: 解体は依存を持たない effect に分ける。上のメッセージ effect の後片付けは
|
|
548
|
+
// 依存の同一性が変わるたびに走るので、そこで世代を進めると、認証先は同じままの
|
|
549
|
+
// 張り直しでも進行中の交換が捨てられる。認証先が変わった場合の破棄は専用の
|
|
550
|
+
// effect(authTargetRef)が担う。ここは本当にアンマウントするときだけ走る。
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
// ref は再マウントを越えて残る。StrictMode や Fast Refresh は
|
|
553
|
+
// マウント → 解体 → 再マウントと走るので、ここで戻さないと
|
|
554
|
+
// handleAuthError が最初の行で return し続け、401 から復帰できなくなる。
|
|
555
|
+
isTornDownRef.current = false;
|
|
556
|
+
return () => {
|
|
557
|
+
isTornDownRef.current = true;
|
|
558
|
+
// 解体後に解決した交換で、この下で消すタイマーを張り直させない。
|
|
559
|
+
// 世代を進めると handleCodeReceived が結果を捨てる。進行中の交換は
|
|
560
|
+
// どれも現行世代のものではなくなるため、居座り判定も解除する。
|
|
561
|
+
exchangeGenerationRef.current += 1;
|
|
562
|
+
exchangingRef.current = false;
|
|
563
|
+
// NOTE: clearRefreshTimer / clearRetryTimer は経由せず直接消す。経由すると
|
|
564
|
+
// 依存配列が空でなくなり、「アンマウント時だけ」がそれらの同一性の安定に
|
|
565
|
+
// 依存する。両者は settle → armCodeRetry → scheduleRefresh →
|
|
566
|
+
// handleCodeReceived まで伝播しているため、影響範囲が広い。
|
|
567
|
+
if (refreshTimerRef.current != null)
|
|
568
|
+
clearTimeout(refreshTimerRef.current);
|
|
569
|
+
if (retryTimerRef.current != null)
|
|
570
|
+
clearTimeout(retryTimerRef.current);
|
|
571
|
+
};
|
|
572
|
+
}, []);
|
|
403
573
|
return { phase, handleAuthError };
|
|
404
574
|
}
|
|
@@ -124,6 +124,19 @@ export declare class SfaClient implements SfaClientInterface {
|
|
|
124
124
|
private objectsPath;
|
|
125
125
|
/** @description Returns the field-types catalog path for the current mode. */
|
|
126
126
|
private fieldTypesPath;
|
|
127
|
+
/**
|
|
128
|
+
* @description Sets the auth headers, returning the token it used.
|
|
129
|
+
*
|
|
130
|
+
* NOTE: the caller keeps that token so a 401 can name it. Reading `this.auth`
|
|
131
|
+
* at the 401 would give the same value today — `auth` is readonly and
|
|
132
|
+
* `SfaProvider` builds a new client per token — but returning it from here
|
|
133
|
+
* keeps the answer true by construction.
|
|
134
|
+
*
|
|
135
|
+
* NOTE: this owns the `Authorization` header. It runs after any caller
|
|
136
|
+
* headers are spread in, so a caller-supplied one is overwritten — and if
|
|
137
|
+
* the client has no auth, the returned `undefined` would not describe it.
|
|
138
|
+
* Callers pass `If-Match` and the like, never `Authorization`.
|
|
139
|
+
*/
|
|
127
140
|
private applyAuth;
|
|
128
141
|
/**
|
|
129
142
|
* @description GET request with auth headers. Works for all read-only
|
|
@@ -388,14 +388,31 @@ export class SfaClient {
|
|
|
388
388
|
return this.mode === 'direct' ? '/v1/field-types' : '/field-types';
|
|
389
389
|
}
|
|
390
390
|
// ── Private transport ──
|
|
391
|
+
/**
|
|
392
|
+
* @description Sets the auth headers, returning the token it used.
|
|
393
|
+
*
|
|
394
|
+
* NOTE: the caller keeps that token so a 401 can name it. Reading `this.auth`
|
|
395
|
+
* at the 401 would give the same value today — `auth` is readonly and
|
|
396
|
+
* `SfaProvider` builds a new client per token — but returning it from here
|
|
397
|
+
* keeps the answer true by construction.
|
|
398
|
+
*
|
|
399
|
+
* NOTE: this owns the `Authorization` header. It runs after any caller
|
|
400
|
+
* headers are spread in, so a caller-supplied one is overwritten — and if
|
|
401
|
+
* the client has no auth, the returned `undefined` would not describe it.
|
|
402
|
+
* Callers pass `If-Match` and the like, never `Authorization`.
|
|
403
|
+
*/
|
|
391
404
|
applyAuth(headers) {
|
|
392
405
|
var _a;
|
|
393
|
-
|
|
394
|
-
|
|
406
|
+
const accessToken = (_a = this.auth) === null || _a === void 0 ? void 0 : _a.accessToken;
|
|
407
|
+
if (accessToken) {
|
|
408
|
+
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
395
409
|
}
|
|
396
410
|
if (this.organizationId) {
|
|
397
411
|
headers['organization-id'] = this.organizationId;
|
|
398
412
|
}
|
|
413
|
+
// NOTE: 空文字は「トークンを送っていない」。値として返すと、受け手は
|
|
414
|
+
// 差し替え済みの遅れ着信と区別できず、認証なしの 401 を握り潰す
|
|
415
|
+
return accessToken || undefined;
|
|
399
416
|
}
|
|
400
417
|
/**
|
|
401
418
|
* @description GET request with auth headers. Works for all read-only
|
|
@@ -408,7 +425,7 @@ export class SfaClient {
|
|
|
408
425
|
const headers = {
|
|
409
426
|
'Content-Type': 'application/json',
|
|
410
427
|
};
|
|
411
|
-
this.applyAuth(headers);
|
|
428
|
+
const sentToken = this.applyAuth(headers);
|
|
412
429
|
const response = yield globalThis.fetch(url, {
|
|
413
430
|
method: 'GET',
|
|
414
431
|
headers,
|
|
@@ -416,7 +433,7 @@ export class SfaClient {
|
|
|
416
433
|
});
|
|
417
434
|
if (!response.ok) {
|
|
418
435
|
if (response.status === 401)
|
|
419
|
-
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
436
|
+
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
|
|
420
437
|
const text = yield response.text().catch(() => '');
|
|
421
438
|
throw new SfaApiError(response.status, response.statusText, text, url);
|
|
422
439
|
}
|
|
@@ -434,7 +451,7 @@ export class SfaClient {
|
|
|
434
451
|
const headers = {
|
|
435
452
|
'Content-Type': 'application/json',
|
|
436
453
|
};
|
|
437
|
-
this.applyAuth(headers);
|
|
454
|
+
const sentToken = this.applyAuth(headers);
|
|
438
455
|
const response = yield globalThis.fetch(url, {
|
|
439
456
|
method: 'POST',
|
|
440
457
|
headers,
|
|
@@ -443,7 +460,7 @@ export class SfaClient {
|
|
|
443
460
|
});
|
|
444
461
|
if (!response.ok) {
|
|
445
462
|
if (response.status === 401)
|
|
446
|
-
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
463
|
+
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
|
|
447
464
|
const text = yield response.text().catch(() => '');
|
|
448
465
|
throw new SfaApiError(response.status, response.statusText, text, url);
|
|
449
466
|
}
|
|
@@ -460,7 +477,7 @@ export class SfaClient {
|
|
|
460
477
|
var _a;
|
|
461
478
|
const url = `${this.apiBase}${path}`;
|
|
462
479
|
const headers = Object.assign({ 'Content-Type': 'application/json' }, options === null || options === void 0 ? void 0 : options.headers);
|
|
463
|
-
this.applyAuth(headers);
|
|
480
|
+
const sentToken = this.applyAuth(headers);
|
|
464
481
|
const response = yield globalThis.fetch(url, {
|
|
465
482
|
method,
|
|
466
483
|
headers,
|
|
@@ -469,7 +486,7 @@ export class SfaClient {
|
|
|
469
486
|
});
|
|
470
487
|
if (!response.ok) {
|
|
471
488
|
if (response.status === 401)
|
|
472
|
-
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
489
|
+
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
|
|
473
490
|
const text = yield response.text().catch(() => '');
|
|
474
491
|
if (response.status === 412) {
|
|
475
492
|
throw SfaConflictError.fromBody(response.status, response.statusText, text, url);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { SfaApiMode, SfaAuth, SfaClientInterface } from './types';
|
|
2
|
+
import type { SfaApiMode, SfaAuth, SfaAuthErrorHandler, SfaClientInterface } from './types';
|
|
3
3
|
export interface SfaContextValue {
|
|
4
4
|
readonly client: SfaClientInterface;
|
|
5
5
|
readonly auth: SfaAuth | undefined;
|
|
@@ -14,7 +14,7 @@ interface SfaProviderProps {
|
|
|
14
14
|
readonly auth?: SfaAuth;
|
|
15
15
|
readonly mode?: SfaApiMode;
|
|
16
16
|
readonly organizationId?: string;
|
|
17
|
-
readonly onAuthError?:
|
|
17
|
+
readonly onAuthError?: SfaAuthErrorHandler;
|
|
18
18
|
/**
|
|
19
19
|
* Disable mutation hooks (create/update/delete). Defaults to false.
|
|
20
20
|
*
|
|
@@ -38,7 +38,7 @@ export function SfaProvider({ apiBase, auth, mode, organizationId, onAuthError,
|
|
|
38
38
|
auth: stableAuth,
|
|
39
39
|
mode: resolvedMode,
|
|
40
40
|
organizationId: resolvedOrgId,
|
|
41
|
-
onAuthError:
|
|
41
|
+
onAuthError: rejectedToken => { var _a; return (_a = onAuthErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onAuthErrorRef, rejectedToken); },
|
|
42
42
|
}), [apiBase, stableAuth, resolvedMode, resolvedOrgId]);
|
|
43
43
|
const value = useMemo(() => ({
|
|
44
44
|
client,
|
|
@@ -2,12 +2,23 @@ export interface SfaAuth {
|
|
|
2
2
|
readonly accessToken: string;
|
|
3
3
|
}
|
|
4
4
|
export type SfaApiMode = 'direct' | 'proxy';
|
|
5
|
+
/**
|
|
6
|
+
* @description Called when a request came back 401, naming the token it was
|
|
7
|
+
* sent with.
|
|
8
|
+
*
|
|
9
|
+
* `rejectedToken` is the access token that request carried, or `undefined`
|
|
10
|
+
* when it went out unauthenticated. A caller that swaps tokens needs it to
|
|
11
|
+
* tell a late reply about a token it has already replaced from a rejection of
|
|
12
|
+
* the one it holds now: the first is nothing to act on, the second means the
|
|
13
|
+
* session has to be re-established.
|
|
14
|
+
*/
|
|
15
|
+
export type SfaAuthErrorHandler = (rejectedToken: string | undefined) => void;
|
|
5
16
|
export interface SfaClientConfig {
|
|
6
17
|
readonly apiBase: string;
|
|
7
18
|
readonly auth?: SfaAuth;
|
|
8
19
|
readonly mode: SfaApiMode;
|
|
9
20
|
readonly organizationId?: string;
|
|
10
|
-
readonly onAuthError?:
|
|
21
|
+
readonly onAuthError?: SfaAuthErrorHandler;
|
|
11
22
|
}
|
|
12
23
|
/**
|
|
13
24
|
* @description Normalized field kinds aligned with keystone's 10-type model.
|
package/src/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SFA_KIT_VERSION = "1.
|
|
1
|
+
export declare const SFA_KIT_VERSION = "1.18.0";
|
package/src/lib/version.js
CHANGED