@goodandready/dsh-key-rotation 0.8.76 → 0.8.78

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.
@@ -0,0 +1,647 @@
1
+ function KeyRotationSection(props) {
2
+ const t = resolveT(props);
3
+ const settingsBinding = React.useSyncExternalStore(
4
+ props.settingsSource.subscribe, props.settingsSource.getSnapshot
5
+ );
6
+ const settingsScope = settingsBinding.service;
7
+ const settingsContract = settingsBinding.kind;
8
+ const bindingRef = React.useRef(settingsBinding);
9
+ bindingRef.current = settingsBinding;
10
+ // (issue 273): getSnapshot must be referentially stable or React 18 unmounts the tree
11
+ const scopeCacheRef = React.useRef({ has: false, value: null });
12
+ const getScopeSnapshot = React.useCallback(() => {
13
+ if (!settingsScope || typeof settingsScope.getSnapshot !== 'function') return null;
14
+ let next = null;
15
+ try {
16
+ next = settingsScope.getSnapshot();
17
+ } catch (_) {
18
+ return null;
19
+ }
20
+ const prev = scopeCacheRef.current;
21
+ if (prev.service !== settingsScope) prev.has = false;
22
+ if (prev.has && Object.is(prev.value, next)) return prev.value;
23
+ // shallow-compare common snapshot shape to avoid identity thrash
24
+ if (prev.has && prev.value && next
25
+ && Object.keys(prev.value).length === Object.keys(next).length
26
+ && Object.keys(next).every((key) => Object.is(prev.value[key], next[key]))) {
27
+ return prev.value;
28
+ }
29
+ scopeCacheRef.current = { has: true, value: next, service: settingsScope };
30
+ return next;
31
+ }, [settingsScope]);
32
+ const scopeSnapshot = React.useSyncExternalStore(
33
+ React.useMemo(() => (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
34
+ getScopeSnapshot
35
+ );
36
+ const [state, setState] = React.useState({ status: 'loading', value: null, revision: undefined, writable: false, error: '', providers: [] });
37
+ const stateRef = React.useRef(state);
38
+ const updateState = React.useCallback((update) => {
39
+ const next = typeof update === 'function' ? update(stateRef.current) : update;
40
+ stateRef.current = next;
41
+ setState(next);
42
+ }, []);
43
+ const catalog = useProviderCatalog(Boolean(settingsScope) && scopeSnapshot?.mode !== 'memory');
44
+ const latestScopeSnapshot = React.useRef(scopeSnapshot);
45
+ latestScopeSnapshot.current = scopeSnapshot;
46
+ const [draft, setDraft] = React.useState(null);
47
+ const draftRef = React.useRef(null);
48
+ const draftBaseRef = React.useRef(null);
49
+ const saveInFlightRef = React.useRef(false);
50
+ const [saving, setSaving] = React.useState(false);
51
+ const [importing, setImporting] = React.useState(false);
52
+ const importRef = React.useRef(false);
53
+ const importCancelRef = React.useRef(null);
54
+ const editEpochRef = React.useRef(0);
55
+ const mountedRef = React.useRef(true);
56
+ const readRef = React.useRef({ generation: 0, controller: null });
57
+ const saveControllerRef = React.useRef(null);
58
+ React.useEffect(() => {
59
+ mountedRef.current = true;
60
+ return () => {
61
+ mountedRef.current = false;
62
+ editEpochRef.current++;
63
+ readRef.current.generation++;
64
+ readRef.current.controller?.abort();
65
+ saveControllerRef.current?.abort();
66
+ importCancelRef.current?.();
67
+ };
68
+ }, []);
69
+ // ── all hooks live ABOVE any early return (React error 310 otherwise) ──
70
+ const [search, setSearch] = React.useState('');
71
+ const [statusFilter, setStatusFilter] = React.useState('all');
72
+ const [confirmModal, setConfirmModal] = React.useState(null);
73
+ // Simulator State (#475)
74
+ const [simProvider, setSimProvider] = React.useState('');
75
+ const [simModel, setSimModel] = React.useState('');
76
+ const [simAttempts, setSimAttempts] = React.useState(3);
77
+ const [simScenario, setSimScenario] = React.useState('success');
78
+ const [simRunning, setSimRunning] = React.useState(false);
79
+ const [simResult, setSimResult] = React.useState(null);
80
+ const [simError, setSimError] = React.useState('');
81
+
82
+ // Auto-Discovery State (#491)
83
+ const [discoveredKeys, setDiscoveredKeys] = React.useState([]);
84
+ const [autoImporting, setAutoImporting] = React.useState(false);
85
+
86
+ // Selection Trace State (#461)
87
+ const [selectionTraces, setSelectionTraces] = React.useState([]);
88
+ const [loadingTraces, setLoadingTraces] = React.useState(false);
89
+
90
+ // Managed Key Drain State (#463)
91
+ const [drainingKeys, setDrainingKeys] = React.useState([]);
92
+ const [drainRefInput, setDrainRefInput] = React.useState('');
93
+ const [loadingDrain, setLoadingDrain] = React.useState(false);
94
+ const [drainActionError, setDrainActionError] = React.useState('');
95
+ const [autoImportMsg, setAutoImportMsg] = React.useState('');
96
+
97
+ // Test Matrix State (#490)
98
+ const [testMatrixRunning, setTestMatrixRunning] = React.useState(false);
99
+ const [exportModal, setExportModal] = React.useState(false);
100
+ const [exportMasked, setExportMasked] = React.useState(true);
101
+ const [exportPassphrase, setExportPassphrase] = React.useState('');
102
+ const [exportError, setExportError] = React.useState('');
103
+ const [importModal, setImportModal] = React.useState(false);
104
+ const [importText, setImportText] = React.useState('');
105
+ const [importPassphrase, setImportPassphrase] = React.useState('');
106
+ const [importMode, setImportMode] = React.useState('merge');
107
+ const [importPreview, setImportPreview] = React.useState(null);
108
+ const [importRevision, setImportRevision] = React.useState(null);
109
+ const [importError, setImportError] = React.useState('');
110
+ const [importRunning, setImportRunning] = React.useState(false);
111
+
112
+ const runExport = async () => {
113
+ setExportError('');
114
+ try {
115
+ const passphrase = exportPassphrase.trim();
116
+ let res;
117
+ if (passphrase) {
118
+ // Secure POST transmission for encrypted export (#552)
119
+ res = await fetch('/dsh-key-rotation/export', {
120
+ method: 'POST',
121
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
122
+ body: JSON.stringify({ masked: exportMasked, passphrase }),
123
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
124
+ });
125
+ } else {
126
+ const q = new URLSearchParams();
127
+ q.set('masked', exportMasked ? 'true' : 'false');
128
+ res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
129
+ headers: { accept: 'application/json' },
130
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
131
+ });
132
+ }
133
+ const data = await res.json();
134
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Export failed');
135
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
136
+ const url = URL.createObjectURL(blob);
137
+ const a = document.createElement('a');
138
+ a.href = url;
139
+ a.download = `dsh-key-rotation-${exportMasked ? 'masked' : 'full'}-${Date.now()}.json`;
140
+ a.click();
141
+ URL.revokeObjectURL(url);
142
+ setExportModal(false);
143
+ } catch (err) {
144
+ setExportError(String(err?.message ?? err));
145
+ }
146
+ };
147
+
148
+ const runImportPreview = async () => {
149
+ setImportError('');
150
+ setImportPreview(null);
151
+ setImportRevision(null);
152
+ setImportRunning(true);
153
+ try {
154
+ let parsed;
155
+ try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
156
+ const res = await fetch('/dsh-key-rotation/config-import', {
157
+ method: 'POST',
158
+ headers: { 'Content-Type': 'application/json', accept: 'application/json' },
159
+ body: JSON.stringify({
160
+ config: parsed.config || (parsed.providers ? parsed : undefined),
161
+ ciphertext: parsed.ciphertext,
162
+ salt: parsed.salt,
163
+ iv: parsed.iv,
164
+ tag: parsed.tag,
165
+ encrypted: parsed.encrypted,
166
+ passphrase: importPassphrase.trim() || undefined,
167
+ mode: importMode,
168
+ dryRun: true,
169
+ }),
170
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
171
+ });
172
+ const data = await res.json();
173
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import preview failed');
174
+ setImportPreview(data.preview);
175
+ setImportRevision(data.revision ?? null);
176
+ } catch (err) {
177
+ setImportError(String(err?.message ?? err));
178
+ } finally {
179
+ setImportRunning(false);
180
+ }
181
+ };
182
+
183
+ const runImportApply = async () => {
184
+ setImportError('');
185
+ setImportRunning(true);
186
+ try {
187
+ let parsed;
188
+ try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
189
+ const res = await fetch('/dsh-key-rotation/config-import', {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json', accept: 'application/json' },
192
+ body: JSON.stringify({
193
+ config: parsed.config || (parsed.providers ? parsed : undefined),
194
+ ciphertext: parsed.ciphertext,
195
+ salt: parsed.salt,
196
+ iv: parsed.iv,
197
+ tag: parsed.tag,
198
+ encrypted: parsed.encrypted,
199
+ passphrase: importPassphrase.trim() || undefined,
200
+ mode: importMode,
201
+ expectedRevision: importRevision !== null ? importRevision : undefined,
202
+ dryRun: false,
203
+ }),
204
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
205
+ });
206
+ const data = await res.json();
207
+ if (!res.ok || !data.ok) {
208
+ if (res.status === 409 || data?.error?.code === 'conflict') {
209
+ setImportPreview(null);
210
+ setImportRevision(null);
211
+ throw new Error(data?.error?.message || 'Configuration was modified concurrently. Please preview again before applying.');
212
+ }
213
+ throw new Error(data?.error?.message || 'Import apply failed');
214
+ }
215
+ setImportModal(false);
216
+ setImportPreview(null);
217
+ setImportRevision(null);
218
+ setImportText('');
219
+ void load();
220
+ } catch (err) {
221
+ setImportError(String(err?.message ?? err));
222
+ } finally {
223
+ setImportRunning(false);
224
+ }
225
+ };
226
+
227
+ const [testMatrixResults, setTestMatrixResults] = React.useState(null);
228
+ const [testMatrixModal, setTestMatrixModal] = React.useState(false);
229
+
230
+ React.useEffect(() => {
231
+ let mounted = true;
232
+ fetch('/dsh-key-rotation/discover', {
233
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
234
+ })
235
+ .then((r) => r.json())
236
+ .then((data) => {
237
+ if (mounted && data?.ok && Array.isArray(data.discovered)) {
238
+ setDiscoveredKeys(data.discovered);
239
+ }
240
+ })
241
+ .catch((e) => { void e; });
242
+ return () => { mounted = false; };
243
+ }, []);
244
+
245
+ const runTestMatrix = async () => {
246
+ setTestMatrixRunning(true);
247
+ setTestMatrixModal(true);
248
+ try {
249
+ const res = await fetch('/dsh-key-rotation/test-matrix', {
250
+ method: 'POST',
251
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined,
252
+ });
253
+ const data = await res.json();
254
+ if (data?.ok) {
255
+ setTestMatrixResults(data);
256
+ }
257
+ } catch (err) {
258
+ setTestMatrixResults({ ok: false, failed: 1, total: 1, results: [{ provider: 'all', ref: 'matrix', ok: false, code: 'error', error: String(err?.message ?? err) }] });
259
+ } finally {
260
+ setTestMatrixRunning(false);
261
+ }
262
+ };
263
+
264
+ const runAutoImport = async () => {
265
+ setAutoImporting(true);
266
+ setAutoImportMsg('');
267
+ try {
268
+ const res = await fetch('/dsh-key-rotation/auto-import', {
269
+ method: 'POST',
270
+ headers: { 'Content-Type': 'application/json' },
271
+ body: JSON.stringify({ all: true }),
272
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined,
273
+ });
274
+ const data = await res.json();
275
+ if (data?.ok) {
276
+ setAutoImportMsg(t('autoImportSuccess').replace('{n}', String(data.importedCount || 0)));
277
+ setDiscoveredKeys([]);
278
+ void load();
279
+ }
280
+ } catch (err) {
281
+ setAutoImportMsg(String(err?.message ?? err));
282
+ } finally {
283
+ setAutoImporting(false);
284
+ }
285
+ };
286
+
287
+ const runSimulator = async () => {
288
+ const activeProv = simProvider || (providersList[0]?.provider || '');
289
+ if (!activeProv) return;
290
+ setSimRunning(true);
291
+ setSimError('');
292
+ try {
293
+ const events = [];
294
+ if (simScenario === 'rate_limit') {
295
+ events.push({ code: '429', message: 'Rate limit reached' });
296
+ } else if (simScenario === 'error_500') {
297
+ events.push({ code: '500', message: 'Internal Server Error' });
298
+ }
299
+ const res = await fetch('/dsh-key-rotation/simulate', {
300
+ method: 'POST',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({
303
+ provider: activeProv,
304
+ model: simModel,
305
+ maxAttempts: Number(simAttempts) || 3,
306
+ events,
307
+ }),
308
+ });
309
+ const data = await res.json();
310
+ if (data && data.ok) {
311
+ setSimResult(data.simulation);
312
+ } else {
313
+ setSimError(data?.error?.message || 'Simulation error');
314
+ }
315
+ } catch (err) {
316
+ setSimError(String(err?.message || err));
317
+ } finally {
318
+ setSimRunning(false);
319
+ }
320
+ };
321
+
322
+ const loadTraces = async () => {
323
+ setLoadingTraces(true);
324
+ try {
325
+ const res = await fetch('/dsh-key-rotation/trace?limit=20');
326
+ const data = await res.json();
327
+ if (data && data.ok) {
328
+ setSelectionTraces(data.traces || []);
329
+ }
330
+ } catch (_) {}
331
+ finally {
332
+ setLoadingTraces(false);
333
+ }
334
+ };
335
+
336
+ const clearTraces = async () => {
337
+ try {
338
+ await fetch('/dsh-key-rotation/trace', { method: 'DELETE' });
339
+ setSelectionTraces([]);
340
+ } catch (_) {}
341
+ };
342
+
343
+ const loadDrainStatus = async () => {
344
+ setLoadingDrain(true);
345
+ try {
346
+ const res = await fetch('/dsh-key-rotation/drain');
347
+ const data = await res.json();
348
+ if (data && data.ok) {
349
+ setDrainingKeys(data.draining || []);
350
+ }
351
+ } catch (_) {}
352
+ finally {
353
+ setLoadingDrain(false);
354
+ }
355
+ };
356
+
357
+ const startKeyDrain = async (ref) => {
358
+ if (!ref || !ref.trim()) return;
359
+ setDrainActionError('');
360
+ try {
361
+ const res = await fetch('/dsh-key-rotation/drain', {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json' },
364
+ body: JSON.stringify({ ref: ref.trim() }),
365
+ });
366
+ const data = await res.json();
367
+ if (data && data.ok) {
368
+ setDrainRefInput('');
369
+ await loadDrainStatus();
370
+ } else {
371
+ setDrainActionError(data?.error?.message || 'Drain request failed');
372
+ }
373
+ } catch (e) {
374
+ setDrainActionError(String(e?.message || e));
375
+ }
376
+ };
377
+
378
+ const cancelKeyDrain = async (ref) => {
379
+ if (!ref) return;
380
+ try {
381
+ await fetch(`/dsh-key-rotation/drain?ref=${encodeURIComponent(ref)}`, { method: 'DELETE' });
382
+ await loadDrainStatus();
383
+ } catch (_) {}
384
+ };
385
+
386
+ const [optimisticReset, setOptimisticReset] = React.useState({});
387
+ const [selected, setSelected] = React.useState(new Set());
388
+ const [bulkCooldown, setBulkCooldown] = React.useState('');
389
+ const [undo, setUndo] = React.useState(null);
390
+ const [bulkOpen, setBulkOpen] = React.useState({});
391
+ const [bulkText, setBulkText] = React.useState({});
392
+
393
+ const togglePauseKey = (pIndex, kIndex) => setField((cur) => {
394
+ const providers = [...(cur.providers ?? [])];
395
+ const ent = { ...providers[pIndex] };
396
+ const paused = [...(ent.paused ?? [])];
397
+ paused[kIndex] = !paused[kIndex];
398
+ ent.paused = paused;
399
+ providers[pIndex] = ent;
400
+ return { ...cur, providers };
401
+ });
402
+
403
+ const applyBulkImport = (pIndex) => {
404
+ const text = bulkText[pIndex] || '';
405
+ const parsed = parseBulkKeys(text);
406
+ if (!parsed.length) return;
407
+ setField((cur) => {
408
+ const providers = [...(cur.providers ?? [])];
409
+ const ent = { ...providers[pIndex] };
410
+ const existingKeys = new Set(ent.keys ?? []);
411
+ const nextKeys = [...(ent.keys ?? [])];
412
+ const nextWeights = [...(ent.weights ?? [])];
413
+ const nextPaused = [...(ent.paused ?? [])];
414
+ for (const item of parsed) {
415
+ if (!existingKeys.has(item.ref)) {
416
+ existingKeys.add(item.ref);
417
+ nextKeys.push(item.ref);
418
+ nextWeights.push(item.weight);
419
+ nextPaused.push(false);
420
+ }
421
+ }
422
+ ent.keys = nextKeys;
423
+ ent.weights = nextWeights;
424
+ ent.paused = nextPaused;
425
+ providers[pIndex] = ent;
426
+ return { ...cur, providers };
427
+ });
428
+ setBulkText((cur) => ({ ...cur, [pIndex]: '' }));
429
+ setBulkOpen((cur) => ({ ...cur, [pIndex]: false }));
430
+ };
431
+ const undoTimer = React.useRef(null);
432
+ React.useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []);
433
+ // (issue 289) a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
434
+ const modalReturnFocusRef = React.useRef(null);
435
+ const modalCardRef = React.useRef(null);
436
+ React.useEffect(() => {
437
+ if (!confirmModal) return undefined;
438
+ modalReturnFocusRef.current = typeof document !== 'undefined' ? document.activeElement : null;
439
+ const onKey = (e) => {
440
+ if (e.key === 'Escape') {
441
+ e.stopPropagation();
442
+ setConfirmModal(null);
443
+ return;
444
+ }
445
+ if (e.key !== 'Tab' || !modalCardRef.current) return;
446
+ const focusables = modalCardRef.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
447
+ if (!focusables.length) return;
448
+ const first = focusables[0];
449
+ const last = focusables[focusables.length - 1];
450
+ if (e.shiftKey && document.activeElement === first) {
451
+ e.preventDefault();
452
+ last.focus();
453
+ } else if (!e.shiftKey && document.activeElement === last) {
454
+ e.preventDefault();
455
+ first.focus();
456
+ }
457
+ };
458
+ document.addEventListener('keydown', onKey, true);
459
+ const id = requestAnimationFrame(() => {
460
+ if (!modalCardRef.current) return;
461
+ const cancelBtn = modalCardRef.current.querySelector('[data-krot-modal-cancel]');
462
+ (cancelBtn || modalCardRef.current.querySelector('button'))?.focus();
463
+ });
464
+ return () => {
465
+ document.removeEventListener('keydown', onKey, true);
466
+ cancelAnimationFrame(id);
467
+ const prev = modalReturnFocusRef.current;
468
+ if (prev && typeof prev.focus === 'function') {
469
+ bestEffort('modal.returnFocus', () => { prev.focus(); });
470
+ }
471
+ };
472
+ }, [confirmModal]);
473
+ const testRunRef = React.useRef(false);
474
+ const [testing, setTesting] = React.useState('');
475
+ const [testResult, setTestResult] = React.useState({});
476
+ const [testAllProvider, setTestAllProvider] = React.useState('');
477
+ const [testingProgress, setTestingProgress] = React.useState({});
478
+ const [secretDraft, setSecretDraft] = React.useState({});
479
+ const [secretError, setSecretError] = React.useState('');
480
+ const secretWritesRef = React.useRef(new Set());
481
+ const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
482
+ const doUndo = () => {
483
+ if (!undo || saveInFlightRef.current || importRef.current || !stateRef.current.writable) return;
484
+ const u = undo;
485
+ setField((cur) => {
486
+ const providers = [...(cur.providers ?? [])];
487
+ if (u.type === 'provider') {
488
+ if (providers.some(p => p.provider === u.entry.provider)) return cur;
489
+ providers.splice(Math.min(u.index, providers.length), 0, u.entry);
490
+ } else {
491
+ // Other providers may have been removed or re-ordered since Undo
492
+ // was staged. Identity, not the old displayed index, selects it.
493
+ const index = providers.findIndex(p => p.provider === u.provider);
494
+ if (index < 0 || providers[index].keys.includes(u.key)) return cur;
495
+ const entry = { ...providers[index] };
496
+ const at = Math.min(u.kIndex, entry.keys.length);
497
+ entry.keys = [...entry.keys]; entry.keys.splice(at, 0, u.key);
498
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
499
+ if (u[field] !== undefined || entry[field]?.length) {
500
+ const values = Array.from({ length: entry.keys.length - 1 }, (_, i) => entry[field]?.[i] ?? fallback);
501
+ values.splice(at, 0, u[field] ?? fallback); entry[field] = values;
502
+ }
503
+ }
504
+ providers[index] = entry;
505
+ }
506
+ return { ...cur, providers };
507
+ });
508
+ setUndo(null);
509
+ };
510
+ // Both buttons must run the same live probe, never a presence-only check.
511
+ // Serialize probes: a large pool must not burst /models requests. The ref
512
+ // also guards repeated clicks before React has rendered disabled buttons.
513
+ const runKeyTests = async (refs, providerId = '') => {
514
+ if (testRunRef.current || refs.length === 0) return;
515
+ testRunRef.current = true;
516
+ setTestAllProvider(providerId);
517
+ setTestResult((m) => {
518
+ const next = { ...m };
519
+ for (const ref of refs) next[ref] = null;
520
+ return next;
521
+ });
522
+ if (providerId) setTestingProgress((m) => ({ ...m, [providerId]: '0/' + refs.length }));
523
+ try {
524
+ let completed = 0;
525
+ for (const ref of refs) {
526
+ setTesting(ref);
527
+ let result;
528
+ try {
529
+ const response = await fetch('/dsh-key-rotation/test', {
530
+ method: 'POST',
531
+ headers: { 'content-type': 'application/json' },
532
+ body: JSON.stringify({ ref, probe: 'models' }),
533
+ });
534
+ const data = await response.json();
535
+ if (!response.ok) {
536
+ result = { ok: false, code: data?.error?.code || ('http-' + response.status),
537
+ message: data?.error?.message || data?.message };
538
+ } else if (typeof data?.ok !== 'boolean') {
539
+ result = { ok: false, code: 'invalid-response' };
540
+ } else {
541
+ result = data;
542
+ }
543
+ } catch (e) {
544
+ result = { ok: false, code: 'error', message: String(e?.message ?? e) };
545
+ }
546
+ setTestResult((m) => ({ ...m, [ref]: result }));
547
+ completed++;
548
+ if (providerId) setTestingProgress((m) => ({ ...m, [providerId]: completed + '/' + refs.length }));
549
+ }
550
+ } finally {
551
+ setTesting('');
552
+ setTestAllProvider('');
553
+ testRunRef.current = false;
554
+ }
555
+ };
556
+ const doTest = (ref) => runKeyTests([ref]);
557
+ const doTestAll = (providerId) => {
558
+ if (!val || !Array.isArray(val.providers)) return;
559
+ const entry = val.providers.find((p) => p.provider === providerId);
560
+ if (!entry || !Array.isArray(entry.keys)) return;
561
+ const refs = [...new Set(entry.keys.filter((k) => typeof k === 'string' && k.trim().length > 0))];
562
+ return runKeyTests(refs, providerId);
563
+ };
564
+
565
+ const acceptSnapshot = React.useCallback((snapshot, source) => {
566
+ if (!mountedRef.current || bindingRef.current.service !== source) return;
567
+ if (!snapshot || snapshot.status === 'unavailable' || snapshot.mode === 'memory'
568
+ || snapshot.available === false) {
569
+ updateState((s) => ({ ...s, status: 'unavailable', writable: false, source }));
570
+ return;
571
+ }
572
+ if (!snapshot.value || typeof snapshot.value !== 'object' || Array.isArray(snapshot.value)
573
+ || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) {
574
+ throw new Error('Invalid settings snapshot or revision');
575
+ }
576
+ updateState((s) => {
577
+ if (s.source === source && Number.isSafeInteger(s.revision) && s.revision > snapshot.revision) return s;
578
+ return { ...s, status: 'ready', value: snapshot.value, revision: snapshot.revision,
579
+ writable: snapshot.writable === true, source };
580
+ });
581
+ }, [updateState]);
582
+
583
+ const load = React.useCallback(async (force = false) => {
584
+ const source = bindingRef.current.service;
585
+ const snapshot = latestScopeSnapshot.current;
586
+ const generation = ++readRef.current.generation;
587
+ readRef.current.controller?.abort();
588
+ if (!source || snapshot?.status === 'unavailable' || snapshot?.mode === 'memory') {
589
+ acceptSnapshot(null, source);
590
+ return;
591
+ }
592
+ updateState((s) => ({ ...s, error: '' }));
593
+ if (!force && snapshot?.status === 'ready') {
594
+ try { acceptSnapshot(snapshot, source); }
595
+ catch (error) { updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: error.message })); }
596
+ return;
597
+ }
598
+ const controller = new AbortController();
599
+ readRef.current.controller = controller;
600
+ const timeout = setTimeout(() => controller.abort(), 15000);
601
+ updateState((s) => ({ ...s, status: s.value ? s.status : 'loading' }));
602
+ try {
603
+ const data = await settingsResponse(await fetch(CONFIG_PATH, {
604
+ headers: { accept: 'application/json' }, signal: controller.signal,
605
+ }));
606
+ if (controller.signal.aborted || generation !== readRef.current.generation || !mountedRef.current) return;
607
+ acceptSnapshot(data, source);
608
+ } catch (error) {
609
+ if (generation !== readRef.current.generation || !mountedRef.current) return;
610
+ updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: String(error?.message ?? error) }));
611
+ } finally {
612
+ clearTimeout(timeout);
613
+ if (readRef.current.controller === controller) readRef.current.controller = null;
614
+ }
615
+ }, [acceptSnapshot, updateState]);
616
+
617
+ React.useEffect(() => {
618
+ readRef.current.generation++;
619
+ readRef.current.controller?.abort();
620
+ if (!settingsScope || scopeSnapshot?.status === 'unavailable' || scopeSnapshot?.mode === 'memory') {
621
+ acceptSnapshot(null, settingsScope);
622
+ } else if (scopeSnapshot?.status === 'ready') {
623
+ try { acceptSnapshot(scopeSnapshot, settingsScope); }
624
+ catch (error) { updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: error.message })); }
625
+ } else {
626
+ void load();
627
+ }
628
+ }, [settingsScope, scopeSnapshot, load, acceptSnapshot, updateState]);
629
+
630
+ const val = draft ?? state.value;
631
+ const status = useRotationStatus();
632
+ const probeCache = useProbeCache();
633
+ const [resetting, setResetting] = React.useState('');
634
+ const doReset = (providerId) => {
635
+ setResetting(providerId);
636
+ setSecretError('');
637
+ const provEntry = val?.providers?.find((p) => p.provider === providerId);
638
+ if (provEntry && Array.isArray(provEntry.keys)) {
639
+ setOptimisticReset((cur) => {
640
+ const next = { ...cur };
641
+ provEntry.keys.forEach((k) => { next[k] = true; });
642
+ return next;
643
+ });
644
+ }
645
+ fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: providerId }), signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined })
646
+ .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
647
+ .then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'unknown error'); })