aegis-desktop 1.0.10 → 1.0.13

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.
@@ -66,15 +66,39 @@
66
66
  * inspects what is *already* in the vault and names it, so the answer to "is a
67
67
  * key in there?" is a command rather than a memory.
68
68
  *
69
+ * ## Two backends: `aegiscloud` (default) and `rclone`
70
+ *
71
+ * The vault used to be rclone-or-nothing, and the default target
72
+ * (`gdrive:AEGIS-AGPL`) assumed a Drive remote the user had already
73
+ * configured by hand. On a fresh install that remote does not exist, so the
74
+ * first `fingerprint publish` failed with rclone's "didn't find section in
75
+ * config file" — the default was a folder the user could not have.
76
+ *
77
+ * So the *default* backend is now `aegiscloud`: the account this machine
78
+ * already registered for with `POST /api/v1/auth/register-device` (see
79
+ * client/auto-provision.js — the same device key every other cloud feature
80
+ * uses, so publishing needs no second sign-in). The bytes are stored per
81
+ * account, addressed by the same `<keyFingerprint>-<behaviour>.<stamp>` name,
82
+ * and read back through the same three-claim check (`present` / `unchanged` /
83
+ * `attested`) — a remote vault is only worth anything if the copy can be
84
+ * fetched and re-verified later.
85
+ *
86
+ * `rclone` remains a first-class backend for anyone who wants the record in
87
+ * *their own* Google Drive / S3 / whatever (`--backend rclone`, or simply
88
+ * `--remote gdrive`), because "where do my records live" is the user's
89
+ * decision and a vendor account is a default, not a requirement.
90
+ *
69
91
  * ## Testing
70
92
  *
71
- * Nothing here talks to a network directly: every remote operation is one
72
- * `rclone` invocation through `rcloneBinary()`, and the binary is overridable
73
- * (`AEGIS_VAULT_RCLONE`, `--rclone`). `test/fingerprint-vault.test.mjs` drives
74
- * the whole surface against a stub, so the suite is hermetic and never touches
75
- * the user's Drive. The remote/root are overridable the same way
76
- * (`AEGIS_VAULT_REMOTE`/`AEGIS_VAULT_ROOT`), which is also what keeps a
77
- * misconfigured vault from writing into some unrelated folder.
93
+ * Every remote operation goes through one dispatcher (`transportFor`), which is
94
+ * either the rclone adapter (`rcloneBinary()` overridable via
95
+ * `AEGIS_VAULT_RCLONE`) or the aegiscloud adapter (whose `fetch` is injectable
96
+ * through `o.fetch`). `test/cli-fingerprint-vault.test.mjs` drives the whole
97
+ * surface against a stub rclone, and `test/cli-fingerprint-cloud.test.mjs`
98
+ * against a stub HTTP vault, so the suite is hermetic and never touches the
99
+ * user's Drive or their account. The remote/root are overridable the same way
100
+ * (`AEGIS_VAULT_REMOTE`/`AEGIS_VAULT_ROOT`, `AEGIS_VAULT_BACKEND`), which is
101
+ * also what keeps a misconfigured vault from writing into an unrelated folder.
78
102
  */
79
103
 
80
104
  const crypto = require('node:crypto');
@@ -86,6 +110,18 @@ const { execFileSync } = require('node:child_process');
86
110
  const fp = require('./fingerprint.js');
87
111
  const authorship = require('./authorship-key.js');
88
112
 
113
+ /**
114
+ * The backends a vault can live on.
115
+ *
116
+ * `aegiscloud` is the default because it is the only one that exists on a
117
+ * fresh install: it authenticates with the device key this machine already
118
+ * registered for, so publishing a record needs no second setup. `rclone` is
119
+ * kept for users who want the copy in a folder they own.
120
+ */
121
+ const BACKEND_CLOUD = 'aegiscloud';
122
+ const BACKEND_RCLONE = 'rclone';
123
+ const DEFAULT_BACKEND = BACKEND_CLOUD;
124
+
89
125
  /** The folder the sealed records live in, and the folder that must not hold a key. */
90
126
  const DEFAULT_REMOTE = 'gdrive';
91
127
  const DEFAULT_ROOT = 'AEGIS-AGPL';
@@ -101,6 +137,11 @@ const INDEX_KIND = 'aegiscode.record.index';
101
137
  const RCLONE_ENV = 'AEGIS_VAULT_RCLONE';
102
138
  const REMOTE_ENV = 'AEGIS_VAULT_REMOTE';
103
139
  const ROOT_ENV = 'AEGIS_VAULT_ROOT';
