@aiquants/auth-react-router 0.13.1 → 0.14.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.
@@ -1,6 +1,16 @@
1
- import type { AuthAllowlistEntry, AuthDirectoryMembershipMode, AuthDirectoryStatus, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
1
+ /**
2
+ * @module admin/server
3
+ * @description The loader, action and store contract of the user administration route.
4
+ * ユーザー管理ルートの loader・action と、その記憶域の契約。
5
+ *
6
+ * 権限は intent ごとに要求する CRUD 種別へ写し (`INTENT_ACCESS`)、拒否は画面を落とさず
7
+ * その操作だけの失敗として返す。上流の候補一覧はこのプロセスから引かず、同期ジョブが書いた
8
+ * ものを読むだけである。
9
+ */
10
+ import type { AuthAllowlistEntry, AuthDirectoryCatalogGroup, AuthDirectoryCatalogSnapshot, AuthDirectoryMembershipMode, AuthDirectoryStatus, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
2
11
  import { data } from "react-router"
3
- import { type PartialUserAdminLabels, resolveUserAdminLabels } from "./labels"
12
+ import { fill as fillLabel } from "./format"
13
+ import { type PartialUserAdminLabels, resolveUserAdminLabels, type UserAdminLabels } from "./labels"
4
14
 
5
15
  /**
6
16
  * Server store interface for managing users, groups, memberships, and allowlists.
@@ -41,19 +51,32 @@ export type UserAdminStore = {
41
51
  *
42
52
  * リンク後、そのグループの名称と所属は上流が所有する。ロール割当はローカルのままである。
43
53
  */
44
- linkDirectoryGroup: (groupId: number, data: { provider: string; externalKey: string; membershipMode: AuthDirectoryMembershipMode }) => Promise<void>
54
+ linkDirectoryGroup: (groupId: number, data: { provider: string; externalId: string; externalKey: string; membershipMode: AuthDirectoryMembershipMode }) => Promise<void>
55
+ /**
56
+ * Creates a local group and links it to an upstream group as one indivisible step.
57
+ * ローカルグループの作成と上流への連携を、分割不可能な 1 手として行う処理。
58
+ *
59
+ * ⚠️ **途中で失敗したら何も残してはならない。** 作成だけが成功すると、運用者には「連携に
60
+ * 失敗した」ではなく「知らないグループが増えた」と見える。実装は 1 つのトランザクションで
61
+ * 行うこと (作成 → 連携の順に別々の書込を並べると、この不可分性は成立しない)。
62
+ */
63
+ createLinkedGroup: (data: { groupKey: string; name: string; description?: string; provider: string; externalId: string; externalKey: string; membershipMode: AuthDirectoryMembershipMode }) => Promise<AuthGroup>
45
64
  /** Stops following the upstream, keeping the memberships already projected. 連携の解除。 */
46
65
  unlinkDirectoryGroup: (groupId: number) => Promise<void>
47
66
  /** Pauses or resumes synchronization for one group. 1 グループの同期を停止・再開する処理。 */
48
67
  setDirectorySyncEnabled: (groupId: number, isSyncEnabled: boolean) => Promise<void>
49
68
  /**
50
- * Queues a synchronization request. 同期要求をキューへ積む処理。
69
+ * Queues a "sync now" request. 「今すぐ同期」の要求を積む処理。
51
70
  *
52
- * 上流へ到達できるのは同期ジョブだけなので、web からは要求を残すことしかできない。
71
+ * ⚠️ 戻り値を `void` にしてはならない。同じ範囲の要求が既に待っているとき、積み増さないのは
72
+ * 正しい (押した数だけ上流への全件走査が並ぶ) が、それを成功と区別せずに返すと、押しても
73
+ * 反応しない画面ができる — しかも詰まった 1 行が以後の押下すべてを飲み込む。
53
74
  *
54
- * @param groupId `null` requests every linked group of the tenant. `null` はテナント全件。
75
+ * @param groupId Target group, or `null` for the whole tenant. 対象グループ (`null` でテナント全件)。
76
+ * @param actor Who asked, for the audit record. 要求した主体 (監査記録用)。
77
+ * @returns Whether it was queued or already waiting. 積んだか、既に待っていたか。
55
78
  */
56
- requestDirectorySync: (groupId: number | null) => Promise<void>
79
+ requestDirectorySync: (groupId: number | null, actor: string) => Promise<UserAdminSyncRequestOutcome>
57
80
  /** Health of the synchronization, for the header warning. 同期の健全性 (警告表示用)。 */
58
81
  getDirectoryStatus: () => Promise<AuthDirectoryStatus>
59
82
  }
@@ -97,13 +120,13 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
97
120
  },
98
121
  async updateUser(id, data) {
99
122
  const idx = users.findIndex((u) => u.id === id)
100
- if (idx === -1) throw new Error(`User not found: ${id}`)
123
+ if (idx === -1) throw new UserAdminOperatorError(`User not found: ${id}`)
101
124
  users[idx] = { ...users[idx], displayName: data.displayName, email: data.email }
102
125
  return users[idx]
103
126
  },
104
127
  async toggleUserActive(id, isActive) {
105
128
  const idx = users.findIndex((u) => u.id === id)
106
- if (idx === -1) throw new Error(`User not found: ${id}`)
129
+ if (idx === -1) throw new UserAdminOperatorError(`User not found: ${id}`)
107
130
  users[idx] = { ...users[idx], isActive }
108
131
  return users[idx]
109
132
  },
@@ -125,7 +148,7 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
125
148
  },
126
149
  async updateGroup(id, data) {
127
150
  const idx = groups.findIndex((g) => g.id === id)
128
- if (idx === -1) throw new Error(`Group not found: ${id}`)
151
+ if (idx === -1) throw new UserAdminOperatorError(`Group not found: ${id}`)
129
152
  groups[idx] = { ...groups[idx], name: data.name, description: data.description }
130
153
  return groups[idx]
131
154
  },
@@ -176,23 +199,26 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
176
199
  // 連携面は「結線されていない配備」を表現する形で応答する
177
200
  async addAllowlistGroup(groupId, description) {
178
201
  const group = groups.find((g) => g.id === groupId)
179
- if (!group) throw new Error(`Group not found: ${groupId}`)
202
+ if (!group) throw new UserAdminOperatorError(`Group not found: ${groupId}`)
180
203
  allowlist = [...allowlist, { id: Math.max(...allowlist.map((a) => a.id), 0) + 1, type: "group", groupId, groupKey: group.groupKey, groupName: group.name, description, owner: { kind: "tenant", tenantId: "default" }, readOnly: false }]
181
204
  },
