@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,501 @@
1
+ .catch((e) => {
2
+ setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e)));
3
+ if (provEntry && Array.isArray(provEntry.keys)) {
4
+ setOptimisticReset((cur) => {
5
+ const next = { ...cur };
6
+ provEntry.keys.forEach((k) => { delete next[k]; });
7
+ return next;
8
+ });
9
+ }
10
+ })
11
+ .finally(() => setResetting(''));
12
+ };
13
+ // (issue 223): re-test a broken key; a successful live probe lifts the 30-day broken quarantine
14
+ const retestBroken = (ref) => {
15
+ setSecretError('');
16
+ fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }), signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(30000) : undefined })
17
+ .then((r) => r.json())
18
+ .then((data) => {
19
+ if (data && data.ok) {
20
+ return fetch('/dsh-key-rotation/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }), signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined });
21
+ }
22
+ setSecretError(t('retestFail'));
23
+ return null;
24
+ })
25
+ .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
26
+ };
27
+ const keyInfo = (providerId, ref) => {
28
+ const entryStatus = status[providerId];
29
+ if (!entryStatus || !ref) return null;
30
+ return (entryStatus.keys ?? []).find((k) => k.ref === ref) ?? null;
31
+ };
32
+
33
+ const [validating, setValidating] = React.useState('');
34
+ const [presetNotice, setPresetNotice] = React.useState('');
35
+ const [checkingBalance, setCheckingBalance] = React.useState('');
36
+ const checkBalances = async (pIndex, provName) => {
37
+ setCheckingBalance(provName);
38
+ try {
39
+ const r = await fetch('/dsh-key-rotation/balance', {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ provider: provName }),
43
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined,
44
+ });
45
+ const data = await r.json();
46
+ if (data && data.ok) {
47
+ load();
48
+ }
49
+ } catch (err) {
50
+ setSecretError(String(err?.message || err));
51
+ } finally {
52
+ setCheckingBalance('');
53
+ }
54
+ };
55
+ const applyPreset = async (pIndex, provName) => {
56
+ try {
57
+ const r = await fetch('/dsh-key-rotation/preset-limits?provider=' + encodeURIComponent(provName));
58
+ const data = await r.json();
59
+ if (data && data.preset) {
60
+ setField((cur) => {
61
+ const providers = [...(cur.providers ?? [])];
62
+ const ent = { ...providers[pIndex] };
63
+ if (data.preset.rpmLimit) ent.rpmLimit = data.preset.rpmLimit;
64
+ if (data.preset.tpmLimit) ent.tpmLimit = data.preset.tpmLimit;
65
+ providers[pIndex] = ent;
66
+ return { ...cur, providers };
67
+ });
68
+ setPresetNotice(t('presetApplied').replace('{prov}', provName).replace('{rpm}', String(data.preset.rpmLimit)).replace('{tpm}', String(data.preset.tpmLimit)));
69
+ setTimeout(() => setPresetNotice(''), 4000);
70
+ } else {
71
+ setPresetNotice(t('presetNone'));
72
+ setTimeout(() => setPresetNotice(''), 3000);
73
+ }
74
+ } catch (err) {
75
+ setPresetNotice(String(err?.message || err));
76
+ setTimeout(() => setPresetNotice(''), 3000);
77
+ }
78
+ };
79
+ const [validationResult, setValidationResult] = React.useState({});
80
+ const validateBeforeSave = (ref, value) => {
81
+ setValidating(ref);
82
+ return fetch('/dsh-key-rotation/test', {
83
+ method: 'POST',
84
+ headers: { 'content-type': 'application/json' },
85
+ body: JSON.stringify({ ref, value }),
86
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(30000) : undefined,
87
+ })
88
+ .then(async (r) => {
89
+ const data = (await r.json().catch(() => null)) ?? {};
90
+ const resData = !r.ok && data.error
91
+ ? { ok: false, code: data.error.code, message: data.error.message }
92
+ : { ok: r.ok, ...data };
93
+ setValidationResult((m) => ({ ...m, [ref]: resData }));
94
+ return resData;
95
+ })
96
+ .catch((e) => ({ ok: false, message: String(e?.message ?? e) }))
97
+ .finally(() => setValidating(''));
98
+ };
99
+ const saveSecret = async (ref, rowKey) => {
100
+ const value = secretDraft[rowKey];
101
+ if (!value || secretWritesRef.current.has(ref)) return;
102
+ secretWritesRef.current.add(ref);
103
+ try {
104
+ setSecretError('');
105
+ // Pre-save validation (issue #118)
106
+ setValidating(ref);
107
+ const vres = await validateBeforeSave(ref, value);
108
+ setValidating('');
109
+ if (vres && vres.ok === false && vres.code === 'no-credential') {
110
+ // No credential yet is fine for a new key being saved
111
+ } else if (vres && !vres.ok) {
112
+ const msg = vres.message ?? vres.error?.message ?? 'validation failed';
113
+ setSecretError(t('keyWriteFailed').replace('{msg}', msg));
114
+ return;
115
+ }
116
+ await fetch('/dsh-key-rotation/key', {
117
+ method: 'PUT',
118
+ headers: { 'content-type': 'application/json' },
119
+ body: JSON.stringify({ ref, value }),
120
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(15000) : undefined,
121
+ })
122
+ .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
123
+ .then(({ ok, data }) => {
124
+ if (!ok) throw new Error(data?.error?.message ?? 'unknown error');
125
+ // (issue 200) leak-detector hint: stored value does not match any known
126
+ // API-key shape - probably a placeholder or a typo.
127
+ if (data?.looksLikeSecret === false) {
128
+ setSecretError(t('notSecretShape'));
129
+ }
130
+ setSecretDraft((cur) => {
131
+ const next = { ...cur };
132
+ if (next[rowKey] === value) delete next[rowKey];
133
+ return next;
134
+ });
135
+ })
136
+ .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
137
+ } finally { secretWritesRef.current.delete(ref); }
138
+ };
139
+ // (issue 290): explicit card states — loading / error / unavailable / empty / ready
140
+ if (state.status === 'loading' || (!val && state.status !== 'error' && state.status !== 'unavailable')) {
141
+ return React.createElement('div', { className: 'krot-state-block', role: 'status', 'aria-live': 'polite', style: { padding: '18px 4px', display: 'flex', flexDirection: 'column', gap: '8px' } },
142
+ React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, margin: 0 } }, t('loading')),
143
+ React.createElement('div', {
144
+ className: 'krot-skeleton',
145
+ 'aria-hidden': 'true',
146
+ style: {
147
+ height: 10, borderRadius: 999, background: 'linear-gradient(90deg, var(--dsw-alias-bg-layer-2), var(--dsw-alias-bg-layer-3), var(--dsw-alias-bg-layer-2))',
148
+ backgroundSize: '200% 100%', animation: 'krot-shimmer 1.2s ease-in-out infinite', maxWidth: 280,
149
+ },
150
+ })
151
+ );
152
+ }
153
+ if (state.status === 'error' && !val) {
154
+ return React.createElement('div', { className: 'krot-state-block', role: 'alert', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '10px', alignItems: 'flex-start' } },
155
+ React.createElement('p', { className: 'krot-err', style: { margin: 0 } }, t('errorTitle') + (state.error ? (': ' + state.error) : '')),
156
+ React.createElement('button', { type: 'button', className: 'krot-btn', onClick: () => load() }, t('retry'))
157
+ );
158
+ }
159
+ if (state.status === 'unavailable' || !settingsScope) {
160
+ return React.createElement('div', { className: 'krot-state-block', role: 'status', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '6px' } },
161
+ React.createElement('p', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', margin: 0 } }, t('unavailableTitle')),
162
+ React.createElement('p', { className: 'krot-hint', style: { margin: 0 } }, t('unavailableDesc'))
163
+ );
164
+ }
165
+
166
+ const providers = catalog.providers;
167
+ const providerById = new Map(providers.map((p) => [p.id, p.name]));
168
+
169
+ const setField = (fn, fromImport = false) => {
170
+ if (!mountedRef.current || saveInFlightRef.current || (importRef.current && !fromImport)
171
+ || !stateRef.current.writable) return;
172
+ const current = draftRef.current ?? stateRef.current.value;
173
+ if (!current) return;
174
+ const next = fn(current);
175
+ if (sameJson(current, next)) return;
176
+ if (!draftBaseRef.current) {
177
+ draftBaseRef.current = { value: structuredClone(current), revision: stateRef.current.revision,
178
+ source: bindingRef.current.service };
179
+ }
180
+ const unchanged = sameJson(draftBaseRef.current.value, next);
181
+ if (unchanged) draftBaseRef.current = null;
182
+ draftRef.current = unchanged ? null : next;
183
+ setDraft(draftRef.current);
184
+ updateState((s) => ({ ...s, error: '' }));
185
+ };
186
+ const clearDraft = () => {
187
+ editEpochRef.current++;
188
+ draftBaseRef.current = null;
189
+ draftRef.current = null;
190
+ setDraft(null);
191
+ setUndo(null);
192
+ setSelected(new Set());
193
+ if (undoTimer.current) clearTimeout(undoTimer.current);
194
+ };
195
+ const readImport = (file, stage) => {
196
+ if (!file || saveInFlightRef.current || importRef.current || !stateRef.current.writable) return;
197
+ if (file.size > 1024 * 1024) { setSecretError('Import file exceeds 1 MiB'); return; }
198
+ const epoch = editEpochRef.current;
199
+ const source = bindingRef.current.service;
200
+ importRef.current = true;
201
+ setImporting(true);
202
+ const reader = new FileReader();
203
+ let finished = false;
204
+ const finish = () => {
205
+ if (finished) return;
206
+ finished = true;
207
+ clearTimeout(timeout);
208
+ importCancelRef.current = null;
209
+ importRef.current = false;
210
+ if (mountedRef.current) setImporting(false);
211
+ };
212
+ const timeout = setTimeout(() => {
213
+ if (mountedRef.current) setSecretError('Import file read timed out');
214
+ finish(); reader.abort?.();
215
+ }, 15000);
216
+ importCancelRef.current = () => { finish(); reader.abort?.(); };
217
+ reader.onload = () => {
218
+ if (finished) return;
219
+ try {
220
+ if (!mountedRef.current || epoch !== editEpochRef.current || source !== bindingRef.current.service) return;
221
+ const value = JSON.parse(String(reader.result));
222
+ requireJson(value);
223
+ stage(value);
224
+ } catch (error) {
225
+ if (mountedRef.current) setSecretError(String(error?.message ?? error));
226
+ } finally { finish(); }
227
+ };
228
+ reader.onerror = reader.onabort = () => { if (mountedRef.current) setSecretError('Could not read the import file'); finish(); };
229
+ try { reader.readAsText(file); }
230
+ catch (error) { if (mountedRef.current) setSecretError(String(error?.message ?? error)); finish(); }
231
+ };
232
+ const providerList = (Array.isArray(val.providers) ? val.providers : [])
233
+ .map((entry, pIndex) => ({ entry, pIndex }))
234
+ .filter(({ entry }) => entry && typeof entry.provider === 'string')
235
+ .filter(({ entry }) => !search || entry.provider.toLowerCase().includes(search.toLowerCase()));
236
+
237
+ const setProvider = (index, id) => setField((cur) => {
238
+ const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
239
+ next[index] = { ...next[index], provider: id };
240
+ return { ...cur, providers: next };
241
+ });
242
+ const addKey = (pIndex) => setField((cur) => {
243
+ const providers = [...(cur.providers ?? [])];
244
+ const entry = { ...(providers[pIndex] ?? {}) };
245
+ const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
246
+ entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
247
+ // keep weights aligned with keys (#215): new key gets default weight 1
248
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
249
+ if (Array.isArray(entry[field]) && entry[field].length) entry[field] = Array.from({ length: entry.keys.length }, (_, i) => entry[field][i] ?? fallback);
250
+ }
251
+ providers[pIndex] = entry;
252
+ return { ...cur, providers };
253
+ });
254
+ const removeKey = (pIndex, kIndex) => setField((cur) => {
255
+ const next = [...(cur.providers ?? [])];
256
+ const entry = next[pIndex];
257
+ if (!entry || !entry.keys[kIndex]) return cur;
258
+ stashUndo({ type: 'key', provider: entry.provider, kIndex, key: entry.keys[kIndex],
259
+ weights: entry.weights?.[kIndex], expiresAt: entry.expiresAt?.[kIndex],
260
+ paused: entry.paused?.[kIndex], revoked: entry.revoked?.[kIndex] });
261
+ next[pIndex] = reorderKeys(entry, entry.keys.map((_, index) => index).filter(index => index !== kIndex));
262
+ return { ...cur, providers: next };
263
+ });
264
+ const removeProvider = (pIndex) => setField((cur) => {
265
+ const arr = Array.isArray(cur.providers) ? cur.providers : [];
266
+ stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
267
+ return { ...cur, providers: arr.filter((_, i) => i !== pIndex) };
268
+ });
269
+ // Key order is attempt order — move it with buttons, do not retype names.
270
+ const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
271
+ const providers = [...(cur.providers ?? [])];
272
+ const entry = providers[pIndex];
273
+ if (!entry) return cur;
274
+ const order = entry.keys.map((_, index) => index);
275
+ const target = kIndex + delta;
276
+ if (target < 0 || target >= order.length) return cur;
277
+ [order[kIndex], order[target]] = [order[target], order[kIndex]];
278
+ providers[pIndex] = reorderKeys(entry, order);
279
+ return { ...cur, providers };
280
+ });
281
+ // (issue 215): set a single key's round-robin weight (integer >= 1)
282
+ const setKeyWeight = (pIndex, kIndex, weight) => setField((cur) => {
283
+ const n = Math.max(1, Math.min(1000, Math.floor(Number(weight) || 1)));
284
+ const providers = [...(cur.providers ?? [])];
285
+ const entry = { ...(providers[pIndex] ?? {}) };
286
+ const weights = [...(entry.weights ?? [])];
287
+ while (weights.length < (entry.keys ?? []).length) weights.push(1);
288
+ weights[kIndex] = n;
289
+ entry.weights = weights;
290
+ providers[pIndex] = entry;
291
+ return { ...cur, providers };
292
+ });
293
+
294
+ // Config codes missing from the known list still render as checked items;
295
+ // otherwise the checkboxes would silently drop a custom rule on first save.
296
+ const selectedCodes = new Set(Array.isArray(val.switchCodes) ? val.switchCodes : []);
297
+ const codeList = [...KNOWN_CODES, ...[...selectedCodes].filter((c) => !KNOWN_CODES.includes(c))];
298
+ const toggleCode = (code, on) => setField((cur) => {
299
+ const current = new Set(Array.isArray(cur.switchCodes) ? cur.switchCodes : []);
300
+ if (on) current.add(code); else current.delete(code);
301
+ return { ...cur, switchCodes: codeList.filter((c) => current.has(c)) };
302
+ });
303
+
304
+ const addProvider = () => setField((cur) => ({
305
+ ...cur,
306
+ providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [] }],
307
+ }));
308
+
309
+ // ── per-model sub-pools + token quotas ──
310
+ const setModels = (pIndex, mutate) => setField((cur) => {
311
+ const providers = [...(cur.providers ?? [])];
312
+ const entry = { ...(providers[pIndex] ?? {}) };
313
+ const models = { ...(entry.models && !Array.isArray(entry.models) ? entry.models : {}) };
314
+ mutate(models);
315
+ if (Object.keys(models).length === 0) delete entry.models;
316
+ else entry.models = models;
317
+ providers[pIndex] = entry;
318
+ return { ...cur, providers };
319
+ });
320
+
321
+ const addModel = (pIndex) => setModels(pIndex, (models) => {
322
+ let name = 'model';
323
+ let n = 2;
324
+ while (Object.prototype.hasOwnProperty.call(models, name)) name = 'model-' + n++;
325
+ const seedKeys = ((draftRef.current ?? stateRef.current.value)?.providers ?? [])[pIndex]?.keys ?? [];
326
+ models[name] = { keys: [...seedKeys] };
327
+ });
328
+
329
+ const renameModel = (pIndex, from, to) => setModels(pIndex, (models) => {
330
+ const id = String(to ?? '');
331
+ if (id === from || !isValidModelId(id)) return;
332
+ if (Object.prototype.hasOwnProperty.call(models, id)) return;
333
+ const rebuilt = {};
334
+ for (const [key, value] of Object.entries(models)) rebuilt[key === from ? id : key] = value;
335
+ for (const key of Object.keys(models)) delete models[key];
336
+ Object.assign(models, rebuilt);
337
+ });
338
+
339
+ const removeModel = (pIndex, model) => setModels(pIndex, (models) => {
340
+ delete models[model];
341
+ });
342
+
343
+ const toggleModelKey = (pIndex, model, ref, on) => setModels(pIndex, (models) => {
344
+ const mp = { ...(models[model] ?? {}) };
345
+ const keys = Array.isArray(mp.keys) ? [...mp.keys] : [];
346
+ const index = keys.indexOf(ref);
347
+ if (on && index < 0) keys.push(ref);
348
+ if (!on && index >= 0) keys.splice(index, 1);
349
+ mp.keys = keys;
350
+ if (!on) {
351
+ const limits = quotaMapOf(mp);
352
+ delete limits[ref];
353
+ models[model] = withQuotas(mp, limits);
354
+ } else {
355
+ models[model] = mp;
356
+ }
357
+ });
358
+
359
+ const setModelTokenLimit = (pIndex, model, ref, raw) => setModels(pIndex, (models) => {
360
+ const mp = { ...(models[model] ?? {}) };
361
+ const limits = quotaMapOf(mp);
362
+ const text = String(raw ?? '').trim();
363
+ if (text === '') delete limits[ref];
364
+ else {
365
+ const num = Number(text);
366
+ if (!Number.isFinite(num) || num <= 0) delete limits[ref];
367
+ else limits[ref] = Math.floor(num);
368
+ }
369
+ models[model] = withQuotas(mp, limits);
370
+ });
371
+
372
+ const reorderModelKeys = (pIndex, model, delta, kIndex) => setModels(pIndex, (models) => {
373
+ const mp = { ...(models[model] ?? {}) };
374
+ const keys = Array.isArray(mp.keys) ? [...mp.keys] : [];
375
+ const target = kIndex + delta;
376
+ if (target < 0 || target >= keys.length) return;
377
+ const order = keys.map((_, i) => i);
378
+ [order[kIndex], order[target]] = [order[target], order[kIndex]];
379
+ models[model] = reorderKeys(mp, order);
380
+ });
381
+
382
+ const save = async () => {
383
+ const savingDraft = draftRef.current;
384
+ const base = draftBaseRef.current;
385
+ if (!savingDraft || !base || saveInFlightRef.current || importRef.current) return;
386
+ if (!stateRef.current.writable || bindingRef.current.service !== base.source) {
387
+ updateState((s) => ({ ...s, error: t('settingsChanged') }));
388
+ return;
389
+ }
390
+ const expectedRevision = base.revision;
391
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
392
+ updateState((s) => ({ ...s, error: t('settingsChanged') }));
393
+ return;
394
+ }
395
+ let ops;
396
+ try {
397
+ ops = changedSettingsOps(base.value, savingDraft); requireJson(ops);
398
+ if (ops.some(op => op.path[0] === 'providers')) validatePoolDraft(savingDraft.providers);
399
+ }
400
+ catch (error) { updateState((s) => ({ ...s, error: error.message })); return; }
401
+ if (!ops.length) { clearDraft(); return; }
402
+ saveInFlightRef.current = true;
403
+ setSaving(true);
404
+ editEpochRef.current++;
405
+ readRef.current.generation++;
406
+ readRef.current.controller?.abort();
407
+ updateState((s) => ({ ...s, error: '' }));
408
+ const controller = new AbortController();
409
+ saveControllerRef.current = controller;
410
+ let timeout;
411
+ try {
412
+ const expired = new Promise((_, reject) => {
413
+ timeout = setTimeout(() => {
414
+ controller.abort();
415
+ reject(new Error(t('saveUnconfirmed')));
416
+ }, 15000);
417
+ });
418
+ const operation = async () => {
419
+ if (settingsContract === 'configForm' && typeof base.source.mutate === 'function') {
420
+ const accepted = await base.source.mutate(ops, expectedRevision);
421
+ if (accepted !== true) throw new Error(t('saveRefused'));
422
+ const committed = base.source.getSnapshot();
423
+ // Shared native queues can settle before publishing this write's
424
+ // snapshot. Confirm an older/absent snapshot through the bridge.
425
+ if (committed?.status === 'ready' && committed.revision > expectedRevision) return committed;
426
+ return settingsResponse(await fetch(CONFIG_PATH, { headers: { accept: 'application/json' }, signal: controller.signal }));
427
+ }
428
+ // Legacy client mutate resolves void on BOTH success and refusal.
429
+ // Host-side mutate, reached through this bridge, throws on refusal.
430
+ return settingsResponse(await fetch(CONFIG_PATH, {
431
+ method: 'PUT', headers: { 'content-type': 'application/json' },
432
+ body: JSON.stringify({ ops, expectedRevision }), signal: controller.signal,
433
+ }));
434
+ };
435
+ const committed = await Promise.race([operation(), expired]);
436
+ if (!mountedRef.current) return;
437
+ if (bindingRef.current.service !== base.source) throw new Error(t('saveUnconfirmed'));
438
+ if (committed?.available === false || committed?.status === 'unavailable'
439
+ || !committed?.value || !Number.isSafeInteger(committed.revision)
440
+ || committed.revision < expectedRevision) throw new Error(t('saveUnconfirmed'));
441
+ acceptSnapshot(committed, base.source);
442
+ clearDraft();
443
+ } catch (error) {
444
+ if (mountedRef.current) updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: String(error?.message ?? error) }));
445
+ } finally {
446
+ clearTimeout(timeout);
447
+ saveInFlightRef.current = false;
448
+ if (saveControllerRef.current === controller) saveControllerRef.current = null;
449
+ if (mountedRef.current) setSaving(false);
450
+ }
451
+ };
452
+
453
+ const field = (labelText, node) => h('label', { className: 'krot-field' },
454
+ h('span', { className: 'krot-label' }, labelText), node);
455
+
456
+ const textInput = (value, onChange, placeholder) => h('input', {
457
+ className: 'krot-in',
458
+ value: value ?? '',
459
+ onChange: (e) => onChange(e.target.value),
460
+ placeholder,
461
+ });
462
+
463
+ const btn = (labelText, onClick, opts = {}) => {
464
+ const { primary, className = '', ...rest } = opts;
465
+ return h('button', { type: 'button', ...rest,
466
+ className: 'krot-btn' + (primary ? ' krot-save' : '') + (className ? ' ' + className : ''),
467
+ onClick, disabled: Boolean(rest.disabled),
468
+ }, labelText);
469
+ };
470
+
471
+ // Key state dot: color and label read at a glance; "key not found"
472
+ // catches an env-name typo that would otherwise stay silent.
473
+ const keyStatus = (providerId, ref, isPaused = false) => {
474
+ if (isPaused) return { color: 'var(--dsw-alias-state-warn-primary)', text: t('keyPaused'), ready: false, paused: true };
475
+ const hit = keyInfo(providerId, ref);
476
+ if (!hit) return null;
477
+ if (hit.authPaused || hit.pauseReason === 'AUTH_FAILURE_PAUSED') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyAuthPaused'), ready: false, authPaused: true };
478
+ if (hit.paused) return { color: 'var(--dsw-alias-state-warn-primary)', text: t('keyPaused'), ready: false, paused: true };
479
+ if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired'), ready: false, expired: true };
480
+ if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked'), ready: false, revoked: true };
481
+ if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing'), ready: false, missing: true };
482
+ if (hit.cooldownMsLeft > 0) {
483
+ if (optimisticReset && ref in optimisticReset) {
484
+ delete optimisticReset[ref];
485
+ }
486
+ return {
487
+ color: 'var(--dsw-alias-state-warn-primary)',
488
+ text: t('keyCooling').replace('{s}', String(Math.ceil(hit.cooldownMsLeft / 1000))),
489
+ ready: false,
490
+ cooling: true,
491
+ };
492
+ }
493
+ if (hit.expiresAt && !hit.expired) {
494
+ const days = Math.ceil((hit.expiresAt - Date.now()) / 86400000);
495
+ const warnDays = Number(val?.expiryWarnDays) || 7;
496
+ if (days <= warnDays) return { color: 'var(--dsw-alias-state-warn-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)), ready: true, expiringSoon: true };
497
+ }
498
+ if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive'), ready: true, active: true };
499
+ return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady'), ready: true };
500
+ };
501
+