@taprootio/site-authoring 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/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- package/src/workspace.js +1085 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import {
|
|
4
|
+
claimCliAuthorization,
|
|
5
|
+
CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED,
|
|
6
|
+
CLI_AUTHORIZATION_CLAIM_STATUS_DENIED,
|
|
7
|
+
CLI_AUTHORIZATION_CLAIM_STATUS_EXPIRED,
|
|
8
|
+
CLI_AUTHORIZATION_CLAIM_STATUS_ISSUED,
|
|
9
|
+
CLI_AUTHORIZATION_CLAIM_STATUS_PENDING,
|
|
10
|
+
poll,
|
|
11
|
+
startCliAuthorization,
|
|
12
|
+
} from "../api.js";
|
|
13
|
+
import {
|
|
14
|
+
AUTHORIZE_CLI_PATH,
|
|
15
|
+
CLI_BINARY_NAME,
|
|
16
|
+
DEFAULT_LOGIN_KEY_NAME,
|
|
17
|
+
LIMITS,
|
|
18
|
+
LOGIN_KEY_NAME_MAXIMUM,
|
|
19
|
+
REFUSAL_PLATFORM_PAUSED,
|
|
20
|
+
REFUSAL_THROTTLED,
|
|
21
|
+
VERB_LOGIN,
|
|
22
|
+
VERB_LOGOUT,
|
|
23
|
+
VERB_SITES,
|
|
24
|
+
} from "../constants.js";
|
|
25
|
+
import { assertCredentialCapacity, credentialApiOrigin, saveCredential } from "../credentials.js";
|
|
26
|
+
import { asSiteAuthoringError, hasControlCharacter, SiteAuthoringError } from "../errors.js";
|
|
27
|
+
import { openAnonymousSession, successResult } from "../session.js";
|
|
28
|
+
import { ApiError } from "../transport.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `taproot-site login` — the CLI half of the device-authorization exchange.
|
|
32
|
+
*
|
|
33
|
+
* The shape of the flow is what keeps the secret out of every place it should
|
|
34
|
+
* not be. The CLI starts an authorization with no credential of its own, prints
|
|
35
|
+
* a URL the owner opens in a browser they already trust, and polls with a
|
|
36
|
+
* high-entropy device code. The credential is minted at claim time and crosses
|
|
37
|
+
* the wire exactly once, straight into a `0600` file the operator never has to
|
|
38
|
+
* see. It reaches no clipboard, no shell history, no stdout, no stderr, and no
|
|
39
|
+
* result — the success payload names the credential by id and display prefix
|
|
40
|
+
* only.
|
|
41
|
+
*
|
|
42
|
+
* The approval URL is composed here from the already-reviewed API origin and
|
|
43
|
+
* never taken from a response, so a compromised or misconfigured server cannot
|
|
44
|
+
* steer an owner to somewhere else to approve a credential.
|
|
45
|
+
*
|
|
46
|
+
* **What TR00645 changed.** This used to authorize one site, named up front by
|
|
47
|
+
* a configuration file the operator had to hand-write before they could log in
|
|
48
|
+
* at all. It now authorizes the account, so it needs no site, no configuration,
|
|
49
|
+
* and no prior knowledge of anything — `npm i -g` then `login` works in any
|
|
50
|
+
* directory. What it mints is exchange-only: it lists sites and swaps itself
|
|
51
|
+
* for short-lived site credentials, and authorizes no write anywhere.
|
|
52
|
+
*
|
|
53
|
+
* This is also the one remaining mint in the CLI whose response cannot be
|
|
54
|
+
* recovered if it is lost, which is why the orphan-handling below survives here
|
|
55
|
+
* and was removed from the exchange path. It happens once a day, in one place,
|
|
56
|
+
* with a human watching.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
const CLAIM_THROTTLED = Symbol("cli authorization claim throttled");
|
|
60
|
+
const CLAIM_LOST = Symbol("cli authorization claim response lost");
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The claim failures that mean "the answer never usefully made it back", not
|
|
64
|
+
* "the claim was refused": a connection that died *after the request was on
|
|
65
|
+
* the wire*, a retry budget spent on dead connections, a 2xx body that could
|
|
66
|
+
* not be read, parsed, or fit in the response bound; an ISSUED payload whose fields failed validation, where the
|
|
67
|
+
* mint has definitely committed; or an answer this client's vocabulary cannot
|
|
68
|
+
* read at all (`login.claim_contract` — a non-object envelope, an absent
|
|
69
|
+
* status, a status value from a newer server). A serializer or version-skew
|
|
70
|
+
* fault produces those AFTER the handler committed, so none of them may
|
|
71
|
+
* abort behind a generic error. All of them keep the poll alive: the next
|
|
72
|
+
* successful claim answers CONSUMED and names the exact credential to revoke,
|
|
73
|
+
* and a deadline reached instead carries the MAY-have-been-issued guidance.
|
|
74
|
+
* Server-side 5xx answers are the same shape of loss and are handled beside
|
|
75
|
+
* these in the read's catch — they arrive as ApiError, not by code.
|
|
76
|
+
*/
|
|
77
|
+
const CLAIM_LOSS_CODES = new Set([
|
|
78
|
+
"transport.mutation_ambiguous",
|
|
79
|
+
"transport.retry_exhausted",
|
|
80
|
+
"transport.invalid_json",
|
|
81
|
+
"transport.response_read",
|
|
82
|
+
"transport.response_too_large",
|
|
83
|
+
"login.claim_unusable",
|
|
84
|
+
"login.claim_contract",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A claim that provably never reached Taproot: the name did not resolve, the
|
|
89
|
+
* connection was refused, the certificate was not trusted. The poll continues
|
|
90
|
+
* exactly as for a lost answer — the approval may still be coming — but
|
|
91
|
+
* nothing is armed, because nothing was sent and so nothing could have minted.
|
|
92
|
+
* Treating this as a possible orphan sent operators to Settings to revoke a
|
|
93
|
+
* credential that never existed, over a certificate their machine did not
|
|
94
|
+
* trust.
|
|
95
|
+
*/
|
|
96
|
+
const CLAIM_UNDELIVERED_CODES = new Set(["transport.network"]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Bidirectional embedding/override (U+202A–U+202E) and isolate (U+2066–U+2069)
|
|
100
|
+
* code points — text carrying them can display other than as written. The
|
|
101
|
+
* server refuses them on the same field; refusing here keeps the failure local
|
|
102
|
+
* and immediate instead of a wire round trip.
|
|
103
|
+
*/
|
|
104
|
+
const BIDI_CONTROL = /[--]/u;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The name recorded on the issued credential, which the approval screen shows
|
|
108
|
+
* the owner before they approve. Trimmed, bounded, and free of control and bidi
|
|
109
|
+
* characters: it is authored on a command line and displayed in a browser.
|
|
110
|
+
*/
|
|
111
|
+
export function normalizeKeyName(value) {
|
|
112
|
+
if (value === undefined) return DEFAULT_LOGIN_KEY_NAME;
|
|
113
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
114
|
+
if (
|
|
115
|
+
trimmed.length === 0 || trimmed.length > LOGIN_KEY_NAME_MAXIMUM || hasControlCharacter(trimmed)
|
|
116
|
+
|| BIDI_CONTROL.test(trimmed)
|
|
117
|
+
) {
|
|
118
|
+
throw new SiteAuthoringError(
|
|
119
|
+
"login.key_name_invalid",
|
|
120
|
+
`--name must be 1 to ${LOGIN_KEY_NAME_MAXIMUM} printable characters.`,
|
|
121
|
+
{ field: "keyName", exitCode: 2 },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return trimmed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The browser page, on the application origin the API base URL already pins.
|
|
129
|
+
* `apiBaseUrl` is the `/api` endpoint on that same origin, so the path prefix
|
|
130
|
+
* is dropped deliberately: the approval screen is an application route.
|
|
131
|
+
*
|
|
132
|
+
* The URL carries no code, on purpose. The owner types the code from this
|
|
133
|
+
* terminal into the page — that entry is the device flow's phishing defence,
|
|
134
|
+
* proving the person at the browser can see the CLI that asked. A prefilled
|
|
135
|
+
* link would reduce approval to one click on a URL anyone could have sent.
|
|
136
|
+
*/
|
|
137
|
+
export function verificationUrlFor(apiBaseUrl) {
|
|
138
|
+
return new URL(AUTHORIZE_CLI_PATH, credentialApiOrigin(apiBaseUrl)).toString();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function clamp(value, minimum, maximum) {
|
|
142
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function describeMinutes(milliseconds) {
|
|
146
|
+
const minutes = Math.round(milliseconds / 60_000);
|
|
147
|
+
return minutes <= 1 ? "about a minute" : `about ${minutes} minutes`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The terminal refusals, each with the one thing the owner should do next.
|
|
152
|
+
* `consumed` is the loudest because it is the only outcome that leaves a live
|
|
153
|
+
* credential nobody holds: the claim minted one and its response was lost, so
|
|
154
|
+
* the credential exists, this CLI never received it, and it can only be
|
|
155
|
+
* revoked — named by its exact id and display prefix when the server supplied
|
|
156
|
+
* them, and by its display name otherwise.
|
|
157
|
+
*/
|
|
158
|
+
function terminalFailure(answer, keyName) {
|
|
159
|
+
const status = answer.status;
|
|
160
|
+
switch (status) {
|
|
161
|
+
case CLI_AUTHORIZATION_CLAIM_STATUS_DENIED:
|
|
162
|
+
return new SiteAuthoringError(
|
|
163
|
+
"login.denied",
|
|
164
|
+
"The authorization was denied in the browser. No credential was issued and nothing was stored.",
|
|
165
|
+
{ status },
|
|
166
|
+
);
|
|
167
|
+
case CLI_AUTHORIZATION_CLAIM_STATUS_EXPIRED:
|
|
168
|
+
return new SiteAuthoringError(
|
|
169
|
+
"login.expired",
|
|
170
|
+
`The authorization expired before it was approved. Run '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
|
|
171
|
+
{ status },
|
|
172
|
+
);
|
|
173
|
+
case CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED:
|
|
174
|
+
return new SiteAuthoringError(
|
|
175
|
+
"login.consumed",
|
|
176
|
+
"This authorization was already claimed, so a credential WAS issued but this command never received it. "
|
|
177
|
+
+ (answer.keyId !== undefined
|
|
178
|
+
? `Revoke the credential with id ${answer.keyId}`
|
|
179
|
+
+ (answer.keyPrefix === undefined ? "" : ` (display prefix ${answer.keyPrefix})`)
|
|
180
|
+
: `Revoke the credential named '${keyName}'`)
|
|
181
|
+
+ ` under Account -> Settings -> API keys, then run '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
|
|
182
|
+
{ status },
|
|
183
|
+
);
|
|
184
|
+
default:
|
|
185
|
+
return new SiteAuthoringError(
|
|
186
|
+
"login.claim_contract",
|
|
187
|
+
"Taproot returned an unsupported CLI authorization claim status.",
|
|
188
|
+
{ field: "status", status },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function translateClaimApiError(error) {
|
|
194
|
+
if (!(error instanceof ApiError) || error.httpStatus !== 404) return error;
|
|
195
|
+
return new SiteAuthoringError(
|
|
196
|
+
"login.unknown",
|
|
197
|
+
`Taproot does not recognize this authorization. Run '${CLI_BINARY_NAME} ${VERB_LOGIN}' to start a new one.`,
|
|
198
|
+
{ status: error.status },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The start endpoint is unauthenticated and rate-limited by network address, so
|
|
204
|
+
* its throttle is not a statement about any credential. The shared refusal
|
|
205
|
+
* guidance would say "this credential exceeded its request budget", which is
|
|
206
|
+
* both wrong and misleading here — there is no credential yet.
|
|
207
|
+
*/
|
|
208
|
+
function translateStartApiError(error) {
|
|
209
|
+
if (!(error instanceof ApiError) || error.refusalKind() !== REFUSAL_THROTTLED) return error;
|
|
210
|
+
return new SiteAuthoringError(
|
|
211
|
+
"login.throttled",
|
|
212
|
+
"Taproot is rate limiting CLI authorization requests from this network. Wait a moment and try again.",
|
|
213
|
+
{ status: error.status },
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A storage failure after the claim is the one local outcome that leaves a
|
|
219
|
+
* live credential nobody holds: the server minted it and the CLI could not keep
|
|
220
|
+
* it. The refusal must say exactly that — naming the credential by id and
|
|
221
|
+
* display prefix, never by value — and direct the owner to revoke it, because
|
|
222
|
+
* silence here is a live credential the owner does not know exists.
|
|
223
|
+
*/
|
|
224
|
+
function unstoredCredentialFailure(error, claimed) {
|
|
225
|
+
const cause = asSiteAuthoringError(error);
|
|
226
|
+
return new SiteAuthoringError(
|
|
227
|
+
"login.credential_unstored",
|
|
228
|
+
`A credential WAS issued (id ${claimed.keyId}, display prefix ${claimed.keyPrefix}) but could not `
|
|
229
|
+
+ `be stored: ${cause.message} Revoke it under Account -> Settings -> API keys, then run `
|
|
230
|
+
+ `'${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
|
|
231
|
+
{ ...(cause.status === undefined ? {} : { status: cause.status }) },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Opens the approval page in the operator's browser.
|
|
237
|
+
*
|
|
238
|
+
* Spawned detached with its streams ignored, because the opener is a launcher
|
|
239
|
+
* rather than a program to wait on: `open` returns immediately, but a desktop
|
|
240
|
+
* environment's `xdg-open` can outlive the shell that started it, and inheriting
|
|
241
|
+
* stdio would let it scribble over the code the operator is reading.
|
|
242
|
+
*
|
|
243
|
+
* The URL is the one this CLI composed from the reviewed origin. Nothing from a
|
|
244
|
+
* response reaches a command line here.
|
|
245
|
+
*/
|
|
246
|
+
async function openInBrowser(url) {
|
|
247
|
+
const [command, arguments_] = process.platform === "darwin"
|
|
248
|
+
? ["open", [url]]
|
|
249
|
+
: process.platform === "win32"
|
|
250
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
251
|
+
: ["xdg-open", [url]];
|
|
252
|
+
await new Promise((resolve, reject) => {
|
|
253
|
+
const child = spawn(command, arguments_, { stdio: "ignore", detached: true });
|
|
254
|
+
child.once("error", reject);
|
|
255
|
+
child.once("spawn", () => {
|
|
256
|
+
child.unref();
|
|
257
|
+
resolve();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Waits for Enter on a terminal, and never anywhere else.
|
|
264
|
+
*
|
|
265
|
+
* Returns false when stdin is not a TTY — a CI job, a pipe, an agent harness —
|
|
266
|
+
* so the prompt degrades to a printed URL instead of a command that hangs
|
|
267
|
+
* forever on input nobody is there to give.
|
|
268
|
+
*/
|
|
269
|
+
async function awaitEnterOnTty(input = process.stdin) {
|
|
270
|
+
if (!input.isTTY) return false;
|
|
271
|
+
await new Promise((resolve) => {
|
|
272
|
+
const readline = createInterface({ input, terminal: false });
|
|
273
|
+
readline.once("line", () => {
|
|
274
|
+
readline.close();
|
|
275
|
+
resolve();
|
|
276
|
+
});
|
|
277
|
+
readline.once("close", resolve);
|
|
278
|
+
});
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The interactive hand-off, in the order the operator has to act in.
|
|
284
|
+
*
|
|
285
|
+
* The code comes first and alone, because copying it is the step that has to
|
|
286
|
+
* happen before the browser takes the foreground — the gh-style ordering. The
|
|
287
|
+
* page still accepts no code from the URL: what is opened is the bare approval
|
|
288
|
+
* page, so the typed entry that proves the approver can see this terminal is
|
|
289
|
+
* intact.
|
|
290
|
+
*
|
|
291
|
+
* Every path prints the URL somewhere. An opener that is missing or refuses
|
|
292
|
+
* must not strand someone with a code and nowhere to type it.
|
|
293
|
+
*/
|
|
294
|
+
async function handOffToBrowser({ verificationUrl, userCode, onProgress, awaitEnter, openUrl }) {
|
|
295
|
+
onProgress(`First, copy this code: ${userCode}`);
|
|
296
|
+
|
|
297
|
+
if (!await awaitEnter()) {
|
|
298
|
+
// Not a terminal: no one is going to press anything.
|
|
299
|
+
onProgress(`Then open this URL in a browser signed in to Taproot: ${verificationUrl}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await openUrl(verificationUrl);
|
|
305
|
+
onProgress(`Opened ${verificationUrl}`);
|
|
306
|
+
} catch {
|
|
307
|
+
onProgress(`Could not open a browser. Open this URL yourself: ${verificationUrl}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function login(invocation) {
|
|
312
|
+
const keyName = normalizeKeyName(invocation.keyName);
|
|
313
|
+
const { apiBaseUrl, client, now, onProgress } = await openAnonymousSession(invocation);
|
|
314
|
+
const environment = invocation.environment ?? process.env;
|
|
315
|
+
const apiOrigin = credentialApiOrigin(apiBaseUrl);
|
|
316
|
+
|
|
317
|
+
// Preflight the store before anything is minted: an unreadable, corrupt, or
|
|
318
|
+
// symlinked store refuses here, where no credential exists yet, instead of
|
|
319
|
+
// after the claim, where the same refusal would orphan a live credential the
|
|
320
|
+
// operator then has to revoke by hand. The projection covers the size bound
|
|
321
|
+
// too, pessimistically — a store with no room left for this record refuses
|
|
322
|
+
// now rather than at the write, which is the same failure arriving after the
|
|
323
|
+
// damage. A residual write failure can still happen (the disk can fill during
|
|
324
|
+
// the poll), which is what `unstoredCredentialFailure` below is for.
|
|
325
|
+
await assertCredentialCapacity(environment, apiOrigin);
|
|
326
|
+
|
|
327
|
+
let authorization;
|
|
328
|
+
try {
|
|
329
|
+
authorization = await startCliAuthorization(client, { keyName });
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw translateStartApiError(error);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const verificationUrl = verificationUrlFor(apiBaseUrl);
|
|
335
|
+
const deadlineMilliseconds = Math.min(authorization.expiresInSeconds * 1_000, LIMITS.loginMaximumMilliseconds);
|
|
336
|
+
const intervalMilliseconds = clamp(
|
|
337
|
+
authorization.pollIntervalSeconds * 1_000,
|
|
338
|
+
LIMITS.loginPollIntervalMinimumMilliseconds,
|
|
339
|
+
LIMITS.loginPollIntervalMaximumMilliseconds,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// The interactive hand-off. Every line here is load bearing — the URL and
|
|
343
|
+
// code reach the operator only through these progress lines, which is why
|
|
344
|
+
// the CLI refuses `--quiet` for this verb outright rather than silencing
|
|
345
|
+
// the one thing the command cannot complete without.
|
|
346
|
+
onProgress(`Authorizing this CLI as '${keyName}'.`);
|
|
347
|
+
await handOffToBrowser({
|
|
348
|
+
verificationUrl,
|
|
349
|
+
userCode: authorization.userCode,
|
|
350
|
+
onProgress,
|
|
351
|
+
awaitEnter: invocation.awaitEnter
|
|
352
|
+
?? (async () => {
|
|
353
|
+
// Asked only once it is known someone can answer: printing "press
|
|
354
|
+
// Enter" into a CI log is an instruction nobody can follow.
|
|
355
|
+
if (!process.stdin.isTTY) return false;
|
|
356
|
+
onProgress("Press Enter to open the authorization URL.");
|
|
357
|
+
return await awaitEnterOnTty();
|
|
358
|
+
}),
|
|
359
|
+
openUrl: invocation.openUrl ?? openInBrowser,
|
|
360
|
+
});
|
|
361
|
+
onProgress(
|
|
362
|
+
`Waiting for approval. This request expires in ${describeMinutes(deadlineMilliseconds)}; press Ctrl+C to stop.`,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
let claimResponseLost = false;
|
|
366
|
+
let claimed;
|
|
367
|
+
try {
|
|
368
|
+
claimed = await poll({
|
|
369
|
+
client,
|
|
370
|
+
now,
|
|
371
|
+
onProgress,
|
|
372
|
+
timeoutMilliseconds: deadlineMilliseconds,
|
|
373
|
+
intervalMilliseconds,
|
|
374
|
+
// Deliberately NOT forwarding poll()'s {deadline} into the claim: the
|
|
375
|
+
// claim is the one read whose success mints a credential server-side, and
|
|
376
|
+
// a final-window claim squeezed into the remaining wall budget could
|
|
377
|
+
// consume the authorization while this side aborts or discards the
|
|
378
|
+
// response — a silently orphaned credential. Each claim gets the
|
|
379
|
+
// transport's full per-request budget; poll()'s own loop still bounds
|
|
380
|
+
// total wall time.
|
|
381
|
+
read: async () => {
|
|
382
|
+
try {
|
|
383
|
+
return await claimCliAuthorization(client, authorization.deviceCode);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
// A throttle is the endpoint doing its job, not a verdict on this
|
|
386
|
+
// authorization. Back off within the same deadline instead of
|
|
387
|
+
// abandoning an approval the owner may already be looking at.
|
|
388
|
+
if (error instanceof ApiError && error.refusalKind() === REFUSAL_THROTTLED) return CLAIM_THROTTLED;
|
|
389
|
+
// A 5xx — even one that survived the transport's own retries — is the
|
|
390
|
+
// server or a proxy failing to answer, not refusing. The handler may
|
|
391
|
+
// have committed the mint before its response pipeline fell over, so
|
|
392
|
+
// this too is a lost answer, never a generic abort. Classified
|
|
393
|
+
// refusals are handled around it: the collapsed 404 and throttles
|
|
394
|
+
// above, and a platform-paused 503 — the one deliberate 5xx this
|
|
395
|
+
// codebase speaks — falls through to abort as the authoritative
|
|
396
|
+
// refusal it is.
|
|
397
|
+
if (
|
|
398
|
+
error instanceof ApiError
|
|
399
|
+
&& error.refusalKind() !== REFUSAL_PLATFORM_PAUSED
|
|
400
|
+
&& typeof error.httpStatus === "number" && error.httpStatus >= 500
|
|
401
|
+
) {
|
|
402
|
+
claimResponseLost = true;
|
|
403
|
+
return CLAIM_LOST;
|
|
404
|
+
}
|
|
405
|
+
// An answer lost between the server and this process is not a verdict
|
|
406
|
+
// either — and it is the one failure that may have committed a mint
|
|
407
|
+
// this side never saw. Aborting here would hide that credential
|
|
408
|
+
// behind a generic transport error; continuing lets the next
|
|
409
|
+
// successful claim answer CONSUMED, which carries the revoke guidance
|
|
410
|
+
// the owner needs.
|
|
411
|
+
if (error instanceof SiteAuthoringError && CLAIM_LOSS_CODES.has(error.code)) {
|
|
412
|
+
claimResponseLost = true;
|
|
413
|
+
return CLAIM_LOST;
|
|
414
|
+
}
|
|
415
|
+
// Undelivered is the one loss that does not arm: the request never
|
|
416
|
+
// left, so the flag that says "a mint may have happened" would be
|
|
417
|
+
// a lie, and a lie in the direction of sending someone to revoke.
|
|
418
|
+
if (error instanceof SiteAuthoringError && CLAIM_UNDELIVERED_CODES.has(error.code)) {
|
|
419
|
+
return CLAIM_LOST;
|
|
420
|
+
}
|
|
421
|
+
// A Ctrl+C inside a claim is ambiguous only when the transport says
|
|
422
|
+
// so. It tags each cancellation with whether a mutation may already
|
|
423
|
+
// have run — an abort mid-flight or after a lost answer, yes; an
|
|
424
|
+
// abort during backoff after nothing but certificate failures, no,
|
|
425
|
+
// because no request ever left. Arming on every cancellation sent
|
|
426
|
+
// operators to Settings to revoke a credential that provably did not
|
|
427
|
+
// exist. The single upgrade to the orphan-naming message happens
|
|
428
|
+
// where the poll is awaited, which also covers a cancellation
|
|
429
|
+
// between polls after an earlier answer was lost.
|
|
430
|
+
if (error instanceof SiteAuthoringError && error.code === "site.cancelled") {
|
|
431
|
+
if (error.ambiguousMutation === true) claimResponseLost = true;
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
throw translateClaimApiError(error);
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
evaluate: (value) => {
|
|
438
|
+
if (value === CLAIM_THROTTLED) {
|
|
439
|
+
return { done: false, progress: "Taproot is rate limiting the check; waiting before the next one." };
|
|
440
|
+
}
|
|
441
|
+
if (value === CLAIM_LOST) {
|
|
442
|
+
return { done: false, progress: "A check's answer was lost; retrying." };
|
|
443
|
+
}
|
|
444
|
+
if (value.status === CLI_AUTHORIZATION_CLAIM_STATUS_ISSUED) return { done: true, value: value.credential };
|
|
445
|
+
if (value.status === CLI_AUTHORIZATION_CLAIM_STATUS_PENDING) {
|
|
446
|
+
// A readable PENDING is authoritative proof that no earlier claim
|
|
447
|
+
// consumed the authorization: a consumed record can never answer
|
|
448
|
+
// PENDING again. Whatever answer was lost before this, it minted
|
|
449
|
+
// nothing — so the ambiguity flag resets, and a later timeout or
|
|
450
|
+
// cancellation speaks plainly instead of warning about a credential
|
|
451
|
+
// that provably does not exist. A loss after this sets it again.
|
|
452
|
+
claimResponseLost = false;
|
|
453
|
+
// Silent: this fires every few seconds for up to fifteen minutes, and
|
|
454
|
+
// each line pushes the code the operator is still reading off screen.
|
|
455
|
+
return { done: false };
|
|
456
|
+
}
|
|
457
|
+
throw terminalFailure(value, keyName);
|
|
458
|
+
},
|
|
459
|
+
// A deadline reached after a lost answer cannot honestly say nothing was
|
|
460
|
+
// issued: one of those lost checks may have consumed the approval and
|
|
461
|
+
// minted. The message must send the owner to look, the way every other
|
|
462
|
+
// possible-orphan path here does.
|
|
463
|
+
timeoutError: () =>
|
|
464
|
+
claimResponseLost
|
|
465
|
+
? new SiteAuthoringError(
|
|
466
|
+
"login.timeout",
|
|
467
|
+
"The polling deadline passed, and at least one check's answer was lost in transit — a credential "
|
|
468
|
+
+ `MAY have been issued without this command receiving it. Nothing was stored locally; check `
|
|
469
|
+
+ `Account -> Settings -> API keys for a credential named '${keyName}' and revoke it before running `
|
|
470
|
+
+ `'${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
|
|
471
|
+
)
|
|
472
|
+
: new SiteAuthoringError(
|
|
473
|
+
"login.timeout",
|
|
474
|
+
"The approval was not completed before this command's polling deadline. Nothing was stored; "
|
|
475
|
+
+ `run '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
|
|
476
|
+
),
|
|
477
|
+
});
|
|
478
|
+
} catch (error) {
|
|
479
|
+
// The one upgrade site for an ambiguous cancellation. It catches both a
|
|
480
|
+
// Ctrl+C that interrupted an in-flight claim (the read marks the loss and
|
|
481
|
+
// rethrows) and one that landed between polls after an earlier answer was
|
|
482
|
+
// lost — including poll()'s own inter-poll sleep, which rejects with the
|
|
483
|
+
// generic cancellation and never passes through the read at all. A
|
|
484
|
+
// cancellation with no lost answer keeps the generic sentence: nothing
|
|
485
|
+
// ambiguous happened.
|
|
486
|
+
if (error instanceof SiteAuthoringError && error.code === "site.cancelled" && claimResponseLost) {
|
|
487
|
+
throw new SiteAuthoringError(
|
|
488
|
+
"site.cancelled",
|
|
489
|
+
"The login was cancelled after a claim's answer was lost — a credential MAY have been issued without "
|
|
490
|
+
+ `this command receiving it. Check Account -> Settings -> API keys for a credential named '${keyName}' `
|
|
491
|
+
+ `and revoke it before running '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let credentialPath;
|
|
498
|
+
try {
|
|
499
|
+
credentialPath = await saveCredential(
|
|
500
|
+
environment,
|
|
501
|
+
{
|
|
502
|
+
apiOrigin,
|
|
503
|
+
accountId: claimed.accountId,
|
|
504
|
+
key: claimed.key,
|
|
505
|
+
keyId: claimed.keyId,
|
|
506
|
+
keyPrefix: claimed.keyPrefix,
|
|
507
|
+
...(claimed.keyExpiresAt === undefined ? {} : { keyExpiresAt: claimed.keyExpiresAt }),
|
|
508
|
+
},
|
|
509
|
+
{ now },
|
|
510
|
+
);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
throw unstoredCredentialFailure(error, claimed);
|
|
513
|
+
}
|
|
514
|
+
onProgress(`Stored the sign-in in ${credentialPath}. The credential itself is never displayed.`);
|
|
515
|
+
onProgress(`Run '${CLI_BINARY_NAME} ${VERB_SITES}' to see the sites you can author.`);
|
|
516
|
+
onProgress(
|
|
517
|
+
`Discard it locally with '${CLI_BINARY_NAME} ${VERB_LOGOUT}'; revoke it in Account -> Settings -> API keys.`,
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
return successResult(VERB_LOGIN, undefined, {
|
|
521
|
+
accountId: claimed.accountId,
|
|
522
|
+
keyId: claimed.keyId,
|
|
523
|
+
keyPrefix: claimed.keyPrefix,
|
|
524
|
+
...(claimed.keyExpiresAt === undefined ? {} : { keyExpiresAt: claimed.keyExpiresAt }),
|
|
525
|
+
userCode: authorization.userCode,
|
|
526
|
+
verificationUrl,
|
|
527
|
+
credentialPath,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CLI_BINARY_NAME, VERB_LOGOUT } from "../constants.js";
|
|
2
|
+
import { credentialApiOrigin, removeCredential } from "../credentials.js";
|
|
3
|
+
import { openAnonymousSession, successResult } from "../session.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `taproot-site logout` — local discard, and nothing more.
|
|
7
|
+
*
|
|
8
|
+
* It removes the stored sign-in for this Taproot origin. It does not revoke
|
|
9
|
+
* anything: the credential remains valid on the server until an owner revokes
|
|
10
|
+
* it in Account -> Settings -> API keys, and saying otherwise would leave a
|
|
11
|
+
* live credential someone believes is dead.
|
|
12
|
+
*
|
|
13
|
+
* Removing a credential that was never stored is a success, not a failure —
|
|
14
|
+
* the state the caller asked for is the state they end up in — so the result
|
|
15
|
+
* reports `removed: false` and exits 0. The credential is never read into a
|
|
16
|
+
* message, a progress line, or the result.
|
|
17
|
+
*
|
|
18
|
+
* Like `login`, this needs no configuration and no site: the sign-in it
|
|
19
|
+
* discards is addressed by API origin.
|
|
20
|
+
*/
|
|
21
|
+
export async function logout(invocation) {
|
|
22
|
+
const { apiBaseUrl, onProgress } = await openAnonymousSession(invocation);
|
|
23
|
+
const { path: credentialPath, removed } = await removeCredential(
|
|
24
|
+
invocation.environment ?? process.env,
|
|
25
|
+
credentialApiOrigin(apiBaseUrl),
|
|
26
|
+
);
|
|
27
|
+
onProgress(
|
|
28
|
+
removed
|
|
29
|
+
? `Discarded the stored Taproot sign-in from ${credentialPath}.`
|
|
30
|
+
: `No stored Taproot sign-in was found in ${credentialPath}.`,
|
|
31
|
+
);
|
|
32
|
+
if (removed) {
|
|
33
|
+
onProgress(
|
|
34
|
+
`This was a local discard only. The credential stays valid until it is revoked in `
|
|
35
|
+
+ `Account -> Settings -> API keys; '${CLI_BINARY_NAME} ${VERB_LOGOUT}' cannot revoke it.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return successResult(VERB_LOGOUT, undefined, { removed, credentialPath });
|
|
39
|
+
}
|