182
205
  async removeAllowlistGroup(id) {
183
206
  allowlist = allowlist.filter((a) => !(a.type === "group" && a.id === id))
184
207
  },
185
208
  async linkDirectoryGroup() {
186
- throw new Error("This in-memory store has no upstream directory; inject a real store to link groups.")
209
+ throw new UserAdminOperatorError("This in-memory store has no upstream directory; inject a real store to link groups.")
210
+ },
211
+ async createLinkedGroup() {
212
+ throw new UserAdminOperatorError("This in-memory store has no upstream directory; inject a real store to create a linked group.")
187
213
  },
188
214
  async unlinkDirectoryGroup() {
189
- throw new Error("This in-memory store has no upstream directory; inject a real store to unlink groups.")
215
+ throw new UserAdminOperatorError("This in-memory store has no upstream directory; inject a real store to unlink groups.")
190
216
  },
191
217
  async setDirectorySyncEnabled() {
192
- throw new Error("This in-memory store has no upstream directory; inject a real store to pause synchronization.")
218
+ throw new UserAdminOperatorError("This in-memory store has no upstream directory; inject a real store to pause synchronization.")
193
219
  },
194
220
  async requestDirectorySync() {
195
- throw new Error("This in-memory store has no upstream directory; inject a real store to request a sync.")
221
+ throw new UserAdminOperatorError("This in-memory store has no upstream directory; inject a real store to request a sync.")
196
222
  },
197
223
  async getDirectoryStatus() {
198
224
  return { isEnabled: false, provider: "", linkedGroupCount: 0, isStale: false, pausedGroupCount: 0, stalenessThresholdSec: 0, erroredGroupIds: [], pendingRequestCount: 0 }
@@ -200,6 +226,127 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
200
226
  }
201
227
  }
202
228
 
