agmsg-cloud 0.1.0-rc.4 → 0.1.0-rc.6
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 +20 -1
- package/dist/src/commands/connect.js +110 -24
- package/dist/src/commands/fetch.js +36 -2
- package/dist/src/commands/login.js +6 -1
- package/dist/src/commands/pull.js +60 -2
- package/dist/src/commands/request.js +97 -33
- package/dist/src/commands/sync.js +21 -1
- package/dist/src/commands/vault.js +117 -24
- package/dist/src/commands/whoami.js +44 -0
- package/dist/src/config.js +29 -1
- package/dist/src/index.js +31 -3
- package/dist/src/machine-id.js +44 -0
- package/dist/src/oss.js +15 -1
- package/dist/src/preflight.js +34 -1
- package/dist/src/recovery-key.js +24 -9
- package/dist/src/slot-advice.js +85 -0
- package/dist/src/vault-container.js +39 -0
- package/dist/src/vault-filing.js +62 -0
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -324,7 +324,26 @@ export class CourierClient {
|
|
|
324
324
|
// machine resolves the name here and pulls by id, rather than handing a
|
|
325
325
|
// name to the data plane.
|
|
326
326
|
async resolveTeamByName(name) {
|
|
327
|
-
|
|
327
|
+
return this.teams(`/v1/edge/teams?name=${encodeURIComponent(name)}`);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Every team in the caller's organization.
|
|
331
|
+
*
|
|
332
|
+
* The same endpoint without the `name` filter, so it discloses nothing the
|
|
333
|
+
* lookup above does not: the control plane scopes both to the caller's org
|
|
334
|
+
* (`WHERE org_id = $1`), which is the property that lets the name lookup live
|
|
335
|
+
* here instead of on the data plane.
|
|
336
|
+
*
|
|
337
|
+
* It exists so a failed lookup can tell two situations apart. "No team by
|
|
338
|
+
* that name" is the same answer whether nothing has ever been connected to
|
|
339
|
+
* this organization or the operator is signed in to the wrong one, and those
|
|
340
|
+
* need opposite next steps.
|
|
341
|
+
*/
|
|
342
|
+
async listTeams() {
|
|
343
|
+
return this.teams('/v1/edge/teams');
|
|
344
|
+
}
|
|
345
|
+
async teams(path) {
|
|
346
|
+
const out = await this.call('GET', path);
|
|
328
347
|
// A response this cannot read is not an answer about which teams exist,
|
|
329
348
|
// and it must not be turned into one. `teams` must be PRESENT and an
|
|
330
349
|
// array, with an empty array the only way to say "none" — the same rule
|
|
@@ -6,7 +6,10 @@ import { originOf, readCredential } from '../credentials.js';
|
|
|
6
6
|
import { generateDeviceIdentity, publicKeyOf, remoteTeamId } from '../oss.js';
|
|
7
7
|
import { deviceIdentityPath } from '../paths.js';
|
|
8
8
|
import { NEEDS, ensurePreflight, formatPreflight, preflight } from '../preflight.js';
|
|
9
|
+
import { adviseOnSlotForSilentFiling, renderSlotAdvice } from '../slot-advice.js';
|
|
10
|
+
import { setupCommand } from '../recovery-key.js';
|
|
9
11
|
import { shellArg } from '../shell-arg.js';
|
|
12
|
+
import { fileTeamInVaultSilently } from '../vault-filing.js';
|
|
10
13
|
function runInherit(command, args) {
|
|
11
14
|
return new Promise((resolve, reject) => {
|
|
12
15
|
// stdio inherited: `remote.sh connect` prints its own progress, and
|
|
@@ -120,9 +123,45 @@ export async function cmdConnect(config, opts) {
|
|
|
120
123
|
// opening it — which needs the recovery key nobody has typed here. A
|
|
121
124
|
// sentence about where the only copy is would be a guess whichever way it
|
|
122
125
|
// went. The condition is named instead, and it is true in every case.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
+
// Filed here rather than left to an instruction. The key was made moments
|
|
127
|
+
// ago by the step above, and until it is in the vault the only copies are on
|
|
128
|
+
// the machines holding them — a window that used to last until someone acted
|
|
129
|
+
// on a line that had already scrolled away (#181).
|
|
130
|
+
//
|
|
131
|
+
// Silently or not at all. `connect` does not ask for a recovery key: a
|
|
132
|
+
// command that stopped to collect one would be a ceremony, and the person
|
|
133
|
+
// running it did not come here for that. Both ways of being unable — no
|
|
134
|
+
// vault, no slot on this machine — fall back to naming the route, which is
|
|
135
|
+
// what this printed before.
|
|
136
|
+
//
|
|
137
|
+
// And the two are told apart, because "nothing happened, silently" is the
|
|
138
|
+
// outcome that leaves someone believing they are backed up.
|
|
139
|
+
const filing = await fileTeamInVaultSilently(config, new CourierClient(config), opts.team);
|
|
140
|
+
const teams = `${filing.filed ? filing.teamCount : 0} team${(filing.filed ? filing.teamCount : 0) === 1 ? '' : 's'}`;
|
|
141
|
+
const backup = filing.filed
|
|
142
|
+
? filing.wrote
|
|
143
|
+
? `Backed up: revision ${filing.revision}, ${teams} in this account's vault.\n` +
|
|
144
|
+
'Losing this machine no longer loses this team — the recovery key opens it again.'
|
|
145
|
+
: // Already there, with the same keys. Said as its own sentence rather
|
|
146
|
+
// than as a write, because claiming a revision this run did not produce
|
|
147
|
+
// is the same kind of false as claiming a backup that never happened —
|
|
148
|
+
// and a re-run of `connect` reaches here every time.
|
|
149
|
+
`Already backed up: this team's keys were already in the vault at revision ${filing.revision},\n` +
|
|
150
|
+
`unchanged, so nothing new was stored. ${teams} in this account's vault.`
|
|
151
|
+
: filing.reason === 'no-vault'
|
|
152
|
+
? // NAMED, for the same reason as the four below: creating the vault and
|
|
153
|
+
// backing up THIS team is what the sentence above promises, and the
|
|
154
|
+
// broad form can refuse over a team that has nothing to do with it.
|
|
155
|
+
`Not backed up: this account has no recovery vault yet.\n` +
|
|
156
|
+
`Run \`${setupCommand(opts.team)}\` to make one — it shows a recovery key\n` +
|
|
157
|
+
"once. Until then the only copies of this team's keys are on the machines\n" +
|
|
158
|
+
'that hold them.'
|
|
159
|
+
: // Four ways of having no slot, four routes, and only one of them is
|
|
160
|
+
// "type the key once and this stops happening". Asked rather than
|
|
161
|
+
// written here, so a machine with no secure store is not sent after a
|
|
162
|
+
// fix that does not exist for it.
|
|
163
|
+
renderSlotAdvice(adviseOnSlotForSilentFiling(filing.slot, { team: opts.team })).trimEnd();
|
|
164
|
+
process.stdout.write(`${backup}\n` +
|
|
126
165
|
`\nTo add a second machine, run \`agmsg-cloud sync ${shellArg(opts.team)}\` there and answer here.\n` +
|
|
127
166
|
// The subject is the operator, and it has to be. Something IS carried
|
|
128
167
|
// between the machines after the yes — the sealed bundle, which is what
|
|
@@ -132,11 +171,33 @@ export async function cmdConnect(config, opts) {
|
|
|
132
171
|
'You do not carry any value between the machines yourself: both screens show\n' +
|
|
133
172
|
'eight digits and you check they are the same.\n');
|
|
134
173
|
if (code !== 0) {
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
174
|
+
// THE CASE THIS USED TO NAME NO LONGER REACHES HERE.
|
|
175
|
+
//
|
|
176
|
+
// It said: "if it was a uniqueness conflict, the team was already connected
|
|
177
|
+
// and there is nothing else to do". That conflict was the OSS step refusing
|
|
178
|
+
// a second connect — `a team_id registers once ... not a transient error to
|
|
179
|
+
// retry` — and it is gone. Searched at the pin this repo carries
|
|
180
|
+
// (`92ddbe5`): no match in `scripts/`. It was removed by `8c2bd4b`, "a
|
|
181
|
+
// connect that already registered resumes instead of dead-ending", which is
|
|
182
|
+
// an ancestor of that pin. The script now adopts the registration and
|
|
183
|
+
// carries on, so a duplicate does not end non-zero at all.
|
|
184
|
+
//
|
|
185
|
+
// Leaving the sentence would have been worse than saying nothing: it asks
|
|
186
|
+
// the reader to look for a message that cannot appear, and the one it
|
|
187
|
+
// describes was the reassuring case, so a real failure would be read as the
|
|
188
|
+
// harmless one.
|
|
189
|
+
//
|
|
190
|
+
// Said as a property of this command rather than as a diagnosis of an error
|
|
191
|
+
// this side has not read — and only the part that was checked. Re-running
|
|
192
|
+
// adds nothing: the OSS step adopts a registration that is already there,
|
|
193
|
+
// and `bootstrapDevice` returns the existing device when the key matches
|
|
194
|
+
// (`devices.ts:309`). It is NOT "it will work": this same machine can be
|
|
195
|
+
// refused for reasons a second run cannot change, and the key having
|
|
196
|
+
// changed is refused rather than registered twice — which is why the
|
|
197
|
+
// sentence stops at "adds nothing".
|
|
198
|
+
process.stdout.write('Read the error above. Running connect again adds nothing that is already\n' +
|
|
199
|
+
'there: a registered team is adopted rather than refused, and this machine\n' +
|
|
200
|
+
'is registered once. It is worth doing if the error looks transient.\n');
|
|
140
201
|
}
|
|
141
202
|
}
|
|
142
203
|
async function registerThisMachine(config, opts, machineName) {
|
|
@@ -192,7 +253,12 @@ async function registerThisMachine(config, opts, machineName) {
|
|
|
192
253
|
// that is not the claimant never becomes one. Connect from here returns to
|
|
193
254
|
// this same 403 forever.
|
|
194
255
|
if (err instanceof CourierError && err.status === 403) {
|
|
195
|
-
|
|
256
|
+
// The id goes in, so the message can PRINT it. The release is asked for
|
|
257
|
+
// by team id, and the console shows teams by slug (`Connections.tsx:154`)
|
|
258
|
+
// — telling someone to go and find it is the placeholder problem this
|
|
259
|
+
// file already refuses elsewhere. It is in hand here; it is the value
|
|
260
|
+
// `bootstrapDevice` was just called with.
|
|
261
|
+
throw new Error(await forbiddenAdvice(client, opts.team, teamId, pubkey));
|
|
196
262
|
}
|
|
197
263
|
throw err;
|
|
198
264
|
}
|
|
@@ -205,19 +271,30 @@ async function registerThisMachine(config, opts, machineName) {
|
|
|
205
271
|
* account have a machine that could approve? Enrollment needs an approver, and
|
|
206
272
|
* an approver is a registered device.
|
|
207
273
|
*
|
|
208
|
-
* The case with no approver
|
|
209
|
-
*
|
|
210
|
-
*
|
|
274
|
+
* The case with no approver has no way out THIS MACHINE CAN TAKE, which is not
|
|
275
|
+
* the same as no way out — and the message used to say the second one.
|
|
276
|
+
*
|
|
277
|
+
* There is a route: releasing the team's claim (`app/scripts/release-claim.ts`)
|
|
278
|
+
* frees the id, and a later connect claims it again. It is run by whoever
|
|
279
|
+
* operates the service, not by the person holding this terminal, and that is
|
|
280
|
+
* the decision rather than an omission — releases are rare enough to be worth a
|
|
281
|
+
* human checkpoint each time, and a self-service release would need an
|
|
282
|
+
* owner-authenticated mutation surface that does not exist. Both are recorded
|
|
283
|
+
* on #71, with the condition that would reopen the question.
|
|
284
|
+
*
|
|
285
|
+
* So the sentence to print is what to ASK FOR, not a command to run. A person
|
|
286
|
+
* told there is no recovery stops looking for one — which is right when there
|
|
287
|
+
* is none and expensive when there is, and #143 is the case where there was.
|
|
211
288
|
*
|
|
212
|
-
* WHY NOT BUILD A WAY BACK. Reassigning a team's claim to a live
|
|
213
|
-
* would fix it, and it is a change nobody should make inside a
|
|
214
|
-
* decides who may take over a team whose first machine is gone, and
|
|
215
|
-
* answer — any live capability in the org — turns a revoked machine
|
|
216
|
-
* machine may become device-0 for that team", which is part of what
|
|
217
|
-
* was for. Named here so the next person who finds this message
|
|
218
|
-
* reaches the same question rather than the same guess.
|
|
289
|
+
* WHY NOT BUILD A WAY BACK HERE. Reassigning a team's claim to a live
|
|
290
|
+
* capability would also fix it, and it is a change nobody should make inside a
|
|
291
|
+
* bug fix: it decides who may take over a team whose first machine is gone, and
|
|
292
|
+
* the obvious answer — any live capability in the org — turns a revoked machine
|
|
293
|
+
* into "any machine may become device-0 for that team", which is part of what
|
|
294
|
+
* revoking was for. Named here so the next person who finds this message
|
|
295
|
+
* unhelpful reaches the same question rather than the same guess.
|
|
219
296
|
*/
|
|
220
|
-
async function forbiddenAdvice(client, team, ownPubkey) {
|
|
297
|
+
async function forbiddenAdvice(client, team, teamId, ownPubkey) {
|
|
221
298
|
const head = `the server would not register this machine for '${team}'.\n\n` +
|
|
222
299
|
'It is not the machine that first connected this team, and only that one can register\n' +
|
|
223
300
|
'without an approver. That is recorded once and never moves, so running connect again\n' +
|
|
@@ -253,10 +330,19 @@ async function forbiddenAdvice(client, team, ownPubkey) {
|
|
|
253
330
|
if (approvers === 0) {
|
|
254
331
|
return (head +
|
|
255
332
|
'There is also no OTHER machine on this account that could approve a request — and a\n' +
|
|
256
|
-
'machine cannot approve its own — so joining is not open either.
|
|
257
|
-
'
|
|
258
|
-
'
|
|
259
|
-
'
|
|
333
|
+
'machine cannot approve its own — so joining is not open either. NOTHING YOU CAN RUN\n' +
|
|
334
|
+
'FROM HERE RECOVERS THIS TEAM: the machine that claimed it can no longer register, and\n' +
|
|
335
|
+
'enrollment needs an approver that does not exist.\n\n' +
|
|
336
|
+
'What does recover it is releasing the team, which frees the id so a connect can claim\n' +
|
|
337
|
+
'it again. That is run by whoever operates this service, not from this terminal. Ask\n' +
|
|
338
|
+
'them to release this team, and say why:\n\n' +
|
|
339
|
+
` team ${team}\n` +
|
|
340
|
+
` id ${teamId}\n\n` +
|
|
341
|
+
'Releasing DELETES EVERYTHING THIS SERVICE HOLDS FOR THE TEAM — its messages, its\n' +
|
|
342
|
+
'member list and their agent registrations, and its policy and identity history. That\n' +
|
|
343
|
+
'is the point of it: the id is freed for a fresh start, not handed over with its past.\n' +
|
|
344
|
+
'Your machines stay signed in and nothing on them is touched; it is this team on the\n' +
|
|
345
|
+
'service that goes. If any of it matters, say so before asking.');
|
|
260
346
|
}
|
|
261
347
|
return (head +
|
|
262
348
|
'Join it from here instead — this account has another machine that can approve the\n' +
|
|
@@ -222,7 +222,25 @@ env = process.env) {
|
|
|
222
222
|
// The choice is made from the VERIFIED matching set, never from the raw
|
|
223
223
|
// queue: picking an unverified blob by position would not be identification.
|
|
224
224
|
const { blob, file } = matches[0];
|
|
225
|
-
await unlockBundle(config.scriptsDir, args.team, file, expected);
|
|
225
|
+
const unlockSaid = await unlockBundle(config.scriptsDir, args.team, file, expected);
|
|
226
|
+
// Relayed HERE, before the ack, because it is already true here.
|
|
227
|
+
//
|
|
228
|
+
// The script has finished: the team is unlocked on this machine and its
|
|
229
|
+
// sync engine is running under a pid the script verified. Printing it after
|
|
230
|
+
// `ackBlob` meant a failed ack threw that away — the local half had
|
|
231
|
+
// succeeded, the operator was shown only the error, and the machine they
|
|
232
|
+
// were about to debug was already working. That is this issue's own defect
|
|
233
|
+
// one step later (raised in review).
|
|
234
|
+
//
|
|
235
|
+
// NOT verbatim, and the difference is worth naming rather than glossing:
|
|
236
|
+
// `trimEnd()` drops trailing whitespace and a single '\n' is re-appended.
|
|
237
|
+
// The body is untouched; only the tail is normalised. A script that says
|
|
238
|
+
// nothing prints nothing, because a blank line would read as an empty
|
|
239
|
+
// answer (raised in review — the comment said "verbatim", which is wider
|
|
240
|
+
// than what this does).
|
|
241
|
+
const said = (unlockSaid ?? '').trimEnd();
|
|
242
|
+
if (said !== '')
|
|
243
|
+
process.stdout.write(said + '\n');
|
|
226
244
|
await client.ackBlob(blob.id);
|
|
227
245
|
// Consumed only now, and the order is load-bearing.
|
|
228
246
|
//
|
|
@@ -238,7 +256,23 @@ env = process.env) {
|
|
|
238
256
|
clearAuthenticatedDigest(originOf(config.baseUrl), consumed.requestId, config.secret, devicePubkey, env);
|
|
239
257
|
const others = blobs.length - 1;
|
|
240
258
|
const duplicates = matches.length - 1;
|
|
241
|
-
|
|
259
|
+
// What `remote.sh unlock` said — its body, with the trailing newline
|
|
260
|
+
// normalised to exactly one — and then what THIS command did.
|
|
261
|
+
//
|
|
262
|
+
// The script's line is the stronger one and it was being thrown away: it
|
|
263
|
+
// waits for the sync engine to report ready, checks the pidfile it recorded
|
|
264
|
+
// matches the process it started, checks that process is alive, and exits
|
|
265
|
+
// non-zero otherwise — so `engine running (pid N)` is a claim about a live
|
|
266
|
+
// pid, and it names the team.
|
|
267
|
+
//
|
|
268
|
+
// In its place this printed `unlocked and acked the confirmed bundle`,
|
|
269
|
+
// which names no object at all. Four lines later the operator was told to
|
|
270
|
+
// "unlock" — a DIFFERENT object, the team's history — with the same verb.
|
|
271
|
+
// Two objects, one word, and nothing on screen to tell them apart (#147).
|
|
272
|
+
//
|
|
273
|
+
// The ack is reported separately because it is this command's own act
|
|
274
|
+
// against the server, not something the script did.
|
|
275
|
+
process.stdout.write(`acked the confirmed bundle` +
|
|
242
276
|
(others > 0 ? `; ${others} other bundle(s) left queued` : '') +
|
|
243
277
|
(duplicates > 0 ? ` (${duplicates} of them carry the same digest)` : '') +
|
|
244
278
|
'\n');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SECRET_RE } from '../machine-id.js';
|
|
1
2
|
import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
|
|
2
3
|
import { isOrgAddress, originOf, readCredential, writeCredential } from '../credentials.js';
|
|
3
4
|
import { settleMachineName, validateMachineName } from '../machine-name.js';
|
|
@@ -13,7 +14,11 @@ import { settleMachineName, validateMachineName } from '../machine-name.js';
|
|
|
13
14
|
// out of the capability URL's last path segment, so a response that does not
|
|
14
15
|
// carry the expected shape is refused rather than stored: a wrong value here
|
|
15
16
|
// would be sent as this machine's Bearer credential on every later command.
|
|
16
|
-
|
|
17
|
+
//
|
|
18
|
+
// Imported, not restated. `machine-id.ts` reads the prefix out of the same
|
|
19
|
+
// shape, and a second copy here would let the two drift: changing the mint in
|
|
20
|
+
// one place would leave the other answering about a format that no longer
|
|
21
|
+
// exists (raised in review).
|
|
17
22
|
// The binary is `agmsg-cloud`, and the approval screen shows the name the
|
|
18
23
|
// SERVER holds for this client id — so the id the CLI sends is what decides
|
|
19
24
|
// whether the person sees "agmsg CLI" (a different program, which never does
|
|
@@ -58,6 +58,57 @@ export async function cmdPull(config, opts) {
|
|
|
58
58
|
'it asks a machine that already has the team, and the two of you compare eight digits.\n');
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The advice that follows "no team named X", chosen from what this
|
|
63
|
+
* organization actually holds.
|
|
64
|
+
*
|
|
65
|
+
* The observation and the advice are separated, because only the observation
|
|
66
|
+
* differs. **No answer this command can get establishes whether the team was
|
|
67
|
+
* connected anywhere** — the lookup is scoped to the caller's organization, so
|
|
68
|
+
* it cannot see the rest of the world at all:
|
|
69
|
+
*
|
|
70
|
+
* - A populated organization does not mean the team was connected elsewhere.
|
|
71
|
+
* An org holding `billing` and not `ops` is equally consistent with `ops`
|
|
72
|
+
* never having been connected.
|
|
73
|
+
* - An empty organization does not mean it was not. The case in #224 had it
|
|
74
|
+
* connected into a different org, and that leaves this one empty.
|
|
75
|
+
* - A list that could not be fetched says nothing in either direction.
|
|
76
|
+
*
|
|
77
|
+
* So the advice is one block, the same in all three, and names both
|
|
78
|
+
* possibilities as conditions. The original defect was a remedy stated as an
|
|
79
|
+
* instruction — "connect it from the machine that runs it" — which asserted the
|
|
80
|
+
* connect had not happened. Stating it unconditionally in ANY branch brings the
|
|
81
|
+
* defect back, which is why the regression pins the conditional form rather
|
|
82
|
+
* than the presence of new words.
|
|
83
|
+
*
|
|
84
|
+
* The list is printed in full. A "…and N more" would make the interesting case
|
|
85
|
+
* — the team IS here under a spelling the operator did not expect — the one
|
|
86
|
+
* most likely to be cut off, and an operator who cannot see it reads the
|
|
87
|
+
* message as saying it is not there. That is a deliberate trade against very
|
|
88
|
+
* large organizations, not an oversight.
|
|
89
|
+
*/
|
|
90
|
+
const WHERE_TO_LOOK = 'Where the team stands outside this organization is not visible from here.\n' +
|
|
91
|
+
'Check which account and organization this machine is signed in to, then:\n' +
|
|
92
|
+
' - if another machine already connected it, sign in to the organization it\n' +
|
|
93
|
+
' was connected into, or pass --team-id if you know the id;\n' +
|
|
94
|
+
' - if it has not been connected anywhere yet, connect it from the machine\n' +
|
|
95
|
+
' that runs it.';
|
|
96
|
+
async function whyNot(client, team) {
|
|
97
|
+
let teams;
|
|
98
|
+
try {
|
|
99
|
+
teams = await client.listTeams();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return `This machine could not list the organization's teams, so what it holds is unknown.\n\n${WHERE_TO_LOOK}`;
|
|
103
|
+
}
|
|
104
|
+
if (teams.length === 0) {
|
|
105
|
+
return `This organization holds no teams, so nothing has been connected into it.\n\n${WHERE_TO_LOOK}`;
|
|
106
|
+
}
|
|
107
|
+
const named = teams.map((t) => ` ${t.teamName ?? '(unnamed)'} ${t.teamId}`).join('\n');
|
|
108
|
+
return (`This organization holds ${teams.length === 1 ? '1 team' : `${teams.length} teams`}, and none of them is "${team}":\n\n` +
|
|
109
|
+
`${named}\n\n` +
|
|
110
|
+
`Check the spelling against that list.\n\n${WHERE_TO_LOOK}`);
|
|
111
|
+
}
|
|
61
112
|
async function resolveTeamId(config, opts) {
|
|
62
113
|
const client = opts.client ?? new CourierClient(config);
|
|
63
114
|
const matches = await client.resolveTeamByName(opts.team);
|
|
@@ -65,8 +116,15 @@ async function resolveTeamId(config, opts) {
|
|
|
65
116
|
// Named as what it is: this org has no such team. The alternative reading
|
|
66
117
|
// — that the team exists under someone else — is not ours to confirm or
|
|
67
118
|
// deny, and the answer is the same either way.
|
|
68
|
-
|
|
69
|
-
|
|
119
|
+
//
|
|
120
|
+
// What is ours to say is whether this organization holds ANY team, and the
|
|
121
|
+
// two cases need opposite moves. An empty organization means the connect
|
|
122
|
+
// has not happened; a populated one means it happened somewhere else, and
|
|
123
|
+
// "connect it from the machine that runs it" then points at work that is
|
|
124
|
+
// already done — which is what #224 reported. Asking the org for its own
|
|
125
|
+
// list adds no disclosure: it is the same org-scoped endpoint, without the
|
|
126
|
+
// filter.
|
|
127
|
+
throw new Error(`no team named "${opts.team}" in this organization.\n\n${await whyNot(client, opts.team)}`);
|
|
70
128
|
}
|
|
71
129
|
if (matches.length > 1) {
|
|
72
130
|
// A name is not unique by construction, so this stops rather than picking.
|
|
@@ -9,22 +9,6 @@ import { originOf } from '../credentials.js';
|
|
|
9
9
|
import { deviceIdentityPath } from '../paths.js';
|
|
10
10
|
import { closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, requesterLedgerScope, } from '../ledger.js';
|
|
11
11
|
import { attachRequestId, clearRecord, listResumableRequesterRecords, reserveRequesterNonce, } from '../pending.js';
|
|
12
|
-
// (c, part 1) B asks to be added.
|
|
13
|
-
//
|
|
14
|
-
// The order below is the protocol, not a style choice:
|
|
15
|
-
//
|
|
16
|
-
// 1. commit to (device key, nonce) and PERSIST the nonce
|
|
17
|
-
// 2. post only the commitment digest
|
|
18
|
-
// 3. wait for the approver's commitment to be fixed
|
|
19
|
-
// 4. only then reveal the key and the nonce
|
|
20
|
-
// 5. wait for the approver to open, and derive the code locally
|
|
21
|
-
//
|
|
22
|
-
// Steps 3 and 4 are what stop the other side choosing its contribution after
|
|
23
|
-
// learning ours. Doing 4 before 3 would leave a request whose code the server
|
|
24
|
-
// could steer, and the human comparison would confirm nothing.
|
|
25
|
-
//
|
|
26
|
-
// The private key never leaves this machine; only the public recipient is sent,
|
|
27
|
-
// and not until step 4.
|
|
28
12
|
export async function cmdRequest(config, args, deps = {}) {
|
|
29
13
|
const out = deps.out ?? ((text) => void process.stdout.write(text));
|
|
30
14
|
const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
|
|
@@ -47,11 +31,39 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
47
31
|
// paid for would charge the same guess twice and let a crash loop exhaust the
|
|
48
32
|
// budget without a single comparison.
|
|
49
33
|
const scope = requesterLedgerScope(config.baseUrl, pubkey);
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
34
|
+
// ASKED FOR EVERY STORED RECORD, INCLUDING ONE THAT ALREADY HAS AN ID.
|
|
35
|
+
//
|
|
36
|
+
// Two different questions, one answer:
|
|
37
|
+
//
|
|
38
|
+
// no id did it land at all? The row can exist while the response was
|
|
39
|
+
// lost, and the commitment is the only handle left.
|
|
40
|
+
// with id is it still live? Nothing about the id says so.
|
|
41
|
+
//
|
|
42
|
+
// This used to run only for the first. A record with an id was trusted
|
|
43
|
+
// however old it was, and the first thing the run did with it was
|
|
44
|
+
// `waitForStatus`, which polls for PROGRESS — so a long-dead enrollment was
|
|
45
|
+
// resumed, waited on, and then reported expired. That is the walkthrough
|
|
46
|
+
// sequence in #143: `resuming enrollment <id>` followed by `<id> is expired`.
|
|
47
|
+
//
|
|
48
|
+
// One list answers both, because `GET /v1/enrollments` is scoped to
|
|
49
|
+
// `status = ANY(NONTERMINAL) AND expires_at > now()`
|
|
50
|
+
// (`app/src/handoff/enrollment.ts:306`) — an expired row is absent from it
|
|
51
|
+
// even while its status still reads non-terminal. So "not in the list" already
|
|
52
|
+
// means "cannot be resumed", and the branch below already does the right
|
|
53
|
+
// thing with that: it closes the attempt, drops the record, and falls through
|
|
54
|
+
// to starting a fresh ceremony in this same run.
|
|
55
|
+
//
|
|
56
|
+
// AND THE LIST IS THE ONLY PLACE THAT ANSWERS IT. Reading the id instead would
|
|
57
|
+
// not do: `expireStale` rolls a stale row over on touch, and its callers are
|
|
58
|
+
// the create and claim paths, so a read returns the status the ceremony left
|
|
59
|
+
// behind and says nothing about the deadline. (That the walkthrough saw `<id>
|
|
60
|
+
// is expired` at all means something else had already touched it.) Pinned on
|
|
61
|
+
// the server side in `app/test/handoff-enrollment.test.ts`, because a change
|
|
62
|
+
// there would otherwise leave every test here green.
|
|
63
|
+
//
|
|
64
|
+
// The cost is one extra request per resume. What it buys is that the run
|
|
65
|
+
// either resumes something that can still finish, or starts one that can.
|
|
66
|
+
if (record) {
|
|
55
67
|
// The list must SUCCEED before its emptiness means anything.
|
|
56
68
|
//
|
|
57
69
|
// Swallowing the error and treating it as "no live requests" turned a
|
|
@@ -64,12 +76,20 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
64
76
|
mine = await client.listEnrollments();
|
|
65
77
|
}
|
|
66
78
|
catch (err) {
|
|
67
|
-
|
|
79
|
+
// The two cases have different unknowns and the sentence says which one
|
|
80
|
+
// it is. With no id, whether the request ever landed is open; with an id
|
|
81
|
+
// it landed and what is open is whether it can still finish. Saying "did
|
|
82
|
+
// it reach the server" to someone holding an id describes a doubt they do
|
|
83
|
+
// not have, and the remedy is the same either way — which is what makes
|
|
84
|
+
// the wrong half safe to notice and cheap to fix.
|
|
85
|
+
writeErr((requestId
|
|
86
|
+
? `cannot tell whether enrollment ${requestId} is still live: `
|
|
87
|
+
: 'cannot tell whether the earlier request reached the server: ') +
|
|
68
88
|
`${err instanceof Error ? err.message : String(err)}\n` +
|
|
69
89
|
' Nothing has been changed — the attempt and its nonce are kept so a\n' +
|
|
70
90
|
' later run can finish it. Try again when the server is reachable.\n');
|
|
71
91
|
process.exitCode = 1;
|
|
72
|
-
return;
|
|
92
|
+
return 'not_enrolled';
|
|
73
93
|
}
|
|
74
94
|
const match = mine.filter((e) => e.commitment === record.commitmentHex);
|
|
75
95
|
if (match.length === 1) {
|
|
@@ -90,7 +110,7 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
90
110
|
writeErr('the server reports more than one live request for this commitment; ' +
|
|
91
111
|
'refusing to guess which one is yours\n');
|
|
92
112
|
process.exitCode = 1;
|
|
93
|
-
return;
|
|
113
|
+
return 'not_enrolled';
|
|
94
114
|
}
|
|
95
115
|
}
|
|
96
116
|
if (!record || !requestId) {
|
|
@@ -116,13 +136,13 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
116
136
|
if (before.remaining === 0) {
|
|
117
137
|
writeErr(renderBudgetExhausted(before));
|
|
118
138
|
process.exitCode = 1;
|
|
119
|
-
return;
|
|
139
|
+
return 'not_enrolled';
|
|
120
140
|
}
|
|
121
141
|
const blocked = await keyAlreadySpokenFor(client, pubkey);
|
|
122
142
|
if (blocked) {
|
|
123
143
|
writeErr(blocked);
|
|
124
144
|
process.exitCode = 1;
|
|
125
|
-
return;
|
|
145
|
+
return 'not_enrolled';
|
|
126
146
|
}
|
|
127
147
|
const { commitmentHex: plannedCommitment, openingNonce: plannedNonce } = createSasCommitment(pubkey);
|
|
128
148
|
const charged = consumeAttempt(scope, plannedCommitment, env);
|
|
@@ -135,7 +155,7 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
135
155
|
' command again to resume it rather than starting a second one.\n\n');
|
|
136
156
|
}
|
|
137
157
|
process.exitCode = 1;
|
|
138
|
-
return;
|
|
158
|
+
return 'not_enrolled';
|
|
139
159
|
}
|
|
140
160
|
out(renderBudgetWarning(charged.budget));
|
|
141
161
|
// Persisted BEFORE the digest is posted. A crash after the post but before
|
|
@@ -220,6 +240,7 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
220
240
|
// the production walkthrough).
|
|
221
241
|
out(` Added.${nextStepsAreOurs ? ' Fetch the bundle with `agmsg-cloud fetch <team>`.' : ''} (${settled.status})\n`);
|
|
222
242
|
done();
|
|
243
|
+
return 'enrolled';
|
|
223
244
|
}
|
|
224
245
|
catch (err) {
|
|
225
246
|
if (err instanceof CeremonyError) {
|
|
@@ -227,19 +248,31 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
227
248
|
// A timeout leaves it, so the next run resumes instead of spending another
|
|
228
249
|
// attempt on a request that is still live.
|
|
229
250
|
if (err.reason === 'timed_out') {
|
|
230
|
-
//
|
|
231
|
-
//
|
|
251
|
+
// "May still be live" was printed as though it were "is still live".
|
|
252
|
+
//
|
|
253
|
+
// The wait has a five-minute deadline and an enrollment's window can
|
|
254
|
+
// close inside it — and expiry alone does not move the status, so the
|
|
255
|
+
// poll ends as "did not reach … in time" for a row that is expired and
|
|
256
|
+
// untouched. Both halves of the old sentence were then false: the next
|
|
257
|
+
// run does not resume it, and the new ceremony it starts does cost an
|
|
258
|
+
// attempt. Someone following the remedy spent one having been told it
|
|
259
|
+
// was free (#258).
|
|
260
|
+
//
|
|
261
|
+
// Asked of the same list the resume path asks, which is scoped to
|
|
262
|
+
// `status = ANY(NONTERMINAL) AND expires_at > now()` — so presence
|
|
263
|
+
// there IS resumability, and there is no second definition to keep in
|
|
264
|
+
// step.
|
|
232
265
|
writeErr(`enrollment did not complete: ${err.message}\n`);
|
|
233
|
-
writeErr(
|
|
266
|
+
writeErr(await resumeAdvice(client, record.commitmentHex));
|
|
234
267
|
process.exitCode = 1;
|
|
235
|
-
return;
|
|
268
|
+
return 'not_enrolled';
|
|
236
269
|
}
|
|
237
270
|
done();
|
|
238
271
|
closeAttempt(scope, record.commitmentHex, 'failed', err.reason, env);
|
|
239
272
|
writeErr(renderBudgetWarning(readBudget(scope, env)));
|
|
240
273
|
writeErr(`enrollment did not complete: ${err.message}\n`);
|
|
241
274
|
process.exitCode = 1;
|
|
242
|
-
return;
|
|
275
|
+
return 'not_enrolled';
|
|
243
276
|
}
|
|
244
277
|
// The two ways this machine's key cannot become a device on this account.
|
|
245
278
|
//
|
|
@@ -257,7 +290,7 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
257
290
|
writeErr(renderBudgetWarning(readBudget(scope, env)));
|
|
258
291
|
writeErr(await renderKeyRefusal(err.code, client, pubkey));
|
|
259
292
|
process.exitCode = 1;
|
|
260
|
-
return;
|
|
293
|
+
return 'not_enrolled';
|
|
261
294
|
}
|
|
262
295
|
throw err;
|
|
263
296
|
}
|
|
@@ -287,6 +320,37 @@ const NOTHING_COMPARED = ' No digits were shown and no bundle was sent.\n';
|
|
|
287
320
|
* about ownership, and reading silence as "none of these are yours" would
|
|
288
321
|
* refuse every ordinary rejoin.
|
|
289
322
|
*/
|
|
323
|
+
/**
|
|
324
|
+
* What to tell someone whose wait ran out — after asking whether the thing they
|
|
325
|
+
* would be resuming still exists.
|
|
326
|
+
*
|
|
327
|
+
* Three answers, because there are three states and only one of them is the one
|
|
328
|
+
* the message used to assume. Saying nothing about the cost is not an option
|
|
329
|
+
* either: the whole defect was a person spending an attempt they had been told
|
|
330
|
+
* was free, and a sentence that simply omits the price leaves them guessing at
|
|
331
|
+
* the same moment.
|
|
332
|
+
*/
|
|
333
|
+
export async function resumeAdvice(client, commitmentHex) {
|
|
334
|
+
let live;
|
|
335
|
+
try {
|
|
336
|
+
live = await client.listEnrollments();
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
// Unknown, said as unknown. Guessing "still live" is the bug being fixed;
|
|
340
|
+
// guessing "gone" would tell someone their attempt is spent when it may
|
|
341
|
+
// not be.
|
|
342
|
+
return (` Could not check whether it is still live: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
343
|
+
' Nothing has been changed. Run the command again when the server is reachable:\n' +
|
|
344
|
+
' if the enrollment is still live it resumes at no further cost, and if it is\n' +
|
|
345
|
+
' not, that run starts a new ceremony and costs another attempt.\n');
|
|
346
|
+
}
|
|
347
|
+
if (live.some((e) => e.commitment === commitmentHex)) {
|
|
348
|
+
return ' Run the same command again to resume it; this costs no further attempt.\n';
|
|
349
|
+
}
|
|
350
|
+
return (' This attempt is over: the enrollment is no longer live on the server, so it\n' +
|
|
351
|
+
' cannot be resumed. Running the command again starts a NEW ceremony, which\n' +
|
|
352
|
+
' costs another attempt.\n');
|
|
353
|
+
}
|
|
290
354
|
async function keyAlreadySpokenFor(client, pubkey) {
|
|
291
355
|
let devices;
|
|
292
356
|
try {
|
|
@@ -101,7 +101,27 @@ export async function cmdSync(config, opts) {
|
|
|
101
101
|
// one that knows there is nothing to do: fetch and pull follow immediately
|
|
102
102
|
// below. The decision is made HERE, once, rather than inferred inside each
|
|
103
103
|
// step (raised in review).
|
|
104
|
-
await request(config, { label }, { nextStepsFromCaller: true });
|
|
104
|
+
const enrolled = await request(config, { label }, { nextStepsFromCaller: true });
|
|
105
|
+
// NOTHING BELOW CAN SUCCEED WITHOUT IT, so a failed ceremony ends the run
|
|
106
|
+
// here.
|
|
107
|
+
//
|
|
108
|
+
// It used to continue. `cmdRequest` reports failure by setting
|
|
109
|
+
// `process.exitCode` and returning normally — it does not throw — and this
|
|
110
|
+
// line ignored the return value, so `pull` ran next and resolved the team by
|
|
111
|
+
// name against an account this machine had not joined. What the operator was
|
|
112
|
+
// left holding was `no team named "<team>" in this organization`: true, and
|
|
113
|
+
// not the reason anything failed. The enrollment message had scrolled past
|
|
114
|
+
// two steps earlier.
|
|
115
|
+
//
|
|
116
|
+
// Nothing is printed here. `request` has already said what happened and what
|
|
117
|
+
// to do about it, in the words that fit the case it hit; a summary from this
|
|
118
|
+
// side would either repeat it or, worse, generalise over cases it cannot
|
|
119
|
+
// tell apart.
|
|
120
|
+
//
|
|
121
|
+
// Compared against the success value rather than the failure one, so a
|
|
122
|
+
// future outcome that is neither stops the run as well.
|
|
123
|
+
if (enrolled !== 'enrolled')
|
|
124
|
+
return;
|
|
105
125
|
// PULL BEFORE FETCH, and the old order was the wrong way round.
|
|
106
126
|
//
|
|
107
127
|
// The comment that used to sit below said the messages "needed the key that
|