140
+ const BACKEND_ENV = 'AEGIS_VAULT_BACKEND';
141
+ const API_BASE_ENV = 'AEGIS_API_BASE';
142
+
143
+ /** Where the aegiscloud backend lives, and the routes it speaks. */
144
+ const CLOUD_PREFIX = '/api/v1/vault';
104
145
 
105
146
  /** Every remote call is bounded: a hung rclone must not hang a mint or a GUI turn. */
106
147
  const RCLONE_TIMEOUT_MS = 60_000;
@@ -157,10 +198,18 @@ function rcloneBinary(env = process.env, argv = []) {
157
198
  * Where the vault is. Both halves are injectable because both are things a
158
199
  * caller can get wrong in a way that writes a record somewhere unintended.
159
200
  */
160
- function vaultTarget(env = process.env, argv = []) {
201
+ function vaultTarget(env = process.env, argv = [], link = null) {
202
+ const remote = flag(argv, '--remote') || env[REMOTE_ENV] || (link && link.remote) || DEFAULT_REMOTE;
161
203
  return {
162
- remote: flag(argv, '--remote') || env[REMOTE_ENV] || DEFAULT_REMOTE,
163
- root: flag(argv, '--root') || env[ROOT_ENV] || DEFAULT_ROOT,
204
+ // `--backend` wins, then the env. A caller who names an rclone remote
205
+ // (`--remote gdrive`, `AEGIS_VAULT_REMOTE=r2`) is asking for THAT remote, so
206
+ // naming one switches the backend rather than being silently ignored by a
207
+ // cloud default — the old behaviour of reading `--remote` and then talking
208
+ // to aegiscloud would be the worst of both.
209
+ backend: backendFor(env, argv, remote, link),
210
+ remote,
211
+ root: flag(argv, '--root') || env[ROOT_ENV] || (link && link.root) || DEFAULT_ROOT,
212
+ apiBase: String(flag(argv, '--base') || env[API_BASE_ENV] || 'https://aegiscloud.org').replace(/\/+$/, ''),
164
213
  rclone: rcloneBinary(env, argv),
165
214
  // Carried so `rcloneRun` can hand the *injected* environment to the child.
166
215
  // Passing rclone the ambient process env instead made every AEGIS_VAULT_*
@@ -171,6 +220,35 @@ function vaultTarget(env = process.env, argv = []) {
171
220
  };
172
221
  }
173
222
 
223
+ /**
224
+ * Which backend this target resolves to.
225
+ *
226
+ * Precedence, and each step is a user saying something specific:
227
+ *
228
+ * 1. `--backend` / `$AEGIS_VAULT_BACKEND` — explicit, and an unknown value is
229
+ * an ERROR rather than a quiet fallback: "publish my records to aegiscloud"
230
+ * silently becoming "…to your local Drive" (or the reverse) is a data
231
+ * question, not a cosmetic one.
232
+ * 2. an explicit `--remote` / `$AEGIS_VAULT_REMOTE` — that is an rclone remote
233
+ * name, so it selects rclone.
234
+ * 3. otherwise the default: `aegiscloud`.
235
+ */
236
+ function backendFor(env, argv, remote, link = null) {
237
+ const explicit = String(flag(argv, '--backend') || env[BACKEND_ENV] || '').trim().toLowerCase();
238
+ if (explicit) {
239
+ if (explicit !== BACKEND_CLOUD && explicit !== BACKEND_RCLONE) {
240
+ throw new Error(`vault: unknown backend "${explicit}" — expected ${BACKEND_CLOUD} or ${BACKEND_RCLONE}`);
241
+ }
242
+ return explicit;
243
+ }
244
+ if (flag(argv, '--remote') || env[REMOTE_ENV]) return BACKEND_RCLONE;
245
+ // A link says where its own copy went; honouring it is what makes `anchor`
246
+ // work on a record published before the user changed their default.
247
+ if (link && (link.backend === BACKEND_CLOUD || link.backend === BACKEND_RCLONE)) return link.backend;
248
+ if (link && link.backend === undefined && link.remote && link.remote !== DEFAULT_REMOTE) return BACKEND_RCLONE;
249
+ return DEFAULT_BACKEND;
250
+ }
251
+
174
252
  function remoteSpec(target, ...segments) {
175
253
  return `${target.remote}:${[target.root, ...segments].filter(Boolean).join('/')}`;
176
254
  }
@@ -229,6 +307,224 @@ function tempDir() {
229
307
  return fs.mkdtempSync(path.join(os.tmpdir(), 'aegis-vault-'));
230
308
  }
231
309
 
310
+ // ---------------------------------------------------------------------------
311
+ // the aegiscloud backend
312
+ // ---------------------------------------------------------------------------
313
+
314
+ /**
315
+ * The account key this machine already has, or null.
316
+ *
317
+ * Deliberately NOT a second sign-in: `client/credentials.js` is the one store
318
+ * the whole product resolves a key from, and a machine that has ever used any
319
+ * cloud feature has already registered a device (client/auto-provision.js) and
320
+ * saved that key there. Reading it means "publish to my vault" costs the user
321
+ * nothing beyond the account they already have; any other arrangement would be
322
+ * a second credential with its own expiry and its own way to be stale.
323
+ *
324
+ * `AEGIS_API_KEY` is honoured by credentials.js's own resolution order, so the
325
+ * env override works here for free.
326
+ */
327
+ function cloudApiKey(o = {}) {
328
+ if (o.apiKey) return String(o.apiKey).trim();
329
+ const env = o.env || process.env;
330
+ try {
331
+ const credentials = require('./credentials.js');
332
+ // `dir` is passed explicitly and derived from the INJECTED env. Without it
333
+ // the store falls back to `os.homedir()`, so a host that scopes AEGIS_HOME
334
+ // (the desktop's data dir, every test) would publish to — or 401 against —
335
+ // whichever account the real user happens to be logged into.
336
+ return String(credentials.resolveApiKey({ env, dir: credentials.aegisHome(env) }).key || '').trim();
337
+ } catch {
338
+ // A host that cannot load the store is not a crash here: the caller gets
339
+ // "no account key" and the same instruction a first-time user gets.
340
+ return '';
341
+ }
342
+ }
343
+
344
+ /**
345
+ * One aegiscloud vault call.
346
+ *
347
+ * `fetch` is injectable (`o.fetch`) for the same reason `rcloneBinary` is: the
348
+ * suite must be hermetic, and a test that can only pass by reaching the real
349
+ * account is a test that gets skipped. Errors are wrapped with the server's own
350
+ * message, because "HTTP 401" is not a diagnosis — the account half of it is.
351
+ */
352
+ async function cloudRun(target, method, route, { body = null, raw = false, timeout = RCLONE_TIMEOUT_MS, fetchImpl = null, apiKey = null } = {}) {
353
+ const key = apiKey || '';
354
+ if (!key) {
355
+ const err = new Error(
356
+ 'vault: no AEGIS account key on this machine — the aegiscloud vault is the account ' +
357
+ 'you already have. Run `aegiscode login`, or publish to a folder you own with ' +
358
+ '`--backend rclone --remote <name>`.',
359
+ );
360
+ err.code = 'ENOKEY';
361
+ throw err;
362
+ }
363
+ const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null);
364
+ if (!doFetch) {
365
+ const err = new Error('vault: this Node has no global fetch — upgrade to Node 22+ to use the aegiscloud vault');
366
+ err.code = 'ENOFETCH';
367
+ throw err;
368
+ }
369
+ const controller = new AbortController();
370
+ const timer = setTimeout(() => controller.abort(), timeout);
371
+ let res;
372
+ try {
373
+ res = await doFetch(`${target.apiBase}${CLOUD_PREFIX}${route}`, {
374
+ method,
375
+ headers: {
376
+ 'Content-Type': 'application/json',
377
+ 'X-API-Key': key,
378
+ Authorization: `Bearer ${key}`,
379
+ },
380
+ body: body === null ? undefined : JSON.stringify(body),
381
+ signal: controller.signal,
382
+ });
383
+ } catch (e) {
384
+ const err = new Error(`vault: could not reach ${target.apiBase} — ${(e && e.message) || e}`);
385
+ err.code = 'ENETWORK';
386
+ throw err;
387
+ } finally {
388
+ clearTimeout(timer);
389
+ }
390
+ const text = await res.text();
391
+ let data = null;
392
+ try {
393
+ data = text ? JSON.parse(text) : null;
394
+ } catch {
395
+ data = null;
396
+ }
397
+ if (!res.ok) {
398
+ // A 404 on the collection route is not "the object is missing" — it is a
399
+ // server that predates `/api/v1/vault` (this client ships ahead of the
400
+ // account service it talks to). Saying "not found" there sends the user
401
+ // looking for a record that was never uploaded; name the real situation and
402
+ // the fallback that works today.
403
+ if (res.status === 404 && (route === '' || route.startsWith('?'))) {
404
+ const err = new Error(
405
+ `vault: ${target.apiBase} does not serve the account vault yet (${CLOUD_PREFIX} is ` +
406
+ 'missing on this server). Publish to a folder you own instead: ' +
407
+ '`--backend rclone --remote <name> --root <folder>`.',
408
+ );
409
+ err.code = 'ENOVAULT';
410
+ err.status = 404;
411
+ throw err;
412
+ }
413
+ const msg = (data && (data.error || data.message)) || text.slice(0, 200) || `HTTP ${res.status}`;
414
+ const err = new Error(`vault: ${method} ${CLOUD_PREFIX}${route} failed — ${msg}`);
415
+ err.code = 'ECLOUD';
416
+ err.status = res.status;
417
+ throw err;
418
+ }
419
+ return raw ? text : data === null ? {} : data;
420
+ }
421
+
422
+ /**
423
+ * A route-safe id for one object.
424
+ *
425
+ * Encoded per SEGMENT, not with one `encodeURIComponent` over the whole name:
426
+ * the object lives under `records/`, so the slash is part of the route rather
427
+ * than part of a name. Encoding it as `%2F` works through Flask's `<path:>`
428
+ * converter, but it turns every message and every log line into
429
+ * `records%2F…`, and a proxy that rejects encoded slashes turns a publish into
430
+ * a mystery 404. The record names themselves are already filename-safe
431
+ * (client-side `baseName`), so this is belt-and-braces, not the guard.
432
+ */
433
+ function cloudObject(target, name) {
434
+ return `/${String(name || '')
435
+ .split('/')
436
+ .filter(Boolean)
437
+ .map((segment) => encodeURIComponent(segment))
438
+ .join('/')}`;
439
+ }
440
+
441
+ /**
442
+ * The transport the rest of this module talks to.
443
+ *
444
+ * Every remote operation is expressed once, here, in terms the module above
445
+ * understands — `put`, `cat`, `list`, `listRecursive` — so `publish`, `anchor`,
446
+ * `vault` and `--audit` do not each grow a second implementation per backend.
447
+ * The rclone half is the code that was already here, unchanged; the cloud half
448
+ * is the same four operations against `${apiBase}/api/v1/vault`.
449
+ */
450
+ function transportFor(target, opts = {}) {
451
+ if (target.backend === BACKEND_CLOUD) {
452
+ const apiKey = cloudApiKey(opts);
453
+ const call = (method, route, extra) =>
454
+ cloudRun(target, method, route, { ...extra, fetchImpl: opts.fetch, apiKey });
455
+ return {
456
+ backend: BACKEND_CLOUD,
457
+ /** Upload one object. `rel` is root-relative (`records/x.json`). */
458
+ async put(rel, text) {
459
+ await call('PUT', cloudObject(target, remoteObjectName(rel)), { body: { content: text } });
460
+ },
461
+ /** Upload an existing local file, byte for byte — `publish` needs this. */
462
+ async putFile(rel, localPath) {
463
+ await this.put(rel, fs.readFileSync(localPath, 'utf8'));
464
+ },
465
+ /** Read one object, `null` when it is not there. */
466
+ async cat(rel) {
467
+ try {
468
+ const data = await call('GET', cloudObject(target, remoteObjectName(rel)));
469
+ return data && typeof data.content === 'string' ? data.content : null;
470
+ } catch (e) {
471
+ if (e.status === 404) return null;
472
+ throw e;
473
+ }
474
+ },
475
+ /** `{ Name, Size, ModTime, ID, Path }` for one directory (non-recursive). */
476
+ async list(rel) {
477
+ const data = await call('GET', `?dir=${encodeURIComponent(relativeToRoot(target, rel))}`);
478
+ return Array.isArray(data && data.files) ? data.files : [];
479
+ },
480
+ /** The same shape, for the whole vault (`vault --audit`). */
481
+ async listRecursive(prefix) {
482
+ const data = await call('GET', `?dir=${encodeURIComponent(relativeToRoot(target, prefix))}&recursive=1`);
483
+ return Array.isArray(data && data.files) ? data.files : [];
484
+ },
485
+ describe: `${target.apiBase} (account vault)`,
486
+ };
487
+ }
488
+
489
+ return {
490
+ backend: BACKEND_RCLONE,
491
+ async put(rel, text, { name = 'object' } = {}) {
492
+ const shelf = tempDir();
493
+ try {
494
+ const file = path.join(shelf, name);
495
+ fs.writeFileSync(file, text);
496
+ rcloneRun(target, ['copyto', file, remoteSpecOf(target, rel)]);
497
+ } finally {
498
+ fs.rmSync(shelf, { recursive: true, force: true });
499
+ }
500
+ },
501
+ /** Upload an existing local file, byte for byte — `publish` needs this. */
502
+ async putFile(rel, localPath) {
503
+ rcloneRun(target, ['copyto', localPath, remoteSpecOf(target, rel)]);
504
+ },
505
+ async cat(rel) {
506
+ try {
507
+ return rcloneRun(target, ['cat', remoteSpecOf(target, rel)]);
508
+ } catch (e) {
509
+ if (e.code === 'ERCLONE' && /not found|does not exist|error 404/i.test(e.stderr || e.message)) return null;
510
+ throw e;
511
+ }
512
+ },
513
+ async list(rel) {
514
+ return listRaw(target, rel);
515
+ },
516
+ async listRecursive(prefix) {
517
+ return rcloneLsjsonRecursive(target, prefix);
518
+ },
519
+ describe: `${target.remote}:${target.root}`,
520
+ };
521
+ }
522
+
523
+ /** `records/x.json` → `records/x.json`; strips the root/spec forms the readers accept. */
524
+ function remoteObjectName(rel) {
525
+ return relativeToRoot({ remote: '', root: '' }, rel).replace(/^\/+/, '');
526
+ }
527
+
232
528
  // ---------------------------------------------------------------------------
233
529
  // the guard: what may leave the machine
234
530
  // ---------------------------------------------------------------------------
@@ -426,7 +722,7 @@ function readRecord({ file = null, record = null, secret = null, env = process.e
426
722
  * @param {Date|string} [o.now] publishedAt, injectable so a test can pin it
427
723
  * @returns {{ok: boolean, anchor: object, url: string|null, remotePath: string, dryRun: boolean}}
428
724
  */
429
- function publish(o = {}) {
725
+ async function publish(o = {}) {
430
726
  const env = o.env || process.env;
431
727
  const argv = o.argv || [];
432
728
  const target = vaultTarget(env, argv);
@@ -460,10 +756,8 @@ function publish(o = {}) {
460
756
  // different names: `anchor` found one, `vault list` paired the other, and the
461
757
  // record's own link could not be fetched by the name it was stored under.
462
758
  const anchorName = `${recordName}${ANCHOR_SUFFIX}`;
463
- const names = [
464
- remoteSpec(target, RECORDS_DIR, recordName),
465
- remoteSpec(target, RECORDS_DIR, anchorName),
466
- ];
759
+ const recordRel = `${RECORDS_DIR}/${recordName}`;
760
+ const anchorRel = `${RECORDS_DIR}/${anchorName}`;
467
761
 
468
762
  const digest = sha256(text);
469
763
  const anchor = {
@@ -477,78 +771,83 @@ function publish(o = {}) {
477
771
  attestation: record.attestation,
478
772
  sha256: digest,
479
773
  bytes: Buffer.byteLength(text, 'utf8'),
774
+ // The backend is recorded because a link has to say WHERE the copy is for
775
+ // `anchor` to look in the right place: the same record can be published to
776
+ // the account vault and to a Drive folder, and the two are different links.
777
+ backend: target.backend,
480
778
  remote: target.remote,
481
779
  root: target.root,
482
780
  // `path` is for a human (and for pasting into rclone); `key` is the
483
781
  // root-relative form the reader uses. Both, because a single field that
484
782
  // means one thing to a person and another to rclone is how the root got
485
783
  // applied twice.
486
- path: `${target.root}/${RECORDS_DIR}/${recordName}`,
487
- key: `${RECORDS_DIR}/${recordName}`,
784
+ path: target.backend === BACKEND_CLOUD
785
+ ? `${CLOUD_PREFIX}/${recordRel}`
786
+ : `${target.root}/${recordRel}`,
787
+ key: recordRel,
488
788
  emittedAt: new Date(o.now || Date.now()).toISOString(),
489
789
  driveId: null,
490
790
  url: null,
491
791
  index: wantIndex ? `${target.root}/${RECORDS_DIR}/${INDEX_NAME}` : null,
492
792
  };
493
793
 
794
+ const transport = transportFor(target, o);
795
+
494
796
  if (dryRun) {
495
797
  return {
496
798
  ok: true,
497
799
  dryRun: true,
800
+ backend: target.backend,
498
801
  anchor,
499
802
  url: null,
500
803
  remotePath: anchor.path,
501
- commands: [`rclone copyto ${file || '<record>'} ${names[0]}`, `rclone copyto <anchor> ${names[1]}`],
804
+ commands: target.backend === BACKEND_CLOUD
805
+ ? [`PUT ${target.apiBase}${CLOUD_PREFIX}/${recordRel}`, `PUT ${target.apiBase}${CLOUD_PREFIX}/${anchorRel}`]
806
+ : [`rclone copyto ${file || '<record>'} ${remoteSpec(target, recordRel)}`, `rclone copyto <anchor> ${remoteSpec(target, anchorRel)}`],
502
807
  };
503
808
  }
504
809
 
505
- const shelf = tempDir();
506
- try {
507
- // Upload the *sealed bytes*. When publishing from a path, that is the file
508
- // itself; otherwise it is written out first so a download can be diffed
509
- // against the thing that was sealed.
510
- const localRecord = file || path.join(shelf, recordName);
511
- if (!file) fs.writeFileSync(localRecord, text);
512
- rcloneRun(target, ['copyto', localRecord, names[0]]);
513
-
514
- // The Drive id comes from a listing, not from a shareable-link call: a
515
- // `link` would change the folder's sharing, and publishing a record must
516
- // not touch who can read the vault.
517
- const listed = listRaw(target, RECORDS_DIR);
518
- const entry = listed.find((f) => f.Name === recordName) || null;
519
- if (entry) {
520
- anchor.driveId = entry.ID || null;
521
- anchor.url = driveUrl(entry.ID);
522
- }
810
+ // Upload the *sealed bytes*. When publishing from a path, that is the file
811
+ // itself; otherwise it is written out first so a download can be diffed
812
+ // against the thing that was sealed.
813
+ if (file) await transport.putFile(recordRel, file);
814
+ else await transport.put(recordRel, text, { name: recordName });
815
+
816
+ // The Drive id comes from a listing, not from a shareable-link call: a
817
+ // `link` would change the folder's sharing, and publishing a record must
818
+ // not touch who can read the vault. The cloud backend returns its own
819
+ // object id and url from the same kind of listing.
820
+ const listed = await transport.list(RECORDS_DIR);
821
+ const entry = listed.find((f) => String(f.Name) === recordName) || null;
822
+ if (entry) {
823
+ anchor.driveId = entry.ID || null;
824
+ anchor.url = entry.url || (target.backend === BACKEND_CLOUD ? null : driveUrl(entry.ID));
825
+ }
523
826
 
524
- const localAnchor = file ? `${file}${ANCHOR_SUFFIX}` : null;
525
- const anchorText = JSON.stringify(anchor, null, 2) + '\n';
526
- const anchorFile = path.join(shelf, anchorName);
527
- fs.writeFileSync(anchorFile, anchorText);
528
- rcloneRun(target, ['copyto', anchorFile, names[1]]);
529
- if (localAnchor) fs.writeFileSync(localAnchor, anchorText);
827
+ const localAnchor = file ? `${file}${ANCHOR_SUFFIX}` : null;
828
+ const anchorText = JSON.stringify(anchor, null, 2) + '\n';
829
+ await transport.put(anchorRel, anchorText, { name: anchorName });
830
+ if (localAnchor) fs.writeFileSync(localAnchor, anchorText);
530
831
 
531
- let indexLines = null;
532
- if (wantIndex) indexLines = appendIndex(target, anchor);
832
+ let indexLines = null;
833
+ if (wantIndex) indexLines = await appendIndex(target, anchor, transport);
533
834
 
534
- return {
535
- ok: true,
536
- dryRun: false,
537
- anchor,
538
- url: anchor.url,
539
- localAnchor,
540
- remotePath: anchor.path,
541
- bytes: anchor.bytes,
542
- sha256: digest,
543
- indexLines,
544
- };
545
- } finally {
546
- fs.rmSync(shelf, { recursive: true, force: true });
547
- }
835
+ return {
836
+ ok: true,
837
+ dryRun: false,
838
+ backend: target.backend,
839
+ anchor,
840
+ url: anchor.url,
841
+ localAnchor,
842
+ remotePath: anchor.path,
843
+ bytes: anchor.bytes,
844
+ sha256: digest,
845
+ indexLines,
846
+ };
548
847
  }
549
848
 
550
- /** Read one remote text file, `null` when it is not there. */
551
- function cat(target, remotePath) {
849
+ /** Read one remote text file through rclone, `null` when it is not there. */
850
+ function rcloneCat(target, remotePath) {
552
851
  try {
553
852
  return rcloneRun(target, ['cat', remoteSpecOf(target, remotePath)]);
554
853
  } catch (e) {
@@ -581,9 +880,12 @@ function listRaw(target, remotePath) {
581
880
  * ledger line loses nothing that cannot be rebuilt from the anchors beside each
582
881
  * record (`list` reads those, not this).
583
882
  */
584
- function appendIndex(target, anchor) {
585
- const remotePath = `${RECORDS_DIR}/${INDEX_NAME}`;
586
- const existing = cat(target, remotePath) || '';
883
+ async function appendIndex(target, anchor, transport = transportFor(target)) {
884
+ const rel = `${RECORDS_DIR}/${INDEX_NAME}`;
885
+ // `cat` resolves null for a missing object on BOTH backends (the rclone
886
+ // adapter maps "not found", the cloud adapter maps a 404), so a first publish
887
+ // starts from an empty ledger rather than from an error.
888
+ const existing = (await transport.cat(rel)) || '';
587
889
  const line = JSON.stringify({
588
890
  kind: INDEX_KIND,
589
891
  id: anchor.id,
@@ -594,15 +896,8 @@ function appendIndex(target, anchor) {
594
896
  driveId: anchor.driveId,
595
897
  emittedAt: anchor.emittedAt,
596
898
  });
597
- const shelf = tempDir();
598
- try {
599
- const file = path.join(shelf, INDEX_NAME);
600
- fs.writeFileSync(file, existing + line + '\n');
601
- rcloneRun(target, ['copyto', file, remoteSpecOf(target, remotePath)]);
602
- return (existing.trim() ? existing.trim().split('\n').length : 0) + 1;
603
- } finally {
604
- fs.rmSync(shelf, { recursive: true, force: true });
605
- }
899
+ await transport.put(rel, existing + line + '\n', { name: INDEX_NAME });
900
+ return (existing.trim() ? existing.trim().split('\n').length : 0) + 1;
606
901
  }
607
902
 
608
903
  // ---------------------------------------------------------------------------
@@ -634,17 +929,21 @@ function localAnchor(file) {
634
929
  * attestation no longer recomputes means the key moved on (or the record was
635
930
  * published by another key), and no digest of the file would ever show that.
636
931
  */
637
- function checkAnchor(o = {}) {
932
+ async function checkAnchor(o = {}) {
638
933
  const env = o.env || process.env;
639
934
  const argv = o.argv || [];
640
- const target = vaultTarget(env, argv);
641
-
642
935
  const anchor = o.anchor || (o.file ? localAnchor(o.file) : null);
643
936
  if (!anchor) {
644
937
  return { ok: false, reason: 'not linked — no anchor beside this record (publish it first)', present: false, unchanged: null, attested: null };
645
938
  }
646
-
647
- const text = cat(target, anchor.key || anchor.path);
939
+ // The link says WHERE it was published, so the check reads the backend off the
940
+ // anchor rather than off this machine's current default: a record published to
941
+ // the account vault must still verify after the user switches their default to
942
+ // a Drive remote (and the other way round).
943
+ const target = vaultTarget(env, argv, anchor);
944
+ const transport = transportFor(target, o);
945
+
946
+ const text = await transport.cat(anchor.key || anchor.path);
648
947
  if (text === null) {
649
948
  // `present`, not `presence`: the field name is part of the answer, and a
650
949
  // consumer reading `.present` got `undefined` from this branch while the
@@ -688,49 +987,55 @@ function checkAnchor(o = {}) {
688
987
  }
689
988
 
690
989
  /** What is in the vault. Reads the anchors, which is why the ledger is optional. */
691
- function list(o = {}) {
990
+ async function list(o = {}) {
692
991
  const env = o.env || process.env;
693
992
  const argv = o.argv || [];
694
993
  const target = vaultTarget(env, argv);
994
+ const transport = transportFor(target, o);
695
995
  const dir = RECORDS_DIR;
696
- const files = listRaw(target, dir);
996
+ const files = await transport.list(dir);
697
997
  const anchors = files.filter((f) => String(f.Name).endsWith(ANCHOR_SUFFIX));
698
998
  const records = files.filter((f) => !String(f.Name).endsWith(ANCHOR_SUFFIX) && String(f.Name).endsWith('.json') && f.Name !== INDEX_NAME);
699
999
 
1000
+ const rows = [];
1001
+ for (const r of records) {
1002
+ // Paired by the same rule that wrote it: the anchor is the record's name
1003
+ // plus the suffix. Stripping `.json` off the record name first would look
1004
+ // for `<stem>.anchor.json`, which is not the name anything was written
1005
+ // under.
1006
+ const anchorFile = anchors.find((a) => String(a.Name) === `${r.Name}${ANCHOR_SUFFIX}`);
1007
+ let anchor = null;
1008
+ if (anchorFile) {
1009
+ // `cat` resolves the path against the root, so `records/<name>` here
1010
+ // means `<remote>:<root>/records/<name>` — stated because getting this
1011
+ // wrong is what put the ledger one directory too deep.
1012
+ const text = await transport.cat(`${dir}/${anchorFile.Name}`);
1013
+ try {
1014
+ anchor = JSON.parse(text);
1015
+ } catch {
1016
+ anchor = { unreadable: true };
1017
+ }
1018
+ }
1019
+ rows.push({
1020
+ name: r.Name,
1021
+ bytes: r.Size,
1022
+ emittedAt: r.ModTime,
1023
+ id: anchor ? anchor.id : null,
1024
+ keyFingerprint: anchor ? anchor.keyFingerprint : null,
1025
+ sha256: anchor ? anchor.sha256 : null,
1026
+ driveId: (anchor && anchor.driveId) || r.ID || null,
1027
+ url: (anchor && anchor.url) || r.url || driveUrl(r.ID),
1028
+ linked: Boolean(anchor),
1029
+ });
1030
+ }
1031
+
700
1032
  return {
701
1033
  ok: true,
1034
+ backend: target.backend,
702
1035
  remote: target.remote,
703
1036
  root: target.root,
704
- records: records.map((r) => {
705
- // Paired by the same rule that wrote it: the anchor is the record's name
706
- // plus the suffix. Stripping `.json` off the record name first would look
707
- // for `<stem>.anchor.json`, which is not the name anything was written
708
- // under.
709
- const anchorFile = anchors.find((a) => String(a.Name) === `${r.Name}${ANCHOR_SUFFIX}`);
710
- let anchor = null;
711
- if (anchorFile) {
712
- const text = cat(target, `${dir}/${anchorFile.Name}`);
713
- // `cat` resolves the path against the root, so `records/<name>` here
714
- // means `<remote>:<root>/records/<name>` — stated because getting this
715
- // wrong is what put the ledger one directory too deep.
716
- try {
717
- anchor = JSON.parse(text);
718
- } catch {
719
- anchor = { unreadable: true };
720
- }
721
- }
722
- return {
723
- name: r.Name,
724
- bytes: r.Size,
725
- emittedAt: r.ModTime,
726
- id: anchor ? anchor.id : null,
727
- keyFingerprint: anchor ? anchor.keyFingerprint : null,
728
- sha256: anchor ? anchor.sha256 : null,
729
- driveId: (anchor && anchor.driveId) || r.ID || null,
730
- url: (anchor && anchor.url) || driveUrl(r.ID),
731
- linked: Boolean(anchor),
732
- };
733
- }),
1037
+ where: transport.describe,
1038
+ records: rows,
734
1039
  };
735
1040
  }
736
1041
 
@@ -747,19 +1052,20 @@ function list(o = {}) {
747
1052
  * capped: an audit that silently stops inspecting is worse than one that says
748
1053
  * it did.
749
1054
  */
750
- function audit(o = {}) {
1055
+ async function audit(o = {}) {
751
1056
  const env = o.env || process.env;
752
1057
  const argv = o.argv || [];
753
1058
  const target = vaultTarget(env, argv);
1059
+ const transport = transportFor(target, o);
754
1060
  const max = Number(flag(argv, '--max')) || MAX_AUDIT_FILES;
755
1061
 
756
1062
  let files;
757
1063
  try {
758
1064
  // The whole root, recursively: the object this audit exists to find
759
1065
  // (`keys/agent.key`) is deliberately outside `records/`.
760
- files = rcloneLsjsonRecursive(target, '');
1066
+ files = await transport.listRecursive('');
761
1067
  } catch (e) {
762
- return { ok: false, error: (e && e.message) || String(e), findings: [], checked: 0 };
1068
+ return { ok: false, backend: target.backend, where: transport.describe, error: (e && e.message) || String(e), findings: [], checked: 0 };
763
1069
  }
764
1070
 
765
1071
  const secret = o.secret || null;
@@ -780,7 +1086,7 @@ function audit(o = {}) {
780
1086
  checked += 1;
781
1087
  let text;
782
1088
  try {
783
- text = rcloneRun(target, ['cat', remoteSpecOf(target, rel)]);
1089
+ text = await transport.cat(rel);
784
1090
  } catch (e) {
785
1091
  uninspected.push({ path: rel, reason: (e && e.message) || String(e) });
786
1092
  continue;
@@ -791,8 +1097,10 @@ function audit(o = {}) {
791
1097
 
792
1098
  return {
793
1099
  ok: findings.length === 0,
1100
+ backend: target.backend,
794
1101
  remote: target.remote,
795
1102
  root: target.root,
1103
+ where: transport.describe,
796
1104
  checked,
797
1105
  files: files.length,
798
1106
  uninspected,
@@ -812,6 +1120,12 @@ function rcloneLsjsonRecursive(target, prefix) {
812
1120
  module.exports = {
813
1121
  DEFAULT_REMOTE,
814
1122
  DEFAULT_ROOT,
1123
+ DEFAULT_BACKEND,
1124
+ BACKEND_CLOUD,
1125
+ BACKEND_RCLONE,
1126
+ CLOUD_PREFIX,
1127
+ BACKEND_ENV,
1128
+ API_BASE_ENV,
815
1129
  RECORDS_DIR,
816
1130
  INDEX_NAME,
817
1131
  ANCHOR_SUFFIX,
@@ -833,6 +1147,10 @@ module.exports = {
833
1147
  driveUrl,
834
1148
  vaultTarget,
835
1149
  rcloneBinary,
1150
+ rcloneCat,
1151
+ transportFor,
1152
+ cloudApiKey,
1153
+ backendFor,
836
1154
  sha256,
837
1155
  flag,
838
1156
  has,