229
+ /**
230
+ * Decides whether an observation is overdue. 観測が期限超過かどうかを決める処理。
231
+ *
232
+ * 上限が正の数でなければ **判定しない** のではなく、既定の上限で判定する。0 や `NaN` で
233
+ * 「決して古くならない」に倒すと、環境変数の打ち間違いが警告そのものを黙って無効化する。
234
+ *
235
+ * @param observedAt When the upstream was observed. 上流を観測した時刻。
236
+ * @param thresholdSec Deployment's staleness bound in seconds. 配備の陳腐化上限 (秒)。
237
+ * @returns True when the observation is overdue. 期限超過なら true。
238
+ */
239
+ function isStaleObservation(observedAt: Date, thresholdSec: number): boolean {
240
+ const bound = Number.isFinite(thresholdSec) && thresholdSec > 0 ? thresholdSec : DEFAULT_STALENESS_SEC
241
+ return (Date.now() - observedAt.getTime()) / 1000 > bound
242
+ }
243
+
244
+ /** 配備が上限を示さなかったときに使う既定 (秒)。判定を無効化するより、既定で判定する。 */
245
+ /**
246
+ * Largest action body this screen ever needs. この画面の操作が要する本文の上限。
247
+ *
248
+ * ⚠️ intent は本文の中にあるため、ガードより先に本文を読む以外の選択肢が無い。この画面が送るのは
249
+ * 短い文字列だけなので、64 KiB は通常の操作すべてに対して桁で余裕がある。上限そのものより、
250
+ * **認証を通っていない要求が積み上げられる量を定数で抑える** ことが目的である。
251
+ */
252
+ const MAX_ACTION_BODY_BYTES = 64 * 1024
253
+
254
+ const DEFAULT_STALENESS_SEC = 5400
255
+
256
+ /**
257
+ * Loads the upstream catalog, turning any failure into a renderable state.
258
+ * 上流の候補一覧を読み、あらゆる失敗を **描画可能な状態** へ変える処理。
259
+ *
260
+ * ⚠️ ここで例外を通すと、上流が落ちているだけでユーザー管理面が丸ごと開かなくなる。候補一覧は
261
+ * 選択欄を出すための利便であり、グループの閲覧や許可リストの管理はそれと無関係に成立する。
262
+ * 「判らないなら拒否」を貫くべき認可・サインイン判定とは、意図的に扱いを分けている。
263
+ *
264
+ * @param port The injected catalog port, if any. 注入された候補一覧ポート (無い場合あり)。
265
+ * @param needed False for segments that never show a picker. 選択欄を出さない面では false。
266
+ * @param request The incoming request, for per-request credentials. リクエスト (資格情報の解決用)。
267
+ * @param thresholdSec Deployment's staleness bound in seconds. 配備の陳腐化上限 (秒)。
268
+ * @returns A state the views can render without further checks. ビューがそのまま描ける状態。
269
+ */
270
+ async function loadCatalog(port: { listGroups: (request: Request) => Promise<UserAdminCatalogSnapshot> } | undefined, needed: boolean, request: Request, thresholdSec: number): Promise<UserAdminCatalog> {
271
+ if (!port) return { kind: "not-wired" }
272
+ if (!needed) return { kind: "not-wired" }
273
+ try {
274
+ const { groups, observedAt } = await port.listGroups(request)
275
+ // ⚠️ 一度も観測していない一覧を `ready` で返してはならない。0 件の答えとして描かれ、
276
+ // 運用者には「上流にグループが無い」と読める — 実際には同期が一度も走っていないだけである
277
+ if (observedAt === null || !Number.isFinite(observedAt.getTime())) return { kind: "unavailable", reason: "neverObserved" }
278
+ // ⚠️ 陳腐化の判定は **サーバーで** 行う。ブラウザの時計で判定すると、遅れている端末では
279
+ // 経過が負になり「古い」という警告が出なくなる — 安全側の警告が、利用者側の入力で
280
+ // 消える形になる。水和のずれも同時に無くなる
281
+ return { kind: "ready", groups, observedAt: observedAt.toISOString(), isStale: isStaleObservation(observedAt, thresholdSec) }
282
+ } catch (error) {
283
+ // 原文はサーバー側のログにだけ残す。画面へ運ぶのは分類だけである
284
+ console.error("[@aiquants/auth-react-router] directoryCatalog.listGroups failed; the picker falls back to manual entry:", error)
285
+ return { kind: "unavailable", reason: classifyCatalogFailure(error) }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Normalizes an upstream key for comparison. 上流キーを比較用に正規化する処理。
291
+ *
292
+ * 前後の空白と大文字小文字を同一視する。**画面と実装で正規化がずれると**、選択欄は「未連携」と
293
+ * 見なすキーをサーバーが「連携済み」と見なす、といった食い違いが生まれる。1 か所に置く。
294
+ *
295
+ * @param key Raw upstream key. 生の上流キー。
296
+ * @returns The comparable form. 比較可能な形。
297
+ */
298
+ export function normalizeUpstreamKey(key: string): string {
299
+ return key.trim().toLowerCase()
300
+ }
301
+
302
+ /**
303
+ * An error whose own text is meant for the operator to read.
304
+ * 文面を **運用者に読ませるつもりで** 投げるエラー。
305
+ *
306
+ * ⚠️ ストアの例外文をそのまま画面へ返してはならない。ドライバの例外はスキーマ名・表名・制約名・
307
+ * 衝突した値・接続先をそのまま含み、それが閲覧権限しか持たない主体の画面に出る。一方で
308
+ * 「最後の管理者は外せません」のような業務上の拒否理由は、読めなければ操作そのものが行き詰まる。
309
+ * 両者は文面からは区別できないので、**投げる側が宣言する**。宣言の無い例外は一般的な文言になる。
310
+ *
311
+ * ホストのストアは、この class を継承しても `operatorFacing: true` を持たせてもよい
312
+ * (別パッケージのエラー型を継承できない事情に配慮した二経路である)。
313
+ */
314
+ export class UserAdminOperatorError extends Error {
315
+ /** 運用者向けであることの印。継承できない場合はこの属性だけでもよい。 */
316
+ readonly operatorFacing = true
317
+ constructor(message: string) {
318
+ super(message)
319
+ this.name = "UserAdminOperatorError"
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Decides whether an error's own text may reach the operator. 例外文を運用者へ見せてよいか判定する処理。
325
+ *
326
+ * @param error The thrown value. 投げられた値。
327
+ * @returns True when the store declared it operator-facing. ストアが運用者向けと宣言していれば true。
328
+ */
329
+ function isOperatorFacing(error: unknown): error is Error {
330
+ return error instanceof UserAdminOperatorError || (typeof error === "object" && error !== null && (error as { operatorFacing?: unknown }).operatorFacing === true && error instanceof Error)
331
+ }
332
+
333
+ /**
334
+ * An input problem the operator can fix, carrying a label key instead of a sentence.
335
+ * 運用者が直せる入力の問題。文ではなく **ラベルのキー** を運ぶ。
336
+ *
337
+ * ⚠️ メッセージを実装側で組むと、既定 (英語) の配備に日本語が出る。文言はラベルに集約し、
338
+ * ここでは「どの文言か」と差し替え値だけを運ぶ。
339
+ */
340
+ export class UserAdminInputError extends Error {
341
+ constructor(
342
+ readonly labelKey: keyof UserAdminLabels["errors"],
343
+ readonly values: Record<string, string | number> = {},
344
+ ) {
345
+ super(labelKey)
346
+ this.name = "UserAdminInputError"
347
+ }
348
+ }
349
+
203
350
  /**
204
351
  * Tells a per-operation authorization denial apart from any other failure, without importing an authz library.
205
352
  * 認可ライブラリを import せずに、操作単位の認可拒否を他の失敗と見分ける判定。
@@ -230,7 +377,8 @@ function isForbidden(error: unknown): boolean {
230
377
  function requireAllowlistType(formData: FormData): "email" | "domain" {
231
378
  const raw = String(formData.get("type") ?? "")
232
379
  if (raw === "email" || raw === "domain") return raw
233
- throw new Error(`type は "email" または "domain" のいずれかで指定してください (受け取った値: ${JSON.stringify(raw)})`)
380
+ // 受け取った値は反射しない。運用者が直せるのは「どの欄が不正か」までである
381
+ throw new UserAdminInputError("invalidAllowlistType")
234
382
  }
235
383
 
236
384
  /** 表示するタブ。未知の値は先頭のタブへ寄せる。 */
@@ -257,11 +405,6 @@ function segmentFromPathname(pathname: string): (typeof SEGMENTS)[number] {
257
405
  return (SEGMENTS as readonly string[]).includes(last) ? (last as (typeof SEGMENTS)[number]) : "users"
258
406
  }
259
407
 
260
- /**
261
- * The CRUD verb an operation performs. Deliberately a plain string union so this package stays
262
- * independent of any particular authorization library.
263
- * 操作が行う CRUD 種別。特定の認可ライブラリに依存しないよう素の文字列 union で表現する。
264
- */
265
408
  /**
266
409
  * The only route-args field this module reads. React Router's full `LoaderFunctionArgs` satisfies it, so
267
410
  * route wiring is unchanged — but declaring the minimum keeps callers (and tests) from fabricating fields
@@ -270,6 +413,11 @@ function segmentFromPathname(pathname: string): (typeof SEGMENTS)[number] {
270
413
  */
271
414
  type RouteArgs = { request: Request }
272
415
 
416
+ /**
417
+ * The CRUD verb an operation performs. Deliberately a plain string union so this package stays
418
+ * independent of any particular authorization library.
419
+ * 操作が行う CRUD 種別。特定の認可ライブラリに依存しないよう素の文字列 union で表現する。
420
+ */
273
421
  export type UserAdminAccess = "read" | "create" | "update" | "delete"
274
422
 
275
423
  /**
@@ -320,6 +468,8 @@ const INTENT_ACCESS = new Map<string, readonly UserAdminAccess[]>([
320
468
  // 連携の開始は、以後の所属の **追加と削除の両方** を上流へ委ねる行為である。所属を足せる
321
469
  // だけの主体が「足すことも消すこともできる仕掛け」を据えられてはならない。属性変更でもある
322
470
  // ため update も要る
471
+ // 作成と連携を 1 手で行う。要求する権限は両者の合併である (どちらか一方で通してはならない)
472
+ ["createLinkedGroup", ["create", "update", "delete"]],
323
473
  ["linkDirectoryGroup", ["update", "create", "delete"]],
324
474
  // 解除は台帳とグループ単位のサインイン許可を **消す**。属性を戻すだけではないので、
325
475
  // 削除権限を持たない主体には許さない
@@ -328,7 +478,107 @@ const INTENT_ACCESS = new Map<string, readonly UserAdminAccess[]>([
328
478
  // 書き込みを伴わないが update を要求する。読取権限だけの主体が上流問い合わせを誘発できると、
329
479
  // レート制限を外部から枯渇させられる
330
480
  ["requestDirectorySync", ["update"]],
481
+ // 上流の候補一覧は書込みではないが、**外部組織の部門構成と規模** を列挙する。読取権限だけの
482
+ // 主体に見せる理由は無く、連携を作れない主体には選択欄そのものが無意味である。要求する権限は
483
+ // 連携を作る intent と同じにする (どれか 1 つで通せば、その 1 つで一覧が読める)
484
+ ["catalog", ["update", "create", "delete"]],
331
485
  ])
486
+
487
+ /**
488
+ * Every intent the permission table knows. 権限表が知っている intent すべて。
489
+ *
490
+ * ⚠️ 消費側の網羅性検査のために公開する。検査側が intent を手書きで並べる形しか無いと、
491
+ * 実装に intent が増えても表は勝手には伸びず、**最も権限の重い新しい intent** だけが
492
+ * 無検査で通る状態が静かに生まれる。突き合わせる相手を実装側から提供する。
493
+ *
494
+ * @returns Intent names, in declaration order. 宣言順の intent 名。
495
+ */
496
+ export function userAdminIntents(): string[] {
497
+ return [...INTENT_ACCESS.keys()]
498
+ }
499
+
500
+ /**
501
+ * The CRUD verbs one intent requires. 1 つの intent が要求する CRUD 種別。
502
+ *
503
+ * ⚠️ 消費側が権限表を検査できるようにするために公開する。表そのものは公開しない — 書き換え
504
+ * 可能な参照を渡すと、検査の対象が実行時に差し替えられる形になる。
505
+ *
506
+ * @param intent - Intent name. intent 名。
507
+ * @returns The required verbs, or an empty list for an unknown intent. 要求する種別 (未知なら空)。
508
+ */
509
+ export function userAdminAccessFor(intent: string): readonly UserAdminAccess[] {
510
+ return INTENT_ACCESS.get(intent) ?? []
511
+ }
512
+ /**
513
+ * One upstream group as the picker shows it. 選択欄に出す上流グループ 1 件。
514
+ *
515
+ * ⚠️ **別名ではなく `@aiquants/auth-core` の型そのものである。** この形は上流アダプタ・永続化層・
516
+ * 管理 UI の 3 層を通る。層ごとに「同じ意味の別の型」を宣言すると、構造的部分型のせいでどの境界も
517
+ * 通ってしまい、上流へ足した欄が途中で黙って捨てられる (実測: 別々に宣言していた間は、必須欄を
518
+ * 足しても読み手側の境界で `tsc --strict` が 0 を返した)。欄の集合は `auth-core` の型テストが固定
519
+ * しているので、足すときは 3 層すべての受け渡しを見直すことになる。
520
+ *
521
+ * 特定のディレクトリ実装 (Google 等) の型は依然として import しない — `auth-core` は上流に依存
522
+ * しない語彙のパッケージであり、`AuthDirectoryStatus` などを既にここから受け取っている。
523
+ */
524
+ export type UserAdminCatalogGroup = AuthDirectoryCatalogGroup
525
+
526
+ /**
527
+ * The upstream catalog as the loader hands it to the views. loader がビューへ渡す上流の候補一覧。
528
+ *
529
+ * ⚠️ **これは利便性のための読み取りである。** 上流へ届かなくても画面は開かなければならない
530
+ * (グループ一覧や許可リストの閲覧は、選択欄の可否とは無関係である)。したがって失敗は
531
+ * `reason` として **描画可能な状態** に変換し、例外にしない。認可やサインイン判定のように
532
+ * 「判らないなら拒否」であるべき経路とは、意図的に扱いを変えている。
533
+ */
534
+ export type UserAdminCatalog = { kind: "ready"; groups: UserAdminCatalogGroup[]; observedAt: string; isStale: boolean } | { kind: "unavailable"; reason: UserAdminCatalogFailure } | { kind: "not-wired" }
535
+
536
+ /**
537
+ * What the catalog port answers: the groups, and when the upstream was actually observed.
538
+ * 候補一覧ポートの答え。グループと、上流を実際に観測した時刻。
539
+ *
540
+ * ⚠️ `observedAt` は省略できない。この一覧は上流を直に叩いた結果とは限らず、資格情報を持つ別の
541
+ * プロセスが書いた写しであることが多い。時刻を伴わない一覧は「上流の現在」と区別できず、運用者は
542
+ * 既に消えたグループを選ぶ。`null` は **一度も観測していない** ことを意味する (空の答えではない)。
543
+ */
544
+ export type UserAdminCatalogSnapshot = AuthDirectoryCatalogSnapshot
545
+
546
+ /**
547
+ * What a "sync now" request did. 「今すぐ同期」の要求が行ったこと。
548
+ *
549
+ * `already-pending` は失敗ではない — 同じ範囲の要求が既に待っている。ただし **成功でもない**:
550
+ * 何も積まれていないので、待っている行が誰にも拾われなければ何も起きない。
551
+ */
552
+ export type UserAdminSyncRequestOutcome = "queued" | "already-pending"
553
+
554
+ /**
555
+ * Why the upstream catalog could not be listed, classified for display.
556
+ * 上流の候補一覧を引けなかった理由を、表示のために分類したもの。
557
+ *
558
+ * ⚠️ **上流の例外文をそのまま画面へ出してはならない。** サービスアカウントのアドレス、借用先、
559
+ * 内部 URL、資格情報の断片が混じり得る。管理面を開けるだけの主体に、それらを読ませる理由は無い。
560
+ * 原文は `console.error` へ出し、画面にはこの分類に対応する自前の文言を出す。
561
+ */
562
+ export type UserAdminCatalogFailure = "denied" | "rateLimited" | "upstreamError" | "neverObserved" | "unknown"
563
+
564
+ /**
565
+ * Classifies a catalog failure without quoting the upstream. 上流を引用せずに失敗を分類する処理。
566
+ *
567
+ * 上流アダプタの型に依存しないよう、`status` という数値プロパティの有無だけで判定する。
568
+ * 判別できないものは `unknown` へ倒す (推測した分類を出すより、判らないと言う方が正しい)。
569
+ *
570
+ * @param error The thrown value. 投げられた値。
571
+ * @returns The display classification. 表示用の分類。
572
+ */
573
+ function classifyCatalogFailure(error: unknown): UserAdminCatalogFailure {
574
+ const status = typeof error === "object" && error !== null && "status" in error ? (error as { status: unknown }).status : undefined
575
+ if (typeof status !== "number") return "unknown"
576
+ if (status === 401 || status === 403) return "denied"
577
+ if (status === 429) return "rateLimited"
578
+ if (status >= 500) return "upstreamError"
579
+ return "unknown"
580
+ }
581
+
332
582
  /**
333
583
  * Neutral per-resource permission summary the admin shell renders in its header badge.
334
584
  * 管理シェルのヘッダーバッジが描画する、リソース単位の中立な権限サマリ。
@@ -352,6 +602,26 @@ export type UserAdminAppConfig = {
352
602
  * 必須。省略を許すとユーザー名簿と全更新操作が無防備に露出するため、型で強制する。
353
603
  */
354
604
  guards: UserAdminGuards
605
+ /**
606
+ * Names who is acting, for audit columns. 監査列に残す「誰が」を解決する処理。
607
+ *
608
+ * ⚠️ 省略すると、要求を積んだ主体が記録されない。同期要求の表は「誰がいつ何を要求し、結果が
609
+ * どうだったか」を残すために在り、その `requested_by` が定数だと、乱発を疑ったときに誰の
610
+ * ものか判らない — 乱発こそがこの intent に `update` を要求した理由である。
611
+ */
612
+ resolveActor?: (request: Request) => Promise<string> | string
613
+ /**
614
+ * Lists the upstream groups the operator may link to. 連携先の候補を列挙するポート。
615
+ *
616
+ * 任意である。注入しない配備では選択欄を出さず、上流キーの手入力に留まる — 上流へ問い合わせる
617
+ * 資格情報を web 側へ置かない構成を選べるようにするためである (同期ジョブだけが鍵を持つ形)。
618
+ *
619
+ * ⚠️ **失敗しても画面を落としてはならない。** 例外は loader が捕まえて
620
+ * `{ kind: "unavailable" }` へ写す。ここが落ちると、グループ一覧すら開けなくなる。
621
+ */
622
+ directoryCatalog?: {
623
+ listGroups: (request: Request) => Promise<UserAdminCatalogSnapshot>
624
+ }
355
625
  /**
356
626
  * Per-request self-permission lookup for the header annotation. Resolving to a map of
357
627
  * {@link UserAdminPermission} shows the badge; rejecting shows an explicit "unknown" badge — never a
@@ -381,7 +651,7 @@ function requireId(formData: FormData, field: string): number {
381
651
  const n = Number(raw)
382
652
  // NaN / 小数 / 0 以下は不正な ID として弾く (Number(null) === 0 の取り違えも防ぐ)
383
653
  if (raw === null || String(raw).trim() === "" || !Number.isInteger(n) || n <= 0) {
384
- throw new Error(`Invalid or missing "${field}"`)
654
+ throw new UserAdminInputError("invalidField", { field })
385
655
  }
386
656
  return n
387
657
  }
@@ -394,7 +664,7 @@ function requireId(formData: FormData, field: string): number {
394
664
  */
395
665
  function requireText(formData: FormData, field: string): string {
396
666
  const value = String(formData.get(field) ?? "").trim()
397
- if (value === "") throw new Error(`Invalid or missing "${field}"`)
667
+ if (value === "") throw new UserAdminInputError("invalidField", { field })
398
668
  return value
399
669
  }
400
670
 
@@ -407,7 +677,7 @@ function requireText(formData: FormData, field: string): string {
407
677
  */
408
678
  function requireBoolean(formData: FormData, field: string): boolean {
409
679
  const raw = String(formData.get(field) ?? "")
410
- if (raw !== "true" && raw !== "false") throw new Error(`Invalid or missing "${field}"`)
680
+ if (raw !== "true" && raw !== "false") throw new UserAdminInputError("invalidField", { field })
411
681
  return raw === "true"
412
682
  }
413
683
 
@@ -421,7 +691,7 @@ function requireBoolean(formData: FormData, field: string): boolean {
421
691
  */
422
692
  function requireMembershipMode(formData: FormData): AuthDirectoryMembershipMode {
423
693
  const raw = String(formData.get("membershipMode") ?? "")
424
- if (raw !== "direct" && raw !== "transitive") throw new Error('Invalid or missing "membershipMode"')
694
+ if (raw !== "direct" && raw !== "transitive") throw new UserAdminInputError("invalidField", { field: "membershipMode" })
425
695
  return raw
426
696
  }
427
697
 
@@ -440,11 +710,63 @@ function optionalText(formData: FormData, field: string): string | undefined {
440
710
  export function createUserAdminApp(config: UserAdminAppConfig) {
441
711
  const labels = resolveUserAdminLabels(config.labels)
442
712
 
713
+ /**
714
+ * Names who is acting. 「誰が」を解決する処理。
715
+ *
716
+ * 解決できない配備では固定値を残す (記録そのものを落とすより、判らないと記録する方がよい)。
717
+ *
718
+ * @param request The incoming request. 受け取ったリクエスト。
719
+ * @returns The actor label. 監査に残す主体。
720
+ */
721
+ async function actorOf(request: Request): Promise<string> {
722
+ if (!config.resolveActor) return "user-admin"
723
+ try {
724
+ return (await config.resolveActor(request)) || "user-admin"
725
+ } catch (error) {
726
+ console.error("[@aiquants/auth-react-router] resolveActor failed; recording the request as an unnamed actor:", error)
727
+ return "user-admin"
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Decides whether this request may list the upstream catalog. 候補一覧を引いてよいかを決める処理。
733
+ *
734
+ * ⚠️ 候補一覧は外部組織の部門構成を列挙する。`read` で通すと、閲覧権限だけの主体がそれを
735
+ * 読めてしまう。ここでは **連携を作る intent と同じ権限** を要求し、拒否は「見せない」へ写す。
736
+ * 認可以外の理由 (ガードの内部エラー) でも見せない側へ倒す — 利便のための読み取りであり、
737
+ * 判らないときに見せる理由が無い。
738
+ *
739
+ * @param request - The incoming request. 受け取ったリクエスト。
740
+ * @returns True when the catalog may be fetched. 候補一覧を引いてよいなら true。
741
+ */
742
+ async function mayReadCatalog(request: Request): Promise<boolean> {
743
+ if (!config.directoryCatalog) return false
744
+ const actions = INTENT_ACCESS.get("catalog")
745
+ // ⚠️ 要求すべき権限が引けなければ **見せない**。空配列で回すと 0 回の検査で `true` を
746
+ // 返すことになり、認可の補助が fail-open な方向へ倒れる
747
+ if (actions === undefined || actions.length === 0) return false
748
+ try {
749
+ for (const action of actions) await config.guards.requireAccess(request, { action, intent: "catalog" })
750
+ return true
751
+ } catch (error) {
752
+ // 拒否は握り潰さず監査へ流す。ここだけ記録が抜けると、「誰が何を見られなかったか」
753
+ // という問いに答えられない範囲が生まれる
754
+ try {
755
+ config.guards.onDenied?.({ intent: "catalog", actions, error })
756
+ } catch (hookError) {
757
+ console.error("[@aiquants/auth-react-router] onDenied threw; the denial itself still stands:", hookError)
758
+ }
759
+ return false
760
+ }
761
+ }
762
+
443
763
  async function loader({ request }: RouteArgs) {
444
764
  // 読取ガード: 拒否は throw されるためここで処理が打ち切られる
445
765
  await config.guards.requireAccess(request, { action: "read", intent: "loader" })
446
766
  const segment = segmentFromPathname(new URL(request.url).pathname)
447
767
 
768
+ // 候補一覧は **必要な面だけ** で引く。全画面で引くと、上流への往復が毎回の描画に乗る
769
+ const needsCatalog = (segment === "groups" || segment === "directory") && (await mayReadCatalog(request))
448
770
  const [users, groups, groupMembers, allowlist, directoryStatus, myPermissions] = await Promise.all([
449
771
  config.store.listUsers(),
450
772
  config.store.listGroups(),
@@ -459,6 +781,8 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
459
781
  })
460
782
  : Promise.resolve(null),
461
783
  ])
784
+ // 陳腐化の上限は配備の状態から取る。候補一覧の取得はこの後 (上限を知ってから) 行う
785
+ const catalog = await loadCatalog(config.directoryCatalog, needsCatalog, request, directoryStatus.stalenessThresholdSec)
462
786
 
463
787
  return {
464
788
  resourceLabels: config.resourceLabels,
@@ -469,11 +793,21 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
469
793
  allowlist,
470
794
  directoryStatus,
471
795
  myPermissions,
796
+ catalog,
472
797
  labels,
473
798
  }
474
799
  }
475
800
 
476
801
  async function action({ request }: RouteArgs) {
802
+ // ⚠️ 本文を読む前に大きさで断る。intent は本文の中にあるので、ガードより先に読む以外の
803
+ // 選択肢が無い — つまり **認証を通っていない要求でも本文が積まれる**。この画面が送るのは
804
+ // 短い文字列だけなので、上限を先に見るだけで、その積み上がりを定数で抑えられる。
805
+ // 送出側が長さを申告しない場合 (chunked) はここでは分からない。前段のリバースプロキシの
806
+ // 本文上限が最終的な砦であり、この検査はその代わりではなく **手前の安い一段** である
807
+ const declaredLength = Number(request.headers.get("content-length") ?? Number.NaN)
808
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_ACTION_BODY_BYTES) {
809
+ return data({ ok: false, error: labels.errors.requestTooLarge }, { status: 413 })
810
+ }
477
811
  // intent の判定に FormData が要るため、ここでだけ本文を先に読む。
478
812
  // 未知 intent は「何も実行しない」ため、ガード前に読んでも権限判断には影響しない。
479
813
  const formData = await request.formData()
@@ -482,7 +816,10 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
482
816
  // 未知 intent はストアへ到達しない (ガードも走らせず 400 相当を返す)。
483
817
  // Map 参照のためプロトタイプ継承キー ("constructor" 等) を拾わない。
484
818
  const access = INTENT_ACCESS.get(intent)
485
- if (!access) return { ok: false, error: `Unknown intent: ${intent}` }
819
+ // ⚠️ 受け取った値を応答へ echo しない。ここは認可より **手前** なので、認証を通っていない
820
+ // POST でも到達する — echo すると、攻撃者が選んだ文字列を 200 で返す原始的な道具になり、
821
+ // 管理面への探りが 2xx の列に紛れる。状態も 400 にして、成功の集計から外す
822
+ if (!access) return data({ ok: false, error: labels.errors.unknownIntent }, { status: 400 })
486
823
 
487
824
  /** 403 かどうか。Response でも素の例外でも `status` は共通語彙である。 */
488
825
  const isDenied = (error: unknown) => isForbidden(error)
@@ -512,7 +849,7 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
512
849
  }
513
850
 
514
851
  try {
515
- return await dispatch(intent, formData)
852
+ return await dispatch(intent, formData, request)
516
853
  } catch (error) {
517
854
  // ガードの後 (ストアの中) で投げられた拒否も同じ扱いにする。ここを素通りさせると、
518
855
  // 拒否理由がそのまま利用者へ渡り、しかも HTTP は 200 になって監視から消える
@@ -520,12 +857,131 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
520
857
  // ガード拒否 (Response/redirect) はそのまま伝播させる。ストア由来の業務エラーだけ
521
858
  // {ok:false,error} に写して UI へ届ける (ロックアウト理由・重複パターン等)
522
859
  if (error instanceof Response) throw error
523
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
860
+ // 実装側で文を組まず、ラベルから引く (既定は英語の配備に日本語が出るのを避ける)
861
+ if (error instanceof UserAdminInputError) {
862
+ // ⚠️ `{field}` には **操作者が画面で見ている名前** を差し込む。通信上のキーを
863
+ // そのまま出すと、日本語の文の中に `membershipMode` のような識別子が現れ、
864
+ // どの欄のことなのか読み手には判らない。表に無い欄はキーのまま出す
865
+ const raw = error.values.field
866
+ const values = typeof raw === "string" ? { ...error.values, field: labels.errors.fieldNames[raw] ?? raw } : error.values
867
+ const template = labels.errors[error.labelKey]
868
+ return { ok: false, error: fillLabel(typeof template === "string" ? template : String(error.labelKey), values) }
869
+ }
870
+ // ⚠️ 例外文をそのまま返してよいのは、ストアが **運用者向けと宣言した** ものだけである。
871
+ // ドライバの例外は一意制約名・表名・衝突した値・接続先をそのまま含み、それが閲覧権限
872
+ // しか持たない主体の画面へ出る。原文は必ずサーバーのログへ残す
873
+ console.error(`[@aiquants/auth-react-router] intent "${intent}" failed:`, error)
874
+ return { ok: false, error: isOperatorFacing(error) ? error.message : labels.errors.unexpected }
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Resolves the stable upstream id for a chosen key. 選ばれたキーに対応する安定 ID を決める処理。
880
+ *
881
+ * ⚠️ 正準キーは安定 ID である。アドレスは上流で改名され得るため、それだけを保存すると、改名
882
+ * された瞬間にリンクが上流を見失う。一覧が引けているならそこから引く (画面の送信値を信用
883
+ * しない)。引けていない配備 (手入力しか手段が無い) では送信値を使い、無ければアドレスを
884
+ * そのまま置く — 同期が最初に成功した時点で、上流の本当の ID へ自動的に書き戻される。
885
+ *
886
+ * @param formData The submitted form. 送信されたフォーム。
887
+ * @param externalKey The chosen upstream key. 選ばれた上流キー。
888
+ * @param catalog The catalog resolved for this request. この要求で解決済みの候補一覧。
889
+ * @returns The upstream id to persist. 保存する上流 ID。
890
+ */
891
+ function resolveExternalId(formData: FormData, externalKey: string, catalog: UserAdminCatalog): string {
892
+ const submitted = String(formData.get("externalId") ?? "").trim()
893
+ if (catalog.kind === "ready") {
894
+ // ⚠️ 送られてきた安定 ID が候補一覧に在るなら、それを優先する。アドレスは一意とは
895
+ // 限らない (別名行、改名の途中、別ドメインの同名) ため、アドレスで引き当てると
896
+ // 選択欄で 2 件目を選んだ利用者に 1 件目を結び付けることになる。取り消せない委譲を
897
+ // 取り違えたまま確定させないため、識別子が在るときは識別子で決める
898
+ if (submitted !== "" && catalog.groups.some((g) => g.externalId === submitted)) return submitted
899
+ const wanted = normalizeUpstreamKey(externalKey)
900
+ const match = catalog.groups.find((g) => normalizeUpstreamKey(g.externalKey) === wanted)
901
+ if (match) return match.externalId
902
+ }
903
+ return submitted === "" ? externalKey : submitted
904
+ }
905
+
906
+ /**
907
+ * Refuses the link intents when this deployment has no directory. ディレクトリ未設定の配備で連携を拒否する処理。
908
+ *
909
+ * ⚠️ 画面は連携の欄そのものを出さないが、それは **UI だけの約束** である。組み立てた POST は
910
+ * 通ってしまい、`provider` が空、あるいは配備と食い違う行が残る。そういう行は同期側が
911
+ * 「別プロバイダ」として恒久的に無視するため、**連携済みに見えて永久に同期されない** グループが
912
+ * できる。加えて供給元は送られてきた値ではなく配備の状態から取る — 唯一の源はここである。
913
+ *
914
+ * @returns The deployment's provider key. この配備の供給元キー。
915
+ * @throws {UserAdminInputError} When the directory is not configured. ディレクトリ未設定の場合。
916
+ */
917
+ function requireDirectoryEnabled(status: AuthDirectoryStatus): string {
918
+ const provider = status.provider.trim()
919
+ // ⚠️ 前後の空白を落として返す。落とさずに保存すると、同期側の `!==` 比較が恒久的に
920
+ // 「別プロバイダ」と判定し、連携済みに見えて永久に同期されないグループができる
921
+ if (!status.isEnabled || provider === "") throw new UserAdminInputError("directoryDisabled")
922
+ return provider
923
+ }
924
+
925
+ /**
926
+ * Refuses an upstream group that is already linked to another local group.
927
+ * 既に別のローカルグループへ連携している上流グループを拒否する処理。
928
+ *
929
+ * 画面は連携済みの選択肢を選べなくしているが、それは **UI だけの約束** である。二重に連携すると
930
+ * 同じ上流の所属が 2 つのグループへ投影され、メンバーが二重に数えられ、両方のロールが配られる。
931
+ *
932
+ * @param externalKey The upstream key about to be linked. 連携しようとしている上流キー。
933
+ * @throws {UserAdminInputError} When it is already linked. 既に連携している場合。
934
+ */
935
+ async function assertNotAlreadyLinked(externalId: string, externalKey: string): Promise<void> {
936
+ // ⚠️ 突合の第一キーは **安定 ID** である。アドレスで比べると、上流でグループが改名された
937
+ // 直後 (旧アドレスは別名として残る) に、まだ再同期していないリンクとは一致せず、同じ上流
938
+ // グループが 2 つのローカルグループへ投影される。DB の一意制約は ID 側に張ってあるため、
939
+ // アドレスだけで検査すると制約も後ろ盾にならない
940
+ const wantedId = normalizeUpstreamKey(externalId)
941
+ const wantedKey = normalizeUpstreamKey(externalKey)
942
+ const groups = await config.store.listGroups()
943
+ const clash = groups.find((g) => g.externalLink !== undefined && (normalizeUpstreamKey(g.externalLink.externalId) === wantedId || normalizeUpstreamKey(g.externalLink.externalKey) === wantedKey))
944
+ if (clash) throw new UserAdminInputError("alreadyLinked", { key: externalKey, group: clash.name })
945
+ }
946
+
947
+ /**
948
+ * Refuses an upstream key the catalog does not list. 候補一覧に無い上流キーを拒否する処理。
949
+ *
950
+ * 候補一覧を注入していない配備では検査できない。**検査できないことを黙って通す** のは
951
+ * 望ましくないが、ここで拒否すると手入力しか手段が無い配備で連携が一切作れなくなる。
952
+ * 「一覧が在るなら一覧に従う」という条件付きの検査であることを、意図として明示しておく。
953
+ *
954
+ * @param externalKey The key the operator chose or typed. 選択または入力された上流キー。
955
+ * @param catalog The catalog already resolved for this request. この要求で解決済みの候補一覧。
956
+ * @throws {UserAdminInputError} When a catalog exists and does not list the key. 一覧が在り、そこに無い場合。
957
+ */
958
+ function assertKnownUpstream(externalId: string, externalKey: string, catalog: UserAdminCatalog): void {
959
+ // 一覧が引けていないときは検査しない。**ここで拒否すると、上流が落ちている間は連携が
960
+ // 一切作れなくなる** — しかも画面は「アドレスを手入力してください」と案内している。
961
+ // 検査は綴り違いを早く知らせるためのものであり、可用性を落としてよい理由にはならない
962
+ if (catalog.kind !== "ready") return
963
+ const wantedId = normalizeUpstreamKey(externalId)
964
+ const wantedKey = normalizeUpstreamKey(externalKey)
965
+ // ⚠️ 2 欄が **同じ 1 件を指していること** まで確かめる。片方だけ照合すると、一覧に在る
966
+ // アドレスと、別のグループの安定 ID を組み合わせた送信が通ってしまう
967
+ if (!catalog.groups.some((g) => normalizeUpstreamKey(g.externalId) === wantedId && normalizeUpstreamKey(g.externalKey) === wantedKey)) {
968
+ throw new UserAdminInputError("unknownUpstream", { key: externalKey })
524
969
  }
525
970
  }
526
971
 
527
972
  /** intent ごとの実処理。認可・入力検証を通過した後にのみ呼ばれる。 */
528
- async function dispatch(intent: string, formData: FormData) {
973
+ async function dispatch(intent: string, formData: FormData, request: Request) {
974
+ // 候補一覧は **必要な intent に入ってから** 読む。先に読むと、上流と無関係な操作
975
+ // (利用者の作成、許可リストの編集) にまで上流への往復が乗る
976
+ // 配備の状態はこの送信で 1 度だけ読む。`requireDirectoryEnabled` が読んだものを使い回す —
977
+ // 読み直すと 1 回の連携作成で 4 往復になり、しかも `provider` と鮮度上限が別の瞬間の
978
+ // スナップショットから来ることになる
979
+ let cachedStatus: AuthDirectoryStatus | null = null
980
+ const status = async () => {
981
+ cachedStatus ??= await config.store.getDirectoryStatus()
982
+ return cachedStatus
983
+ }
984
+ const catalog = async () => loadCatalog(config.directoryCatalog, true, request, (await status()).stalenessThresholdSec)
529
985
  if (intent === "createUser") {
530
986
  await config.store.createUser({ displayName: requireText(formData, "displayName"), email: requireText(formData, "email") })
531
987
  return { ok: true }
@@ -539,7 +995,7 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
539
995
  if (intent === "toggleUserActive") {
540
996
  // 破壊側 (無効化) を既定にしない。明示的な "true"/"false" 以外は不正入力として弾く。
541
997
  const rawActive = String(formData.get("isActive") ?? "")
542
- if (rawActive !== "true" && rawActive !== "false") throw new Error('Invalid or missing "isActive"')
998
+ if (rawActive !== "true" && rawActive !== "false") throw new UserAdminInputError("invalidField", { field: "isActive" })
543
999
  await config.store.toggleUserActive(requireId(formData, "userId"), rawActive === "true")
544
1000
  return { ok: true }
545
1001
  }
@@ -583,10 +1039,39 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
583
1039
  await config.store.removeAllowlistGroup(requireId(formData, "entryId"))
584
1040
  return { ok: true }
585
1041
  }
1042
+ if (intent === "createLinkedGroup") {
1043
+ const externalKey = requireText(formData, "externalKey")
1044
+ const provider = requireDirectoryEnabled(await status())
1045
+ // 一覧はこの送信で 1 度だけ読む。安定 ID の解決と存在検査で 2 度読むと、その分だけ
1046
+ // 上流への往復が増える
1047
+ const resolved = await catalog()
1048
+ const externalId = resolveExternalId(formData, externalKey, resolved)
1049
+ await assertNotAlreadyLinked(externalId, externalKey)
1050
+ // 候補一覧が在るなら、選択肢に無いキーは受け付けない。手入力の綴り違いは「10 分後に
1051
+ // 赤いバナー」ではなく、その場で分かるべきである
1052
+ assertKnownUpstream(externalId, externalKey, resolved)
1053
+ await config.store.createLinkedGroup({
1054
+ groupKey: requireText(formData, "groupKey"),
1055
+ name: requireText(formData, "name"),
1056
+ description: optionalText(formData, "description"),
1057
+ provider,
1058
+ externalId,
1059
+ externalKey,
1060
+ membershipMode: requireMembershipMode(formData),
1061
+ })
1062
+ return { ok: true }
1063
+ }
586
1064
  if (intent === "linkDirectoryGroup") {
1065
+ const externalKey = requireText(formData, "externalKey")
1066
+ const provider = requireDirectoryEnabled(await status())
1067
+ const resolved = await catalog()
1068
+ const externalId = resolveExternalId(formData, externalKey, resolved)
1069
+ await assertNotAlreadyLinked(externalId, externalKey)
1070
+ assertKnownUpstream(externalId, externalKey, resolved)
587
1071
  await config.store.linkDirectoryGroup(requireId(formData, "groupId"), {
588
- provider: requireText(formData, "provider"),
589
- externalKey: requireText(formData, "externalKey"),
1072
+ provider,
1073
+ externalId,
1074
+ externalKey,
590
1075
  membershipMode: requireMembershipMode(formData),
591
1076
  })
592
1077
  return { ok: true }
@@ -601,8 +1086,10 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
601
1086
  }
602
1087
  if (intent === "requestDirectorySync") {
603
1088
  const raw = String(formData.get("groupId") ?? "").trim()
604
- await config.store.requestDirectorySync(raw === "" ? null : requireId(formData, "groupId"))
605
- return { ok: true }
1089
+ const outcome = await config.store.requestDirectorySync(raw === "" ? null : requireId(formData, "groupId"), await actorOf(request))
1090
+ // ⚠️ 何が起きたかを画面へ返す。`{ ok: true }` だけを返すと、積まれなかったことが
1091
+ // 成功と見分けられず、押しても反応しない画面になる
1092
+ return { ok: true, syncRequest: outcome }
606
1093
  }
607
1094
  if (intent === "removeAllowlistEntry") {
608
1095
  await config.store.removeAllowlistEntry(requireId(formData, "entryId"))
@@ -610,7 +1097,10 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
610
1097
  }
611
1098
 
612
1099
  // INTENT_ACCESS を通過した intent は上のいずれかで処理済み (到達しない安全網)
613
- return { ok: false, error: `Unknown intent: ${intent}` }
1100
+ // 権限表を通ったのに分岐が無い intent は、表と実装の食い違いである (`catalog` のような
1101
+ // 読み取り専用キーが POST で来た場合を含む)。利用者へ値は返さず、ログにだけ残す
1102
+ console.error(`[@aiquants/auth-react-router] intent "${intent}" is in the permission table but has no handler`)
1103
+ return data({ ok: false, error: labels.errors.unknownIntent }, { status: 400 })
614
1104
  }
615
1105
 
616
1106
  return { loader, action }