@bankr/cli 0.3.37 → 0.3.43

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.
@@ -8,12 +8,121 @@ import { getErrorMessage } from "../lib/errors.js";
8
8
  const DEFAULT_DASHBOARD_URL = "https://bankr.bot/api-keys";
9
9
  const MAX_OTP_ATTEMPTS = 3;
10
10
  const INVALID_CODE_STATUSES = [400, 401, 422];
11
+ /** The `code` the API's MFA gates send on every step-up 401 envelope
12
+ * (`sendStepUpRequired` in packages/api/src/middleware/mfaGate.ts). Duplicated
13
+ * here because the CLI can't import `@bankr/*` packages. */
14
+ const MFA_STEP_UP_CODE = "MFA_STEP_UP_REQUIRED";
15
+ /**
16
+ * Thrown when a login-gated call answers with the MFA step-up envelope. A
17
+ * passkey ceremony can't run in a terminal, so the email flow can't recover;
18
+ * the caller turns this into the browser-key guidance instead of the server's
19
+ * fallback text.
20
+ */
21
+ export class MfaStepUpError extends Error {
22
+ constructor(context) {
23
+ super("MFA step-up required");
24
+ this.context = context;
25
+ this.name = "MfaStepUpError";
26
+ }
27
+ }
28
+ /** Keyed on `code`, never on message text, so a server wording change can't
29
+ * silently turn this back into the opaque failure. */
30
+ export function mfaStepUpErrorFrom(body) {
31
+ if (!body || typeof body !== "object")
32
+ return null;
33
+ const { code, context } = body;
34
+ if (code !== MFA_STEP_UP_CODE)
35
+ return null;
36
+ return new MfaStepUpError(typeof context === "string" ? context : "unknown");
37
+ }
38
+ const NETWORK_ERROR_CODES = new Set([
39
+ "ECONNRESET",
40
+ "ECONNREFUSED",
41
+ "ETIMEDOUT",
42
+ "ENOTFOUND",
43
+ "EAI_AGAIN",
44
+ "EPIPE",
45
+ "UND_ERR_SOCKET",
46
+ "UND_ERR_CONNECT_TIMEOUT",
47
+ ]);
48
+ /** Any 5xx: a gateway page (ALB/CloudFront 502/503/504) the API never saw, or
49
+ * an API catch-all such as a DB blip. Idempotent callers retry it; the API's
50
+ * own message, when it sent one, is kept for the final report. */
51
+ export class TransientHttpError extends Error {
52
+ constructor(status, apiMessage) {
53
+ super(apiMessage ?? `Bankr API unavailable (${status})`);
54
+ this.status = status;
55
+ this.name = "TransientHttpError";
56
+ }
57
+ }
58
+ /** True for a request the API never answered: undici's `fetch failed`, a
59
+ * socket-level code, or a gateway error page. An API refusal is not one. */
60
+ export function isTransientNetworkError(err, depth = 0) {
61
+ if (depth > 5)
62
+ return false;
63
+ if (!(err instanceof Error) || err instanceof MfaStepUpError)
64
+ return false;
65
+ if (err instanceof TransientHttpError)
66
+ return true;
67
+ // undici: "fetch failed" when no connection, "terminated" on a mid-body drop.
68
+ if (err instanceof TypeError &&
69
+ (err.message === "fetch failed" || err.message === "terminated")) {
70
+ return true;
71
+ }
72
+ const code = err.code;
73
+ if (typeof code === "string" && NETWORK_ERROR_CODES.has(code))
74
+ return true;
75
+ return isTransientNetworkError(err.cause, depth + 1);
76
+ }
77
+ /**
78
+ * Retry a login step on network blips only. The OTP is already spent by the
79
+ * time the wallet step runs, so a dropped connection here used to cost the
80
+ * user a fresh code; three quick attempts ride out the transient case.
81
+ */
82
+ export async function withNetworkRetry(run, opts = {}) {
83
+ const { attempts = 3, baseDelayMs = 1000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), } = opts;
84
+ for (let attempt = 1;; attempt++) {
85
+ try {
86
+ return await run();
87
+ }
88
+ catch (err) {
89
+ if (attempt >= attempts || !isTransientNetworkError(err))
90
+ throw err;
91
+ await sleep(baseDelayMs * 2 ** (attempt - 1));
92
+ }
93
+ }
94
+ }
95
+ /** Key names are unique per user, so the default carries the time to the
96
+ * second: a scripted re-login seconds after a dropped mint response (which
97
+ * already created the key) must not collide with the earlier default. */
98
+ export function defaultCliKeyName(now = new Date()) {
99
+ const iso = now.toISOString();
100
+ return `CLI-${iso.slice(0, 10)}-${iso.slice(11, 19).replace(/:/g, "")}`;
101
+ }
102
+ /** Headless login (`--code` / `--ni`) accepts the Terms of Service as part of
103
+ * logging in, so the one-time code isn't burned on a missing flag. Interactive
104
+ * login returns undefined to defer to the prompt. */
105
+ export function resolveTermsAcceptance(opts) {
106
+ // `--accept-terms` is set-only (no `--no-accept-terms`), so `||` is safe.
107
+ return opts.acceptTerms || opts.headless || undefined;
108
+ }
109
+ /** The web app that fronts an API URL: `BANKR_WEB_URL` when set (local dev,
110
+ * staging), bankr.bot for the official API, else the API host with its
111
+ * `api.`/`api-` prefix dropped. */
112
+ export function deriveWebOrigin(apiUrl) {
113
+ const override = process.env.BANKR_WEB_URL?.trim();
114
+ if (override)
115
+ return override.replace(/\/+$/, "");
116
+ if (apiUrl === DEFAULT_API_URL)
117
+ return "https://bankr.bot";
118
+ // Anchored to the host's start so a host merely containing "api." is intact.
119
+ return apiUrl.replace(/\/+$/, "").replace(/^(https?:\/\/)api[.-]/, "$1");
120
+ }
11
121
  function deriveDashboardUrl(apiUrl) {
12
122
  if (apiUrl === DEFAULT_API_URL) {
13
123
  return DEFAULT_DASHBOARD_URL;
14
124
  }
15
- return (apiUrl.replace(/\/+$/, "").replace("api.", "").replace("api-", "") +
16
- "/api-keys");
125
+ return `${deriveWebOrigin(apiUrl)}/api-keys`;
17
126
  }
