agmsg-cloud 0.0.1 → 0.1.0-rc.4
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/README.md +39 -2
- package/dist/src/api.js +517 -0
- package/dist/src/authenticated-digest.js +234 -0
- package/dist/src/browser.js +241 -0
- package/dist/src/ceremony.js +181 -0
- package/dist/src/commands/approve.js +392 -0
- package/dist/src/commands/connect.js +273 -0
- package/dist/src/commands/fetch.js +249 -0
- package/dist/src/commands/login.js +334 -0
- package/dist/src/commands/logout.js +74 -0
- package/dist/src/commands/pull.js +80 -0
- package/dist/src/commands/request.js +371 -0
- package/dist/src/commands/sync.js +138 -0
- package/dist/src/commands/vault.js +478 -0
- package/dist/src/commands/watch.js +47 -0
- package/dist/src/config.js +34 -0
- package/dist/src/credentials.js +374 -0
- package/dist/src/device-slot.js +148 -0
- package/dist/src/filelock.js +167 -0
- package/dist/src/index.js +242 -0
- package/dist/src/ledger.js +296 -0
- package/dist/src/machine-name.js +90 -0
- package/dist/src/oss-env.js +49 -0
- package/dist/src/oss.js +289 -0
- package/dist/src/paths.js +8 -0
- package/dist/src/pending.js +330 -0
- package/dist/src/pick-request.js +56 -0
- package/dist/src/preflight.js +257 -0
- package/dist/src/recovery-key.js +386 -0
- package/dist/src/sas.js +18 -0
- package/dist/src/secure-store.js +176 -0
- package/dist/src/shell-arg.js +18 -0
- package/dist/src/slot-advice.js +74 -0
- package/dist/src/vault-container.js +115 -0
- package/dist/src/vault-crypto.js +190 -0
- package/dist/src/vault-protocol.js +358 -0
- package/dist/src/version.js +57 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
- package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
- package/package.json +50 -7
- package/bin/agmsg-cloud.js +0 -4
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
|
|
2
|
+
import { isOrgAddress, originOf, readCredential, writeCredential } from '../credentials.js';
|
|
3
|
+
import { settleMachineName, validateMachineName } from '../machine-name.js';
|
|
4
|
+
// `login` — the device-authorization flow, from this machine's side.
|
|
5
|
+
//
|
|
6
|
+
// It is the one subcommand that runs with no credential, so it takes its
|
|
7
|
+
// endpoint as a flag and everything else from the server's own response. The
|
|
8
|
+
// ordering below is the contract, not a preference: the capability URL is
|
|
9
|
+
// written to disk BEFORE activation, because a credential minted but never
|
|
10
|
+
// durably written is exactly the crash the provisional/activate split exists to
|
|
11
|
+
// survive.
|
|
12
|
+
// Must agree with the server's mint (edge/capability.ts). The secret is pulled
|
|
13
|
+
// out of the capability URL's last path segment, so a response that does not
|
|
14
|
+
// carry the expected shape is refused rather than stored: a wrong value here
|
|
15
|
+
// would be sent as this machine's Bearer credential on every later command.
|
|
16
|
+
const SECRET_RE = /^agsy_[a-f0-9]{8}_[A-Za-z0-9_-]{43}$/;
|
|
17
|
+
// The binary is `agmsg-cloud`, and the approval screen shows the name the
|
|
18
|
+
// SERVER holds for this client id — so the id the CLI sends is what decides
|
|
19
|
+
// whether the person sees "agmsg CLI" (a different program, which never does
|
|
20
|
+
// this login) or "agmsg-cloud CLI". Reported from a real approval screen.
|
|
21
|
+
//
|
|
22
|
+
// The server allowlist carries both ids during the swap (#103), so this side
|
|
23
|
+
// can move without an ordering constraint; the old entry is removed afterwards.
|
|
24
|
+
const CLIENT_ID = 'agmsg-cloud-cli';
|
|
25
|
+
// Terminal outcomes get the server's own reason back, verbatim and named: each
|
|
26
|
+
// one is a different thing for the operator to do, and collapsing them into
|
|
27
|
+
// "login failed" throws that away.
|
|
28
|
+
const TERMINAL = {
|
|
29
|
+
access_denied: 'the request was denied in the browser',
|
|
30
|
+
expired_token: 'the code expired before it was approved — run login again',
|
|
31
|
+
machine_exists: 'that org already runs a machine under this name — run login again with a different --machine-name, or remove the existing machine first',
|
|
32
|
+
over_machines_cap: "this org is at its machine limit — remove a machine or move to a plan that allows more",
|
|
33
|
+
payment_required: 'this org has no active subscription — start one in the console, then run login again',
|
|
34
|
+
};
|
|
35
|
+
function secretFromCapabilityUrl(capabilityUrl) {
|
|
36
|
+
const segments = new URL(capabilityUrl).pathname.split('/');
|
|
37
|
+
const secret = segments[segments.length - 1] ?? '';
|
|
38
|
+
if (!SECRET_RE.test(secret)) {
|
|
39
|
+
throw new Error('the server returned a capability URL in an unrecognised shape; refusing to store it');
|
|
40
|
+
}
|
|
41
|
+
return secret;
|
|
42
|
+
}
|
|
43
|
+
// A network error is fatal for the request that OPENS the grant — there is
|
|
44
|
+
// nothing to wait for yet — but not for the poll that follows it. That poll
|
|
45
|
+
// runs for as long as the operator takes to reach their browser, over a
|
|
46
|
+
// connection the server is free to close when it goes idle, and from here an
|
|
47
|
+
// idle-timeout reset is indistinguishable from a real outage. Ending a login on
|
|
48
|
+
// one discards a code that is still valid and still approvable.
|
|
49
|
+
class Unreachable extends Error {
|
|
50
|
+
code;
|
|
51
|
+
constructor(host, code) {
|
|
52
|
+
super(`could not reach ${host}${code ? ` (${code})` : ''} — check the address and your network, or pass --endpoint if you run your own agmsg cloud`);
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.name = 'Unreachable';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function cmdLogin(opts) {
|
|
58
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
59
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
60
|
+
const now = opts.now ?? (() => Date.now());
|
|
61
|
+
const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
62
|
+
// The flag, validated, before anything is asked. `settleMachineName` returns
|
|
63
|
+
// it verbatim when it is given, so nothing below needs the prompt to know
|
|
64
|
+
// what the caller wants — and a malformed value should be refused before the
|
|
65
|
+
// command starts talking to a server.
|
|
66
|
+
const asked = opts.machineName === undefined ? undefined : validateMachineName(opts.machineName);
|
|
67
|
+
// Said out loud on EVERY run, default or not. A default is convenient, and
|
|
68
|
+
// the thing it takes away is the moment the operator typed the destination —
|
|
69
|
+
// so the destination is printed instead. Nobody should have to guess which
|
|
70
|
+
// server their machine is about to be registered with.
|
|
71
|
+
process.stdout.write(`Connecting to ${new URL(endpoint).host}\n`);
|
|
72
|
+
const post = async (path, body, bearer) => {
|
|
73
|
+
try {
|
|
74
|
+
return await fetchImpl(`${endpoint}${path}`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
'content-type': 'application/json',
|
|
78
|
+
...(bearer === undefined ? {} : { authorization: `Bearer ${bearer}` }),
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify(body),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
// Node's network failures surface as a bare "fetch failed" with the cause
|
|
85
|
+
// buried. That is the first thing a new user sees when the host is wrong,
|
|
86
|
+
// down, or unreachable, and on its own it names neither the destination
|
|
87
|
+
// nor anything to do about it.
|
|
88
|
+
throw new Unreachable(new URL(endpoint).host, err.cause?.code);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
// P1: the recovery this command's own error message promises must exist.
|
|
92
|
+
// Two crashes leave a credential on disk that the server has not been told
|
|
93
|
+
// about, or has: (a) the durable write landed but the activate never reached
|
|
94
|
+
// the server, and (b) it reached the server and the response was lost. Both
|
|
95
|
+
// are repaired by retrying the IDEMPOTENT activate with what is already
|
|
96
|
+
// stored, before opening a new grant — a fresh grant would instead collide
|
|
97
|
+
// with the machine name the stored credential is holding.
|
|
98
|
+
//
|
|
99
|
+
// Read BEFORE the machine name is asked for. The prompt used to run first, so
|
|
100
|
+
// a machine that was already signed in was asked to name itself and only then
|
|
101
|
+
// told the question had no bearing — and the asking is not a wasted
|
|
102
|
+
// keystroke, it is a claim. Two people read it as evidence that the stored
|
|
103
|
+
// credential had been deleted, and went looking for a file that was on disk
|
|
104
|
+
// the whole time (#193).
|
|
105
|
+
//
|
|
106
|
+
// rc.2 checked first. #169 moved the prompt to the top of the command to
|
|
107
|
+
// reuse the name on both screens; nothing in it was about ordering, so
|
|
108
|
+
// nothing looked at the ordering.
|
|
109
|
+
const stored = readCredential(originOf(endpoint), process.env);
|
|
110
|
+
if (stored && (asked === undefined || stored.machineName === asked)) {
|
|
111
|
+
const res = await post('/v1/device/activate', {}, stored.secret);
|
|
112
|
+
if (res.ok) {
|
|
113
|
+
process.stdout.write(`Already signed in as machine "${stored.machineName}".\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const code = await errorCode(res);
|
|
117
|
+
// 401 not_found / 410 revoked|expired: the credential is genuinely dead, so
|
|
118
|
+
// a fresh login is the right move (the machine name was freed with it).
|
|
119
|
+
// Anything else is the server failing, and starting a new grant on top of a
|
|
120
|
+
// credential that may still be live would be the wrong repair.
|
|
121
|
+
if (res.status !== 401 && res.status !== 410) {
|
|
122
|
+
throw new Error(`a credential for ${originOf(endpoint)} is stored but could not be activated (${res.status} ${code}) — not starting a new login on top of it`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Only now is the name needed: this run is opening a grant, so there really
|
|
126
|
+
// is a machine to name.
|
|
127
|
+
//
|
|
128
|
+
// Asked for, not assumed. The name is what an approver reads to tell one
|
|
129
|
+
// machine from another, and defaulting to the hostname without showing it is
|
|
130
|
+
// how two machines came to be called the same thing in production.
|
|
131
|
+
// --machine-name still wins outright; a terminal gets a pre-filled question;
|
|
132
|
+
// a headless run gets the hostname and is told so.
|
|
133
|
+
const machineName = await settleMachineName(opts.machineName, {
|
|
134
|
+
...(opts.nameDeps ?? {}),
|
|
135
|
+
});
|
|
136
|
+
const codeRes = await post('/v1/device/code', {
|
|
137
|
+
client_id: CLIENT_ID,
|
|
138
|
+
scope: 'login',
|
|
139
|
+
machine_name: machineName,
|
|
140
|
+
});
|
|
141
|
+
if (!codeRes.ok) {
|
|
142
|
+
const code = await errorCode(codeRes);
|
|
143
|
+
throw new Error(`could not start login: ${codeRes.status} ${code}`);
|
|
144
|
+
}
|
|
145
|
+
const grant = (await codeRes.json());
|
|
146
|
+
process.stdout.write(`\nOpen this page and check the code matches:\n\n`);
|
|
147
|
+
process.stdout.write(` ${grant.verification_uri_complete}\n\n`);
|
|
148
|
+
process.stdout.write(` code: ${grant.user_code}\n`);
|
|
149
|
+
process.stdout.write(` machine: ${machineName}\n\n`);
|
|
150
|
+
// Armed, never awaited: an approval done from a phone, or from a URL typed by
|
|
151
|
+
// hand, must still be noticed — so the poll below runs whether or not any key
|
|
152
|
+
// is ever pressed. `disarm` is bound to every exit path, because a live stdin
|
|
153
|
+
// listener would hold the process open after login has already finished.
|
|
154
|
+
const opener = (opts.armOpener ?? armEnterToOpen)(grant.verification_uri_complete, endpoint);
|
|
155
|
+
process.stdout.write(`Waiting for approval — nothing is granted until you approve it.\n`);
|
|
156
|
+
try {
|
|
157
|
+
return await pollUntilDecided(grant, {
|
|
158
|
+
post,
|
|
159
|
+
sleep,
|
|
160
|
+
now,
|
|
161
|
+
endpoint,
|
|
162
|
+
armOpener: opts.armOpener ?? armEnterToOpen,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
opener.disarm();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function pollUntilDecided(grant, ctx) {
|
|
170
|
+
const { post, sleep, now, endpoint, armOpener } = ctx;
|
|
171
|
+
// The poll is single-flight by construction: one loop, one request in flight.
|
|
172
|
+
// A concurrent second poll on the same grant would revoke the credential the
|
|
173
|
+
// first poll received.
|
|
174
|
+
let intervalMs = Math.max(grant.interval, 1) * 1000;
|
|
175
|
+
const deadline = now() + grant.expires_in * 1000;
|
|
176
|
+
for (;;) {
|
|
177
|
+
if (now() >= deadline) {
|
|
178
|
+
throw new Error('the code expired before it was approved — run login again');
|
|
179
|
+
}
|
|
180
|
+
await sleep(intervalMs);
|
|
181
|
+
let res;
|
|
182
|
+
try {
|
|
183
|
+
res = await post('/v1/device/token', {
|
|
184
|
+
device_code: grant.device_code,
|
|
185
|
+
client_id: CLIENT_ID,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
// Keep waiting. The grant's own expiry is the deadline that ends this
|
|
190
|
+
// loop; a dropped connection is not evidence that anything is wrong with
|
|
191
|
+
// it. Measured against a control plane that never restarted: the process
|
|
192
|
+
// was up throughout and one keep-alive connection was closed.
|
|
193
|
+
if (err instanceof Unreachable) {
|
|
194
|
+
// Said out loud rather than swallowed: an operator watching a long wait
|
|
195
|
+
// should see that contact was lost and regained, not silence.
|
|
196
|
+
process.stdout.write(` (lost contact with the server, still waiting)\n`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
if (res.ok) {
|
|
202
|
+
const body = (await res.json());
|
|
203
|
+
// Checked, not cast. `org` is half the storage key, and a cast turns a
|
|
204
|
+
// missing field into `undefined` — which would key this credential under
|
|
205
|
+
// a literal "undefined" and put it in the way of the next real one. A
|
|
206
|
+
// server that does not send it is a server this build cannot store a
|
|
207
|
+
// credential for, and saying so is better than inventing a slot.
|
|
208
|
+
const issued = {
|
|
209
|
+
capability_url: String(body['capability_url'] ?? ''),
|
|
210
|
+
machine_name: String(body['machine_name'] ?? ''),
|
|
211
|
+
org: typeof body['org'] === 'string' ? body['org'] : '',
|
|
212
|
+
};
|
|
213
|
+
if (!issued.capability_url || !issued.org) {
|
|
214
|
+
throw new Error('the server answered without the fields this needs (capability_url, org) — it may be older than this CLI');
|
|
215
|
+
}
|
|
216
|
+
// Present is not the same as usable. `org` becomes half a storage key,
|
|
217
|
+
// and the key's delimiter is a space on the grounds that an org address
|
|
218
|
+
// cannot contain one — so a value like `x`, or one carrying a space or a
|
|
219
|
+
// newline, does not fail loudly. It writes a slot under a key nothing
|
|
220
|
+
// will look up again. Checked here so nothing durable happens first, and
|
|
221
|
+
// checked again in `keyFor` so a future caller cannot route around this.
|
|
222
|
+
if (!isOrgAddress(issued.org)) {
|
|
223
|
+
throw new Error('the server answered with an org address this build does not recognise — nothing was stored');
|
|
224
|
+
}
|
|
225
|
+
return finish(issued, endpoint, post);
|
|
226
|
+
}
|
|
227
|
+
const body = await errorBody(res);
|
|
228
|
+
const code = body.code;
|
|
229
|
+
if (code === 'authorization_pending')
|
|
230
|
+
continue;
|
|
231
|
+
if (code === 'slow_down') {
|
|
232
|
+
// The server's own backoff. Honouring it is what keeps a slow approval
|
|
233
|
+
// from turning into a rate-limit refusal.
|
|
234
|
+
intervalMs += 5000;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (code === 'over_machines_cap') {
|
|
238
|
+
// Not a generic refusal: the operator is one upgrade away, and the
|
|
239
|
+
// numbers to decide with came back with the 403.
|
|
240
|
+
throw new Error(atMachineLimit(body, grant, endpoint, armOpener));
|
|
241
|
+
}
|
|
242
|
+
const explained = TERMINAL[code];
|
|
243
|
+
throw new Error(explained ? `login stopped: ${explained}` : `login failed: ${res.status} ${code}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function finish(issued, endpoint, post) {
|
|
247
|
+
const secret = secretFromCapabilityUrl(issued.capability_url);
|
|
248
|
+
// Durable write FIRST. If the process dies between here and activate, the
|
|
249
|
+
// credential is on disk and the machine can be activated by running login
|
|
250
|
+
// again; the reverse order loses the only copy of the secret.
|
|
251
|
+
//
|
|
252
|
+
// The org comes from the token response because it is decided while this
|
|
253
|
+
// process is polling — the approver picks it in the console. It is half the
|
|
254
|
+
// storage key: without it, signing in to a second org on this host would
|
|
255
|
+
// overwrite the first org's secret.
|
|
256
|
+
writeCredential({
|
|
257
|
+
endpoint: originOf(endpoint),
|
|
258
|
+
org: issued.org,
|
|
259
|
+
secret,
|
|
260
|
+
capabilityUrl: issued.capability_url,
|
|
261
|
+
machineName: issued.machine_name,
|
|
262
|
+
});
|
|
263
|
+
const res = await post('/v1/device/activate', {}, secret);
|
|
264
|
+
if (!res.ok) {
|
|
265
|
+
const code = await errorCode(res);
|
|
266
|
+
throw new Error(`the credential was saved but could not be activated (${res.status} ${code}) — run login again`);
|
|
267
|
+
}
|
|
268
|
+
process.stdout.write(`\nSigned in as machine "${issued.machine_name}".\n`);
|
|
269
|
+
process.stdout.write(`Its sync address is saved on this machine; no token to copy.\n`);
|
|
270
|
+
}
|
|
271
|
+
async function errorCode(res) {
|
|
272
|
+
return (await errorBody(res)).code;
|
|
273
|
+
}
|
|
274
|
+
// A response body can be read once. Every caller that wants more than the code
|
|
275
|
+
// — the machine-limit message wants the numbers beside it — has to come
|
|
276
|
+
// through here, or the second read finds an already-consumed stream and the
|
|
277
|
+
// facts vanish silently. (Found by the test that asserted the numbers.)
|
|
278
|
+
async function errorBody(res) {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = (await res.json());
|
|
281
|
+
return {
|
|
282
|
+
code: typeof parsed.error === 'string' ? parsed.error : 'unknown',
|
|
283
|
+
...(typeof parsed.plan === 'string' ? { plan: parsed.plan } : {}),
|
|
284
|
+
...(typeof parsed.limit === 'number' ? { limit: parsed.limit } : {}),
|
|
285
|
+
...(typeof parsed.in_use === 'number' ? { inUse: parsed.in_use } : {}),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return { code: 'unknown' };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// "Your plan is at its machine limit" — with the plan, the numbers, and a way
|
|
293
|
+
// to act on it.
|
|
294
|
+
//
|
|
295
|
+
// The old text named the limit and nothing else: not which plan, not how many
|
|
296
|
+
// machines are in use, not where to change it. Someone reading it had to go
|
|
297
|
+
// and find all three. The numbers come from the server's 403 rather than a
|
|
298
|
+
// copy of the plan table here, so what is displayed and what is enforced
|
|
299
|
+
// cannot drift apart.
|
|
300
|
+
//
|
|
301
|
+
// The Enter offer is the same one login already makes for the approval page,
|
|
302
|
+
// deliberately: it declines to say "press Enter" where no keypress can arrive
|
|
303
|
+
// and says what to do instead. Writing a second one here would recreate the
|
|
304
|
+
// defect that one exists to fix.
|
|
305
|
+
function atMachineLimit(body, grant, endpoint, armOpener) {
|
|
306
|
+
const lines = ['Cannot add this machine — your plan is at its machine limit.', ''];
|
|
307
|
+
if (body.plan !== undefined)
|
|
308
|
+
lines.push(` plan: ${body.plan}`);
|
|
309
|
+
if (body.limit !== undefined && body.inUse !== undefined) {
|
|
310
|
+
lines.push(` machines: ${body.inUse} of ${body.limit} in use`);
|
|
311
|
+
}
|
|
312
|
+
// The console's own origin, taken from the address the SERVER gave for this
|
|
313
|
+
// login rather than assembled here — a self-hosted stack has its own, and a
|
|
314
|
+
// guess would send the operator somewhere that is not their console. Only
|
|
315
|
+
// the origin is used: the console selects Billing in the page, so there is
|
|
316
|
+
// no path to link to (see the report accompanying this change).
|
|
317
|
+
let consoleUrl = null;
|
|
318
|
+
try {
|
|
319
|
+
consoleUrl = new URL(grant.verification_uri).origin;
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
consoleUrl = null;
|
|
323
|
+
}
|
|
324
|
+
// The same check the approval page goes through. A URL we would not open is
|
|
325
|
+
// not one to put in front of someone either.
|
|
326
|
+
if (consoleUrl && verificationUrlIsSafe(consoleUrl, endpoint)) {
|
|
327
|
+
lines.push('', 'Upgrade in the console (open Billing there):', ` ${consoleUrl}`);
|
|
328
|
+
armOpener(consoleUrl, endpoint);
|
|
329
|
+
}
|
|
330
|
+
else if (consoleUrl) {
|
|
331
|
+
lines.push('', 'Upgrade in the console.');
|
|
332
|
+
}
|
|
333
|
+
return lines.join('\n');
|
|
334
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DEFAULT_ENDPOINT } from '../browser.js';
|
|
2
|
+
import { originOf, removeCredentials } from '../credentials.js';
|
|
3
|
+
import { clearRecordsForOrigin } from '../pending.js';
|
|
4
|
+
/**
|
|
5
|
+
* The endpoint `logout` was asked for, or a refusal.
|
|
6
|
+
*
|
|
7
|
+
* The argv this command accepts is CLOSED — `[]`, or exactly
|
|
8
|
+
* `--endpoint <url>` — rather than filtered for the one flag it reads.
|
|
9
|
+
*
|
|
10
|
+
* Because it is destructive AND defaulted. Reading only `--endpoint` and
|
|
11
|
+
* ignoring the rest means `logout --endpont https://self.test` finds no flag,
|
|
12
|
+
* falls back to the hosted service, and deletes the production credential
|
|
13
|
+
* while the person believed they had named their own stack. So does
|
|
14
|
+
* `logout https://self.test`. Neither is an exotic input: they are a typo and
|
|
15
|
+
* a reasonable guess at the syntax.
|
|
16
|
+
*
|
|
17
|
+
* A command that removes a secret must not act on an argv it did not
|
|
18
|
+
* understand.
|
|
19
|
+
*/
|
|
20
|
+
export function logoutEndpoint(argv) {
|
|
21
|
+
const refuse = () => {
|
|
22
|
+
throw new Error('usage: agmsg-cloud logout [--endpoint <url>]');
|
|
23
|
+
};
|
|
24
|
+
if (argv.length === 0)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (argv.length !== 2 || argv[0] !== '--endpoint')
|
|
27
|
+
return refuse();
|
|
28
|
+
const value = argv[1];
|
|
29
|
+
// A missing value would otherwise swallow the next token; there is no next
|
|
30
|
+
// token here, but the same shape (`--endpoint --force`) is what the flag
|
|
31
|
+
// reader elsewhere in this file guards against.
|
|
32
|
+
if (value.length === 0 || value.startsWith('--'))
|
|
33
|
+
return refuse();
|
|
34
|
+
try {
|
|
35
|
+
// Parsed here rather than at the point of deletion: an unparseable address
|
|
36
|
+
// should be refused before anything is read or removed.
|
|
37
|
+
void new URL(value);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return refuse();
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
export function cmdLogout(opts = {}) {
|
|
45
|
+
const env = opts.env ?? process.env;
|
|
46
|
+
const out = opts.out ?? ((text) => void process.stdout.write(text));
|
|
47
|
+
const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
48
|
+
const origin = originOf(endpoint);
|
|
49
|
+
const removed = removeCredentials(origin, env);
|
|
50
|
+
const pending = clearRecordsForOrigin(origin, env);
|
|
51
|
+
if (removed.length === 0) {
|
|
52
|
+
// Not an error. "There was nothing to remove" is the state someone running
|
|
53
|
+
// this twice is in, and the state someone checking is in — and it is the
|
|
54
|
+
// same end state they asked for. Exiting non-zero would make a script that
|
|
55
|
+
// signs out defensively fail on the run where it worked.
|
|
56
|
+
out(`No credential stored for ${origin}.\n`);
|
|
57
|
+
if (pending > 0)
|
|
58
|
+
out(`Cleared ${pending} pending enrollment record(s).\n`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Named, not counted. The person signing out is entitled to know which
|
|
62
|
+
// identity just left this machine — one host can hold credentials for
|
|
63
|
+
// several orgs, and "signed out" without saying whose is how someone
|
|
64
|
+
// discovers later that the wrong one went.
|
|
65
|
+
for (const credential of removed) {
|
|
66
|
+
out(`Signed out machine "${credential.machineName}" from ${origin} (org ${credential.org}).\n`);
|
|
67
|
+
}
|
|
68
|
+
if (pending > 0)
|
|
69
|
+
out(`Cleared ${pending} pending enrollment record(s).\n`);
|
|
70
|
+
// Said because the omission is the surprising part: someone who ran this to
|
|
71
|
+
// clean up a machine should not have to wonder whether their history went
|
|
72
|
+
// with it.
|
|
73
|
+
out(`This machine's device key and local team keys were not touched.\n`);
|
|
74
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawnOssInherit } from '../oss-env.js';
|
|
2
|
+
import { shellArg } from '../shell-arg.js';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CourierClient, isUuid } from '../api.js';
|
|
5
|
+
import { originOf, readCredential } from '../credentials.js';
|
|
6
|
+
import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
|
|
7
|
+
function runInherit(command, args) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
// stdio inherited: `remote.sh pull` prints its own progress, and this is
|
|
10
|
+
// the slowest step of joining a second machine.
|
|
11
|
+
const child = spawnOssInherit(command, args);
|
|
12
|
+
child.on('error', reject);
|
|
13
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export async function cmdPull(config, opts) {
|
|
17
|
+
ensurePreflight(preflight(config.scriptsDir, NEEDS.pull));
|
|
18
|
+
// The capability URL, not the control-plane endpoint: the sync gateway
|
|
19
|
+
// answers the former and never sees the latter.
|
|
20
|
+
const credential = readCredential(originOf(config.baseUrl));
|
|
21
|
+
if (!credential) {
|
|
22
|
+
throw new Error(`no stored credential for ${originOf(config.baseUrl)} — run \`agmsg-cloud login\` on this machine first`);
|
|
23
|
+
}
|
|
24
|
+
// `--team-id` skips the lookup, and with it every check the lookup's
|
|
25
|
+
// response boundary performs. Whatever is typed here goes to the OSS side
|
|
26
|
+
// as an identity, so it is checked in the one place left that can.
|
|
27
|
+
if (opts.teamId !== undefined && !isUuid(opts.teamId)) {
|
|
28
|
+
throw new Error(`--team-id must be a team UUID, not "${opts.teamId}"`);
|
|
29
|
+
}
|
|
30
|
+
const teamId = opts.teamId ?? (await resolveTeamId(config, opts));
|
|
31
|
+
process.stdout.write(`\nPulling "${opts.team}" onto machine "${credential.machineName}".\n`);
|
|
32
|
+
const run = opts.runner ?? runInherit;
|
|
33
|
+
const code = await run('bash', [
|
|
34
|
+
join(config.scriptsDir, 'remote.sh'),
|
|
35
|
+
'pull',
|
|
36
|
+
'--endpoint',
|
|
37
|
+
credential.capabilityUrl,
|
|
38
|
+
'--team-id',
|
|
39
|
+
teamId,
|
|
40
|
+
opts.team,
|
|
41
|
+
]);
|
|
42
|
+
if (code !== 0) {
|
|
43
|
+
// The OSS script has already said what went wrong on this terminal.
|
|
44
|
+
throw new Error(`pull failed (remote.sh exited ${code})`);
|
|
45
|
+
}
|
|
46
|
+
// Says nothing about whether it is locked, because the OSS script now says
|
|
47
|
+
// that accurately for THIS machine: it decides on whether the current
|
|
48
|
+
// epoch's key is here, not on how many envelopes arrived, so it prints the
|
|
49
|
+
// locked line or the usable one. Restating it from here would either
|
|
50
|
+
// duplicate it or contradict it, and this side cannot tell which.
|
|
51
|
+
//
|
|
52
|
+
// What it adds is the route, which the OSS script no longer names because
|
|
53
|
+
// its route is not ours. Phrased as a condition rather than a claim, so it
|
|
54
|
+
// is true after either line above.
|
|
55
|
+
process.stdout.write(`\n"${opts.team}" is on this machine.\n`);
|
|
56
|
+
if (opts.nextStepsFromCaller !== true) {
|
|
57
|
+
process.stdout.write(`If it is still locked, \`agmsg-cloud sync ${shellArg(opts.team)}\` completes the key handoff:\n` +
|
|
58
|
+
'it asks a machine that already has the team, and the two of you compare eight digits.\n');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function resolveTeamId(config, opts) {
|
|
62
|
+
const client = opts.client ?? new CourierClient(config);
|
|
63
|
+
const matches = await client.resolveTeamByName(opts.team);
|
|
64
|
+
if (matches.length === 0) {
|
|
65
|
+
// Named as what it is: this org has no such team. The alternative reading
|
|
66
|
+
// — that the team exists under someone else — is not ours to confirm or
|
|
67
|
+
// deny, and the answer is the same either way.
|
|
68
|
+
throw new Error(`no team named "${opts.team}" in this organization.\n\n` +
|
|
69
|
+
'Connect it from the machine that runs it first, or pass --team-id if you have it.');
|
|
70
|
+
}
|
|
71
|
+
if (matches.length > 1) {
|
|
72
|
+
// A name is not unique by construction, so this stops rather than picking.
|
|
73
|
+
// Pulling the wrong team writes another team's messages into this machine's
|
|
74
|
+
// store under a name the operator chose, which no later step would notice.
|
|
75
|
+
const ids = matches.map((m) => ` ${m.teamId}`).join('\n');
|
|
76
|
+
throw new Error(`"${opts.team}" is ambiguous — this organization has ${matches.length} teams with that name:\n\n${ids}\n\n` +
|
|
77
|
+
'Re-run with --team-id <id> to say which one.');
|
|
78
|
+
}
|
|
79
|
+
return matches[0].teamId;
|
|
80
|
+
}
|