@goodandready/dsh-key-rotation 0.8.20 → 0.8.21

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/lib/client.js CHANGED
@@ -139,6 +139,11 @@ window.__ModuleLoader__.load({
139
139
  emptyDesc: 'Add a provider and at least one API key name to start rotating.',
140
140
  errorTitle: 'Could not load settings',
141
141
  retry: 'Retry',
142
+ chooseProvider: 'Choose a provider',
143
+ readOnly: 'Settings are read-only.',
144
+ settingsChanged: 'The settings service changed. Discard this draft and reload before saving.',
145
+ saveRefused: 'The host did not accept this save. Your draft is kept; discard it to load the latest settings.',
146
+ saveUnconfirmed: 'The save result could not be confirmed. Your draft is kept; reload before trying again.',
142
147
  unavailableTitle: 'Settings bridge unavailable',
143
148
  unavailableDesc: 'The host did not expose the dsh-key-rotation settings namespace. Reopen Settings or check the plugin is enabled.',
144
149
  modalClose: 'Close dialog',
@@ -278,6 +283,11 @@ window.__ModuleLoader__.load({
278
283
  emptyDesc: '添加提供商及至少一个 API 密钥名称以开始轮换。',
279
284
  errorTitle: '无法加载设置',
280
285
  retry: '重试',
286
+ chooseProvider: '请选择提供商',
287
+ readOnly: '当前设置为只读。',
288
+ settingsChanged: '设置服务已变更。请放弃当前草稿并重新加载后再保存。',
289
+ saveRefused: '宿主未接受此次保存。草稿已保留;放弃草稿可加载最新设置。',
290
+ saveUnconfirmed: '无法确认保存结果。草稿已保留,请重新加载后再试。',
281
291
  unavailableTitle: '设置桥接不可用',
282
292
  unavailableDesc: '宿主未暴露 dsh-key-rotation 设置命名空间。请重新打开设置或检查插件是否已启用。',
283
293
  modalClose: '关闭对话框',
@@ -644,13 +654,17 @@ window.__ModuleLoader__.load({
644
654
  );
645
655
  }
646
656
 
647
- // Provider registration is not part of configForms's value. Fetch it
657
+ // Provider registration is not part of settingsScope's value. Fetch it
648
658
  // independently, and never turn an unavailable catalog into proof of absence.
649
- function useProviderCatalog() {
659
+ function useProviderCatalog(enabled = true) {
650
660
  const [catalog, setCatalog] = React.useState({ status: 'loading', providers: [] });
651
661
  const requestRef = React.useRef(null);
652
662
  const reload = React.useCallback(() => {
653
663
  requestRef.current?.abort();
664
+ if (!enabled) {
665
+ setCatalog({ status: 'unavailable', providers: [] });
666
+ return Promise.resolve();
667
+ }
654
668
  const controller = new AbortController();
655
669
  requestRef.current = controller;
656
670
  setCatalog((s) => ({ ...s, status: 'loading' }));
@@ -672,7 +686,7 @@ window.__ModuleLoader__.load({
672
686
  setCatalog((s) => ({ ...s, status: 'error' }));
673
687
  }
674
688
  });
675
- }, []);
689
+ }, [enabled]);
676
690
  React.useEffect(() => {
677
691
  reload();
678
692
  // A provider may have been installed/enabled while this page was open.
@@ -685,55 +699,182 @@ window.__ModuleLoader__.load({
685
699
  return { ...catalog, reload };
686
700
  }
687
701
 
702
+ // Services from different core generations are optional at the plugin boundary.
703
+ // One binding belongs to one Cordis activation, not to each React page mount.
704
+ function createSettingsSource(ctx) {
705
+ const listeners = new Set();
706
+ const candidates = new Map();
707
+ let value = { kind: null, service: null };
708
+ const publish = () => {
709
+ const kind = candidates.has('configForm') ? 'configForm'
710
+ : candidates.has('settingsScope') ? 'settingsScope' : null;
711
+ const service = candidates.get(kind) ?? null;
712
+ if (value.kind === kind && value.service === service) return;
713
+ value = { kind, service };
714
+ for (const listener of [...listeners]) listener();
715
+ };
716
+ if (typeof ctx?.inject === 'function') {
717
+ const watch = (name, kind, resolve) => {
718
+ ctx.inject([name], (owner) => {
719
+ let service;
720
+ try { service = resolve(owner.get(name)); } catch (_) { return; }
721
+ if (!service || typeof service.getSnapshot !== 'function'
722
+ || typeof service.subscribe !== 'function') return;
723
+ candidates.set(kind, service);
724
+ publish();
725
+ owner.effect(() => () => {
726
+ if (candidates.get(kind) === service) candidates.delete(kind);
727
+ publish();
728
+ });
729
+ });
730
+ };
731
+ watch('configForms', 'configForm', (forms) => forms?.get?.(NS));
732
+ watch('settingsScope', 'settingsScope', (binder) => binder?.bind?.({ namespace: NS }));
733
+ if (typeof ctx.effect === 'function') {
734
+ ctx.effect(() => () => { candidates.clear(); publish(); listeners.clear(); });
735
+ }
736
+ }
737
+ return {
738
+ getSnapshot: () => value,
739
+ subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
740
+ };
741
+ }
742
+
743
+ function sameJson(a, b) {
744
+ if (Object.is(a, b)) return true;
745
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object'
746
+ || Array.isArray(a) !== Array.isArray(b)) return false;
747
+ const keys = Object.keys(a);
748
+ return keys.length === Object.keys(b).length
749
+ && keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameJson(a[key], b[key]));
750
+ }
751
+
752
+ // A redacted, resolved snapshot is not a complete user section. Submit only
753
+ // changed top-level fields; arrays are intentionally edited atomically.
754
+ function changedSettingsOps(base, next) {
755
+ const keys = new Set([...Object.keys(base), ...Object.keys(next)]);
756
+ return [...keys].filter((key) => !sameJson(base[key], next[key])).map((key) =>
757
+ next[key] === undefined ? { op: 'unset', path: [key] }
758
+ : { op: 'set', path: [key], value: next[key] });
759
+ }
760
+
761
+ function requireJson(value, depth = 0) {
762
+ if (depth > 40) throw new Error('Configuration is nested too deeply');
763
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
764
+ if (typeof value === 'number' && Number.isFinite(value)) return;
765
+ if (Array.isArray(value)) { value.forEach(child => requireJson(child, depth + 1)); return; }
766
+ if (value && typeof value === 'object') {
767
+ for (const [key, child] of Object.entries(value)) {
768
+ if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('Unsafe configuration field');
769
+ if (child !== undefined) requireJson(child, depth + 1);
770
+ }
771
+ return;
772
+ }
773
+ throw new Error('Configuration must contain finite, JSON-compatible values');
774
+ }
775
+
776
+
777
+ function reorderKeys(entry, order) {
778
+ const next = { ...entry, keys: order.map(index => entry.keys[index]) };
779
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
780
+ if (Array.isArray(entry[field]) && entry[field].length) next[field] = order.map(index => entry[field][index] ?? fallback);
781
+ }
782
+ return next;
783
+ }
784
+
785
+ function validatePoolDraft(providers) {
786
+ if (!Array.isArray(providers)) throw new Error('Provider pools must be an array');
787
+ const names = new Set();
788
+ for (const entry of providers) {
789
+ if (!entry || typeof entry.provider !== 'string' || !entry.provider.trim()
790
+ || names.has(entry.provider) || !Array.isArray(entry.keys)
791
+ || entry.keys.some(key => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
792
+ || new Set(entry.keys).size !== entry.keys.length) {
793
+ throw new Error('Choose a unique provider and valid, unique credential reference names');
794
+ }
795
+ names.add(entry.provider);
796
+ }
797
+ }
798
+
799
+ async function settingsResponse(response) {
800
+ let data;
801
+ try { data = await response.json(); }
802
+ catch (_) { throw new Error('Settings response is not valid JSON (HTTP ' + response.status + ')'); }
803
+ if (!response.ok || data?.error) {
804
+ throw new Error(data?.error?.message || 'Settings request failed (HTTP ' + response.status + ')');
805
+ }
806
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid settings response');
807
+ return data;
808
+ }
809
+
688
810
  function KeyRotationSection(props) {
689
811
  const t = resolveT(props);
690
- const configForms = React.useMemo(() => {
691
- try {
692
- if (props.ctx && props.ctx.configForms) {
693
- return props.ctx.configForms.get(NS);
694
- }
695
- } catch (_) {
696
- return null;
697
- }
698
- return null;
699
- }, [props.ctx]);
812
+ const settingsBinding = React.useSyncExternalStore(
813
+ props.settingsSource.subscribe, props.settingsSource.getSnapshot
814
+ );
815
+ const settingsScope = settingsBinding.service;
816
+ const settingsContract = settingsBinding.kind;
817
+ const bindingRef = React.useRef(settingsBinding);
818
+ bindingRef.current = settingsBinding;
700
819
  // #273: getSnapshot must be referentially stable or React 18 unmounts the tree
701
820
  const scopeCacheRef = React.useRef({ has: false, value: null });
702
821
  const getScopeSnapshot = React.useCallback(() => {
703
- if (!configForms || typeof configForms.getSnapshot !== 'function') return null;
822
+ if (!settingsScope || typeof settingsScope.getSnapshot !== 'function') return null;
704
823
  let next = null;
705
824
  try {
706
- next = configForms.getSnapshot();
825
+ next = settingsScope.getSnapshot();
707
826
  } catch (_) {
708
827
  return null;
709
828
  }
710
829
  const prev = scopeCacheRef.current;
830
+ if (prev.service !== settingsScope) prev.has = false;
711
831
  if (prev.has && Object.is(prev.value, next)) return prev.value;
712
832
  // shallow-compare common snapshot shape to avoid identity thrash
713
833
  if (prev.has && prev.value && next
714
- && prev.value.status === next.status
715
- && prev.value.revision === next.revision
716
- && prev.value.value === next.value) {
834
+ && Object.keys(prev.value).length === Object.keys(next).length
835
+ && Object.keys(next).every((key) => Object.is(prev.value[key], next[key]))) {
717
836
  return prev.value;
718
837
  }
719
- scopeCacheRef.current = { has: true, value: next };
838
+ scopeCacheRef.current = { has: true, value: next, service: settingsScope };
720
839
  return next;
721
- }, [configForms]);
840
+ }, [settingsScope]);
722
841
  const scopeSnapshot = React.useSyncExternalStore(
723
- React.useMemo(() => (cb) => (configForms && typeof configForms.subscribe === 'function' ? configForms.subscribe(cb) : () => {}), [configForms]),
842
+ React.useMemo(() => (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
724
843
  getScopeSnapshot
725
844
  );
726
- const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
727
- const catalog = useProviderCatalog();
845
+ const [state, setState] = React.useState({ status: 'loading', value: null, revision: undefined, writable: false, error: '', providers: [] });
846
+ const stateRef = React.useRef(state);
847
+ const updateState = React.useCallback((update) => {
848
+ const next = typeof update === 'function' ? update(stateRef.current) : update;
849
+ stateRef.current = next;
850
+ setState(next);
851
+ }, []);
852
+ const catalog = useProviderCatalog(Boolean(settingsScope) && scopeSnapshot?.mode !== 'memory');
728
853
  const latestScopeSnapshot = React.useRef(scopeSnapshot);
729
854
  latestScopeSnapshot.current = scopeSnapshot;
730
- // #290: surface configForms availability as card state
731
- React.useEffect(() => {
732
- if (!configForms) {
733
- setState((s) => (s.status === 'unavailable' ? s : { ...s, status: 'unavailable' }));
734
- }
735
- }, [configForms]);
736
855
  const [draft, setDraft] = React.useState(null);
856
+ const draftRef = React.useRef(null);
857
+ const draftBaseRef = React.useRef(null);
858
+ const saveInFlightRef = React.useRef(false);
859
+ const [saving, setSaving] = React.useState(false);
860
+ const [importing, setImporting] = React.useState(false);
861
+ const importRef = React.useRef(false);
862
+ const importCancelRef = React.useRef(null);
863
+ const editEpochRef = React.useRef(0);
864
+ const mountedRef = React.useRef(true);
865
+ const readRef = React.useRef({ generation: 0, controller: null });
866
+ const saveControllerRef = React.useRef(null);
867
+ React.useEffect(() => {
868
+ mountedRef.current = true;
869
+ return () => {
870
+ mountedRef.current = false;
871
+ editEpochRef.current++;
872
+ readRef.current.generation++;
873
+ readRef.current.controller?.abort();
874
+ saveControllerRef.current?.abort();
875
+ importCancelRef.current?.();
876
+ };
877
+ }, []);
737
878
  // ── all hooks live ABOVE any early return (React error 310 otherwise) ──
738
879
  const [search, setSearch] = React.useState('');
739
880
  const [statusFilter, setStatusFilter] = React.useState('all');
@@ -743,6 +884,7 @@ window.__ModuleLoader__.load({
743
884
  const [bulkCooldown, setBulkCooldown] = React.useState('');
744
885
  const [undo, setUndo] = React.useState(null);
745
886
  const undoTimer = React.useRef(null);
887
+ React.useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []);
746
888
  // #289 a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
747
889
  const modalReturnFocusRef = React.useRef(null);
748
890
  const modalCardRef = React.useRef(null);
@@ -790,13 +932,36 @@ window.__ModuleLoader__.load({
790
932
  const [testingProgress, setTestingProgress] = React.useState({});
791
933
  const [secretDraft, setSecretDraft] = React.useState({});
792
934
  const [secretError, setSecretError] = React.useState('');
935
+ const secretWritesRef = React.useRef(new Set());
793
936
  const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
794
- const doUndo = () => { if (!undo) return; const u = undo; setUndo(null); setField((cur) => {
795
- const providers = [...(cur.providers ?? [])];
796
- if (u.type === 'provider') providers.splice(Math.min(u.index, providers.length), 0, u.entry);
797
- else if (providers[u.index]) { const keys=[...providers[u.index].keys]; keys.splice(Math.min(u.kIndex, keys.length), 0, u.key); providers[u.index] = { ...providers[u.index], keys }; }
798
- return { ...cur, providers };
799
- }); };
937
+ const doUndo = () => {
938
+ if (!undo || saveInFlightRef.current || importRef.current || !stateRef.current.writable) return;
939
+ const u = undo;
940
+ setField((cur) => {
941
+ const providers = [...(cur.providers ?? [])];
942
+ if (u.type === 'provider') {
943
+ if (providers.some(p => p.provider === u.entry.provider)) return cur;
944
+ providers.splice(Math.min(u.index, providers.length), 0, u.entry);
945
+ } else {
946
+ // Other providers may have been removed or re-ordered since Undo
947
+ // was staged. Identity, not the old displayed index, selects it.
948
+ const index = providers.findIndex(p => p.provider === u.provider);
949
+ if (index < 0 || providers[index].keys.includes(u.key)) return cur;
950
+ const entry = { ...providers[index] };
951
+ const at = Math.min(u.kIndex, entry.keys.length);
952
+ entry.keys = [...entry.keys]; entry.keys.splice(at, 0, u.key);
953
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
954
+ if (u[field] !== undefined || entry[field]?.length) {
955
+ const values = Array.from({ length: entry.keys.length - 1 }, (_, i) => entry[field]?.[i] ?? fallback);
956
+ values.splice(at, 0, u[field] ?? fallback); entry[field] = values;
957
+ }
958
+ }
959
+ providers[index] = entry;
960
+ }
961
+ return { ...cur, providers };
962
+ });
963
+ setUndo(null);
964
+ };
800
965
  // Both buttons must run the same live probe, never a presence-only check.
801
966
  // Serialize probes: a large pool must not burst /models requests. The ref
802
967
  // also guards repeated clicks before React has rendered disabled buttons.
@@ -852,41 +1017,70 @@ window.__ModuleLoader__.load({
852
1017
  return runKeyTests(refs, providerId);
853
1018
  };
854
1019
 
855
- const load = React.useCallback(() => {
856
- const scopeIsReady = () => latestScopeSnapshot.current?.status === 'ready'
857
- && latestScopeSnapshot.current.value;
858
- setState((s) => ({ ...s, status: 'loading', error: '' }));
859
- fetch(CONFIG_PATH, { headers: { accept: 'application/json' } })
860
- .then((r) => r.json())
861
- .then((data) => {
862
- // The scope may have become ready while this fallback was in flight.
863
- // A bridge snapshot must not replace that newer value or its revision.
864
- if (scopeIsReady()) return;
865
- setState({
866
- status: 'ready',
867
- value: data.value ?? null,
868
- revision: data.revision ?? 0,
869
- providers: Array.isArray(data.providers) ? data.providers : [],
870
- error: data.error ? data.error.message : '',
871
- });
872
- })
873
- .catch((e) => {
874
- if (!scopeIsReady()) setState((s) => ({ ...s, status: 'error', error: String(e) }));
875
- });
876
- }, []);
1020
+ const acceptSnapshot = React.useCallback((snapshot, source) => {
1021
+ if (!mountedRef.current || bindingRef.current.service !== source) return;
1022
+ if (!snapshot || snapshot.status === 'unavailable' || snapshot.mode === 'memory'
1023
+ || snapshot.available === false) {
1024
+ updateState((s) => ({ ...s, status: 'unavailable', writable: false, source }));
1025
+ return;
1026
+ }
1027
+ if (!snapshot.value || typeof snapshot.value !== 'object' || Array.isArray(snapshot.value)
1028
+ || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) {
1029
+ throw new Error('Invalid settings snapshot or revision');
1030
+ }
1031
+ updateState((s) => {
1032
+ if (s.source === source && Number.isSafeInteger(s.revision) && s.revision > snapshot.revision) return s;
1033
+ return { ...s, status: 'ready', value: snapshot.value, revision: snapshot.revision,
1034
+ writable: snapshot.writable === true, source };
1035
+ });
1036
+ }, [updateState]);
1037
+
1038
+ const load = React.useCallback(async (force = false) => {
1039
+ const source = bindingRef.current.service;
1040
+ const snapshot = latestScopeSnapshot.current;
1041
+ const generation = ++readRef.current.generation;
1042
+ readRef.current.controller?.abort();
1043
+ if (!source || snapshot?.status === 'unavailable' || snapshot?.mode === 'memory') {
1044
+ acceptSnapshot(null, source);
1045
+ return;
1046
+ }
1047
+ updateState((s) => ({ ...s, error: '' }));
1048
+ if (!force && snapshot?.status === 'ready') {
1049
+ try { acceptSnapshot(snapshot, source); }
1050
+ catch (error) { updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: error.message })); }
1051
+ return;
1052
+ }
1053
+ const controller = new AbortController();
1054
+ readRef.current.controller = controller;
1055
+ const timeout = setTimeout(() => controller.abort(), 15000);
1056
+ updateState((s) => ({ ...s, status: s.value ? s.status : 'loading' }));
1057
+ try {
1058
+ const data = await settingsResponse(await fetch(CONFIG_PATH, {
1059
+ headers: { accept: 'application/json' }, signal: controller.signal,
1060
+ }));
1061
+ if (controller.signal.aborted || generation !== readRef.current.generation || !mountedRef.current) return;
1062
+ acceptSnapshot(data, source);
1063
+ } catch (error) {
1064
+ if (generation !== readRef.current.generation || !mountedRef.current) return;
1065
+ updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: String(error?.message ?? error) }));
1066
+ } finally {
1067
+ clearTimeout(timeout);
1068
+ if (readRef.current.controller === controller) readRef.current.controller = null;
1069
+ }
1070
+ }, [acceptSnapshot, updateState]);
877
1071
 
878
1072
  React.useEffect(() => {
879
- if (scopeSnapshot && scopeSnapshot.status === 'ready' && scopeSnapshot.value) {
880
- setState((s) => ({
881
- ...s,
882
- status: 'ready',
883
- value: scopeSnapshot.value,
884
- revision: scopeSnapshot.revision ?? s.revision,
885
- }));
1073
+ readRef.current.generation++;
1074
+ readRef.current.controller?.abort();
1075
+ if (!settingsScope || scopeSnapshot?.status === 'unavailable' || scopeSnapshot?.mode === 'memory') {
1076
+ acceptSnapshot(null, settingsScope);
1077
+ } else if (scopeSnapshot?.status === 'ready') {
1078
+ try { acceptSnapshot(scopeSnapshot, settingsScope); }
1079
+ catch (error) { updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: error.message })); }
886
1080
  } else {
887
- load();
1081
+ void load();
888
1082
  }
889
- }, [load, scopeSnapshot]);
1083
+ }, [settingsScope, scopeSnapshot, load, acceptSnapshot, updateState]);
890
1084
 
