@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 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. Non-production environments (`stg.chikyu.mobi`, `dev01`-`dev09`, `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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geniee-inc/sfa-kit",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "React hooks and components for building pages that connect to GENIEE SFA/CRM data.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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
- * Every non-production chikyu environment — `stg.chikyu.mobi`, the dev tier,
7
- * local development, and Amplify PR previews — uses the staging Keystone API.
8
- * Everything else — `app.chikyu.net`, an origin this table does not know, or
9
- * no origin at all — uses production.
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
- /** Dev-tier chikyu (legacy frontend) hosts: dev01-dev09, devdc, deva0, and the like. */
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
- * Every non-production chikyu environment — `stg.chikyu.mobi`, the dev tier,
28
- * local development, and Amplify PR previews — uses the staging Keystone API.
29
- * Everything else — `app.chikyu.net`, an origin this table does not know, or
30
- * no origin at all — uses production.
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: () => void;
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 401 landing within this window of the previous one belongs to the same
25
- * recovery run. Without it, a token Keystone happily mints but the API keeps
26
- * rejecting loops forever, because every exchange succeeds and resets counters.
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
- /** 401-triggered recovery cycles allowed inside AUTH_ERROR_WINDOW_SECONDS. */
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 を受けてコードを要求済みかどうか。SfaClient は 401 を返した
103
- * リクエストごとに onAuthError を呼ぶため、並行リクエストの分をまとめる。
115
+ * 直近に交換できたトークン。401 がどのトークンに対するものかの照合に使う。
116
+ * 拒否として受理した時点で null にする。SfaClient は 401 を返した
117
+ * リクエストごとに onAuthError を呼ぶので、同じトークンの並行分は以降
118
+ * 照合に失敗して落ちる。
104
119
  */
105
- const isAuthErrorPendingRef = useRef(false);
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
- const sendCodeRequest = useCallback(() => {
158
- sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
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
- isAuthErrorPendingRef.current = false;
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
- if (isSettledRef.current)
376
+ const handleAuthError = useCallback((rejectedToken) => {
377
+ // 解体後の 401 で復帰を始めない。もう無い画面のために使い捨てコードを
378
+ // ホストへ要求し続けることになる
379
+ if (isTornDownRef.current || isSettledRef.current)
330
380
  return;
331
- if (isAuthErrorPendingRef.current)
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
- isAuthErrorPendingRef.current = true;
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
- // ここに達した時点で交換自体は通り続けている。その 401 は差し替え前の
344
- // トークンで投げられたリクエストの遅れ着信であることが多く、手元では
345
- // 見分けられない(onAuthError は拒否されたトークンを渡さない)。
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
- const codeTimeout = setTimeout(() => {
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
- clearTimeout(codeTimeout);
399
- clearRefreshTimer();
400
- clearRetryTimer();
544
+ clearCodeTimeout();
401
545
  };
402
- }, [trustedOrigin, handleCodeReceived, clearRefreshTimer, clearRetryTimer]);
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
- if ((_a = this.auth) === null || _a === void 0 ? void 0 : _a.accessToken) {
394
- headers['Authorization'] = `Bearer ${this.auth.accessToken}`;
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?: () => void;
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: () => { var _a; return (_a = onAuthErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onAuthErrorRef); },
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?: () => void;
21
+ readonly onAuthError?: SfaAuthErrorHandler;
11
22
  }
12
23
  /**
13
24
  * @description Normalized field kinds aligned with keystone's 10-type model.
@@ -1 +1 @@
1
- export declare const SFA_KIT_VERSION = "1.17.0";
1
+ export declare const SFA_KIT_VERSION = "1.18.0";
@@ -1,3 +1,3 @@
1
1
  // このファイルは libs/sfa-kit/scripts/sync-version.cjs が生成する。
2
2
  // 手で編集しないこと。バージョンを上げるときは libs/sfa-kit/package.json を変更する。
3
- export const SFA_KIT_VERSION = '1.17.0';
3
+ export const SFA_KIT_VERSION = '1.18.0';