18
127
  async function fetchPrivyConfig(apiUrl) {
19
128
  const res = await fetch(`${apiUrl}/cli/config`, {
@@ -73,7 +182,9 @@ export function resolveKeyPermissionOverrides(opts) {
73
182
  return overrides;
74
183
  }
75
184
  async function verifyPrivyOtp(privyConfig, email, code) {
76
- const res = await fetch("https://auth.privy.io/api/v1/passwordless/authenticate", {
185
+ // Retry only a request that never got an answer. If Privy did answer and
186
+ // the socket dropped, the replay reads as INVALID_CODE: no worse than today.
187
+ const res = await withNetworkRetry(() => fetch("https://auth.privy.io/api/v1/passwordless/authenticate", {
77
188
  method: "POST",
78
189
  headers: {
79
190
  "Content-Type": "application/json",
@@ -81,7 +192,7 @@ async function verifyPrivyOtp(privyConfig, email, code) {
81
192
  "privy-client-id": privyConfig.privyClientId,
82
193
  },
83
194
  body: JSON.stringify({ email, code, mode: "login-or-sign-up" }),
84
- });
195
+ }));
85
196
  if (!res.ok) {
86
197
  if (INVALID_CODE_STATUSES.includes(res.status)) {
87
198
  throw new Error("INVALID_CODE");
@@ -93,26 +204,49 @@ async function verifyPrivyOtp(privyConfig, email, code) {
93
204
  }
94
205
  return (await res.json());
95
206
  }
207
+ /**
208
+ * Parse a login-gated response. The body is read as text first so a gateway
209
+ * HTML error page becomes a `TransientHttpError` instead of a `SyntaxError`
210
+ * from `res.json()`. Every 5xx is transient (callers that opted into the
211
+ * retry replay it; others just report it); a 4xx keeps the API's message.
212
+ */
213
+ export async function readGatedResponse(res, label) {
214
+ const text = await res.text();
215
+ let body;
216
+ try {
217
+ body = JSON.parse(text);
218
+ }
219
+ catch {
220
+ body = undefined;
221
+ }
222
+ const isJsonObject = !!body && typeof body === "object";
223
+ const { message, error } = (isJsonObject ? body : {});
224
+ const apiMessage = (typeof message === "string" && message) ||
225
+ (typeof error === "string" && error) ||
226
+ undefined;
227
+ if (!res.ok) {
228
+ if (res.status >= 500)
229
+ throw new TransientHttpError(res.status, apiMessage);
230
+ throw (mfaStepUpErrorFrom(body) ??
231
+ new Error(apiMessage ?? `${label} (${res.status})`));
232
+ }
233
+ if (!isJsonObject)
234
+ throw new Error(`${label}: unexpected response`);
235
+ return body;
236
+ }
96
237
  async function callGenerateWallet(apiUrl, tokens) {
97
238
  const res = await fetch(`${apiUrl}/cli/generate-wallet`, {
98
239
  method: "POST",
99
240
  headers: authHeaders(tokens),
100
241
  });
101
- const body = (await res.json());
102
- if (!res.ok) {
103
- throw new Error(body.message || body.error || `Wallet generation failed (${res.status})`);
104
- }
105
- return body;
242
+ return readGatedResponse(res, "Wallet generation failed");
106
243
  }
107
244
  async function callAcceptTerms(apiUrl, tokens) {
108
245
  const res = await fetch(`${apiUrl}/user/accept-terms`, {
109
246
  method: "POST",
110
247
  headers: authHeaders(tokens),
111
248
  });
112
- if (!res.ok) {
113
- const body = (await res.json().catch(() => ({})));
114
- throw new Error(body.message || body.error || `Accept terms failed (${res.status})`);
115
- }
249
+ await readGatedResponse(res, "Accept terms failed");
116
250
  }
117
251
  async function callGenerateApiKey(apiUrl, tokens, opts) {
118
252
  const res = await fetch(`${apiUrl}/api-keys`, {
@@ -120,11 +254,111 @@ async function callGenerateApiKey(apiUrl, tokens, opts) {
120
254
  headers: authHeaders(tokens),
121
255
  body: JSON.stringify(opts),
122
256
  });
123
- const body = (await res.json());
257
+ // Parsed through the same seam so a gateway page reports cleanly; the mint
258
+ // itself is still never retried (see runGatedStep).
259
+ return readGatedResponse(res, "API key generation failed");
260
+ }
261
+ // ── Out-of-band MFA approval ─────────────────────────────────────────
262
+ // Mirrors `MFA_CHALLENGE_STATUSES` in @bankr/shared (the CLI can't import it).
263
+ const CHALLENGE_STATUSES = [
264
+ "pending",
265
+ "verified",
266
+ "expired",
267
+ "abandoned",
268
+ "failed",
269
+ ];
270
+ /** Narrow the status poll body; anything unrecognised reads as `failed`. */
271
+ function parseChallengeStatus(body) {
272
+ const status = body?.status;
273
+ return CHALLENGE_STATUSES.includes(status)
274
+ ? status
275
+ : "failed";
276
+ }
277
+ /**
278
+ * One poll's outcome from its HTTP status. Only a definitive refusal ends the
279
+ * wait: the gate (401, an API without this route yet), another wallet's token
280
+ * (403) or a gone challenge (404). Anything else — 429, 5xx, a dropped
281
+ * connection — is a blip, so the wait keeps polling as if still pending.
282
+ */
283
+ export function pollOutcome(httpStatus, body) {
284
+ if (httpStatus >= 200 && httpStatus < 300)
285
+ return parseChallengeStatus(body);
286
+ return [401, 403, 404].includes(httpStatus) ? "failed" : "pending";
287
+ }
288
+ /** Poll cadence: 2s while an approval is likely imminent, then 5s. */
289
+ const POLL_FAST_MS = 2000;
290
+ const POLL_SLOW_MS = 5000;
291
+ const POLL_FAST_WINDOW_MS = 30000;
292
+ /**
293
+ * Poll until the challenge leaves `pending` or the deadline passes: once at
294
+ * once, then every 2s for the first 30s, then every 5s. Pure over its
295
+ * collaborators so the loop is unit-testable without a network.
296
+ */
297
+ export async function waitForChallengeApproval(deps) {
298
+ const now = deps.now ?? Date.now;
299
+ const startedAt = now();
300
+ for (;;) {
301
+ const status = await deps.poll();
302
+ if (status !== "pending")
303
+ return status;
304
+ if (now() >= deps.deadlineMs)
305
+ return "expired";
306
+ await deps.sleep(now() - startedAt < POLL_FAST_WINDOW_MS ? POLL_FAST_MS : POLL_SLOW_MS);
307
+ }
308
+ }
309
+ async function createMfaChallenge(apiUrl, tokens, context) {
310
+ const res = await fetch(`${apiUrl}/user/mfa/challenge`, {
311
+ method: "POST",
312
+ headers: authHeaders(tokens),
313
+ body: JSON.stringify({ context }),
314
+ });
124
315
  if (!res.ok) {
125
- throw new Error(body.message || body.error || `API key generation failed (${res.status})`);
316
+ throw new Error(`Couldn't start passkey approval (${res.status})`);
126
317
  }
127
- return body;
318
+ return (await res.json());
319
+ }
320
+ async function readMfaChallengeStatus(apiUrl, tokens, token) {
321
+ try {
322
+ const res = await fetch(`${apiUrl}/user/mfa/challenge/${token}`, {
323
+ headers: authHeaders(tokens),
324
+ });
325
+ return pollOutcome(res.status, await res.json().catch(() => null));
326
+ }
327
+ catch {
328
+ return "pending";
329
+ }
330
+ }
331
+ /** Mirrors the server's challenge TTL; the deadline is kept on the local clock
332
+ * so a skewed machine can't expire the wait early (the poll reports a real
333
+ * server-side expiry as `expired` regardless). */
334
+ const CHALLENGE_TTL_MS = 5 * 60 * 1000;
335
+ /**
336
+ * The browser hop for an MFA-enabled account: create a challenge for THIS
337
+ * session, hand the user the confirm link, and wait for their passkey on the
338
+ * web to mint the grants. Resolves true once approved.
339
+ */
340
+ async function approveInBrowser(apiUrl, tokens) {
341
+ const { token } = await createMfaChallenge(apiUrl, tokens, "api-key:generate");
342
+ const url = `${deriveWebOrigin(apiUrl)}/mfa/confirm/${token}`;
343
+ output.blank();
344
+ output.warn("Two-factor authentication is enabled on this account.");
345
+ output.info("Approve this login with your passkey:");
346
+ output.brandBold(` ${url}`);
347
+ if (!output.isNonInteractive()) {
348
+ await open(url).catch(() => undefined);
349
+ }
350
+ const spin = output.spinner("Waiting for approval in your browser...");
351
+ const status = await waitForChallengeApproval({
352
+ poll: () => readMfaChallengeStatus(apiUrl, tokens, token),
353
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
354
+ deadlineMs: Date.now() + CHALLENGE_TTL_MS,
355
+ });
356
+ if (status === "verified") {
357
+ spin.succeed("Approved");
358
+ return true;
359
+ }
360
+ spin.fail(`Approval ${status}`);
361
+ return false;
128
362
  }
129
363
  // ── OTP verification helpers ─────────────────────────────────────────
130
364
  async function fetchPrivyConfigOrFail(apiUrl) {
@@ -220,20 +454,63 @@ async function authenticateWithEmail(apiUrl, providedEmail, providedCode) {
220
454
  }
221
455
  output.fatal("Verification failed. Please try again.");
222
456
  }
457
+ /**
458
+ * Run a login-gated call; on the MFA step-up envelope, take the browser
459
+ * approval hop once and retry. A second refusal, or an approval that expires
460
+ * or fails, exits with the browser-key guidance. Network blips are retried
461
+ * only where the caller opts in, so a step is never replayed by default: a key
462
+ * mint whose response was dropped has already consumed its unique name.
463
+ */
464
+ async function runGatedStep(apiUrl, tokens, label, run, opts = {}) {
465
+ const { retryOnNetworkBlip = false } = opts;
466
+ for (const retry of [false, true]) {
467
+ const spin = output.spinner(label);
468
+ try {
469
+ const result = retryOnNetworkBlip
470
+ ? await withNetworkRetry(run)
471
+ : await run();
472
+ spin.stop();
473
+ return result;
474
+ }
475
+ catch (err) {
476
+ spin.stop();
477
+ if (!(err instanceof MfaStepUpError) || retry) {
478
+ return failLoginStep(`${label} failed`, err, apiUrl);
479
+ }
480
+ }
481
+ const approved = await approveInBrowser(apiUrl, tokens).catch((err) => {
482
+ output.error(getErrorMessage(err));
483
+ return false;
484
+ });
485
+ if (!approved)
486
+ return exitWithApiKeyGuidance(apiUrl);
487
+ }
488
+ throw new Error("unreachable");
489
+ }
490
+ /** Exit with the generic epilogue, or the browser-key path on a step-up
491
+ * refusal the approval hop didn't clear. */
492
+ function failLoginStep(label, err, apiUrl) {
493
+ if (!(err instanceof MfaStepUpError)) {
494
+ return output.fatal(`${label}: ${getErrorMessage(err)}`);
495
+ }
496
+ return exitWithApiKeyGuidance(apiUrl);
497
+ }
498
+ /** The remediation is the point of this message, so every line stays on
499
+ * stderr with the headline: a scripted caller capturing stderr sees all of it. */
500
+ function exitWithApiKeyGuidance(apiUrl) {
501
+ output.error("Two-factor authentication is enabled on this account.");
502
+ output.errorDetail(" The passkey approval didn't complete.");
503
+ output.errorDetail(` Generate an API key at ${deriveDashboardUrl(apiUrl)} instead (verify with your passkey there), then run:`);
504
+ output.errorDetail(" bankr login --api-key bk_YOUR_KEY");
505
+ process.exit(1);
506
+ }
223
507
  // ── Email login flow ────────────────────────────────────────────────
224
508
  async function emailLoginFlow(apiUrl, opts) {
225
509
  // Step 1: Authenticate via email OTP
226
510
  const { tokens } = await authenticateWithEmail(apiUrl, opts.email, opts.code);
227
- // Step 2: Generate/resolve wallet
228
- const walletSpin = output.spinner("Setting up wallet...");
229
- let wallet;
230
- try {
231
- wallet = await callGenerateWallet(apiUrl, tokens);
232
- walletSpin.stop();
233
- }
234
- catch (err) {
235
- output.failWith(walletSpin, "Wallet setup failed", err);
236
- }
511
+ // Step 2: Resolve (or create) the wallet. The label stays neutral because
512
+ // whether the user is new only comes back in the response.
513
+ const wallet = await runGatedStep(apiUrl, tokens, "Loading wallet...", () => callGenerateWallet(apiUrl, tokens), { retryOnNetworkBlip: true });
237
514
  // Show wallet info
238
515
  output.blank();
239
516
  if (wallet.isNewUser) {
@@ -253,29 +530,21 @@ async function emailLoginFlow(apiUrl, opts) {
253
530
  output.blank();
254
531
  output.dim(" Please review our Terms of Service: https://bankr.bot/terms");
255
532
  output.blank();
256
- const accepted = opts.acceptTerms ??
257
- (headless
258
- ? false
259
- : await confirm({
260
- message: "Do you accept the Terms of Service?",
261
- default: false,
262
- theme: output.bankrTheme,
263
- }));
533
+ const accepted = resolveTermsAcceptance({ headless, acceptTerms: opts.acceptTerms }) ??
534
+ (await confirm({
535
+ message: "Do you accept the Terms of Service?",
536
+ default: false,
537
+ theme: output.bankrTheme,
538
+ }));
264
539
  if (!accepted) {
265
540
  output.fatal("Terms must be accepted to continue.");
266
541
  }
267
- const termsSpin = output.spinner("Accepting terms...");
268
- try {
269
- await callAcceptTerms(apiUrl, tokens);
270
- termsSpin.succeed("Terms accepted");
271
- }
272
- catch (err) {
273
- output.failWith(termsSpin, "Failed to accept terms", err);
274
- }
542
+ await runGatedStep(apiUrl, tokens, "Accepting terms...", () => callAcceptTerms(apiUrl, tokens), { retryOnNetworkBlip: true });
543
+ output.success("Terms accepted");
275
544
  }
276
545
  // Step 4: Generate API key
277
546
  output.blank();
278
- const defaultKeyName = `CLI-${new Date().toISOString().slice(0, 10)}`;
547
+ const defaultKeyName = defaultCliKeyName();
279
548
  const keyName = opts.keyName ??
280
549
  (headless
281
550
  ? defaultKeyName
@@ -301,23 +570,15 @@ async function emailLoginFlow(apiUrl, opts) {
301
570
  default: false,
302
571
  theme: output.bankrTheme,
303
572
  }));
304
- const keySpin = output.spinner("Generating API key...");
305
- let apiKeyResult;
306
- try {
307
- apiKeyResult = await callGenerateApiKey(apiUrl, tokens, {
308
- name: keyName.trim(),
309
- walletApiEnabled: enableWallet,
310
- ...overrides,
311
- tokenLaunchApiEnabled: enableTokenLaunch,
312
- llmGatewayEnabled: enableLlm,
313
- allowedIps: opts.allowedIps,
314
- allowedRecipients: opts.allowedRecipients,
315
- });
316
- keySpin.stop();
317
- }
318
- catch (err) {
319
- output.failWith(keySpin, "Failed to generate API key", err);
320
- }
573
+ const apiKeyResult = await runGatedStep(apiUrl, tokens, "Generating API key...", () => callGenerateApiKey(apiUrl, tokens, {
574
+ name: keyName.trim(),
575
+ walletApiEnabled: enableWallet,
576
+ ...overrides,
577
+ tokenLaunchApiEnabled: enableTokenLaunch,
578
+ llmGatewayEnabled: enableLlm,
579
+ allowedIps: opts.allowedIps,
580
+ allowedRecipients: opts.allowedRecipients,
581
+ }));
321
582
  // Show result
322
583
  output.blank();
323
584
  const features = [
@@ -1,7 +1,7 @@
1
- export declare function profileViewCommand(opts: {
1
+ export declare function projectViewCommand(opts: {
2
2
  json?: boolean;
3
3
  }): Promise<void>;
4
- export declare function profileCreateCommand(opts: {
4
+ export declare function projectCreateCommand(opts: {
5
5
  name?: string;
6
6
  description?: string;
7
7
  token?: string;
@@ -9,7 +9,7 @@ export declare function profileCreateCommand(opts: {
9
9
  website?: string;
10
10
  json?: boolean;
11
11
  }): Promise<void>;
12
- export declare function profileUpdateCommand(opts: {
12
+ export declare function projectUpdateCommand(opts: {
13
13
  slug?: string;
14
14
  name?: string;
15
15
  description?: string;
@@ -18,13 +18,13 @@ export declare function profileUpdateCommand(opts: {
18
18
  website?: string;
19
19
  json?: boolean;
20
20
  }): Promise<void>;
21
- export declare function profileDeleteCommand(opts: {
21
+ export declare function projectDeleteCommand(opts: {
22
22
  slug?: string;
23
23
  }): Promise<void>;
24
- export declare function profileAddUpdateCommand(opts: {
24
+ export declare function projectAddUpdateCommand(opts: {
25
25
  slug?: string;
26
26
  title?: string;
27
27
  content?: string;
28
28
  json?: boolean;
29
29
  }): Promise<void>;
30
- //# sourceMappingURL=profile.d.ts.map
30
+ //# sourceMappingURL=project.d.ts.map