891
1085
  const val = draft ?? state.value;
892
1086
  const status = useRotationStatus();
@@ -960,7 +1154,9 @@ window.__ModuleLoader__.load({
960
1154
  };
961
1155
  const saveSecret = async (ref, rowKey) => {
962
1156
  const value = secretDraft[rowKey];
963
- if (!value) return;
1157
+ if (!value || secretWritesRef.current.has(ref)) return;
1158
+ secretWritesRef.current.add(ref);
1159
+ try {
964
1160
  setSecretError('');
965
1161
  // Pre-save validation (issue #118)
966
1162
  setValidating(ref);
@@ -973,7 +1169,7 @@ window.__ModuleLoader__.load({
973
1169
  setSecretError(t('keyWriteFailed').replace('{msg}', msg));
974
1170
  return;
975
1171
  }
976
- fetch('/dsh-key-rotation/key', {
1172
+ await fetch('/dsh-key-rotation/key', {
977
1173
  method: 'PUT',
978
1174
  headers: { 'content-type': 'application/json' },
979
1175
  body: JSON.stringify({ ref, value }),
@@ -988,11 +1184,12 @@ window.__ModuleLoader__.load({
988
1184
  }
989
1185
  setSecretDraft((cur) => {
990
1186
  const next = { ...cur };
991
- delete next[rowKey];
1187
+ if (next[rowKey] === value) delete next[rowKey];
992
1188
  return next;
993
1189
  });
994
1190
  })
995
1191
  .catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
1192
+ } finally { secretWritesRef.current.delete(ref); }
996
1193
  };
997
1194
  // #290: explicit card states — loading / error / unavailable / empty / ready
998
1195
  if (state.status === 'loading' || (!val && state.status !== 'error' && state.status !== 'unavailable')) {
@@ -1008,13 +1205,13 @@ window.__ModuleLoader__.load({
1008
1205
  })
1009
1206
  );
1010
1207
  }
1011
- if (state.status === 'error') {
1208
+ if (state.status === 'error' && !val) {
1012
1209
  return React.createElement('div', { className: 'krot-state-block', role: 'alert', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '10px', alignItems: 'flex-start' } },
1013
1210
  React.createElement('p', { className: 'krot-err', style: { margin: 0 } }, t('errorTitle') + (state.error ? (': ' + state.error) : '')),
1014
1211
  React.createElement('button', { type: 'button', className: 'krot-btn', onClick: () => load() }, t('retry'))
1015
1212
  );
1016
1213
  }
1017
- if (state.status === 'unavailable' || !configForms) {
1214
+ if (state.status === 'unavailable' || !settingsScope) {
1018
1215
  return React.createElement('div', { className: 'krot-state-block', role: 'status', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '6px' } },
1019
1216
  React.createElement('p', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', margin: 0 } }, t('unavailableTitle')),
1020
1217
  React.createElement('p', { className: 'krot-hint', style: { margin: 0 } }, t('unavailableDesc'))
@@ -1024,8 +1221,73 @@ window.__ModuleLoader__.load({
1024
1221
  const providers = catalog.providers;
1025
1222
  const providerById = new Map(providers.map((p) => [p.id, p.name]));
1026
1223
 
1027
- const setField = (fn) => setDraft(fn(val));
1028
- const providerList = Array.isArray(val.providers) ? val.providers.filter((p) => Array.isArray(p.keys) && p.keys.length > 0).filter((p) => !search || p.provider.toLowerCase().includes(search.toLowerCase())) : [];
1224
+ const setField = (fn, fromImport = false) => {
1225
+ if (!mountedRef.current || saveInFlightRef.current || (importRef.current && !fromImport)
1226
+ || !stateRef.current.writable) return;
1227
+ const current = draftRef.current ?? stateRef.current.value;
1228
+ if (!current) return;
1229
+ const next = fn(current);
1230
+ if (sameJson(current, next)) return;
1231
+ if (!draftBaseRef.current) {
1232
+ draftBaseRef.current = { value: structuredClone(current), revision: stateRef.current.revision,
1233
+ source: bindingRef.current.service };
1234
+ }
1235
+ const unchanged = sameJson(draftBaseRef.current.value, next);
1236
+ if (unchanged) draftBaseRef.current = null;
1237
+ draftRef.current = unchanged ? null : next;
1238
+ setDraft(draftRef.current);
1239
+ updateState((s) => ({ ...s, error: '' }));
1240
+ };
1241
+ const clearDraft = () => {
1242
+ editEpochRef.current++;
1243
+ draftBaseRef.current = null;
1244
+ draftRef.current = null;
1245
+ setDraft(null);
1246
+ setUndo(null);
1247
+ setSelected(new Set());
1248
+ if (undoTimer.current) clearTimeout(undoTimer.current);
1249
+ };
1250
+ const readImport = (file, stage) => {
1251
+ if (!file || saveInFlightRef.current || importRef.current || !stateRef.current.writable) return;
1252
+ if (file.size > 1024 * 1024) { setSecretError('Import file exceeds 1 MiB'); return; }
1253
+ const epoch = editEpochRef.current;
1254
+ const source = bindingRef.current.service;
1255
+ importRef.current = true;
1256
+ setImporting(true);
1257
+ const reader = new FileReader();
1258
+ let finished = false;
1259
+ const finish = () => {
1260
+ if (finished) return;
1261
+ finished = true;
1262
+ clearTimeout(timeout);
1263
+ importCancelRef.current = null;
1264
+ importRef.current = false;
1265
+ if (mountedRef.current) setImporting(false);
1266
+ };
1267
+ const timeout = setTimeout(() => {
1268
+ if (mountedRef.current) setSecretError('Import file read timed out');
1269
+ finish(); reader.abort?.();
1270
+ }, 15000);
1271
+ importCancelRef.current = () => { finish(); reader.abort?.(); };
1272
+ reader.onload = () => {
1273
+ if (finished) return;
1274
+ try {
1275
+ if (!mountedRef.current || epoch !== editEpochRef.current || source !== bindingRef.current.service) return;
1276
+ const value = JSON.parse(String(reader.result));
1277
+ requireJson(value);
1278
+ stage(value);
1279
+ } catch (error) {
1280
+ if (mountedRef.current) setSecretError(String(error?.message ?? error));
1281
+ } finally { finish(); }
1282
+ };
1283
+ reader.onerror = reader.onabort = () => { if (mountedRef.current) setSecretError('Could not read the import file'); finish(); };
1284
+ try { reader.readAsText(file); }
1285
+ catch (error) { if (mountedRef.current) setSecretError(String(error?.message ?? error)); finish(); }
1286
+ };
1287
+ const providerList = (Array.isArray(val.providers) ? val.providers : [])
1288
+ .map((entry, pIndex) => ({ entry, pIndex }))
1289
+ .filter(({ entry }) => entry && typeof entry.provider === 'string')
1290
+ .filter(({ entry }) => !search || entry.provider.toLowerCase().includes(search.toLowerCase()));
1029
1291
 
1030
1292
  const setProvider = (index, id) => setField((cur) => {
1031
1293
  const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
@@ -1038,20 +1300,21 @@ window.__ModuleLoader__.load({
1038
1300
  const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
1039
1301
  entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
1040
1302
  // keep weights aligned with keys (#215): new key gets default weight 1
1041
- if (Array.isArray(entry.weights) && entry.weights.length > 0) entry.weights = [...entry.weights, 1];
1303
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
1304
+ if (Array.isArray(entry[field]) && entry[field].length) entry[field] = Array.from({ length: entry.keys.length }, (_, i) => entry[field][i] ?? fallback);
1305
+ }
1042
1306
  providers[pIndex] = entry;
1043
1307
  return { ...cur, providers };
1044
1308
  });
1045
- const removeKey = (pIndex, kIndex) => { setField((cur) => {
1046
- const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
1047
- stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
1048
- next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
1049
- // weights are positional - drop along with the key (#215)
1050
- if (Array.isArray(next[pIndex].weights) && next[pIndex].weights.length > 0) {
1051
- next[pIndex] = { ...next[pIndex], weights: next[pIndex].weights.filter((_, i) => i !== kIndex) };
1052
- }
1309
+ const removeKey = (pIndex, kIndex) => setField((cur) => {
1310
+ const next = [...(cur.providers ?? [])];
1311
+ const entry = next[pIndex];
1312
+ if (!entry || !entry.keys[kIndex]) return cur;
1313
+ stashUndo({ type: 'key', provider: entry.provider, kIndex, key: entry.keys[kIndex],
1314
+ weights: entry.weights?.[kIndex], expiresAt: entry.expiresAt?.[kIndex] });
1315
+ next[pIndex] = reorderKeys(entry, entry.keys.map((_, index) => index).filter(index => index !== kIndex));
1053
1316
  return { ...cur, providers: next };
1054
- }); };
1317
+ });
1055
1318
  const removeProvider = (pIndex) => setField((cur) => {
1056
1319
  const arr = Array.isArray(cur.providers) ? cur.providers : [];
1057
1320
  stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
@@ -1060,23 +1323,13 @@ window.__ModuleLoader__.load({
1060
1323
  // Key order is attempt order — move it with buttons, do not retype names.
1061
1324
  const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
1062
1325
  const providers = [...(cur.providers ?? [])];
1063
- const entry = { ...(providers[pIndex] ?? {}) };
1064
- const keys = [...(entry.keys ?? [])];
1326
+ const entry = providers[pIndex];
1327
+ if (!entry) return cur;
1328
+ const order = entry.keys.map((_, index) => index);
1065
1329
  const target = kIndex + delta;
1066
- if (target < 0 || target >= keys.length) return cur;
1067
- const moved = keys[kIndex];
1068
- keys[kIndex] = keys[target];
1069
- keys[target] = moved;
1070
- entry.keys = keys;
1071
- // weights are positional - swap along with the keys (#215)
1072
- const weights = [...(entry.weights ?? [])];
1073
- if (weights.length > 0) {
1074
- const w = weights[kIndex];
1075
- weights[kIndex] = weights[target];
1076
- weights[target] = w;
1077
- entry.weights = weights;
1078
- }
1079
- providers[pIndex] = entry;
1330
+ if (target < 0 || target >= order.length) return cur;
1331
+ [order[kIndex], order[target]] = [order[target], order[kIndex]];
1332
+ providers[pIndex] = reorderKeys(entry, order);
1080
1333
  return { ...cur, providers };
1081
1334
  });
1082
1335
  // #215: set a single key's round-robin weight (integer >= 1)
@@ -1104,48 +1357,80 @@ window.__ModuleLoader__.load({
1104
1357
 
1105
1358
  const addProvider = () => setField((cur) => ({
1106
1359
  ...cur,
1107
- providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [''] }],
1360
+ providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [] }],
1108
1361
  }));
1109
1362
 
1110
- const save = () => {
1111
- if (!draft) return;
1112
- setState((s) => ({ ...s, status: 'saving', error: '' }));
1113
- const saveViaBridge = () => {
1114
- fetch(CONFIG_PATH, {
1115
- method: 'PUT',
1116
- headers: { 'content-type': 'application/json' },
1117
- body: JSON.stringify({ section: draft, expectedRevision: state.revision }),
1118
- })
1119
- .then((r) => r.json())
1120
- .then((data) => {
1121
- if (data.error) {
1122
- setState((s) => ({ ...s, status: 'error', error: data.error.message }));
1123
- return;
1124
- }
1125
- setState((s) => ({ status: 'ready', value: data.value ?? draft, revision: data.revision ?? s.revision, error: '', providers: s.providers }));
1126
- setDraft(null);
1127
- })
1128
- .catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
1129
- };
1130
- if (configForms && typeof configForms.set === 'function') {
1131
- (async () => {
1132
- try {
1133
- for (const [k, v] of Object.entries(draft)) {
1134
- await configForms.set(k, v);
1135
- }
1136
- setState((s) => ({ ...s, status: 'ready', value: draft, error: '' }));
1137
- setDraft(null);
1138
- } catch (_) {
1139
- saveViaBridge();
1140
- }
1141
- })();
1363
+ const save = async () => {
1364
+ const savingDraft = draftRef.current;
1365
+ const base = draftBaseRef.current;
1366
+ if (!savingDraft || !base || saveInFlightRef.current || importRef.current) return;
1367
+ if (!stateRef.current.writable || bindingRef.current.service !== base.source) {
1368
+ updateState((s) => ({ ...s, error: t('settingsChanged') }));
1369
+ return;
1370
+ }
1371
+ const expectedRevision = base.revision;
1372
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
1373
+ updateState((s) => ({ ...s, error: t('settingsChanged') }));
1142
1374
  return;
1143
1375
  }
1144
- saveViaBridge();
1376
+ let ops;
1377
+ try {
1378
+ ops = changedSettingsOps(base.value, savingDraft); requireJson(ops);
1379
+ if (ops.some(op => op.path[0] === 'providers')) validatePoolDraft(savingDraft.providers);
1380
+ }
1381
+ catch (error) { updateState((s) => ({ ...s, error: error.message })); return; }
1382
+ if (!ops.length) { clearDraft(); return; }
1383
+ saveInFlightRef.current = true;
1384
+ setSaving(true);
1385
+ editEpochRef.current++;
1386
+ readRef.current.generation++;
1387
+ readRef.current.controller?.abort();
1388
+ updateState((s) => ({ ...s, error: '' }));
1389
+ const controller = new AbortController();
1390
+ saveControllerRef.current = controller;
1391
+ let timeout;
1392
+ try {
1393
+ const expired = new Promise((_, reject) => {
1394
+ timeout = setTimeout(() => {
1395
+ controller.abort();
1396
+ reject(new Error(t('saveUnconfirmed')));
1397
+ }, 15000);
1398
+ });
1399
+ const operation = async () => {
1400
+ if (settingsContract === 'configForm' && typeof base.source.mutate === 'function') {
1401
+ const accepted = await base.source.mutate(ops, expectedRevision);
1402
+ if (accepted !== true) throw new Error(t('saveRefused'));
1403
+ const committed = base.source.getSnapshot();
1404
+ // Shared native queues can settle before publishing this write's
1405
+ // snapshot. Confirm an older/absent snapshot through the bridge.
1406
+ if (committed?.status === 'ready' && committed.revision > expectedRevision) return committed;
1407
+ return settingsResponse(await fetch(CONFIG_PATH, { headers: { accept: 'application/json' }, signal: controller.signal }));
1408
+ }
1409
+ // Legacy client mutate resolves void on BOTH success and refusal.
1410
+ // Host-side mutate, reached through this bridge, throws on refusal.
1411
+ return settingsResponse(await fetch(CONFIG_PATH, {
1412
+ method: 'PUT', headers: { 'content-type': 'application/json' },
1413
+ body: JSON.stringify({ ops, expectedRevision }), signal: controller.signal,
1414
+ }));
1415
+ };
1416
+ const committed = await Promise.race([operation(), expired]);
1417
+ if (!mountedRef.current) return;
1418
+ if (bindingRef.current.service !== base.source) throw new Error(t('saveUnconfirmed'));
1419
+ if (committed?.available === false || committed?.status === 'unavailable'
1420
+ || !committed?.value || !Number.isSafeInteger(committed.revision)
1421
+ || committed.revision < expectedRevision) throw new Error(t('saveUnconfirmed'));
1422
+ acceptSnapshot(committed, base.source);
1423
+ clearDraft();
1424
+ } catch (error) {
1425
+ if (mountedRef.current) updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: String(error?.message ?? error) }));
1426
+ } finally {
1427
+ clearTimeout(timeout);
1428
+ saveInFlightRef.current = false;
1429
+ if (saveControllerRef.current === controller) saveControllerRef.current = null;
1430
+ if (mountedRef.current) setSaving(false);
1431
+ }
1145
1432
  };
1146
1433
 
1147
-
1148
-
1149
1434
  const field = (labelText, node) => h('label', { className: 'krot-field' },
1150
1435
  h('span', { className: 'krot-label' }, labelText), node);
1151
1436
 
@@ -1156,12 +1441,13 @@ window.__ModuleLoader__.load({
1156
1441
  placeholder,
1157
1442
  });
1158
1443
 
1159
- const btn = (labelText, onClick, opts) => h('button', {
1160
- className: 'krot-btn' + (opts && opts.primary ? ' krot-save' : ''),
1161
- onClick,
1162
- disabled: Boolean(opts && opts.disabled),
1163
- title: (opts && opts.title) || undefined,
1164
- }, labelText);
1444
+ const btn = (labelText, onClick, opts = {}) => {
1445
+ const { primary, className = '', ...rest } = opts;
1446
+ return h('button', { type: 'button', ...rest,
1447
+ className: 'krot-btn' + (primary ? ' krot-save' : '') + (className ? ' ' + className : ''),
1448
+ onClick, disabled: Boolean(rest.disabled),
1449
+ }, labelText);
1450
+ };
1165
1451
 
1166
1452
  // Key state dot: color and label read at a glance; "key not found"
1167
1453
  // catches an env-name typo that would otherwise stay silent.
@@ -1187,8 +1473,8 @@ window.__ModuleLoader__.load({
1187
1473
  };
1188
1474
 
1189
1475
  const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
1190
- const providerRows = providerList.map((entry, pIndex) => {
1191
- const options = [];
1476
+ const providerRows = providerList.map(({ entry, pIndex }) => {
1477
+ const options = [h('option', { key: '', value: '' }, t('chooseProvider'))];
1192
1478
  if (entry.provider && !providerById.has(entry.provider)) {
1193
1479
  options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + (catalog.status === 'ready' ? t('notRegistered')
1194
1480
  : catalog.status === 'loading' ? t('loading') : t('providerCatalogUnavailable')) + ')'));
@@ -1230,7 +1516,7 @@ window.__ModuleLoader__.load({
1230
1516
  const keyRows = filteredIndices.map(({ key, kIndex }) => {
1231
1517
  const st = keyStatus(entry.provider, key);
1232
1518
  const info = keyInfo(entry.provider, key);
1233
- const rowKey = entry.provider + '/' + kIndex;
1519
+ const rowKey = entry.provider + '/' + key;
1234
1520
  const typed = secretDraft[rowKey];
1235
1521
  const fromEnv = Boolean(info && info.source === 'env');
1236
1522
 
@@ -1261,7 +1547,7 @@ window.__ModuleLoader__.load({
1261
1547
  placeholder: info && info.tail ? '••••' + info.tail : t('keyValuePlaceholder'),
1262
1548
  onChange: (e) => setSecretDraft((cur) => ({ ...cur, [rowKey]: e.target.value })),
1263
1549
  }));
1264
- if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
1550
+ if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'save-secret' }));
1265
1551
  }
1266
1552
  if (info && typeof info.usage === 'number' && info.usage > 0) {
1267
1553
  let tip = 'requests through this key';
@@ -1318,7 +1604,7 @@ window.__ModuleLoader__.load({
1318
1604
  btn('✕', () => removeKey(pIndex, kIndex), { title: t('removeKey') }),
1319
1605
  ));
1320
1606
 
1321
- return h('div', { key: kIndex, className: 'krot-key' },
1607
+ return h('div', { key, className: 'krot-key' },
1322
1608
  nameRow,
1323
1609
  h('div', { className: 'krot-meta' }, meta),
1324
1610
  );
@@ -1440,8 +1726,9 @@ window.__ModuleLoader__.load({
1440
1726
  setField((cur) => {
1441
1727
  const next = [...(cur.providers ?? [])];
1442
1728
  if (!next[pIndex]) return cur;
1443
- const sorted = [...(next[pIndex].keys ?? [])].sort((a, b) => usageOf(b) - usageOf(a));
1444
- next[pIndex] = { ...next[pIndex], keys: sorted };
1729
+ const entry = next[pIndex];
1730
+ const order = entry.keys.map((_, index) => index).sort((a, b) => usageOf(entry.keys[b]) - usageOf(entry.keys[a]));
1731
+ next[pIndex] = reorderKeys(entry, order);
1445
1732
  return { ...cur, providers: next };
1446
1733
  });
1447
1734
  }, { title: t('sortUsage') }),
@@ -1485,7 +1772,7 @@ window.__ModuleLoader__.load({
1485
1772
  h('div', { className: 'krot-keys' }, keyRows),
1486
1773
  h(UpdaterSection, { t }),
1487
1774
  h('div', { className: 'krot-foot' },
1488
- btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
1775
+ btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
1489
1776
  switchesLine,
1490
1777
  budgetLine,
1491
1778
  sloLine,
@@ -1599,10 +1886,10 @@ window.__ModuleLoader__.load({
1599
1886
  });
1600
1887
  }, { disabled: selected.size === 0 || !bulkCooldown }),
1601
1888
  btn(t('bulkRemove'), () => {
1602
- const ids = [...selected];
1603
- if (!ids.length) return;
1889
+ const ids = new Set(selected);
1890
+ if (!ids.size) return;
1604
1891
  setConfirmModal({
1605
- title: t('confirmBulkRemoveTitle').replace('{n}', String(ids.length)),
1892
+ title: t('confirmBulkRemoveTitle').replace('{n}', String(ids.size)),
1606
1893
  desc: t('confirmBulkRemoveDesc'),
1607
1894
  actionLabel: t('bulkRemove'),
1608
1895
  danger: true,
@@ -1661,11 +1948,11 @@ window.__ModuleLoader__.load({
1661
1948
  field(t('selfHealingLabel'), textInput(String(val.selfHealingIntervalMinutes ?? 30), (v) => setField((cur) => ({ ...cur, selfHealingIntervalMinutes: Number(v) || 0 })))),
1662
1949
  field(t('proactiveGuardLabel'), h('label', { style: { display: 'flex', alignItems: 'center', gap: '8px', height: '36px', cursor: 'pointer' } }, [
1663
1950
  h('input', {
1664
- type: 'checkbox',
1951
+ key: 'input', type: 'checkbox',
1665
1952
  checked: val.proactiveRateLimitGuard ?? true,
1666
1953
  onChange: (e) => setField((cur) => ({ ...cur, proactiveRateLimitGuard: e.target.checked })),
1667
1954
  }),
1668
- h('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('proactiveGuardLabel')),
1955
+ h('span', { key: 'label', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('proactiveGuardLabel')),
1669
1956
  ]))
1670
1957
  )
1671
1958
  );
@@ -1683,15 +1970,15 @@ window.__ModuleLoader__.load({
1683
1970
  const a = document.createElement('a'); a.href = url; a.download = 'pools.json'; a.click(); URL.revokeObjectURL(url);
1684
1971
  }, {}),
1685
1972
  h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('importPools'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
1686
- const f = e.target.files[0]; if (!f) return;
1687
- const reader = new FileReader();
1688
- reader.onload = () => { try { const imp = JSON.parse(String(reader.result)); if (!Array.isArray(imp)) throw new Error('expected array'); setField((cur) => {
1689
- const curProviders = Array.isArray(cur.providers) ? [...cur.providers] : [];
1690
- const map = new Map(curProviders.map((p) => [p.provider, p]));
1691
- for (const p of imp) { if (p && typeof p.provider === 'string') map.set(p.provider, p); }
1692
- return { ...cur, providers: [...map.values()] };
1693
- }); } catch (err) { setSecretError(String(err.message || err)); } };
1694
- reader.readAsText(f);
1973
+ const file = e.target.files[0]; e.target.value = '';
1974
+ readImport(file, (imported) => {
1975
+ validatePoolDraft(imported);
1976
+ setField((cur) => {
1977
+ const map = new Map((cur.providers ?? []).map((p) => [p.provider, p]));
1978
+ for (const p of imported) map.set(p.provider, p);
1979
+ return { ...cur, providers: [...map.values()] };
1980
+ }, true);
1981
+ });
1695
1982
  } })),
1696
1983
  btn(t('snapshotExport'), () => {
1697
1984
  fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
@@ -1704,19 +1991,16 @@ window.__ModuleLoader__.load({
1704
1991
  .catch((e) => setSecretError(String(e?.message ?? e)));
1705
1992
  }, {}),
1706
1993
  h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('snapshotImport'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
1707
- const f = e.target.files[0]; if (!f) return;
1708
- const reader2 = new FileReader();
1709
- reader2.onload = () => {
1710
- try {
1711
- const snap = JSON.parse(String(reader2.result));
1712
- if (!snap || typeof snap !== 'object' || Array.isArray(snap)) throw new Error('expected snapshot object');
1713
- fetch('/dsh-key-rotation/snapshot', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ snapshot: snap }) })
1714
- .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
1715
- .then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'import failed'); load(); })
1716
- .catch((err) => setSecretError(t('keyWriteFailed').replace('{msg}', String(err?.message ?? err))));
1717
- } catch (err) { setSecretError(String(err.message || err)); }
1718
- };
1719
- reader2.readAsText(f);
1994
+ const file = e.target.files[0]; e.target.value = '';
1995
+ readImport(file, (imported) => {
1996
+ const snap = imported?.snapshot ?? imported;
1997
+ if (!snap || typeof snap !== 'object' || Array.isArray(snap)) throw new Error('Expected a settings snapshot');
1998
+ if (Object.prototype.hasOwnProperty.call(snap, 'providers')) validatePoolDraft(snap.providers);
1999
+ const patch = { ...snap };
2000
+ // Exported empty secret fields mean "not included", not "erase".
2001
+ if (!patch.webhookActionToken) delete patch.webhookActionToken;
2002
+ setField((cur) => ({ ...cur, ...patch }), true);
2003
+ });
1720
2004
  } }))
1721
2005
  ),
1722
2006
  h('p', { className: 'krot-hint', style: { marginTop: '4px' } }, t('keyHint'))
@@ -1724,7 +2008,7 @@ window.__ModuleLoader__.load({
1724
2008
 
1725
2009
  const alerts = [
1726
2010
  secretError ? h('div', { key: 'se', className: 'krot-alert-bad' }, secretError) : null,
1727
- state.error ? h('div', { key: 'st', className: 'krot-alert-bad' }, state.error) : null,
2011
+ state.error ? h('div', { key: 'st', role: 'alert', className: 'krot-alert-bad' }, state.error) : null,
1728
2012
  undo ? h('div', { key: 'un', className: 'krot-alert-ok' },
1729
2013
  h('span', null, undo.type === 'provider' ? t('undoProvider') : t('undoKey')),
1730
2014
  btn(t('undo'), doUndo, { className: 'krot-btn-sm' })
@@ -1773,19 +2057,28 @@ window.__ModuleLoader__.load({
1773
2057
  h('div', { className: 'krot-page-sub' }, t('header.sub'))
1774
2058
  );
1775
2059
 
1776
- return h('div', { className: 'krot' },
2060
+ return h('div', { className: 'krot', 'aria-busy': saving || importing },
1777
2061
  confirmModalEl,
1778
2062
  headerSection,
1779
2063
  statsSection,
1780
- poolsSection,
1781
- failoverSection,
1782
- timingSection,
1783
- backupSection,
2064
+ h('fieldset', { disabled: saving || importing || !state.writable,
2065
+ style: { border: 0, padding: 0, margin: 0, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '14px' } },
2066
+ poolsSection,
2067
+ failoverSection,
2068
+ timingSection,
2069
+ backupSection,
2070
+ ),
1784
2071
  alerts.length > 0 ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } }, alerts) : null,
1785
2072
  h('div', { className: 'krot-foot', style: { marginTop: '6px', paddingTop: '14px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1786
- btn(t('save'), save, { primary: true }),
1787
- btn(t('discard'), load, {}),
1788
- state.status === 'saving' ? h('span', { className: 'krot-hint' }, t('saving')) : null,
2073
+ btn(t('save'), save, { primary: true, disabled: saving || importing || !state.writable || !draft }),
2074
+ btn(t('discard'), () => {
2075
+ if (saveInFlightRef.current || importRef.current) return;
2076
+ clearDraft();
2077
+ setSecretError('');
2078
+ void load();
2079
+ }, { disabled: saving || importing }),
2080
+ saving || importing ? h('span', { className: 'krot-hint' }, t(saving ? 'saving' : 'loading')) : null,
2081
+ !state.writable ? h('span', { className: 'krot-hint' }, t('readOnly')) : null,
1789
2082
  )
1790
2083
  );
1791
2084
  }
@@ -1867,6 +2160,7 @@ window.__ModuleLoader__.load({
1867
2160
  }
1868
2161
 
1869
2162
  function apply(ctx) {
2163
+ const settingsSource = createSettingsSource(ctx);
1870
2164
  ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-key-rotation: dictionaries');
1871
2165
  // Header chip (#201): status dot in the session header utilities slot,
1872
2166
  // same slot dsh-gitea / dsh-subscriptions use for their header widgets.
@@ -1908,35 +2202,24 @@ window.__ModuleLoader__.load({
1908
2202
  h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
1909
2203
  (page || open) ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
1910
2204
  }
1911
- // #275: card only — no top-level sidebar row. The host sidebar is a
1912
- // shared flat list; a duplicate row wastes a slot and confuses Settings.
1913
- // Plugin-list seat (plugins.item) first: it is what the current core
1914
- // (0.1.6-alpha.2) renders as the plugin's own page with its configuration. The
1915
- // label is a static string on purpose — it is resolved while the page renders,
1916
- // and a locale lookup there would take the whole client batch down with it.
1917
- try {
1918
- ctx.slots.inject('plugins.item', () =>
1919
- ctx.slots.register(
1920
- { name: 'plugins.item', id: ROW_ID, order: 60, label: () => 'Key Rotation', locale: NS, inject: () => ({ ctx }) },
1921
- KeyRotationCard,
1922
- ));
1923
- // Row seat and the legacy seat stay as fallbacks.
1924
- ctx.slots.inject('plugins.row.config', () =>
1925
- ctx.slots.register(
1926
- { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, inject: () => ({ ctx }) },
1927
- KeyRotationCard,
1928
- ));
1929
- ctx.slots.inject('settings.plugin.item', () =>
1930
- ctx.slots.register(
1931
- { name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
1932
- KeyRotationCard,
2205
+ // Register each available seat independently. A missing legacy seat must
2206
+ // not prevent the current bundle or row seat from registering.
2207
+ for (const [name, key] of [
2208
+ ['plugins.bundle.config', PKG],
2209
+ ['plugins.row.config', ROW_CONFIG_KEY],
2210
+ ['settings.plugin.item', NS],
2211
+ ]) {
2212
+ try {
2213
+ ctx.slots.inject(name, () => ctx.slots.register(
2214
+ { name, key, locale: NS, inject: () => ({ ctx, settingsSource }) }, KeyRotationCard,
1933
2215
  ));
1934
- } catch (e) {
1935
- bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings seats register failed', e); });
2216
+ } catch (error) {
2217
+ bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings seat unavailable', name, error); });
2218
+ }
1936
2219
  }
1937
2220
  }
1938
2221
 
1939
- module.exports = { apply, inject: ['slots', 'locale', 'configForms'] };
2222
+ module.exports = { apply, inject: ['slots', 'locale'] };
1940
2223
  return module.exports;
1941
2224
  },
1942
2225
  updateCheck: 'Check for updates',