dsh-webdav-sync 1.0.3 → 1.0.5
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/README.md +38 -19
- package/lib/client.js +109 -31
- package/lib/dirs.d.ts +1 -1
- package/lib/dirs.js +1 -1
- package/lib/engine.d.ts +22 -5
- package/lib/engine.js +136 -34
- package/lib/index.js +30 -10
- package/lib/keys.d.ts +125 -0
- package/lib/keys.js +168 -0
- package/lib/state.js +19 -6
- package/lib/store.d.ts +18 -8
- package/lib/store.js +23 -33
- package/lib/wire.d.ts +90 -16
- package/lib/wire.js +12 -1
- package/package.json +2 -2
package/lib/engine.js
CHANGED
|
@@ -22,14 +22,15 @@ import { dirsDiffer, dirsPartOf, readDirs, writeDirs } from './dirs.js';
|
|
|
22
22
|
import { applyManifest, applyPatch, collect, isMissing, keepAside, message, missingPlugins, readManifest, readPatch, writeIfChanged, } from './store.js';
|
|
23
23
|
import { cached, ensureStateDir, keptDir, loadConfig, loadState, normaliseUrl, releasePassword, remember, sameAccount, saveConfig, saveState, stateKey, syncsPart, usable, } from './state.js';
|
|
24
24
|
import { resolvePaths } from './paths.js';
|
|
25
|
+
import { applyKeys, readKeys } from './keys.js';
|
|
25
26
|
/**
|
|
26
27
|
* This computer's whole setup, as parts.
|
|
27
28
|
*
|
|
28
|
-
* The
|
|
29
|
-
* so they arrive separately and are put back together here.
|
|
30
|
-
* server's `dirs` part, and it is used for the directories this
|
|
31
|
-
* read — see {@link dirsPartOf} for why carrying those beats
|
|
32
|
-
* empty ones.
|
|
29
|
+
* The profile parts are read once, and the keys with them; the extra directories
|
|
30
|
+
* have to be walked, so they arrive separately and are put back together here.
|
|
31
|
+
* `carried` is the server's `dirs` part, and it is used for the directories this
|
|
32
|
+
* computer could not read — see {@link dirsPartOf} for why carrying those beats
|
|
33
|
+
* reading them as empty ones.
|
|
33
34
|
*/
|
|
34
35
|
function partsOf(snapshot, carried) {
|
|
35
36
|
const dirs = dirsPartOf(snapshot.dirs, carried);
|
|
@@ -54,9 +55,9 @@ function serially(work) {
|
|
|
54
55
|
queue = next.then(() => undefined, () => undefined);
|
|
55
56
|
return next;
|
|
56
57
|
}
|
|
57
|
-
/** The engine for a profile directory and
|
|
58
|
-
export function engineFor(paths, config) {
|
|
59
|
-
return { paths, config };
|
|
58
|
+
/** The engine for a profile directory, a configuration, and the host's credential store. */
|
|
59
|
+
export function engineFor(paths, config, credentials) {
|
|
60
|
+
return { paths, config, credentials };
|
|
60
61
|
}
|
|
61
62
|
// ---------------------------------------------------------------------------
|
|
62
63
|
// Sync
|
|
@@ -162,7 +163,12 @@ async function syncOnce(engine, state, force) {
|
|
|
162
163
|
}
|
|
163
164
|
state.blind = undefined;
|
|
164
165
|
}
|
|
165
|
-
const snapshot = await take(paths, config);
|
|
166
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
167
|
+
if (snapshot.keysProblems.length === 0 && state.local?.keys !== undefined) {
|
|
168
|
+
// Older state files predate the explicit marker; an existing keys hash is
|
|
169
|
+
// already a trustworthy baseline, even before this run measures it again.
|
|
170
|
+
state.keysBaseline = true;
|
|
171
|
+
}
|
|
166
172
|
let remote = await openRemote(engine, state, read);
|
|
167
173
|
const first = state.local === undefined;
|
|
168
174
|
// A directory could not be read this run: its part stands in for the server's
|
|
@@ -174,7 +180,11 @@ async function syncOnce(engine, state, force) {
|
|
|
174
180
|
&& sameHashes(hashesOf(partsOf(snapshot, remote.part('dirs'))), state.local)
|
|
175
181
|
&& !force) {
|
|
176
182
|
// Neither side moved: the last sync is still the state of the world.
|
|
177
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
here: [], there: [], pushed: false, reloadNeeded: false,
|
|
185
|
+
...keyProblems(snapshot.keysProblems),
|
|
186
|
+
...(await pendings(engine, snapshot.parts.plugins)),
|
|
187
|
+
};
|
|
178
188
|
}
|
|
179
189
|
if (remote.notModified) {
|
|
180
190
|
// The server said the file is the one last read, which was worth knowing for
|
|
@@ -184,6 +194,10 @@ async function syncOnce(engine, state, force) {
|
|
|
184
194
|
}
|
|
185
195
|
const carried = remote.part('dirs');
|
|
186
196
|
const localParts = partsOf(snapshot, carried);
|
|
197
|
+
// A failed credential read is unknown, not a deletion or a partial replacement.
|
|
198
|
+
// Defer this part while the other parts sync, carrying the server's copy below.
|
|
199
|
+
if (snapshot.keysProblems.length > 0)
|
|
200
|
+
delete localParts.keys;
|
|
187
201
|
const local = hashesOf(localParts);
|
|
188
202
|
// The server holds no file of ours. This computer's setup becomes the first
|
|
189
203
|
// version, which is what joining an empty folder means.
|
|
@@ -205,10 +219,10 @@ async function syncOnce(engine, state, force) {
|
|
|
205
219
|
for (const part of PARTS) {
|
|
206
220
|
const remoteHas = remote.hashes[part] !== undefined;
|
|
207
221
|
const remotePart = () => remote.part(part);
|
|
208
|
-
if (!syncsPart(config, part)) {
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
222
|
+
if (!syncsPart(config, part) || (part === 'keys' && snapshot.keysProblems.length > 0)) {
|
|
223
|
+
// Disabled or unreadable: carry the server's copy untouched. In particular,
|
|
224
|
+
// a missing/locked credential service must not look like local key deletion
|
|
225
|
+
// and erase the keys already uploaded by a successful manual sync.
|
|
212
226
|
if (remoteHas)
|
|
213
227
|
merged[part] = remotePart();
|
|
214
228
|
continue;
|
|
@@ -217,7 +231,8 @@ async function syncOnce(engine, state, force) {
|
|
|
217
231
|
// the last sync — not merely when its presence changed. Comparing presence
|
|
218
232
|
// would read an edit made only on the other computer as "unchanged" and
|
|
219
233
|
// quietly keep this computer's older copy.
|
|
220
|
-
const
|
|
234
|
+
const missingKeyBaseline = !first && state.keysBaseline !== true && part === 'keys';
|
|
235
|
+
const changedHere = !first && !missingKeyBaseline && local[part] !== state.local?.[part];
|
|
221
236
|
const changedThere = first || remote.hashes[part] !== state.remote?.[part];
|
|
222
237
|
// A part the other computer took away is taken away here too — but never the
|
|
223
238
|
// plugin list, which is what loads the shell, and never on a first sync,
|
|
@@ -326,11 +341,12 @@ async function syncOnce(engine, state, force) {
|
|
|
326
341
|
const pushNeeded = force || remote.rebuilt === true || !sameHashes(mergedHashes, remote.hashes);
|
|
327
342
|
if (!pushNeeded) {
|
|
328
343
|
const written = await applyMerged(engine, snapshot, applied);
|
|
329
|
-
state.remote = remote.hashes;
|
|
330
344
|
// Read back rather than assume: a directory whose file was just written — or
|
|
331
345
|
// removed — has to be hashed as it now is, or the next sync would see an edit
|
|
332
|
-
// that this one made itself and call it a conflict.
|
|
333
|
-
|
|
346
|
+
// that this one made itself and call it a conflict. Keep the key baselines if
|
|
347
|
+
// this read could not measure them, so recovery still sees pending edits.
|
|
348
|
+
const after = await take(paths, config, engine.credentials);
|
|
349
|
+
recordHashes(state, hashesOf(partsOf(after, undefined)), remote.hashes, snapshot.keysProblems.length === 0 && after.keysProblems.length === 0);
|
|
334
350
|
// A part can come in without the file needing to be written: when only the
|
|
335
351
|
// server moved, the merge agrees with it. The person still has to be told
|
|
336
352
|
// what changed here, so the notice is recorded on this path too — clearing
|
|
@@ -350,6 +366,7 @@ async function syncOnce(engine, state, force) {
|
|
|
350
366
|
...(saved === undefined ? {} : { saved }),
|
|
351
367
|
pushed: false,
|
|
352
368
|
reloadNeeded: written.reloadNeeded,
|
|
369
|
+
...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
|
|
353
370
|
...(await pendings(engine, applied.plugins)),
|
|
354
371
|
};
|
|
355
372
|
}
|
|
@@ -411,7 +428,7 @@ function unreadablePart(bundle) {
|
|
|
411
428
|
if (!isObject(value))
|
|
412
429
|
return part;
|
|
413
430
|
const record = value;
|
|
414
|
-
if (part === 'models'
|
|
431
|
+
if (part === 'models') {
|
|
415
432
|
if (!isObject(record.config))
|
|
416
433
|
return part;
|
|
417
434
|
continue;
|
|
@@ -427,6 +444,17 @@ function unreadablePart(bundle) {
|
|
|
427
444
|
return part;
|
|
428
445
|
continue;
|
|
429
446
|
}
|
|
447
|
+
if (part === 'keys') {
|
|
448
|
+
// Ref names and their values, both strings: anything else is a file this
|
|
449
|
+
// plugin did not write, and reading it as empty would be a lie about what
|
|
450
|
+
// this computer holds.
|
|
451
|
+
const refs = record.refs;
|
|
452
|
+
if (!isObject(refs))
|
|
453
|
+
return part;
|
|
454
|
+
if (!Object.entries(refs).every(([ref, value]) => ref !== '' && typeof value === 'string'))
|
|
455
|
+
return part;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
430
458
|
// The remaining part is the leftover patch rows.
|
|
431
459
|
if (!Array.isArray(record.entries))
|
|
432
460
|
return part;
|
|
@@ -543,11 +571,11 @@ async function push(engine, state, snapshot, merged) {
|
|
|
543
571
|
// failed upload never leaves this computer holding another computer's setup
|
|
544
572
|
// with nothing to say where its own went.
|
|
545
573
|
const applied = await applyMerged(engine, snapshot, merged.applied);
|
|
546
|
-
const
|
|
574
|
+
const after = await take(paths, config, engine.credentials);
|
|
575
|
+
const now = hashesOf(partsOf(after, undefined));
|
|
547
576
|
state.server = written;
|
|
548
577
|
state.sum = sum(sealed);
|
|
549
|
-
state
|
|
550
|
-
state.local = now;
|
|
578
|
+
recordHashes(state, now, hashesOf(bundle), snapshot.keysProblems.length === 0 && after.keysProblems.length === 0);
|
|
551
579
|
state.blind = written.etag === undefined || written.etag === '' ? { ...before } : undefined;
|
|
552
580
|
state.undo = undefined;
|
|
553
581
|
const notice = merged.here.length > 0 || merged.there.length > 0
|
|
@@ -566,6 +594,7 @@ async function push(engine, state, snapshot, merged) {
|
|
|
566
594
|
...(merged.saved === undefined ? {} : { saved: merged.saved }),
|
|
567
595
|
pushed: true,
|
|
568
596
|
reloadNeeded: applied.reloadNeeded,
|
|
597
|
+
...keyProblems([...snapshot.keysProblems, ...applied.keysProblems]),
|
|
569
598
|
...(await pendings(engine, merged.merged.plugins)),
|
|
570
599
|
};
|
|
571
600
|
}
|
|
@@ -608,13 +637,12 @@ async function applyMerged(engine, snapshot, applied) {
|
|
|
608
637
|
reloadNeeded = true;
|
|
609
638
|
}
|
|
610
639
|
}
|
|
611
|
-
if (applied.models !== undefined || applied.
|
|
640
|
+
if (applied.models !== undefined || applied.patch !== undefined) {
|
|
612
641
|
// `null` means "take this away", which is how a removal made on another
|
|
613
642
|
// computer reaches this one. The patch part has no row of its own to remove,
|
|
614
643
|
// so it is emptied instead.
|
|
615
644
|
const next = applyPatch(snapshot.patch, {
|
|
616
645
|
models: applied.models,
|
|
617
|
-
defaults: applied.defaults,
|
|
618
646
|
...(applied.patch === null ? { patch: { entries: [] } } : { patch: applied.patch }),
|
|
619
647
|
});
|
|
620
648
|
if (next !== snapshot.patch.text) {
|
|
@@ -635,7 +663,53 @@ async function applyMerged(engine, snapshot, applied) {
|
|
|
635
663
|
reloadNeeded = true;
|
|
636
664
|
}
|
|
637
665
|
}
|
|
638
|
-
|
|
666
|
+
// The keys go last and through the host's credential service rather than a file
|
|
667
|
+
// of this plugin's own ({@link applyKeys}): each one is a call on a store another
|
|
668
|
+
// package owns, so a refusal there leaves the profile's files written and the key
|
|
669
|
+
// reported. Nothing here reloads, either — DSH resolves a credential per call, so
|
|
670
|
+
// the next provider request already sees the new value.
|
|
671
|
+
let keysProblems = [];
|
|
672
|
+
if (applied.keys !== undefined) {
|
|
673
|
+
keysProblems = await applyKeys(engine.credentials, applied.keys, async () => {
|
|
674
|
+
if (kept)
|
|
675
|
+
return;
|
|
676
|
+
kept = true;
|
|
677
|
+
await keepBefore(engine, snapshot);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
return { reloadNeeded, keysProblems };
|
|
681
|
+
}
|
|
682
|
+
/** Record measured parts without turning an unreadable key store into a baseline. */
|
|
683
|
+
function recordHashes(state, local, remote, keysReadable) {
|
|
684
|
+
if (keysReadable)
|
|
685
|
+
state.keysBaseline = true;
|
|
686
|
+
if (!keysReadable) {
|
|
687
|
+
// Keep both comparison points: when the service returns, local and remote
|
|
688
|
+
// key edits still need to be merged rather than accepted as already applied.
|
|
689
|
+
if (state.local?.keys === undefined)
|
|
690
|
+
delete local.keys;
|
|
691
|
+
else
|
|
692
|
+
local.keys = state.local.keys;
|
|
693
|
+
remote = { ...remote };
|
|
694
|
+
if (state.remote?.keys === undefined)
|
|
695
|
+
delete remote.keys;
|
|
696
|
+
else
|
|
697
|
+
remote.keys = state.remote.keys;
|
|
698
|
+
// The server may have changed keys during this attempt. A conditional 304
|
|
699
|
+
// would hide its body on recovery even though those keys are still pending.
|
|
700
|
+
state.server = undefined;
|
|
701
|
+
}
|
|
702
|
+
state.local = local;
|
|
703
|
+
state.remote = remote;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* The `keysProblems` field of a result, present only when there is one.
|
|
707
|
+
*
|
|
708
|
+
* The card reads an absent field as "every key went in", so an empty list is left
|
|
709
|
+
* off rather than sent as an empty one to be filtered on the other side.
|
|
710
|
+
*/
|
|
711
|
+
function keyProblems(problems) {
|
|
712
|
+
return problems.length === 0 ? {} : { keysProblems: problems };
|
|
639
713
|
}
|
|
640
714
|
/**
|
|
641
715
|
* Seal this computer's setup into `sync/before-apply` before a merge writes.
|
|
@@ -752,7 +826,7 @@ export async function restore(engine) {
|
|
|
752
826
|
const remote = await openRemote(engine, state, { ...read, notModified: false });
|
|
753
827
|
if (remote.empty)
|
|
754
828
|
throw new Error('服务器上还没有 dsh 配置:没有可恢复的内容');
|
|
755
|
-
const snapshot = await take(paths, config);
|
|
829
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
756
830
|
// What is here *on disk* is what the notice is about, so the directories are
|
|
757
831
|
// hashed without standing in the server's copy for the ones this computer could
|
|
758
832
|
// not read: a folder that is merely absent reads as nothing here, differs from
|
|
@@ -782,7 +856,7 @@ export async function restore(engine) {
|
|
|
782
856
|
const written = await applyMerged(engine, snapshot, applied);
|
|
783
857
|
state.undo = kept ?? '';
|
|
784
858
|
state.remote = remote.hashes;
|
|
785
|
-
state.local = hashesOf(partsOf(await take(paths, config), undefined));
|
|
859
|
+
state.local = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
|
|
786
860
|
state.sum = read.data === undefined ? state.sum : sum(read.data);
|
|
787
861
|
state.server = read.version;
|
|
788
862
|
state.blind = undefined;
|
|
@@ -802,6 +876,7 @@ export async function restore(engine) {
|
|
|
802
876
|
...(kept === undefined ? {} : { saved: keptDir(paths) }),
|
|
803
877
|
pushed: false,
|
|
804
878
|
reloadNeeded: written.reloadNeeded,
|
|
879
|
+
...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
|
|
805
880
|
...(await pendings(engine, applied.plugins)),
|
|
806
881
|
};
|
|
807
882
|
});
|
|
@@ -842,7 +917,7 @@ export async function upload(engine) {
|
|
|
842
917
|
}
|
|
843
918
|
await keepAside(keptDir(paths), 'server-replaced', read.data);
|
|
844
919
|
}
|
|
845
|
-
const snapshot = await take(paths, config);
|
|
920
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
846
921
|
const result = await push(engine, state, snapshot, {
|
|
847
922
|
merged: { ...partsOf(snapshot, undefined) },
|
|
848
923
|
applied: {},
|
|
@@ -876,7 +951,7 @@ export async function undo(engine) {
|
|
|
876
951
|
throw new Error('恢复之前留下的那份副本已经不在了,没有东西可以放回去');
|
|
877
952
|
}
|
|
878
953
|
const bundle = (await open(new Uint8Array(data), config.passphrase));
|
|
879
|
-
const snapshot = await take(paths, config);
|
|
954
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
880
955
|
const merged = {};
|
|
881
956
|
const applied = {};
|
|
882
957
|
const back = [];
|
|
@@ -902,6 +977,7 @@ export async function undo(engine) {
|
|
|
902
977
|
there: [],
|
|
903
978
|
pushed: false,
|
|
904
979
|
reloadNeeded: written.reloadNeeded,
|
|
980
|
+
...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
|
|
905
981
|
...(await pendings(engine, merged.plugins)),
|
|
906
982
|
};
|
|
907
983
|
});
|
|
@@ -922,7 +998,7 @@ export async function status(engine) {
|
|
|
922
998
|
const { paths, config } = engine;
|
|
923
999
|
const state = await loadState(paths);
|
|
924
1000
|
const fresh = state.key === stateKey(config);
|
|
925
|
-
const snapshot = await take(paths, config).catch(() => undefined);
|
|
1001
|
+
const snapshot = await take(paths, config, engine.credentials).catch(() => undefined);
|
|
926
1002
|
const local = snapshot === undefined ? {} : hashesOf(partsOf(snapshot, undefined));
|
|
927
1003
|
const parts = [];
|
|
928
1004
|
const known = fresh ? state.local : undefined;
|
|
@@ -983,6 +1059,10 @@ export async function status(engine) {
|
|
|
983
1059
|
...(snapshot === undefined || snapshot.dirs.problems.length === 0
|
|
984
1060
|
? {}
|
|
985
1061
|
: { dirProblems: snapshot.dirs.problems }),
|
|
1062
|
+
// The same, for a key that could not be read: a store that cannot be read must
|
|
1063
|
+
// not look like a store with nothing in it, or this computer would look like
|
|
1064
|
+
// one with no keys and the next sync would believe it.
|
|
1065
|
+
...(snapshot === undefined ? {} : keyProblems(snapshot.keysProblems)),
|
|
986
1066
|
undoable: fresh && state.undo !== undefined && state.undo !== '',
|
|
987
1067
|
};
|
|
988
1068
|
}
|
|
@@ -1030,9 +1110,9 @@ export function viewOf(config) {
|
|
|
1030
1110
|
passphraseSet: config.passphrase !== '',
|
|
1031
1111
|
autoEvery: config.autoEvery,
|
|
1032
1112
|
models: config.models,
|
|
1033
|
-
defaults: config.defaults,
|
|
1034
1113
|
plugins: config.plugins,
|
|
1035
1114
|
patch: config.patch,
|
|
1115
|
+
keys: config.keys,
|
|
1036
1116
|
dirs: [...config.dirs],
|
|
1037
1117
|
dirsEnabled: config.dirsEnabled,
|
|
1038
1118
|
rememberPassword: config.rememberPassword,
|
|
@@ -1103,7 +1183,7 @@ export async function preview(engine) {
|
|
|
1103
1183
|
const dav = davOf(config);
|
|
1104
1184
|
const read = await dav.get(state.server);
|
|
1105
1185
|
const remote = await openRemote(engine, state, read);
|
|
1106
|
-
const snapshot = await take(paths, config);
|
|
1186
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
1107
1187
|
const local = hashesOf(partsOf(snapshot, remote.part('dirs')));
|
|
1108
1188
|
const first = state.local === undefined;
|
|
1109
1189
|
const changes = [];
|
|
@@ -1117,6 +1197,13 @@ export async function preview(engine) {
|
|
|
1117
1197
|
if (!changedHere && !changedThere)
|
|
1118
1198
|
continue;
|
|
1119
1199
|
if (changedHere && changedThere) {
|
|
1200
|
+
// A credential is the one part whose own copy the merge takes outright, since
|
|
1201
|
+
// there is no local time to weigh against the server's stamp — the same rule
|
|
1202
|
+
// {@link localChangedAt} states for it, said here as the answer the card shows.
|
|
1203
|
+
if (part === 'keys') {
|
|
1204
|
+
changes.push({ part, take: 'local' });
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1120
1207
|
const serverAt = Date.parse(remote.created);
|
|
1121
1208
|
const ours = localChangedAt(snapshot, part);
|
|
1122
1209
|
changes.push({ part, take: !Number.isFinite(serverAt) || ours >= serverAt ? 'newer-local' : 'newer-remote' });
|
|
@@ -1130,7 +1217,7 @@ export async function preview(engine) {
|
|
|
1130
1217
|
// Small helpers
|
|
1131
1218
|
// ---------------------------------------------------------------------------
|
|
1132
1219
|
/** Read this computer's setup and the files it came from, in one pass. */
|
|
1133
|
-
export async function take(paths, config) {
|
|
1220
|
+
export async function take(paths, config, credentials) {
|
|
1134
1221
|
const patch = await readPatch(paths);
|
|
1135
1222
|
const manifest = await readManifest(paths);
|
|
1136
1223
|
// Switched off, the directories are not walked at all: reading them is the one
|
|
@@ -1138,7 +1225,14 @@ export async function take(paths, config) {
|
|
|
1138
1225
|
// does not carry has nothing to say. An empty read still tells the merge there is
|
|
1139
1226
|
// nothing here to compare, which is exactly true.
|
|
1140
1227
|
const dirs = await readDirs(config.dirsEnabled ? config.dirs : []);
|
|
1141
|
-
|
|
1228
|
+
const parts = collect(patch, manifest);
|
|
1229
|
+
// The keys, read from the names the parts above just named. Switched off, they
|
|
1230
|
+
// are not read at all — the same "nothing to say" the directories get — and a
|
|
1231
|
+
// host with no credential store resolves nothing rather than reading as empty.
|
|
1232
|
+
const keys = config.keys ? await readKeys(credentials, parts) : { problems: [] };
|
|
1233
|
+
if (keys.part !== undefined)
|
|
1234
|
+
parts.keys = keys.part;
|
|
1235
|
+
return { patch, manifest, parts, dirs, keysProblems: keys.problems };
|
|
1142
1236
|
}
|
|
1143
1237
|
/** Per-part hashes of a setup; a part that is not there has none. */
|
|
1144
1238
|
export function hashesOf(parts) {
|
|
@@ -1169,6 +1263,14 @@ export function localChangedAt(snapshot, part) {
|
|
|
1169
1263
|
// file inside any of them stands for the whole part.
|
|
1170
1264
|
if (part === 'dirs')
|
|
1171
1265
|
return snapshot.dirs.mtimeMs;
|
|
1266
|
+
// A credential has neither a file of this plugin's own nor a time the host's
|
|
1267
|
+
// store reports, so there is nothing to weigh the server's stamp against. This
|
|
1268
|
+
// computer's copy wins instead — it is the one that authenticates the requests
|
|
1269
|
+
// made here, and it may have been stored a moment ago through the Models page,
|
|
1270
|
+
// which touches no profile file at all. The copy that loses is archived by
|
|
1271
|
+
// {@link keepConflict} like any other. See `docs/decisions/0054-sync-api-keys.md`.
|
|
1272
|
+
if (part === 'keys')
|
|
1273
|
+
return Number.POSITIVE_INFINITY;
|
|
1172
1274
|
return snapshot.patch.mtimeMs;
|
|
1173
1275
|
}
|
|
1174
1276
|
/** The top-level packages present in the profile's node_modules. */
|
package/lib/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { validateProfileDir, resolveFromEnv } from './paths.js';
|
|
|
20
20
|
import { RelayServer, makeRelayPassword, makeRelayUser } from './relay.js';
|
|
21
21
|
import { check, dismiss, engineFor, loadConfig, preview, restore, sameAccount, status, sync, turnOff, undo, upload, usable, } from './engine.js';
|
|
22
22
|
import { installPlugins } from './install.js';
|
|
23
|
+
import { keysPort } from './keys.js';
|
|
23
24
|
import { sameSecret } from './crypto.js';
|
|
24
25
|
import { normaliseDirs } from './dirs.js';
|
|
25
26
|
import { parseHeaders } from './dav.js';
|
|
@@ -90,12 +91,12 @@ export async function configure(engine, body) {
|
|
|
90
91
|
next.passphrase = body.passphrase;
|
|
91
92
|
if (typeof body.models === 'boolean')
|
|
92
93
|
next.models = body.models;
|
|
93
|
-
if (typeof body.defaults === 'boolean')
|
|
94
|
-
next.defaults = body.defaults;
|
|
95
94
|
if (typeof body.plugins === 'boolean')
|
|
96
95
|
next.plugins = body.plugins;
|
|
97
96
|
if (typeof body.patch === 'boolean')
|
|
98
97
|
next.patch = body.patch;
|
|
98
|
+
if (typeof body.keys === 'boolean')
|
|
99
|
+
next.keys = body.keys;
|
|
99
100
|
if (typeof body.rememberPassword === 'boolean')
|
|
100
101
|
next.rememberPassword = body.rememberPassword;
|
|
101
102
|
if (typeof body.headers === 'string') {
|
|
@@ -176,6 +177,23 @@ export async function configure(engine, body) {
|
|
|
176
177
|
await persistConfig(engine.paths, next);
|
|
177
178
|
engine.config = next;
|
|
178
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The host's credential service, in the shape the sync uses it, or `undefined` on
|
|
182
|
+
* a host that has none.
|
|
183
|
+
*
|
|
184
|
+
* A lookup rather than an injected dependency: a host without the service is a
|
|
185
|
+
* working state — the `keys` part then reads nothing, writes nothing and says so —
|
|
186
|
+
* and, like every other service here, it can come and go while the plugin is
|
|
187
|
+
* mounted. `ctx.get` is the untyped accessor, so the shape is checked rather than
|
|
188
|
+
* trusted from a type this plugin cannot import (it depends on no DSH package).
|
|
189
|
+
*/
|
|
190
|
+
function credentialPort(ctx) {
|
|
191
|
+
const service = ctx.get('credentials');
|
|
192
|
+
if (service === undefined || typeof service.resolve !== 'function' || typeof service.set !== 'function') {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
return keysPort(service);
|
|
196
|
+
}
|
|
179
197
|
/**
|
|
180
198
|
* Plugin apply: mount the settings presentation policy, the HTTP routes and the
|
|
181
199
|
* auto-sync timer on any webServer that comes and goes.
|
|
@@ -196,7 +214,8 @@ export function apply(ctx, config = {}) {
|
|
|
196
214
|
const paths = resolveFromEnv(readVolatile(currentConfig().profileDir));
|
|
197
215
|
const { config: stored } = await loadConfig(paths);
|
|
198
216
|
const secret = stored.rememberPassword ? undefined : heldPassword(paths);
|
|
199
|
-
|
|
217
|
+
const config = secret === undefined || secret === '' ? stored : { ...stored, password: secret };
|
|
218
|
+
return engineFor(paths, config, credentialPort(ctx));
|
|
200
219
|
};
|
|
201
220
|
// In dsh 0.1.7+, the settings service projects volatile Config fields into
|
|
202
221
|
// forms. auto: false tells the host this plugin ships its own card, which the
|
|
@@ -329,7 +348,7 @@ export function apply(ctx, config = {}) {
|
|
|
329
348
|
send(res, 200, await installPlugins(current.paths));
|
|
330
349
|
});
|
|
331
350
|
});
|
|
332
|
-
startTimer(ctx,
|
|
351
|
+
startTimer(ctx, engine);
|
|
333
352
|
}
|
|
334
353
|
/** Make sure the state directory exists, so the first sync has somewhere to write. */
|
|
335
354
|
async function saveStateIfUsable(engine) {
|
|
@@ -349,7 +368,7 @@ async function saveStateIfUsable(engine) {
|
|
|
349
368
|
* A failure is recorded in the state by `sync` itself and changes nothing here:
|
|
350
369
|
* the next tick tries again, which is the whole point of a timer.
|
|
351
370
|
*/
|
|
352
|
-
function startTimer(ctx,
|
|
371
|
+
function startTimer(ctx, currentEngine) {
|
|
353
372
|
let timer;
|
|
354
373
|
let stopped = false;
|
|
355
374
|
const tick = async () => {
|
|
@@ -357,11 +376,12 @@ function startTimer(ctx, currentConfig) {
|
|
|
357
376
|
return;
|
|
358
377
|
let wait = 0;
|
|
359
378
|
try {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
379
|
+
// Use the same fresh engine as the routes: reading only sync.json would
|
|
380
|
+
// drop both the host credential service and a session-only WebDAV password.
|
|
381
|
+
const current = await currentEngine();
|
|
382
|
+
const minutes = current.config.autoEvery;
|
|
383
|
+
if (usable(current.config) && minutes > 0) {
|
|
384
|
+
await sync(current).catch(() => undefined);
|
|
365
385
|
wait = minutes * 60_000;
|
|
366
386
|
}
|
|
367
387
|
}
|
package/lib/keys.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `keys` part: the API keys this computer stores for the providers its
|
|
3
|
+
* profile names, and putting back the ones a merge brings.
|
|
4
|
+
*
|
|
5
|
+
* A DSH profile never holds a key. It holds the *name* of one — `apiKeyEnv` on a
|
|
6
|
+
* provider route, `secretEnv` on a webhook — and DSH resolves that name through
|
|
7
|
+
* its credential service, which layers the launch environment over this
|
|
8
|
+
* computer's own store (`$DSH_HOME/.credentials.yaml`). So a synced setup travels
|
|
9
|
+
* complete and still cannot reach a single provider: the names arrive, and on the
|
|
10
|
+
* other computer they resolve to nothing. That is the whole of this part.
|
|
11
|
+
*
|
|
12
|
+
* Two things follow from where the values live, and they are the two rules of
|
|
13
|
+
* this module:
|
|
14
|
+
*
|
|
15
|
+
* - The names come from the synced configuration, not from the store. The
|
|
16
|
+
* credential service has no way to enumerate references — a configuration
|
|
17
|
+
* surface learns which ones exist from its own schema — so this plugin reads the
|
|
18
|
+
* names out of the very parts it is about to send, and asks the service for each
|
|
19
|
+
* one. That also keeps the two in step for free: a key travels exactly when the
|
|
20
|
+
* part that names it travels.
|
|
21
|
+
* - Nothing here deletes. A key removed on one computer stays on the others, and a
|
|
22
|
+
* key that cannot be written is reported rather than skipped silently. See
|
|
23
|
+
* `docs/decisions/0054-sync-api-keys.md`.
|
|
24
|
+
*/
|
|
25
|
+
import type { KeyProblem, KeysPart, Part } from './wire.js';
|
|
26
|
+
/**
|
|
27
|
+
* The configuration field names DSH resolves as a credential reference.
|
|
28
|
+
*
|
|
29
|
+
* Taken from the schemas the shipped plugins declare: `apiKeyEnv` on the
|
|
30
|
+
* `llm-pi-ai` provider routes, on `llm-deepseek-api-key` and on
|
|
31
|
+
* `web-search-deepseek`; `secretEnv` on `webhook-github`. A plugin outside this
|
|
32
|
+
* list names its credential some other way and its key does not travel — which is
|
|
33
|
+
* why the list is here, in one place, rather than inline.
|
|
34
|
+
*/
|
|
35
|
+
export declare const CREDENTIAL_FIELDS: readonly string[];
|
|
36
|
+
/** One reference's value as the credential service resolved it. */
|
|
37
|
+
export interface ResolvedKey {
|
|
38
|
+
/** The non-empty secret. */
|
|
39
|
+
value: string;
|
|
40
|
+
/**
|
|
41
|
+
* Which layer supplied it: `env`, `file`, `project-env` or `user-env`.
|
|
42
|
+
*
|
|
43
|
+
* Only `file` — the provider-managed store, the one layer `set` writes to —
|
|
44
|
+
* travels. See {@link readKeys}.
|
|
45
|
+
*/
|
|
46
|
+
source: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* DSH's credential service, as far as this plugin uses it.
|
|
50
|
+
*
|
|
51
|
+
* Structural on purpose: the service belongs to another package, and this plugin
|
|
52
|
+
* neither depends on it nor requires it to be there. The two methods are `resolve`
|
|
53
|
+
* and `set` on `CredentialProvider`; `store` is the name this module gives the
|
|
54
|
+
* write, so a reader of the engine never has to know which word DSH picked.
|
|
55
|
+
*/
|
|
56
|
+
export interface CredentialService {
|
|
57
|
+
resolve(ref: string): Promise<ResolvedKey | undefined>;
|
|
58
|
+
set(ref: string, value: string): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
/** The two operations the sync needs: what is behind a name, and how to store one. */
|
|
61
|
+
export interface KeysPort {
|
|
62
|
+
resolve(ref: string): Promise<ResolvedKey | undefined>;
|
|
63
|
+
store(ref: string, value: string): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
/** What one read of this computer's keys produced. */
|
|
66
|
+
export interface KeysRead {
|
|
67
|
+
/** What travels, absent when this computer has no value for any name it knows. */
|
|
68
|
+
part?: KeysPart;
|
|
69
|
+
/** References that could not be read, with the reason. */
|
|
70
|
+
problems: KeyProblem[];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The credential service in the shape this plugin uses it.
|
|
74
|
+
*
|
|
75
|
+
* `set` rejects while a read-only layer shadows the reference, which is exactly
|
|
76
|
+
* the fact that matters here: a value that would not take effect is not one to
|
|
77
|
+
* report as stored. {@link applyKeys} therefore asks first and writes second.
|
|
78
|
+
*/
|
|
79
|
+
export declare function keysPort(service: CredentialService): KeysPort;
|
|
80
|
+
/**
|
|
81
|
+
* Every credential-reference name the given parts name, deduplicated and sorted.
|
|
82
|
+
*
|
|
83
|
+
* Walks the parts as they are — no schema, no interpretation — because the value
|
|
84
|
+
* of a reference field is a name wherever it sits, and the shape of a provider
|
|
85
|
+
* config is the provider plugin's to decide, not this one's.
|
|
86
|
+
*/
|
|
87
|
+
export declare function credentialRefs(parts: Partial<Record<Part, unknown>>): string[];
|
|
88
|
+
/**
|
|
89
|
+
* Read the keys this computer holds for the names in hand.
|
|
90
|
+
*
|
|
91
|
+
* Only the managed store travels: a name the launching environment supplies is
|
|
92
|
+
* left out, and so is one that falls through to a `.env`. Two reasons, and they
|
|
93
|
+
* agree — the store is the one layer this plugin can write back to, and an
|
|
94
|
+
* environment export belongs to whoever started DSH, not to this computer's
|
|
95
|
+
* stored setup. The environment-wins rule is the service's own
|
|
96
|
+
* (`resolve().source` is `'env'` whenever the inherited environment has the name,
|
|
97
|
+
* and a write is refused while it does), so a name that is both exported and
|
|
98
|
+
* stored is read as an export and left here; unsetting it in the shell is what
|
|
99
|
+
* makes the stored value the one in force, and the one that travels.
|
|
100
|
+
*
|
|
101
|
+
* A name with no stored value is skipped, not reported: plenty of profiles name a
|
|
102
|
+
* provider nobody has signed in to, and "this key is not here either" is not a
|
|
103
|
+
* failure of the sync. A name that cannot be resolved *at all* is different, and
|
|
104
|
+
* is reported — a store that cannot be read must not look like a store with
|
|
105
|
+
* nothing in it, or a sync would read it as "this computer has no keys".
|
|
106
|
+
*/
|
|
107
|
+
export declare function readKeys(port: KeysPort | undefined, parts: Partial<Record<Part, unknown>>): Promise<KeysRead>;
|
|
108
|
+
/**
|
|
109
|
+
* Put the merged keys part into this computer's store, and report what would not
|
|
110
|
+
* go in.
|
|
111
|
+
*
|
|
112
|
+
* A value already in force here is left alone. That is not an optimisation: the
|
|
113
|
+
* service refuses a write while a read-only layer shadows the reference, and a
|
|
114
|
+
* value that is already the one in force — the same key, exported and stored —
|
|
115
|
+
* would be reported as a failure for saying so twice.
|
|
116
|
+
*
|
|
117
|
+
* `beforeWrite` is called at most once, just before the first value that really
|
|
118
|
+
* replaces something. A credential has no copy kept by the caller any other way,
|
|
119
|
+
* so it is the callback's job to seal one: overwriting a key without keeping the
|
|
120
|
+
* value it had would leave undo with nothing to put back.
|
|
121
|
+
*
|
|
122
|
+
* `null` — the merge's way of saying the part was taken away — writes nothing. No
|
|
123
|
+
* credential is ever removed by a sync.
|
|
124
|
+
*/
|
|
125
|
+
export declare function applyKeys(port: KeysPort | undefined, part: KeysPart | null | undefined, beforeWrite?: () => Promise<void>): Promise<KeyProblem[]>;
|