dsh-webdav-sync 1.0.3 → 1.0.4
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 +88 -24
- package/lib/index.js +22 -3
- 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 +88 -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,7 @@ 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);
|
|
166
167
|
let remote = await openRemote(engine, state, read);
|
|
167
168
|
const first = state.local === undefined;
|
|
168
169
|
// A directory could not be read this run: its part stands in for the server's
|
|
@@ -330,7 +331,7 @@ async function syncOnce(engine, state, force) {
|
|
|
330
331
|
// Read back rather than assume: a directory whose file was just written — or
|
|
331
332
|
// removed — has to be hashed as it now is, or the next sync would see an edit
|
|
332
333
|
// that this one made itself and call it a conflict.
|
|
333
|
-
state.local = hashesOf(partsOf(await take(paths, config), undefined));
|
|
334
|
+
state.local = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
|
|
334
335
|
// A part can come in without the file needing to be written: when only the
|
|
335
336
|
// server moved, the merge agrees with it. The person still has to be told
|
|
336
337
|
// what changed here, so the notice is recorded on this path too — clearing
|
|
@@ -350,6 +351,7 @@ async function syncOnce(engine, state, force) {
|
|
|
350
351
|
...(saved === undefined ? {} : { saved }),
|
|
351
352
|
pushed: false,
|
|
352
353
|
reloadNeeded: written.reloadNeeded,
|
|
354
|
+
...keyProblems(written.keysProblems),
|
|
353
355
|
...(await pendings(engine, applied.plugins)),
|
|
354
356
|
};
|
|
355
357
|
}
|
|
@@ -411,7 +413,7 @@ function unreadablePart(bundle) {
|
|
|
411
413
|
if (!isObject(value))
|
|
412
414
|
return part;
|
|
413
415
|
const record = value;
|
|
414
|
-
if (part === 'models'
|
|
416
|
+
if (part === 'models') {
|
|
415
417
|
if (!isObject(record.config))
|
|
416
418
|
return part;
|
|
417
419
|
continue;
|
|
@@ -427,6 +429,17 @@ function unreadablePart(bundle) {
|
|
|
427
429
|
return part;
|
|
428
430
|
continue;
|
|
429
431
|
}
|
|
432
|
+
if (part === 'keys') {
|
|
433
|
+
// Ref names and their values, both strings: anything else is a file this
|
|
434
|
+
// plugin did not write, and reading it as empty would be a lie about what
|
|
435
|
+
// this computer holds.
|
|
436
|
+
const refs = record.refs;
|
|
437
|
+
if (!isObject(refs))
|
|
438
|
+
return part;
|
|
439
|
+
if (!Object.entries(refs).every(([ref, value]) => ref !== '' && typeof value === 'string'))
|
|
440
|
+
return part;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
430
443
|
// The remaining part is the leftover patch rows.
|
|
431
444
|
if (!Array.isArray(record.entries))
|
|
432
445
|
return part;
|
|
@@ -543,7 +556,7 @@ async function push(engine, state, snapshot, merged) {
|
|
|
543
556
|
// failed upload never leaves this computer holding another computer's setup
|
|
544
557
|
// with nothing to say where its own went.
|
|
545
558
|
const applied = await applyMerged(engine, snapshot, merged.applied);
|
|
546
|
-
const now = hashesOf(partsOf(await take(paths, config), undefined));
|
|
559
|
+
const now = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
|
|
547
560
|
state.server = written;
|
|
548
561
|
state.sum = sum(sealed);
|
|
549
562
|
state.remote = hashesOf(bundle);
|
|
@@ -566,6 +579,7 @@ async function push(engine, state, snapshot, merged) {
|
|
|
566
579
|
...(merged.saved === undefined ? {} : { saved: merged.saved }),
|
|
567
580
|
pushed: true,
|
|
568
581
|
reloadNeeded: applied.reloadNeeded,
|
|
582
|
+
...keyProblems(applied.keysProblems),
|
|
569
583
|
...(await pendings(engine, merged.merged.plugins)),
|
|
570
584
|
};
|
|
571
585
|
}
|
|
@@ -608,13 +622,12 @@ async function applyMerged(engine, snapshot, applied) {
|
|
|
608
622
|
reloadNeeded = true;
|
|
609
623
|
}
|
|
610
624
|
}
|
|
611
|
-
if (applied.models !== undefined || applied.
|
|
625
|
+
if (applied.models !== undefined || applied.patch !== undefined) {
|
|
612
626
|
// `null` means "take this away", which is how a removal made on another
|
|
613
627
|
// computer reaches this one. The patch part has no row of its own to remove,
|
|
614
628
|
// so it is emptied instead.
|
|
615
629
|
const next = applyPatch(snapshot.patch, {
|
|
616
630
|
models: applied.models,
|
|
617
|
-
defaults: applied.defaults,
|
|
618
631
|
...(applied.patch === null ? { patch: { entries: [] } } : { patch: applied.patch }),
|
|
619
632
|
});
|
|
620
633
|
if (next !== snapshot.patch.text) {
|
|
@@ -635,7 +648,30 @@ async function applyMerged(engine, snapshot, applied) {
|
|
|
635
648
|
reloadNeeded = true;
|
|
636
649
|
}
|
|
637
650
|
}
|
|
638
|
-
|
|
651
|
+
// The keys go last and through the host's credential service rather than a file
|
|
652
|
+
// of this plugin's own ({@link applyKeys}): each one is a call on a store another
|
|
653
|
+
// package owns, so a refusal there leaves the profile's files written and the key
|
|
654
|
+
// reported. Nothing here reloads, either — DSH resolves a credential per call, so
|
|
655
|
+
// the next provider request already sees the new value.
|
|
656
|
+
let keysProblems = [];
|
|
657
|
+
if (applied.keys !== undefined) {
|
|
658
|
+
keysProblems = await applyKeys(engine.credentials, applied.keys, async () => {
|
|
659
|
+
if (kept)
|
|
660
|
+
return;
|
|
661
|
+
kept = true;
|
|
662
|
+
await keepBefore(engine, snapshot);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return { reloadNeeded, keysProblems };
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* The `keysProblems` field of a result, present only when there is one.
|
|
669
|
+
*
|
|
670
|
+
* The card reads an absent field as "every key went in", so an empty list is left
|
|
671
|
+
* off rather than sent as an empty one to be filtered on the other side.
|
|
672
|
+
*/
|
|
673
|
+
function keyProblems(problems) {
|
|
674
|
+
return problems.length === 0 ? {} : { keysProblems: problems };
|
|
639
675
|
}
|
|
640
676
|
/**
|
|
641
677
|
* Seal this computer's setup into `sync/before-apply` before a merge writes.
|
|
@@ -752,7 +788,7 @@ export async function restore(engine) {
|
|
|
752
788
|
const remote = await openRemote(engine, state, { ...read, notModified: false });
|
|
753
789
|
if (remote.empty)
|
|
754
790
|
throw new Error('服务器上还没有 dsh 配置:没有可恢复的内容');
|
|
755
|
-
const snapshot = await take(paths, config);
|
|
791
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
756
792
|
// What is here *on disk* is what the notice is about, so the directories are
|
|
757
793
|
// hashed without standing in the server's copy for the ones this computer could
|
|
758
794
|
// not read: a folder that is merely absent reads as nothing here, differs from
|
|
@@ -782,7 +818,7 @@ export async function restore(engine) {
|
|
|
782
818
|
const written = await applyMerged(engine, snapshot, applied);
|
|
783
819
|
state.undo = kept ?? '';
|
|
784
820
|
state.remote = remote.hashes;
|
|
785
|
-
state.local = hashesOf(partsOf(await take(paths, config), undefined));
|
|
821
|
+
state.local = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
|
|
786
822
|
state.sum = read.data === undefined ? state.sum : sum(read.data);
|
|
787
823
|
state.server = read.version;
|
|
788
824
|
state.blind = undefined;
|
|
@@ -802,6 +838,7 @@ export async function restore(engine) {
|
|
|
802
838
|
...(kept === undefined ? {} : { saved: keptDir(paths) }),
|
|
803
839
|
pushed: false,
|
|
804
840
|
reloadNeeded: written.reloadNeeded,
|
|
841
|
+
...keyProblems(written.keysProblems),
|
|
805
842
|
...(await pendings(engine, applied.plugins)),
|
|
806
843
|
};
|
|
807
844
|
});
|
|
@@ -842,7 +879,7 @@ export async function upload(engine) {
|
|
|
842
879
|
}
|
|
843
880
|
await keepAside(keptDir(paths), 'server-replaced', read.data);
|
|
844
881
|
}
|
|
845
|
-
const snapshot = await take(paths, config);
|
|
882
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
846
883
|
const result = await push(engine, state, snapshot, {
|
|
847
884
|
merged: { ...partsOf(snapshot, undefined) },
|
|
848
885
|
applied: {},
|
|
@@ -876,7 +913,7 @@ export async function undo(engine) {
|
|
|
876
913
|
throw new Error('恢复之前留下的那份副本已经不在了,没有东西可以放回去');
|
|
877
914
|
}
|
|
878
915
|
const bundle = (await open(new Uint8Array(data), config.passphrase));
|
|
879
|
-
const snapshot = await take(paths, config);
|
|
916
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
880
917
|
const merged = {};
|
|
881
918
|
const applied = {};
|
|
882
919
|
const back = [];
|
|
@@ -902,6 +939,7 @@ export async function undo(engine) {
|
|
|
902
939
|
there: [],
|
|
903
940
|
pushed: false,
|
|
904
941
|
reloadNeeded: written.reloadNeeded,
|
|
942
|
+
...keyProblems(written.keysProblems),
|
|
905
943
|
...(await pendings(engine, merged.plugins)),
|
|
906
944
|
};
|
|
907
945
|
});
|
|
@@ -922,7 +960,7 @@ export async function status(engine) {
|
|
|
922
960
|
const { paths, config } = engine;
|
|
923
961
|
const state = await loadState(paths);
|
|
924
962
|
const fresh = state.key === stateKey(config);
|
|
925
|
-
const snapshot = await take(paths, config).catch(() => undefined);
|
|
963
|
+
const snapshot = await take(paths, config, engine.credentials).catch(() => undefined);
|
|
926
964
|
const local = snapshot === undefined ? {} : hashesOf(partsOf(snapshot, undefined));
|
|
927
965
|
const parts = [];
|
|
928
966
|
const known = fresh ? state.local : undefined;
|
|
@@ -983,6 +1021,10 @@ export async function status(engine) {
|
|
|
983
1021
|
...(snapshot === undefined || snapshot.dirs.problems.length === 0
|
|
984
1022
|
? {}
|
|
985
1023
|
: { dirProblems: snapshot.dirs.problems }),
|
|
1024
|
+
// The same, for a key that could not be read: a store that cannot be read must
|
|
1025
|
+
// not look like a store with nothing in it, or this computer would look like
|
|
1026
|
+
// one with no keys and the next sync would believe it.
|
|
1027
|
+
...(snapshot === undefined ? {} : keyProblems(snapshot.keysProblems)),
|
|
986
1028
|
undoable: fresh && state.undo !== undefined && state.undo !== '',
|
|
987
1029
|
};
|
|
988
1030
|
}
|
|
@@ -1030,9 +1072,9 @@ export function viewOf(config) {
|
|
|
1030
1072
|
passphraseSet: config.passphrase !== '',
|
|
1031
1073
|
autoEvery: config.autoEvery,
|
|
1032
1074
|
models: config.models,
|
|
1033
|
-
defaults: config.defaults,
|
|
1034
1075
|
plugins: config.plugins,
|
|
1035
1076
|
patch: config.patch,
|
|
1077
|
+
keys: config.keys,
|
|
1036
1078
|
dirs: [...config.dirs],
|
|
1037
1079
|
dirsEnabled: config.dirsEnabled,
|
|
1038
1080
|
rememberPassword: config.rememberPassword,
|
|
@@ -1103,7 +1145,7 @@ export async function preview(engine) {
|
|
|
1103
1145
|
const dav = davOf(config);
|
|
1104
1146
|
const read = await dav.get(state.server);
|
|
1105
1147
|
const remote = await openRemote(engine, state, read);
|
|
1106
|
-
const snapshot = await take(paths, config);
|
|
1148
|
+
const snapshot = await take(paths, config, engine.credentials);
|
|
1107
1149
|
const local = hashesOf(partsOf(snapshot, remote.part('dirs')));
|
|
1108
1150
|
const first = state.local === undefined;
|
|
1109
1151
|
const changes = [];
|
|
@@ -1117,6 +1159,13 @@ export async function preview(engine) {
|
|
|
1117
1159
|
if (!changedHere && !changedThere)
|
|
1118
1160
|
continue;
|
|
1119
1161
|
if (changedHere && changedThere) {
|
|
1162
|
+
// A credential is the one part whose own copy the merge takes outright, since
|
|
1163
|
+
// there is no local time to weigh against the server's stamp — the same rule
|
|
1164
|
+
// {@link localChangedAt} states for it, said here as the answer the card shows.
|
|
1165
|
+
if (part === 'keys') {
|
|
1166
|
+
changes.push({ part, take: 'local' });
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1120
1169
|
const serverAt = Date.parse(remote.created);
|
|
1121
1170
|
const ours = localChangedAt(snapshot, part);
|
|
1122
1171
|
changes.push({ part, take: !Number.isFinite(serverAt) || ours >= serverAt ? 'newer-local' : 'newer-remote' });
|
|
@@ -1130,7 +1179,7 @@ export async function preview(engine) {
|
|
|
1130
1179
|
// Small helpers
|
|
1131
1180
|
// ---------------------------------------------------------------------------
|
|
1132
1181
|
/** Read this computer's setup and the files it came from, in one pass. */
|
|
1133
|
-
export async function take(paths, config) {
|
|
1182
|
+
export async function take(paths, config, credentials) {
|
|
1134
1183
|
const patch = await readPatch(paths);
|
|
1135
1184
|
const manifest = await readManifest(paths);
|
|
1136
1185
|
// Switched off, the directories are not walked at all: reading them is the one
|
|
@@ -1138,7 +1187,14 @@ export async function take(paths, config) {
|
|
|
1138
1187
|
// does not carry has nothing to say. An empty read still tells the merge there is
|
|
1139
1188
|
// nothing here to compare, which is exactly true.
|
|
1140
1189
|
const dirs = await readDirs(config.dirsEnabled ? config.dirs : []);
|
|
1141
|
-
|
|
1190
|
+
const parts = collect(patch, manifest);
|
|
1191
|
+
// The keys, read from the names the parts above just named. Switched off, they
|
|
1192
|
+
// are not read at all — the same "nothing to say" the directories get — and a
|
|
1193
|
+
// host with no credential store resolves nothing rather than reading as empty.
|
|
1194
|
+
const keys = config.keys ? await readKeys(credentials, parts) : { problems: [] };
|
|
1195
|
+
if (keys.part !== undefined)
|
|
1196
|
+
parts.keys = keys.part;
|
|
1197
|
+
return { patch, manifest, parts, dirs, keysProblems: keys.problems };
|
|
1142
1198
|
}
|
|
1143
1199
|
/** Per-part hashes of a setup; a part that is not there has none. */
|
|
1144
1200
|
export function hashesOf(parts) {
|
|
@@ -1169,6 +1225,14 @@ export function localChangedAt(snapshot, part) {
|
|
|
1169
1225
|
// file inside any of them stands for the whole part.
|
|
1170
1226
|
if (part === 'dirs')
|
|
1171
1227
|
return snapshot.dirs.mtimeMs;
|
|
1228
|
+
// A credential has neither a file of this plugin's own nor a time the host's
|
|
1229
|
+
// store reports, so there is nothing to weigh the server's stamp against. This
|
|
1230
|
+
// computer's copy wins instead — it is the one that authenticates the requests
|
|
1231
|
+
// made here, and it may have been stored a moment ago through the Models page,
|
|
1232
|
+
// which touches no profile file at all. The copy that loses is archived by
|
|
1233
|
+
// {@link keepConflict} like any other. See `docs/decisions/0054-sync-api-keys.md`.
|
|
1234
|
+
if (part === 'keys')
|
|
1235
|
+
return Number.POSITIVE_INFINITY;
|
|
1172
1236
|
return snapshot.patch.mtimeMs;
|
|
1173
1237
|
}
|
|
1174
1238
|
/** 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
|
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[]>;
|
package/lib/keys.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
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 { message } from './store.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 const CREDENTIAL_FIELDS = ['apiKeyEnv', 'secretEnv'];
|
|
36
|
+
/**
|
|
37
|
+
* The credential service in the shape this plugin uses it.
|
|
38
|
+
*
|
|
39
|
+
* `set` rejects while a read-only layer shadows the reference, which is exactly
|
|
40
|
+
* the fact that matters here: a value that would not take effect is not one to
|
|
41
|
+
* report as stored. {@link applyKeys} therefore asks first and writes second.
|
|
42
|
+
*/
|
|
43
|
+
export function keysPort(service) {
|
|
44
|
+
return {
|
|
45
|
+
resolve: (ref) => service.resolve(ref),
|
|
46
|
+
store: (ref, value) => service.set(ref, value),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every credential-reference name the given parts name, deduplicated and sorted.
|
|
51
|
+
*
|
|
52
|
+
* Walks the parts as they are — no schema, no interpretation — because the value
|
|
53
|
+
* of a reference field is a name wherever it sits, and the shape of a provider
|
|
54
|
+
* config is the provider plugin's to decide, not this one's.
|
|
55
|
+
*/
|
|
56
|
+
export function credentialRefs(parts) {
|
|
57
|
+
const found = new Set();
|
|
58
|
+
for (const part of Object.values(parts))
|
|
59
|
+
walk(part, found);
|
|
60
|
+
return [...found].sort();
|
|
61
|
+
}
|
|
62
|
+
function walk(value, into) {
|
|
63
|
+
if (value === null || typeof value !== 'object')
|
|
64
|
+
return;
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
for (const item of value)
|
|
67
|
+
walk(item, into);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const [key, held] of Object.entries(value)) {
|
|
71
|
+
if (CREDENTIAL_FIELDS.includes(key)) {
|
|
72
|
+
const ref = typeof held === 'string' ? held.trim() : '';
|
|
73
|
+
if (ref !== '')
|
|
74
|
+
into.add(ref);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
walk(held, into);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read the keys this computer holds for the names in hand.
|
|
82
|
+
*
|
|
83
|
+
* Only the managed store travels: a name the launching environment supplies is
|
|
84
|
+
* left out, and so is one that falls through to a `.env`. Two reasons, and they
|
|
85
|
+
* agree — the store is the one layer this plugin can write back to, and an
|
|
86
|
+
* environment export belongs to whoever started DSH, not to this computer's
|
|
87
|
+
* stored setup. The environment-wins rule is the service's own
|
|
88
|
+
* (`resolve().source` is `'env'` whenever the inherited environment has the name,
|
|
89
|
+
* and a write is refused while it does), so a name that is both exported and
|
|
90
|
+
* stored is read as an export and left here; unsetting it in the shell is what
|
|
91
|
+
* makes the stored value the one in force, and the one that travels.
|
|
92
|
+
*
|
|
93
|
+
* A name with no stored value is skipped, not reported: plenty of profiles name a
|
|
94
|
+
* provider nobody has signed in to, and "this key is not here either" is not a
|
|
95
|
+
* failure of the sync. A name that cannot be resolved *at all* is different, and
|
|
96
|
+
* is reported — a store that cannot be read must not look like a store with
|
|
97
|
+
* nothing in it, or a sync would read it as "this computer has no keys".
|
|
98
|
+
*/
|
|
99
|
+
export async function readKeys(port, parts) {
|
|
100
|
+
const refs = credentialRefs(parts);
|
|
101
|
+
if (refs.length === 0)
|
|
102
|
+
return { problems: [] };
|
|
103
|
+
if (port === undefined)
|
|
104
|
+
return { problems: [{ ref: '', message: NO_SERVICE }] };
|
|
105
|
+
const found = {};
|
|
106
|
+
const problems = [];
|
|
107
|
+
for (const ref of refs) {
|
|
108
|
+
try {
|
|
109
|
+
const resolved = await port.resolve(ref);
|
|
110
|
+
if (resolved !== undefined && resolved.source === 'file' && resolved.value !== '')
|
|
111
|
+
found[ref] = resolved.value;
|
|
112
|
+
}
|
|
113
|
+
catch (cause) {
|
|
114
|
+
problems.push({ ref, message: message(cause) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (Object.keys(found).length === 0)
|
|
118
|
+
return { problems };
|
|
119
|
+
return { part: { refs: found }, problems };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Put the merged keys part into this computer's store, and report what would not
|
|
123
|
+
* go in.
|
|
124
|
+
*
|
|
125
|
+
* A value already in force here is left alone. That is not an optimisation: the
|
|
126
|
+
* service refuses a write while a read-only layer shadows the reference, and a
|
|
127
|
+
* value that is already the one in force — the same key, exported and stored —
|
|
128
|
+
* would be reported as a failure for saying so twice.
|
|
129
|
+
*
|
|
130
|
+
* `beforeWrite` is called at most once, just before the first value that really
|
|
131
|
+
* replaces something. A credential has no copy kept by the caller any other way,
|
|
132
|
+
* so it is the callback's job to seal one: overwriting a key without keeping the
|
|
133
|
+
* value it had would leave undo with nothing to put back.
|
|
134
|
+
*
|
|
135
|
+
* `null` — the merge's way of saying the part was taken away — writes nothing. No
|
|
136
|
+
* credential is ever removed by a sync.
|
|
137
|
+
*/
|
|
138
|
+
export async function applyKeys(port, part, beforeWrite = async () => undefined) {
|
|
139
|
+
if (part === null || part === undefined)
|
|
140
|
+
return [];
|
|
141
|
+
const entries = Object.entries(part.refs ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
142
|
+
if (entries.length === 0)
|
|
143
|
+
return [];
|
|
144
|
+
if (port === undefined)
|
|
145
|
+
return [{ ref: '', message: NO_SERVICE }];
|
|
146
|
+
const problems = [];
|
|
147
|
+
let kept = false;
|
|
148
|
+
for (const [ref, value] of entries) {
|
|
149
|
+
if (typeof value !== 'string' || value === '')
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
const current = await port.resolve(ref);
|
|
153
|
+
if (current !== undefined && current.value === value)
|
|
154
|
+
continue;
|
|
155
|
+
if (!kept) {
|
|
156
|
+
kept = true;
|
|
157
|
+
await beforeWrite();
|
|
158
|
+
}
|
|
159
|
+
await port.store(ref, value);
|
|
160
|
+
}
|
|
161
|
+
catch (cause) {
|
|
162
|
+
problems.push({ ref, message: message(cause) });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return problems;
|
|
166
|
+
}
|
|
167
|
+
/** Said whenever the host has no credential service to read or write at all. */
|
|
168
|
+
const NO_SERVICE = '这台电脑没有 DSH 的凭据服务,API 密钥不在这里读写';
|