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.
- package/lib/auto.js +11 -7
- package/lib/fingerprint.js +76 -17
- package/lib/google-connect.js +171 -0
- package/lib/local/claude-pro.js +134 -11
- package/lib/local/engine.js +111 -26
- package/lib/local/prompt.js +67 -37
- package/lib/local/tools.js +49 -13
- package/lib/settings.js +65 -24
- package/main.js +270 -36
- package/package.json +5 -2
- package/preload.js +45 -12
- package/renderer/app.js +691 -22
- package/renderer/budget.js +18 -9
- package/renderer/companion.js +225 -0
- package/renderer/fingerprint.css +16 -0
- package/renderer/fingerprint.js +136 -2
- package/renderer/index.html +302 -121
- package/renderer/provider-toolbar.js +136 -0
- package/renderer/providers.js +261 -0
- package/renderer/style.css +547 -171
- package/vendor/aegis.js +169 -3
- package/vendor/credentials.js +9 -2
- package/vendor/drive-connect.js +87 -0
- package/vendor/drive-upload.js +176 -0
- package/vendor/google-signin.js +127 -0
- package/vendor/record-vault.js +431 -113
- package/vendor/update.js +366 -8
package/vendor/record-vault.js
CHANGED
|
@@ -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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
464
|
-
|
|
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:
|
|
487
|
-
|
|
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:
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
532
|
-
|
|
832
|
+
let indexLines = null;
|
|
833
|
+
if (wantIndex) indexLines = await appendIndex(target, anchor, transport);
|
|
533
834
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
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
|
|
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
|
|
586
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
705
|
-
|
|
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 =
|
|
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 =
|
|
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,
|