agmsg-cloud 0.1.0-rc.8 → 0.1.0
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/dist/src/api.js +85 -3
- package/dist/src/commands/connect.js +14 -7
- package/dist/src/commands/login.js +120 -3
- package/dist/src/commands/logout.js +38 -7
- package/dist/src/commands/request.js +53 -3
- package/dist/src/commands/sync.js +12 -1
- package/dist/src/commands/vault.js +345 -78
- package/dist/src/commands/watch.js +25 -7
- package/dist/src/config.js +11 -2
- package/dist/src/credentials.js +149 -17
- package/dist/src/data-plane.js +67 -0
- package/dist/src/index.js +45 -24
- package/dist/src/oss.js +30 -1
- package/dist/src/preflight.js +140 -15
- package/dist/src/recovery-key.js +11 -16
- package/dist/src/self-install.js +152 -0
- package/dist/src/slot-advice.js +7 -19
- package/dist/src/vault-inventory.js +118 -0
- package/dist/src/vault-placement.js +94 -0
- package/package.json +1 -1
package/dist/src/credentials.js
CHANGED
|
@@ -3,15 +3,51 @@ import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readF
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
/**
|
|
6
|
-
* A server-minted org address
|
|
6
|
+
* A server-minted org address, in either of the two forms the server mints.
|
|
7
7
|
*
|
|
8
8
|
* Exported because the shape is a contract between the two places that care —
|
|
9
9
|
* the caller that receives one over the wire and the key that is built from it.
|
|
10
10
|
* Stating it in a comment next to the key was not enough: the comment justified
|
|
11
11
|
* a delimiter by a property nothing enforced.
|
|
12
|
+
*
|
|
13
|
+
* org_<canonical uuid> what every server has answered with so far
|
|
14
|
+
* agor_<26 base32> the convention (#210), stored in `orgs.public_id`
|
|
15
|
+
*
|
|
16
|
+
* BOTH, AND THIS HALF SHIPS FIRST. Until this build is what `npm install`
|
|
17
|
+
* fetches, the server cannot start answering with the second form: the previous
|
|
18
|
+
* published build — measured on the tarball the registry serves, not on a
|
|
19
|
+
* branch, `agmsg-cloud@0.1.0-rc.8`, `dist/src/credentials.js` line 14 — matches
|
|
20
|
+
* `org_<uuid>` alone, and `keyFor` below THROWS on anything else. So an
|
|
21
|
+
* installed copy stops being able to store a credential the moment the server
|
|
22
|
+
* changes, and shipping a newer CLI does not repair an installation nobody has
|
|
23
|
+
* updated.
|
|
24
|
+
*
|
|
25
|
+
* Which is also why this accepts both rather than switching: for the whole
|
|
26
|
+
* period between this publish and the server's cutover, this build is talking
|
|
27
|
+
* to a server that still answers with `org_<uuid>`. A build that took only the
|
|
28
|
+
* new form would break exactly the window it exists to bridge.
|
|
29
|
+
*
|
|
30
|
+
* The `agor_` shape is not chosen here. It is the one the database enforces —
|
|
31
|
+
* `orgs.public_id`'s CHECK is `^agor_[a-z2-7]{26}$` (app/migrations/0036).
|
|
32
|
+
*
|
|
33
|
+
* A hand-written fourth copy of a mapping is the defect #216 is about, so what
|
|
34
|
+
* makes this one safe is a test rather than this paragraph:
|
|
35
|
+
* `test/org-address-matches-server.test.ts` parses the prefix, the body length
|
|
36
|
+
* and the alphabet out of `app/src/public-id.ts` and drives THIS function with
|
|
37
|
+
* values built from them. It is hand-written because the two packages share no
|
|
38
|
+
* module — `@agmsg-cloud/sas-core` is one protocol's derivations, not a place
|
|
39
|
+
* for whatever two packages both need.
|
|
40
|
+
*
|
|
41
|
+
* That covers one direction: this build accepting what that server mints. The
|
|
42
|
+
* other — the server refusing to emit anything this build would reject —
|
|
43
|
+
* belongs to the cutover change, and until it lands the contract holding the
|
|
44
|
+
* two together is the merge order itself: the cutover does not land until
|
|
45
|
+
* `npm view agmsg-cloud version` resolves to a build whose `isOrgAddress`
|
|
46
|
+
* accepts the new form.
|
|
12
47
|
*/
|
|
13
48
|
export function isOrgAddress(value) {
|
|
14
|
-
return /^org_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)
|
|
49
|
+
return (/^org_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value) ||
|
|
50
|
+
/^agor_[a-z2-7]{26}$/.test(value));
|
|
15
51
|
}
|
|
16
52
|
/**
|
|
17
53
|
* The storage key: one slot per (host, org).
|
|
@@ -19,9 +55,29 @@ export function isOrgAddress(value) {
|
|
|
19
55
|
* Both halves are derived rather than taken as typed — `https://x.test/` and
|
|
20
56
|
* `https://x.test` are one host and must not become two slots holding two
|
|
21
57
|
* secrets. The separator is a space because neither half can contain one: an
|
|
22
|
-
* origin has no spaces by construction, and
|
|
58
|
+
* origin has no spaces by construction, and neither org address form admits one
|
|
59
|
+
* — `isOrgAddress` is anchored at both ends over alphabets that exclude it, and
|
|
60
|
+
* that is now true of two shapes rather than one.
|
|
23
61
|
*
|
|
24
|
-
*
|
|
62
|
+
* THE KEY IS THE ADDRESS AS RECEIVED, and the two forms of one org are two
|
|
63
|
+
* keys. That is correct while the server emits one form at a time: a machine
|
|
64
|
+
* enrolled before the cutover holds `org_<uuid>` and keeps using it, because
|
|
65
|
+
* nothing rewrites credentials.json and nothing here can turn one form into the
|
|
66
|
+
* other — `agor_` is not derived from the uuid, it is a separate stored value
|
|
67
|
+
* only the server can map. A machine that enrolls again after the cutover gets
|
|
68
|
+
* a second slot for the same org: no collision and no silent overwrite, since
|
|
69
|
+
* distinct keys is exactly what stops the second login clobbering the first.
|
|
70
|
+
*
|
|
71
|
+
* The cost is one reachable message, and it is worth naming rather than calling
|
|
72
|
+
* it harmless. `readCredential(origin)` throws when an origin has more than one
|
|
73
|
+
* slot and none is active — after logging out of the active one, that error now
|
|
74
|
+
* reads "credentials for more than one org (agor_…, org_…)" about what is ONE
|
|
75
|
+
* org wearing both of its addresses. The instruction it gives is still the
|
|
76
|
+
* right one and still works. Cleaning this up means mapping one form to the
|
|
77
|
+
* other, which only the server can do; it belongs to the cutover change, not
|
|
78
|
+
* here, and is recorded on #210.
|
|
79
|
+
*
|
|
80
|
+
* The org half is checked here rather than assumed. A malformed org —
|
|
25
81
|
* `x`, or anything carrying a space or a newline — would not be caught by a
|
|
26
82
|
* non-empty test upstream, and it does not fail loudly either: it writes a slot
|
|
27
83
|
* under a key nothing will ever look up again, which is worse than the
|
|
@@ -84,14 +140,29 @@ function readFile(path) {
|
|
|
84
140
|
* two secrets that both fit: don't.
|
|
85
141
|
*
|
|
86
142
|
* - one credential for the host — return it
|
|
87
|
-
* -
|
|
88
|
-
* - several
|
|
143
|
+
* - none — return null
|
|
144
|
+
* - several — THROW, naming the orgs
|
|
145
|
+
*
|
|
146
|
+
* SEVERAL IS NOW ONLY REACHABLE FROM A FILE WRITTEN BEFORE #399, and the branch
|
|
147
|
+
* stays for exactly that reason. `writeCredential` cannot produce it any more,
|
|
148
|
+
* so on this version's own files the throw is unreachable — but the file
|
|
149
|
+
* outlives the binary that wrote it, and a machine upgrading from rc.8 can be
|
|
150
|
+
* holding two orgs for one host right now. Deleting the branch would not make
|
|
151
|
+
* that machine consistent; it would make it pick one silently, which is the
|
|
152
|
+
* failure the org half of the key was added to stop.
|
|
89
153
|
*
|
|
90
|
-
* The
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
154
|
+
* The `active` tie-break went with the accumulation. It used to answer "several,
|
|
155
|
+
* and one of them is the last login — use that", and preferring the last login
|
|
156
|
+
* is precisely the behaviour that was ruled out: a machine holds one credential
|
|
157
|
+
* per host, so an origin with two is a machine in a state no version can act in
|
|
158
|
+
* rather than one with a default. `active` itself stays, for the `origin === null`
|
|
159
|
+
* path below — a machine may still hold one credential for each of several
|
|
160
|
+
* hosts.
|
|
161
|
+
*
|
|
162
|
+
* Returning `null` for the several case would say "no credential for this host",
|
|
163
|
+
* which is false and sends the caller down the sign-in path — the same collapse
|
|
164
|
+
* of "ambiguous" into "absent" that hid the original defect. It throws instead,
|
|
165
|
+
* and the message names the orgs and the one command that resolves it.
|
|
95
166
|
*/
|
|
96
167
|
export function readCredential(origin, env = process.env) {
|
|
97
168
|
const file = readFile(credentialsPath(env));
|
|
@@ -103,15 +174,14 @@ export function readCredential(origin, env = process.env) {
|
|
|
103
174
|
return null;
|
|
104
175
|
if (matches.length === 1)
|
|
105
176
|
return matches[0][1];
|
|
106
|
-
if (file.active && file.credentials[file.active] && file.active.startsWith(`${origin} `)) {
|
|
107
|
-
return file.credentials[file.active];
|
|
108
|
-
}
|
|
109
177
|
const orgs = matches
|
|
110
178
|
.map(([, c]) => c.org)
|
|
111
179
|
.sort()
|
|
112
180
|
.join(', ');
|
|
113
|
-
throw new Error(`${origin} has credentials for more than one org (${orgs})
|
|
114
|
-
`
|
|
181
|
+
throw new Error(`${origin} has credentials for more than one org (${orgs}), which this version does not store. ` +
|
|
182
|
+
`That file was written by an older agmsg-cloud. Run agmsg-cloud logout --endpoint ${origin} ` +
|
|
183
|
+
`to remove both, then agmsg-cloud login for the org you want — a login now keeps one ` +
|
|
184
|
+
`credential per service.`);
|
|
115
185
|
}
|
|
116
186
|
// An atomic rename prevents a half-written FILE. It does not serialize an
|
|
117
187
|
// update: two logins to different origins both read the same old JSON, both
|
|
@@ -272,6 +342,34 @@ function acquireLock(path) {
|
|
|
272
342
|
spin(LOCK_RETRY_MS);
|
|
273
343
|
}
|
|
274
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Every credential stored for one host, in no particular order, never throwing.
|
|
347
|
+
*
|
|
348
|
+
* `readCredential(origin)` fails closed when a host holds more than one, and
|
|
349
|
+
* that contract is right for the commands that ASK it something — `connect` and
|
|
350
|
+
* `pull` want one secret to present, and there is no honest way to pick between
|
|
351
|
+
* two. It is exactly wrong for `login`.
|
|
352
|
+
*
|
|
353
|
+
* Because `login` is the command that RESOLVES the ambiguity. It calls
|
|
354
|
+
* `readCredential(origin)` before anything else, so on a file written by rc.8 —
|
|
355
|
+
* two orgs on one host, which is the state this whole change exists to end — it
|
|
356
|
+
* threw before reaching the grant, the warning, or the replacing write. The
|
|
357
|
+
* error told the person to run `logout` first, which means "login replaces what
|
|
358
|
+
* is stored" held only for single slots that the NEW writer had produced, and
|
|
359
|
+
* not for the machines already in the state. The fail-closed guard was closed
|
|
360
|
+
* against its own remedy.
|
|
361
|
+
*
|
|
362
|
+
* So this is the reader for the one caller that is allowed to see the whole
|
|
363
|
+
* pile: it returns all of them and lets `login` say how many are going. Nothing
|
|
364
|
+
* else should use it — a command that acts on an ambiguous host is the defect,
|
|
365
|
+
* and only the one that is about to collapse the ambiguity is exempt.
|
|
366
|
+
*/
|
|
367
|
+
export function credentialsForOrigin(origin, env = process.env) {
|
|
368
|
+
const file = readFile(credentialsPath(env));
|
|
369
|
+
return Object.entries(file.credentials)
|
|
370
|
+
.filter(([key]) => key.startsWith(`${origin} `))
|
|
371
|
+
.map(([, credential]) => credential);
|
|
372
|
+
}
|
|
275
373
|
export function writeCredential(credential, env = process.env) {
|
|
276
374
|
const path = credentialsPath(env);
|
|
277
375
|
const dir = dirname(path);
|
|
@@ -287,15 +385,49 @@ export function writeCredential(credential, env = process.env) {
|
|
|
287
385
|
release();
|
|
288
386
|
}
|
|
289
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* ONE CREDENTIAL PER ORIGIN, enforced here (#399).
|
|
390
|
+
*
|
|
391
|
+
* This used to merge: `{ ...file.credentials, [key]: credential }`, so a login
|
|
392
|
+
* under a second org added a slot beside the first and nothing ever removed it.
|
|
393
|
+
* The pile-up was not a feature — the key gained its org half to stop a second
|
|
394
|
+
* login silently CLOBBERING the first, and accumulation was the side effect. The
|
|
395
|
+
* resolver has always conceded as much by throwing when an origin holds several
|
|
396
|
+
* and none is active: what could be stored and what could be used disagreed, and
|
|
397
|
+
* the gap is what someone walked into when `logout` reported signing out of two
|
|
398
|
+
* orgs, one of which no longer existed on the server.
|
|
399
|
+
*
|
|
400
|
+
* PER ORIGIN, NOT PER MACHINE, and the difference is deliberate. The origin half
|
|
401
|
+
* of the key separates deployments — staging from production — which is a
|
|
402
|
+
* distinction someone chose. The org half is the one that accumulated. So a
|
|
403
|
+
* login to one host leaves another host's credential alone, and signing into
|
|
404
|
+
* staging cannot delete a production secret.
|
|
405
|
+
*
|
|
406
|
+
* The replacement happens INSIDE the lock and in ONE commit, rather than as a
|
|
407
|
+
* remove followed by a write. Two durable writes have a window between them
|
|
408
|
+
* where the file holds nothing, and this file is the only copy of a secret: an
|
|
409
|
+
* interrupt in that window would sign a machine out of a service it was never
|
|
410
|
+
* asked to leave. `commit` is temp -> fsync -> rename -> fsync dir, so the file
|
|
411
|
+
* on disk is either entirely the old credential or entirely the new one.
|
|
412
|
+
*
|
|
413
|
+
* Entries for OTHER origins are carried across untouched.
|
|
414
|
+
*/
|
|
290
415
|
function writeLocked(path, dir, credential) {
|
|
291
416
|
const key = keyFor(credential.endpoint, credential.org);
|
|
417
|
+
const origin = originOf(credential.endpoint);
|
|
292
418
|
// Read INSIDE the lock: a copy read before acquiring it is exactly the stale
|
|
293
419
|
// base that loses another entry.
|
|
294
420
|
const file = readFile(path);
|
|
421
|
+
const otherOrigins = Object.entries(file.credentials).filter(([existing]) => !existing.startsWith(`${origin} `));
|
|
295
422
|
const next = {
|
|
296
423
|
version: 1,
|
|
424
|
+
// Still a pointer, and still needed: `readCredential(null)` answers "what
|
|
425
|
+
// does this machine have" for callers that were given no endpoint, and a
|
|
426
|
+
// machine may legitimately hold one credential per origin. What died is
|
|
427
|
+
// choosing between two credentials for the SAME origin, which cannot happen
|
|
428
|
+
// any more.
|
|
297
429
|
active: key,
|
|
298
|
-
credentials: { ...
|
|
430
|
+
credentials: { ...Object.fromEntries(otherOrigins), [key]: credential },
|
|
299
431
|
};
|
|
300
432
|
commit(path, dir, next);
|
|
301
433
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one thing this CLI asks the data plane directly: which server is it.
|
|
3
|
+
*
|
|
4
|
+
* Everything else that touches the sync gateway goes through the OSS scripts.
|
|
5
|
+
* This does not, and the reason is an ordering problem they cannot solve for
|
|
6
|
+
* us: `remote.sh` only reports a server instance for a team it has already
|
|
7
|
+
* PULLED, and a pull is exactly what must not happen until the instance is
|
|
8
|
+
* known.
|
|
9
|
+
*
|
|
10
|
+
* A vault entry is keyed on `(server_instance_id, team_id)`. Placing one means
|
|
11
|
+
* pulling it, and a pull creates a local team under a person's name and gives
|
|
12
|
+
* it history — which, measured on the OSS side, is what makes the NEXT pull
|
|
13
|
+
* refuse. So a pull performed to discover that the entry belonged to another
|
|
14
|
+
* server leaves a team that cannot be replaced by the right one, and this
|
|
15
|
+
* product has no way to free a local name (#446). Nothing may be created until
|
|
16
|
+
* the pair is confirmed.
|
|
17
|
+
*
|
|
18
|
+
* ## Why `/v1/health`, and not `/v1/capabilities`
|
|
19
|
+
*
|
|
20
|
+
* The first version of this asked `/v1/capabilities` with the vault entry's
|
|
21
|
+
* team id, and that request is **not a read on the hosted path** (review).
|
|
22
|
+
* `gateway/src/server.ts` gives that route `scope: 'header'`, and a scoped
|
|
23
|
+
* request whose team id comes back `unclaimed` runs `claimOrVerifyTeamCapped`
|
|
24
|
+
* — for a GET. Probing an entry belonging to another server would therefore
|
|
25
|
+
* CLAIM that id for this org and spend a unit of the teams cap, and could stop
|
|
26
|
+
* the org that actually owns it from claiming it later. A check against
|
|
27
|
+
* creating a local obstacle would have created a server-side one.
|
|
28
|
+
*
|
|
29
|
+
* `/v1/health` is `scope: 'none'` in the same table, and **this sends no team
|
|
30
|
+
* header at all**. With no header the gateway's `healthTeamId` returns before
|
|
31
|
+
* `verifyTeam` (`if (requested)`), so no claim path is reachable — not
|
|
32
|
+
* "unlikely", unreachable. The response carries `server_instance_id`, which is
|
|
33
|
+
* the field the gateway itself caches from it.
|
|
34
|
+
*
|
|
35
|
+
* The absence of that header is load-bearing, so it is what the test asserts.
|
|
36
|
+
*/
|
|
37
|
+
/** The OSS client uses 10s connect / 15s total for its own reads; matched. */
|
|
38
|
+
const HEALTH_TIMEOUT_MS = 15_000;
|
|
39
|
+
/**
|
|
40
|
+
* Ask an endpoint which server instance it is, without asking about any team.
|
|
41
|
+
*
|
|
42
|
+
* MEASURED against the OSS server: `GET /v1/health` with no headers answers
|
|
43
|
+
* 200 with `server_instance_id`, `status`, `database`, and an empty `team_id`.
|
|
44
|
+
* The instance is a property of the server, so naming no team costs nothing
|
|
45
|
+
* here — and naming one is what would have cost something.
|
|
46
|
+
*/
|
|
47
|
+
export async function dataPlaneIdentity(capabilityUrl, fetchImpl = fetch) {
|
|
48
|
+
const url = `${capabilityUrl.replace(/\/+$/, '')}/v1/health`;
|
|
49
|
+
const response = await fetchImpl(url, {
|
|
50
|
+
method: 'GET',
|
|
51
|
+
// No `Agmsg-Team-ID`. See above: the header is what reaches the claim path.
|
|
52
|
+
headers: { 'Agmsg-Protocol-Version': '1' },
|
|
53
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`the sync server did not answer which instance it is (HTTP ${response.status})`);
|
|
57
|
+
}
|
|
58
|
+
const body = await response.json();
|
|
59
|
+
const instance = body.server_instance_id;
|
|
60
|
+
// Shape-checked rather than trusted. This value decides whether a backup is
|
|
61
|
+
// placed, so a wrong one is the accident the check exists to prevent, wearing
|
|
62
|
+
// the check's clothes.
|
|
63
|
+
if (typeof instance !== 'string' || instance === '') {
|
|
64
|
+
throw new Error('the sync server answered without naming which instance it is');
|
|
65
|
+
}
|
|
66
|
+
return { serverInstanceId: instance };
|
|
67
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -33,10 +33,10 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
|
|
|
33
33
|
it is matched against the snapshot your code authenticated
|
|
34
34
|
approve <team> [request-id] (on a key-holding machine) approve the waiting request after the codes match
|
|
35
35
|
watch [--interval-ms <n>] (on a key-holding machine) print pending requests as they arrive
|
|
36
|
-
recovery setup
|
|
37
|
-
team this machine's store reports
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
recovery setup create this account's recovery key and back up every active
|
|
37
|
+
team this machine's store reports
|
|
38
|
+
recovery restore [team] (on any approved machine) open the backup; name a team to
|
|
39
|
+
unlock that one, or none to unlock what the vault holds
|
|
40
40
|
whoami which of this account's machines this one is —
|
|
41
41
|
its name, its organization, and the prefix the
|
|
42
42
|
console shows beside it
|
|
@@ -244,36 +244,57 @@ async function main(argv) {
|
|
|
244
244
|
// the one thing that opens the backup, shown once and stored nowhere.
|
|
245
245
|
// A name that hides that is a name someone runs without reading.
|
|
246
246
|
case 'recovery': {
|
|
247
|
-
const [sub, team] = rest;
|
|
247
|
+
const [sub, team, ...extra] = rest;
|
|
248
248
|
if (sub !== 'setup' && sub !== 'restore') {
|
|
249
249
|
// The two take different arguments, so one line covering both said the
|
|
250
250
|
// wrong thing about each: `setup` does not need a team and `restore`
|
|
251
251
|
// does. A usage message is read as the contract (raised in review).
|
|
252
|
-
throw new Error('usage: agmsg-cloud recovery setup
|
|
253
|
-
' agmsg-cloud recovery restore
|
|
252
|
+
throw new Error('usage: agmsg-cloud recovery setup\n' +
|
|
253
|
+
' agmsg-cloud recovery restore [team]');
|
|
254
254
|
}
|
|
255
|
-
// `setup` takes no team
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
255
|
+
// `setup` takes no team: the vault is the ACCOUNT's, so setting it up
|
|
256
|
+
// covers every connected team this machine reports. The optional form
|
|
257
|
+
// was kept only while agmsg#650 could silently return a short enumeration;
|
|
258
|
+
// that producer now distinguishes unreadable teams, so retaining the
|
|
259
|
+
// argument would keep teaching an expired per-team recovery model (#182).
|
|
260
260
|
//
|
|
261
|
-
// `restore
|
|
262
|
-
//
|
|
263
|
-
//
|
|
261
|
+
// `restore`'s arity is NOT this change's subject. #342a made its team
|
|
262
|
+
// optional -- the disaster case supplies none -- which is why the line
|
|
263
|
+
// above reads `[team]`. This branch predates #342a and its side of this
|
|
264
|
+
// hunk still said `<team>`; taking the hunk wholesale would have
|
|
265
|
+
// re-imposed the requirement and reverted #342a, with no conflict
|
|
266
|
+
// marker to show it.
|
|
264
267
|
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
268
|
+
// `restore` NO LONGER REQUIRES ONE (#342), and the reason is the disaster
|
|
269
|
+
// it is for. It resolved the name through this machine's LOCAL binding
|
|
270
|
+
// before touching the vault, so the machine the command exists to rescue
|
|
271
|
+
// — the one that lost everything — never got as far as opening it:
|
|
272
|
+
// `remote.sh status <team>` answered `team '<x>' has never been
|
|
273
|
+
// connected` and it stopped there. Measured against a stub endpoint that
|
|
274
|
+
// logs every request: the server received nothing. Knowing the name did
|
|
275
|
+
// not help; the name was never the missing thing.
|
|
276
|
+
//
|
|
277
|
+
// The restore argument is kept rather than removed. Naming a team is still
|
|
278
|
+
// what someone does when this machine HAS the team and wants that one
|
|
279
|
+
// unlocked, and that path works today -- deleting it to fix the broken one
|
|
280
|
+
// would trade a defect for a regression (#342a).
|
|
281
|
+
//
|
|
282
|
+
// The exemption that used to sit here is gone with #201: measured, the
|
|
283
|
+
// guard never reached this line, because a command introduced by `usage: `
|
|
284
|
+
// opened no command extent, so the comment sat over a slot no rule was
|
|
285
|
+
// applied to. The line is inside the guard's scope now.
|
|
286
|
+
if (sub === 'setup' && (team !== undefined || extra.length > 0)) {
|
|
287
|
+
throw new Error('usage: agmsg-cloud recovery setup');
|
|
272
288
|
}
|
|
289
|
+
// NO `restore` guard. This branch had one, re-asserting that restore
|
|
290
|
+
// requires a team; #342a removed that requirement and the disaster case
|
|
291
|
+
// supplies no team. Its call below therefore has to keep main's optional
|
|
292
|
+
// form -- the two travel together, and taking this hunk wholesale would
|
|
293
|
+
// have restored both halves silently.
|
|
273
294
|
const config = loadConfig();
|
|
274
295
|
return sub === 'setup'
|
|
275
|
-
? cmdVaultPut(config
|
|
276
|
-
: cmdVaultRestore(config, {
|
|
296
|
+
? cmdVaultPut(config)
|
|
297
|
+
: cmdVaultRestore(config, team === undefined ? {} : { team });
|
|
277
298
|
}
|
|
278
299
|
default:
|
|
279
300
|
throw new Error(`unknown command: ${cmd}\n\n${USAGE}`);
|
package/dist/src/oss.js
CHANGED
|
@@ -29,7 +29,7 @@ function run(cmd, args, input) {
|
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
/** A finished run, whatever its exit code. For callers where non-zero is an ANSWER. */
|
|
32
|
-
function runAllowingFailure(cmd, args) {
|
|
32
|
+
export function runAllowingFailure(cmd, args) {
|
|
33
33
|
return new Promise((resolve, reject) => {
|
|
34
34
|
const child = spawnOssPiped(cmd, args);
|
|
35
35
|
const out = [];
|
|
@@ -211,10 +211,39 @@ export async function connectedTeams(scriptsDir) {
|
|
|
211
211
|
teamId: status.remote_team_id,
|
|
212
212
|
serverInstanceId: status.server_instance_id,
|
|
213
213
|
state: status.state,
|
|
214
|
+
// NOT refused when absent, unlike the ids above. A binding without the
|
|
215
|
+
// ids cannot be filed into a vault entry, which is why those refuse; a
|
|
216
|
+
// binding whose address this version cannot read is still a team, and the
|
|
217
|
+
// only caller that wants the address can say "could not tell" for it. An
|
|
218
|
+
// empty string is that, and `teamsBoundTo` treats it as not matching any
|
|
219
|
+
// host rather than as matching the one being asked about.
|
|
220
|
+
endpoint: typeof status.endpoint === 'string' ? status.endpoint : '',
|
|
214
221
|
});
|
|
215
222
|
}
|
|
216
223
|
return teams;
|
|
217
224
|
}
|
|
225
|
+
export async function teamsBoundTo(scriptsDir, origin) {
|
|
226
|
+
const teams = await connectedTeams(scriptsDir);
|
|
227
|
+
const matched = [];
|
|
228
|
+
const unreadable = [];
|
|
229
|
+
for (const t of teams) {
|
|
230
|
+
if (t.endpoint === '') {
|
|
231
|
+
unreadable.push(t.team);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
let bound;
|
|
235
|
+
try {
|
|
236
|
+
bound = new URL(t.endpoint).origin;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
unreadable.push(t.team);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (bound === origin)
|
|
243
|
+
matched.push(t.team);
|
|
244
|
+
}
|
|
245
|
+
return { matched: matched.sort(), unreadable: unreadable.sort() };
|
|
246
|
+
}
|
|
218
247
|
export async function remoteTeamId(scriptsDir, team) {
|
|
219
248
|
const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
|
|
220
249
|
const text = out.toString().trim();
|