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/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 four profile parts are read once; the extra directories have to be walked,
29
- * so they arrive separately and are put back together here. `carried` is the
30
- * server's `dirs` part, and it is used for the directories this computer could not
31
- * read — see {@link dirsPartOf} for why carrying those beats reading them as
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 a configuration. */
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 { here: [], there: [], pushed: false, reloadNeeded: false, ...(await pendings(engine, snapshot.parts.plugins)) };
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
- // Not carried with this configuration. The server's copy is carried through
210
- // the upload untouched — turning a part off must never delete it for the
211
- // computers that still sync it — and nothing is written here from it.
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 changedHere = !first && local[part] !== state.local?.[part];
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
- state.local = hashesOf(partsOf(await take(paths, config), undefined));
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' || part === 'defaults') {
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 now = hashesOf(partsOf(await take(paths, config), undefined));
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.remote = hashesOf(bundle);
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.defaults !== undefined || applied.patch !== undefined) {
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
- return { reloadNeeded };
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
- return { patch, manifest, parts: collect(patch, manifest), dirs };
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
- return engineFor(paths, secret === undefined || secret === '' ? stored : { ...stored, password: secret });
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, currentConfig);
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, currentConfig) {
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
- const paths = resolveFromEnv(readVolatile(currentConfig().profileDir));
361
- const { config: stored } = await loadConfig(paths);
362
- const minutes = stored.autoEvery;
363
- if (usable(stored) && minutes > 0) {
364
- await sync(engineFor(paths, stored)).catch(() => undefined);
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[]>;