@bridge_gpt/mcp-server 0.2.36 → 0.2.38

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.
Files changed (92) hide show
  1. package/README.md +48 -8
  2. package/build/base-url.js +79 -0
  3. package/build/bridge-api-urls.js +9 -0
  4. package/build/chain-orchestrator.js +93 -15
  5. package/build/claude-user-config-doctor.js +317 -0
  6. package/build/commands.generated.js +2 -1
  7. package/build/conductor/bridge-api-client.js +178 -4
  8. package/build/conductor-bin.js +1 -1
  9. package/build/conductor-bundle-artifacts.js +7 -6
  10. package/build/credential-store.js +205 -4
  11. package/build/direct-ticket-tools.js +70 -0
  12. package/build/doctor.js +239 -80
  13. package/build/executor/cli.js +51 -1
  14. package/build/executor/credentials.js +1 -7
  15. package/build/executor/deps.js +18 -1
  16. package/build/executor/env.js +51 -25
  17. package/build/executor/heartbeat.js +138 -17
  18. package/build/executor/http-client.js +49 -8
  19. package/build/executor/job-errors.js +4 -0
  20. package/build/executor/job-runner.js +422 -22
  21. package/build/executor/observation.js +130 -0
  22. package/build/executor/permissions.js +104 -8
  23. package/build/executor/preflight.js +32 -0
  24. package/build/executor/runner.js +8 -0
  25. package/build/executor/test-clock.js +67 -3
  26. package/build/executor/types.js +4 -1
  27. package/build/executor/worker-command.js +11 -3
  28. package/build/executor/worker-config-isolation.js +287 -0
  29. package/build/executor/worker-finalization.js +68 -14
  30. package/build/executor/worktree.js +46 -4
  31. package/build/index.js +614 -244
  32. package/build/init.js +363 -73
  33. package/build/install-bridge.js +568 -80
  34. package/build/launcher-config-inspection.js +351 -0
  35. package/build/mcp-invoke.js +49 -6
  36. package/build/mcp-provisioning.js +30 -7
  37. package/build/mcp-registration-doctor.js +14 -5
  38. package/build/notifications.js +553 -0
  39. package/build/pipeline-orchestrator.js +146 -4
  40. package/build/pipeline-utils.js +3 -0
  41. package/build/pipelines.generated.js +22 -9
  42. package/build/plan-execution-ledger.js +550 -0
  43. package/build/plan-phase-routing.js +272 -0
  44. package/build/plane/alembic-head.js +110 -0
  45. package/build/plane/build-freshness.js +167 -0
  46. package/build/plane/cli.js +480 -0
  47. package/build/plane/defaults.js +266 -0
  48. package/build/plane/manifest.js +377 -0
  49. package/build/plane/member-logs.js +147 -0
  50. package/build/plane/member-roster.js +147 -0
  51. package/build/plane/preflight.js +289 -0
  52. package/build/plane/shutdown.js +195 -0
  53. package/build/plane/status.js +125 -0
  54. package/build/plane/supervisor.js +569 -0
  55. package/build/plane/test-fakes.js +156 -0
  56. package/build/plane/types.js +75 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/run-unit-tests-launcher.js +2 -0
  59. package/build/setup-epic.js +662 -27
  60. package/build/sfcc/log-gate.js +38 -11
  61. package/build/sfcc/log-query.js +55 -15
  62. package/build/sfcc/ocapi-shape.js +70 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-body.js +92 -0
  66. package/build/sfcc/read-projection.js +185 -0
  67. package/build/sfcc/read-result.js +158 -0
  68. package/build/sfcc/reads-custom-object-def.js +57 -34
  69. package/build/sfcc/reads-site-preference.js +86 -33
  70. package/build/sfcc/reads-system-object.js +50 -38
  71. package/build/sfcc/sfcc-result.js +106 -0
  72. package/build/sfcc/tool-wrapper.js +56 -13
  73. package/build/sfcc/write-grants.js +45 -22
  74. package/build/sfcc/write-guard.js +21 -13
  75. package/build/sfcc/write-result.js +71 -15
  76. package/build/sfcc/write-tool-common.js +126 -32
  77. package/build/sfcc/writes-custom-object-def.js +6 -2
  78. package/build/sfcc/writes-system-object.js +11 -50
  79. package/build/start-tickets-prereqs.js +129 -0
  80. package/build/start-tickets.js +17 -13
  81. package/build/ticket-backend-metadata.js +59 -0
  82. package/build/ticket-key-utils.js +92 -0
  83. package/build/tool-error-envelope.js +71 -0
  84. package/build/tool-surface-gating.js +72 -0
  85. package/build/update-status.js +102 -0
  86. package/build/upgrade-advice.js +47 -0
  87. package/build/upgrade-cli.js +417 -101
  88. package/build/version.generated.js +1 -1
  89. package/build/worktree-core.js +73 -0
  90. package/docs/CONDUCTOR.md +23 -8
  91. package/package.json +3 -3
  92. package/pipelines/implement-ticket.json +15 -5
@@ -156,7 +156,7 @@ import { claudeReviewWorkflowPath, writeClaudeReviewWorkflow, } from "./claude-r
156
156
  import { runSetupEpicCli } from "./setup-epic.js";
157
157
  import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
158
158
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
159
- import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, lookupSelfServeBootstrapPendingCredential, discardBootstrapPendingCredential, getBootstrapPendingTarget, resolveBapiCredentials, } from "./credential-store.js";
159
+ import { upsertBapiCredential, getPrimaryCredentialStorePath, prepareBootstrapPendingCredential, repointBootstrapPendingCredential, promoteBootstrapPendingCredential, lookupSelfServeBootstrapPendingCredential, prepareSelfServeBootstrapChallenge as prepareSelfServeBootstrapChallengeCredential, discardBootstrapPendingCredential, getBootstrapPendingTarget, resolveBapiCredentials, } from "./credential-store.js";
160
160
  // BAPI-631: the optional GitHub connect offer reuses the standalone command's flow and
161
161
  // API primitives verbatim — no duplicated polling, browser, or picker logic here.
162
162
  import { fetchGithubConfigurationState } from "./connect-github-api.js";
@@ -384,29 +384,34 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
384
384
  "Self-serve onboarding (no account, no API key, no pre-issued invite):",
385
385
  " --email <addr> Create a brand-new Bridge workspace from just an email —",
386
386
  " the primary path for a first-time user with nothing yet.",
387
- " It requests a fresh workspace for that email, then creates",
388
- " the project and mints your own admin API key in one command.",
389
387
  " Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
390
388
  " interactive prompt — which is also what answering 2 on a",
391
- " bare run reaches. The email is NOT a",
392
- " secret (it is shown as you type), but it is never printed to",
393
- " a log. Mutually",
394
- " exclusive with --api-key and --invite. The address labels the",
395
- " new workspace AND is used for transactional invite/setup",
396
- " delivery, so a setup message may be sent to it. Delivery is",
397
- " asynchronous and best-effort: it is queued after signup rather",
398
- " than awaited, so do not wait on the message to continue — this",
399
- " command already prints everything you need. No email",
400
- " verification is performed; the address is not confirmed before",
401
- " the workspace is created.",
389
+ " bare run reaches. The email is NOT a secret (it is shown as",
390
+ " you type), but it is never printed to a log. Mutually",
391
+ " exclusive with --api-key and --invite.",
392
+ "",
393
+ " VERIFIED: Bridge emails a 6-digit code to that address and",
394
+ " this command prompts you for it. Nothing is created until",
395
+ " the code is entered no workspace, no project, no API key.",
396
+ " Delivery is asynchronous, so the message arrives shortly",
397
+ " after you are prompted rather than instantly. The code",
398
+ " expires 60 minutes after it is requested, and you can ask",
399
+ " for a new one if it does not arrive; earlier codes keep",
400
+ " working until one is accepted.",
401
+ "",
402
+ " REQUIRES A TERMINAL: there is no flag or environment",
403
+ " variable for the code — either would put a live credential",
404
+ " into your shell history and process list. For unattended",
405
+ " setup use --api-key <key> or --invite <token> instead.",
402
406
  "",
403
- " RESUMABLE: a self-serve run that fails mid-protocol saves its",
407
+ " RESUMABLE: a self-serve run that is interrupted saves its",
404
408
  " signup state under bootstrap-pending:<repo> in the credential",
405
- " store. Re-running the self-serve flow RESUMES that attempt —",
406
- " it does not sign up again so a retry never creates a second",
407
- " workspace. Never copy, display, or hand-remove that record; if",
408
- " the saved invite has genuinely expired the CLI asks before",
409
- " discarding it.",
409
+ " store. Re-running RESUMES that attempt — at the code prompt",
410
+ " if the code has not been entered yet, or at the exchange if",
411
+ " it has so a retry never sends a second code and never",
412
+ " creates a second workspace. The code itself is never saved.",
413
+ " Never copy, display, or hand-remove that record; if the saved",
414
+ " invite has genuinely expired the CLI asks before discarding it.",
410
415
  "",
411
416
  "Bootstrap-invite onboarding (no web UI, no pre-existing key):",
412
417
  " --invite [token] Redeem a bootstrap invite you were already given: creates",
@@ -956,6 +961,8 @@ export function createDefaultInstallBridgeDeps() {
956
961
  repointBootstrapPending: repointBootstrapPendingCredential,
957
962
  promoteBootstrapPending: promoteBootstrapPendingCredential,
958
963
  lookupSelfServeBootstrapPending: lookupSelfServeBootstrapPendingCredential,
964
+ prepareSelfServeBootstrapChallenge: prepareSelfServeBootstrapChallengeCredential,
965
+ now: () => Date.now(),
959
966
  discardBootstrapPending: discardBootstrapPendingCredential,
960
967
  buildShellCommand: buildGenericAgentShellCommand,
961
968
  spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
@@ -1123,6 +1130,16 @@ export async function resolveInviteToken(options, deps) {
1123
1130
  * back in an error message.
1124
1131
  */
1125
1132
  export async function resolveSignupEmail(options, deps) {
1133
+ // BAPI-757 (R-3 / AC-9): self-serve signup now REQUIRES an interactive terminal,
1134
+ // and the check runs FIRST — before `--email` and before `BAPI_SIGNUP_EMAIL` —
1135
+ // so an unattended run fails before any request is made, no verification email
1136
+ // is sent, and no challenge row is created. Supplying the address was never the
1137
+ // hard part; entering a code that only arrives by email is, and there is
1138
+ // deliberately no `--code` flag and no environment variable to supply it (a live
1139
+ // code must never reach shell history or a process list).
1140
+ if (!deps.isTTY || !deps.promptLine) {
1141
+ return { ok: false, error: INSTALL_BRIDGE_SIGNUP_REQUIRES_TTY };
1142
+ }
1126
1143
  if (typeof options.email === "string" && options.email.trim().length > 0) {
1127
1144
  return { ok: true, value: options.email.trim() };
1128
1145
  }
@@ -1130,19 +1147,11 @@ export async function resolveSignupEmail(options, deps) {
1130
1147
  if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
1131
1148
  return { ok: true, value: fromEnv.trim() };
1132
1149
  }
1133
- if (deps.isTTY && deps.promptLine) {
1134
- const entered = (await deps.promptLine("Email for Bridge workspace setup: ")).trim();
1135
- if (entered.length > 0) {
1136
- return { ok: true, value: entered };
1137
- }
1138
- return { ok: false, error: "No email entered." };
1150
+ const entered = (await deps.promptLine("Email for Bridge workspace setup: ")).trim();
1151
+ if (entered.length > 0) {
1152
+ return { ok: true, value: entered };
1139
1153
  }
1140
- return {
1141
- ok: false,
1142
- error: "An email is required to create a Bridge workspace. Pass --email <addr> or set the " +
1143
- "BAPI_SIGNUP_EMAIL environment variable (no interactive terminal is available to prompt " +
1144
- "for it).",
1145
- };
1154
+ return { ok: false, error: "No email entered." };
1146
1155
  }
1147
1156
  /**
1148
1157
  * Select the onboarding branch as pure logic (deterministic, no I/O, no prompt):
@@ -2556,23 +2565,234 @@ export async function requestCredentialRouteSwitchConfirmation(deps, promptText)
2556
2565
  export function buildSelfServeMintUrl(baseUrl) {
2557
2566
  return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
2558
2567
  }
2568
+ // ===================================================================
2569
+ // BAPI-757 — the verification checkpoint's user-facing copy.
2570
+ //
2571
+ // Every string below is a module constant rather than an inline literal, for two
2572
+ // reasons: the static-contract suite pins several of them, and a uniform outcome
2573
+ // stated in one place cannot drift into four subtly different sentences that
2574
+ // together disclose which failure occurred.
2575
+ // ===================================================================
2576
+ /** Digits in a verification code. Mirrors the server's protocol constant. */
2577
+ export const SIGNUP_VERIFICATION_CODE_LENGTH = 6;
2578
+ /** The one prompt label. The E2E harness and the static contract both pin it. */
2579
+ export const INSTALL_BRIDGE_CODE_PROMPT = "Email verification code: ";
2580
+ /** Shown when a stored, unexpired challenge is picked up on a later run. */
2581
+ export const INSTALL_BRIDGE_RESUME_VERIFICATION_HEADING = "Resume email verification";
2582
+ /**
2583
+ * The non-interactive refusal (AC-9 / R-3).
2584
+ *
2585
+ * Names BOTH unattended alternatives, because the whole point of the message is
2586
+ * that the user is running in a context where no prompt can be answered — telling
2587
+ * them only what does not work would leave them stuck.
2588
+ */
2589
+ export const INSTALL_BRIDGE_SIGNUP_REQUIRES_TTY = [
2590
+ "Creating a Bridge workspace requires verifying your email address, and that needs an",
2591
+ "interactive terminal to enter the emailed code. There is deliberately no flag or",
2592
+ "environment variable for the code — either would put a live credential into your shell",
2593
+ "history and process list.",
2594
+ "",
2595
+ "Two onboarding paths still work unattended:",
2596
+ " • An existing API key: install --api-key <key> (or set BAPI_API_KEY)",
2597
+ " • A pre-issued invite: install --invite <token> (or set BAPI_INVITE)",
2598
+ "",
2599
+ "To sign up from scratch, re-run this command in an interactive terminal.",
2600
+ ].join("\n");
2601
+ /**
2602
+ * The UNIFORM recovery message for a rejected verification.
2603
+ *
2604
+ * The server answers a wrong code, an expired code, an unknown handle, and an
2605
+ * exhausted challenge with one identical response — and this message is the client
2606
+ * half of that property. It must never be specialized per case, and it must never
2607
+ * speculate ("maybe it expired?"): a message that narrowed the possibilities would
2608
+ * hand back exactly the signal the uniform response withholds.
2609
+ */
2610
+ export const INSTALL_BRIDGE_VERIFICATION_REJECTED = [
2611
+ "That code was not accepted.",
2612
+ "",
2613
+ "Check the most recent verification email and try again, or request a new code. If the",
2614
+ "signup attempt is too old, re-run this command to start a fresh one.",
2615
+ ].join("\n");
2616
+ /** Resend success. States plainly that earlier codes still work — see R-5. */
2617
+ export const INSTALL_BRIDGE_RESEND_SENT = "A new code is on its way. Any code from an earlier email for this signup still works too.";
2618
+ /** Cooldown. Non-accusatory, and silent about which limiter applied. */
2619
+ export const INSTALL_BRIDGE_RESEND_COOLDOWN = "A verification email was sent moments ago — give it a minute to arrive before requesting another.";
2620
+ /** Send ceiling. Names no bucket: not the address, not the source, not the global cap. */
2621
+ export const INSTALL_BRIDGE_RESEND_THROTTLED = "Bridge is not sending another verification email for this signup right now. Try again later, or use the code from an email you already received.";
2622
+ /**
2623
+ * A throttle that arrives from the VERIFY call, i.e. after a code was submitted.
2624
+ *
2625
+ * Deliberately NOT {@link INSTALL_BRIDGE_RESEND_THROTTLED}. That message tells the
2626
+ * user to fall back on a code they already have — which is backwards here: the
2627
+ * server throttles verification at the invite-mint ceiling, so their code may have
2628
+ * been perfectly correct and there is nothing to fall back to. Re-running is the
2629
+ * remedy, and the code stays valid until it expires.
2630
+ */
2631
+ export const INSTALL_BRIDGE_VERIFY_THROTTLED = "Bridge is at capacity and could not finish creating your workspace just now. Nothing was created — wait a few minutes and re-run this command; the code already emailed to you stays valid until it expires.";
2632
+ /**
2633
+ * A transient server failure during verification.
2634
+ *
2635
+ * Separate from {@link INSTALL_BRIDGE_SIGNUP_UPGRADE_REQUIRED} because a 5xx means
2636
+ * the server understood this CLI perfectly and simply failed. Telling the user to
2637
+ * upgrade would send them to the one recovery step that cannot help, and would
2638
+ * hide a real outage behind a version-skew story.
2639
+ */
2640
+ export const INSTALL_BRIDGE_VERIFY_SERVER_ERROR = "Error: Bridge could not complete verification right now. Nothing was created — wait a few minutes and re-run this command; the code already emailed to you stays valid until it expires.";
2641
+ /**
2642
+ * Local cap on rejected verification attempts before the CLI stops re-prompting.
2643
+ *
2644
+ * AC-4 makes "wrong code" and "this challenge is dead" byte-identical on purpose,
2645
+ * so the CLI genuinely cannot tell them apart. Without a cap, a user who exhausts
2646
+ * the server's attempt budget is re-prompted forever against a challenge that can
2647
+ * never succeed, with Ctrl-C as the only exit.
2648
+ *
2649
+ * Set ABOVE the server's default attempt budget (5) so a legitimately mistyping
2650
+ * user is never cut off before the server itself would refuse — the cap exists to
2651
+ * end a hopeless loop, not to add a second, tighter budget the user cannot see.
2652
+ */
2653
+ export const INSTALL_BRIDGE_MAX_CODE_REJECTIONS = 6;
2654
+ /** Printed when the rejection cap is reached. Still state-neutral (AC-4). */
2655
+ export const INSTALL_BRIDGE_VERIFICATION_GIVING_UP = [
2656
+ "Error: that signup could not be verified.",
2657
+ "",
2658
+ "Nothing has been created. Re-run this command to start a fresh signup — it will email",
2659
+ "a new code to the same address.",
2660
+ ].join("\n");
2661
+ /** The fail-closed enqueue failure (R-4). No suggestion that verification is skippable. */
2662
+ export const INSTALL_BRIDGE_SIGNUP_UNAVAILABLE = "Error: Bridge could not send a verification email, so no workspace was created. Nothing has been set up — try again in a few minutes.";
2559
2663
  /**
2560
- * POST the self-serve mint: an email in, an invite token out. Mirrors
2561
- * {@link exchangeBootstrapInvite}'s fetch/timeout/secret-free-result discipline.
2664
+ * R-2's hard break, rendered for a server that no longer speaks this client's
2665
+ * protocol — or a client that no longer speaks the server's.
2562
2666
  *
2563
- * The email travels ONLY in the JSON request body (never a query string, never a
2564
- * header, never a log line). Bounded by the same 10s timeout as the exchange and
2565
- * the connectivity ping. On any non-success status the failure body is NOT read —
2566
- * the status alone decides the category — because an untrusted upstream body could
2567
- * echo the email or carry internals. A caught fetch exception is likewise never
2568
- * inspected or interpolated; some fetch impls embed the request (and thus the
2569
- * email) in the message.
2667
+ * Deliberately NOT the old generic "Check connectivity and retry": connectivity is
2668
+ * fine, and sending a user to debug their network for a protocol change is the
2669
+ * failure mode this constant exists to prevent.
2570
2670
  */
2571
- export async function mintSelfServeInvite(deps, baseUrl, email) {
2572
- const url = buildSelfServeMintUrl(baseUrl);
2671
+ export const INSTALL_BRIDGE_SIGNUP_UPGRADE_REQUIRED = [
2672
+ "Error: this Bridge server's signup flow does not match this CLI.",
2673
+ "",
2674
+ "Signup now verifies your email address with a code before creating a workspace. Upgrade",
2675
+ "and re-run:",
2676
+ "",
2677
+ " npx -y @bridge_gpt/mcp-server@latest install --email <addr>",
2678
+ ].join("\n");
2679
+ /**
2680
+ * Mask an address for display: first character, then the domain.
2681
+ *
2682
+ * `ada@example.com` renders as `a***@example.com`. Enough for the user to confirm
2683
+ * they typed the right mailbox, without putting a full address into a terminal
2684
+ * transcript that may be pasted into an issue or captured by CI. Anything that
2685
+ * does not look like a mailbox is reduced to a fixed placeholder rather than
2686
+ * echoed — an unparseable value is exactly the one most likely to be a typo worth
2687
+ * not repeating.
2688
+ */
2689
+ export function maskSignupEmail(email) {
2690
+ const trimmed = (email ?? "").trim();
2691
+ const at = trimmed.lastIndexOf("@");
2692
+ if (at <= 0 || at === trimmed.length - 1)
2693
+ return "your email address";
2694
+ return `${trimmed[0]}***${trimmed.slice(at)}`;
2695
+ }
2696
+ /**
2697
+ * Render a challenge's remaining lifetime in calm, approximate language.
2698
+ *
2699
+ * Whole minutes, rounded down, and never a countdown: the exact number does not
2700
+ * change what the user does, and a precise timer turns a routine wait into a
2701
+ * deadline. A challenge already past its expiry says so plainly rather than
2702
+ * rendering a negative number.
2703
+ */
2704
+ export function formatChallengeRemaining(expiresAtIso, nowMs) {
2705
+ const expiresMs = Date.parse(expiresAtIso);
2706
+ if (Number.isNaN(expiresMs))
2707
+ return "";
2708
+ const minutes = Math.floor((expiresMs - nowMs) / 60_000);
2709
+ if (minutes <= 0)
2710
+ return "This code has expired.";
2711
+ if (minutes === 1)
2712
+ return "This code is valid for about another minute.";
2713
+ return `This code is valid for about another ${minutes} minutes.`;
2714
+ }
2715
+ /**
2716
+ * Prompt for the verification code, validating its shape LOCALLY before any
2717
+ * network call.
2718
+ *
2719
+ * Local validation is a courtesy, not a security control — the server re-validates
2720
+ * everything — but it keeps a mistyped code from consuming one of the five attempts
2721
+ * that permanently kill a challenge. The user gets the prompt back instead.
2722
+ *
2723
+ * Pasted input is trimmed: copying a code out of a mail client routinely brings
2724
+ * whitespace with it, and refusing that would be an avoidable dead end.
2725
+ *
2726
+ * The entered value is returned and never echoed, never logged, and never written
2727
+ * to disk. `null` means the user gave up (an empty line, or an unreadable prompt).
2728
+ */
2729
+ export async function promptForVerificationCode(deps, log, maxAttempts = 3) {
2730
+ if (!deps.promptLine)
2731
+ return null;
2732
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
2733
+ let entered;
2734
+ try {
2735
+ entered = (await deps.promptLine(INSTALL_BRIDGE_CODE_PROMPT)).trim();
2736
+ }
2737
+ catch {
2738
+ // Secret-free by construction: the caught value is never surfaced or
2739
+ // inspected, because a readline failure can carry the partial input.
2740
+ return null;
2741
+ }
2742
+ if (entered.length === 0)
2743
+ return null;
2744
+ if (new RegExp(`^[0-9]{${SIGNUP_VERIFICATION_CODE_LENGTH}}$`).test(entered)) {
2745
+ return entered;
2746
+ }
2747
+ // Describes the SHAPE, never the value the user typed.
2748
+ log(` That does not look like a code — it is ${SIGNUP_VERIFICATION_CODE_LENGTH} digits.`);
2749
+ }
2750
+ return null;
2751
+ }
2752
+ /*
2753
+ * `mintSelfServeInvite` and `SelfServeMintResult` were REMOVED in BAPI-757.
2754
+ *
2755
+ * They read `token` from the request phase's success body. Under R-2 that field
2756
+ * no longer exists for any client version, and a helper that still looked for it
2757
+ * would be a live path back to an unverified signup — so it is deleted rather than
2758
+ * deprecated. The replacement is the three-phase client immediately below.
2759
+ */
2760
+ // ===================================================================
2761
+ // BAPI-757 — the three-phase self-serve signup client.
2762
+ //
2763
+ // The server no longer returns a token from the request phase, for ANY client
2764
+ // version (R-2). These three helpers replace the single-shot mint above:
2765
+ //
2766
+ // requestSelfServeChallenge → POST /setup/bootstrap/self-serve
2767
+ // verifySelfServeChallenge → POST /setup/bootstrap/self-serve/verify
2768
+ // resendSelfServeChallenge → POST /setup/bootstrap/self-serve/resend
2769
+ //
2770
+ // All three keep the discipline the deleted single-shot mint established, and that
2771
+ // `exchangeBootstrapInvite` still follows: the same 10s timeout, a COARSE result
2772
+ // category on failure, and a failure body that is never read — an untrusted
2773
+ // upstream body could echo the email or the code, and some fetch implementations
2774
+ // embed the request (and therefore the code) in a thrown error's message, so caught
2775
+ // exceptions are never inspected either. The one exception is the resend 429, which
2776
+ // reads a FIXED server-controlled detail string carrying no request value.
2777
+ // ===================================================================
2778
+ /** The challenge REQUEST endpoint: `<base>/setup/bootstrap/self-serve`. */
2779
+ export function buildSelfServeVerifyUrl(baseUrl) {
2780
+ return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve/verify`;
2781
+ }
2782
+ /** The challenge RESEND endpoint: `<base>/setup/bootstrap/self-serve/resend`. */
2783
+ export function buildSelfServeResendUrl(baseUrl) {
2784
+ return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve/resend`;
2785
+ }
2786
+ /**
2787
+ * POST the challenge REQUEST: an email in, a handle out. Never a token.
2788
+ *
2789
+ * The email travels ONLY in the JSON body — never a query string, never a header,
2790
+ * never a log line.
2791
+ */
2792
+ export async function requestSelfServeChallenge(deps, baseUrl, email) {
2573
2793
  let resp;
2574
2794
  try {
2575
- resp = await deps.fetch(url, {
2795
+ resp = await deps.fetch(buildSelfServeMintUrl(baseUrl), {
2576
2796
  method: "POST",
2577
2797
  headers: { "Content-Type": "application/json" },
2578
2798
  body: JSON.stringify({ invitee_email: email }),
@@ -2580,8 +2800,57 @@ export async function mintSelfServeInvite(deps, baseUrl, email) {
2580
2800
  });
2581
2801
  }
2582
2802
  catch (err) {
2583
- // The message may contain the request (some fetch impls echo it), so it is
2584
- // deliberately NOT inspected or interpolated here.
2803
+ void err;
2804
+ return { ok: false, category: "failed" };
2805
+ }
2806
+ if (resp.ok) {
2807
+ let handle;
2808
+ let expiresAt;
2809
+ try {
2810
+ const body = (await resp.json());
2811
+ handle = body?.handle;
2812
+ expiresAt = body?.expires_at;
2813
+ }
2814
+ catch {
2815
+ return { ok: false, category: "failed" };
2816
+ }
2817
+ if (typeof handle !== "string" ||
2818
+ handle.trim().length === 0 ||
2819
+ typeof expiresAt !== "string" ||
2820
+ expiresAt.trim().length === 0) {
2821
+ // A success body without challenge metadata is not something this client can
2822
+ // act on. It is also what an OLD server (pre-BAPI-757, still returning a
2823
+ // token) looks like — and continuing from one would mean skipping
2824
+ // verification entirely, so it is refused rather than tolerated.
2825
+ return { ok: false, category: "failed" };
2826
+ }
2827
+ return { ok: true, handle: handle.trim(), expiresAt: expiresAt.trim() };
2828
+ }
2829
+ if (resp.status === 429)
2830
+ return { ok: false, category: "rate-limited" };
2831
+ if (resp.status === 503)
2832
+ return { ok: false, category: "unavailable" };
2833
+ if (resp.status === 400 || resp.status === 422)
2834
+ return { ok: false, category: "invalid" };
2835
+ return { ok: false, category: "failed" };
2836
+ }
2837
+ /**
2838
+ * POST the VERIFY phase: a handle and a code in, the invite token out.
2839
+ *
2840
+ * The code is sent once, in the body, and is never echoed back into terminal
2841
+ * output or into any error this returns.
2842
+ */
2843
+ export async function verifySelfServeChallenge(deps, baseUrl, handle, code) {
2844
+ let resp;
2845
+ try {
2846
+ resp = await deps.fetch(buildSelfServeVerifyUrl(baseUrl), {
2847
+ method: "POST",
2848
+ headers: { "Content-Type": "application/json" },
2849
+ body: JSON.stringify({ handle, code }),
2850
+ signal: AbortSignal.timeout(10_000),
2851
+ });
2852
+ }
2853
+ catch (err) {
2585
2854
  void err;
2586
2855
  return { ok: false, category: "failed" };
2587
2856
  }
@@ -2592,7 +2861,6 @@ export async function mintSelfServeInvite(deps, baseUrl, email) {
2592
2861
  token = body?.token;
2593
2862
  }
2594
2863
  catch {
2595
- // An unreadable success body is not actionable and must not be exposed.
2596
2864
  return { ok: false, category: "failed" };
2597
2865
  }
2598
2866
  if (typeof token !== "string" ||
@@ -2602,14 +2870,83 @@ export async function mintSelfServeInvite(deps, baseUrl, email) {
2602
2870
  }
2603
2871
  return { ok: true, token };
2604
2872
  }
2605
- // Non-success: the status alone decides the category. The failure body is NEVER
2606
- // read it is untrusted and could echo the email or leak internals.
2607
- if (resp.status === 429) {
2873
+ // 401 is the server's uniform invalid-challenge outcome. It covers a wrong code,
2874
+ // an expired code, an unknown handle, and an exhausted challenge, and the client
2875
+ // deliberately cannot tell them apart — so neither can a caller watching the
2876
+ // client's output.
2877
+ if (resp.status === 401)
2878
+ return { ok: false, category: "rejected" };
2879
+ if (resp.status === 429)
2608
2880
  return { ok: false, category: "rate-limited" };
2881
+ // A 5xx is the server failing, not the protocol disagreeing. Kept distinct so the
2882
+ // caller can say "try again" instead of "upgrade the CLI".
2883
+ if (resp.status >= 500)
2884
+ return { ok: false, category: "server-error" };
2885
+ return { ok: false, category: "failed" };
2886
+ }
2887
+ /**
2888
+ * POST the RESEND phase: the handle alone, timing metadata out.
2889
+ *
2890
+ * No email field — accepting one would let a caller request mail for an address
2891
+ * they hold no challenge for, which is the abuse the per-email budget bounds.
2892
+ */
2893
+ export async function resendSelfServeChallenge(deps, baseUrl, handle) {
2894
+ let resp;
2895
+ try {
2896
+ resp = await deps.fetch(buildSelfServeResendUrl(baseUrl), {
2897
+ method: "POST",
2898
+ headers: { "Content-Type": "application/json" },
2899
+ body: JSON.stringify({ handle }),
2900
+ signal: AbortSignal.timeout(10_000),
2901
+ });
2609
2902
  }
2610
- if (resp.status === 400 || resp.status === 422) {
2611
- return { ok: false, category: "invalid" };
2903
+ catch (err) {
2904
+ void err;
2905
+ return { ok: false, category: "failed" };
2906
+ }
2907
+ if (resp.ok) {
2908
+ let expiresAt;
2909
+ let resendsRemaining;
2910
+ try {
2911
+ const body = (await resp.json());
2912
+ expiresAt = body?.expires_at;
2913
+ resendsRemaining = body?.resends_remaining;
2914
+ }
2915
+ catch {
2916
+ return { ok: false, category: "failed" };
2917
+ }
2918
+ if (typeof expiresAt !== "string" || expiresAt.trim().length === 0) {
2919
+ return { ok: false, category: "failed" };
2920
+ }
2921
+ return {
2922
+ ok: true,
2923
+ expiresAt: expiresAt.trim(),
2924
+ resendsRemaining: typeof resendsRemaining === "number" && Number.isFinite(resendsRemaining)
2925
+ ? resendsRemaining
2926
+ : 0,
2927
+ };
2612
2928
  }
2929
+ // Cooldown and the send ceilings are BOTH 429 and are distinguished only by the
2930
+ // fixed detail string. Reading it is safe here precisely because it is fixed and
2931
+ // server-controlled — it carries no request value — and the distinction lets the
2932
+ // CLI say "wait a moment" instead of "try again later". Any parse problem falls
2933
+ // through to the more conservative `rate-limited`.
2934
+ if (resp.status === 429) {
2935
+ let detail = "";
2936
+ try {
2937
+ const body = (await resp.json());
2938
+ detail = typeof body?.detail === "string" ? body.detail : "";
2939
+ }
2940
+ catch {
2941
+ detail = "";
2942
+ }
2943
+ return {
2944
+ ok: false,
2945
+ category: detail.toLowerCase().includes("very recently") ? "cooldown" : "rate-limited",
2946
+ };
2947
+ }
2948
+ if (resp.status === 401)
2949
+ return { ok: false, category: "rejected" };
2613
2950
  return { ok: false, category: "failed" };
2614
2951
  }
2615
2952
  /**
@@ -3905,7 +4242,14 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
3905
4242
  // ---- Step 1 — scaffold (secret-free placeholders only) ----
3906
4243
  currentStep = INSTALL_BRIDGE_STEP_LABELS.scaffold;
3907
4244
  log(currentStep);
3908
- await deps.runInit(deps.cwd);
4245
+ // BAPI-728: hand init the identity this run already resolved and validated.
4246
+ // `credentialAvailable: true` is a statement about THIS flow, not a lookup:
4247
+ // Step 2 mints/verifies the key and Step 4 persists it, so a placeholder
4248
+ // warning here would be a false alarm about a config the installer is
4249
+ // actively configuring. The key value itself never crosses this boundary.
4250
+ await deps.runInit(deps.cwd, {
4251
+ creationContext: { repoName, baseUrl, credentialAvailable: true },
4252
+ });
3909
4253
  // ---- Step 2 — mint (invite mode) or verify (normal mode) the credential ----
3910
4254
  let inviteFingerprint = "";
3911
4255
  if (bootstrapInviteMode) {
@@ -3923,13 +4267,18 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
3923
4267
  let resumedSelfServeReplay = false;
3924
4268
  let keySecret = "";
3925
4269
  let reusedPendingSecret = false;
4270
+ // BAPI-757: a LIVE challenge found by the lookup below. When set, the run
4271
+ // resumes at the code prompt and MUST NOT request or resend — the emailed
4272
+ // code is still valid, and asking again would spend one of the user's own
4273
+ // per-address sends for nothing.
4274
+ let liveChallenge = null;
3926
4275
  if (selfServeSignupMode) {
3927
- const lookup = await deps.lookupSelfServeBootstrapPending({ repoName }, credentialWriteDeps);
4276
+ const lookup = await deps.lookupSelfServeBootstrapPending({ repoName, now: deps.now() }, credentialWriteDeps);
3928
4277
  if (!lookup.ok) {
3929
- // Fail BEFORE the mint endpoint is called and leave the stored record
3930
- // untouched — an ordinary-invite record, a malformed record, and an
3931
- // unreadable store are all states where minting again would compound the
3932
- // problem rather than recover from it.
4278
+ // Fail BEFORE any endpoint is called and leave the stored record
4279
+ // untouched — an ordinary-invite record, a malformed record, a partial
4280
+ // challenge record, and an unreadable store are all states where
4281
+ // starting over would compound the problem rather than recover from it.
3933
4282
  return fatal(`Error: ${lookup.error}`);
3934
4283
  }
3935
4284
  if (lookup.state === "resumable") {
@@ -3943,37 +4292,176 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
3943
4292
  reusedPendingSecret = true;
3944
4293
  log(` resuming your previous signup attempt for ${repoName}`);
3945
4294
  }
4295
+ else if (lookup.state === "challenge") {
4296
+ liveChallenge = {
4297
+ handle: lookup.challengeHandle,
4298
+ expiresAt: lookup.challengeExpiresAt,
4299
+ email: lookup.signupEmail,
4300
+ };
4301
+ }
4302
+ // `challenge-expired` and `none` both fall through to a fresh request.
3946
4303
  }
3947
4304
  /**
3948
- * Mint a self-serve invite and durably store it WITH a fresh `key_secret`.
4305
+ * Run the self-serve signup through request verify, then durably store the
4306
+ * resulting invite token WITH a fresh `key_secret` (BAPI-757).
3949
4307
  *
3950
- * Shared by the initial signup and the consented refresh below — the only two
3951
- * places a self-serve token is ever created. The ordering inside is the same
3952
- * load-bearing protocol described below: mint the token, durably store the
3953
- * token plus the `key_secret`, and only THEN exchange. Logs its own
3954
- * (secret-free) failure and returns false; the caller exits non-zero.
4308
+ * Three phases, in this order, and the order is the protocol:
4309
+ *
4310
+ * 1. **Request** (skipped when resuming a live challenge). The server
4311
+ * creates a challenge and queues an email. No tenant, no invitation, and
4312
+ * nothing billable exists yet.
4313
+ * 2. **Persist the challenge, THEN prompt.** The email has already been
4314
+ * sent and the user's per-address send budget already charged by this
4315
+ * point, so the handle reaches disk before anything can interrupt the
4316
+ * run. A terminal closed at the prompt resumes the SAME challenge with
4317
+ * the SAME code.
4318
+ * 3. **Verify**, which is what actually mints — and hands back the very
4319
+ * `inviteToken` the untouched persist-before-exchange region below
4320
+ * already expected.
4321
+ *
4322
+ * The verification code is prompted for, submitted once, and never written to
4323
+ * disk, never logged, and never echoed.
4324
+ *
4325
+ * Logs its own (secret-free) failure and returns false; the caller exits
4326
+ * non-zero.
3955
4327
  */
3956
4328
  const mintAndPrepareSelfServe = async () => {
3957
- const mint = await mintSelfServeInvite(deps, baseUrl, signupEmail);
3958
- if (!mint.ok) {
3959
- if (mint.category === "rate-limited") {
3960
- // BAPI-668 (R12-1): the limiter is a single GLOBAL per-hour signup bucket,
3961
- // not a per-IP/per-email one so "temporarily rate limited" read as "you
3962
- // did something wrong" when nothing on the user's machine is at fault.
3963
- // Message-only: the return value and the caller's exit code are unchanged.
3964
- reportFatal("Error: Bridge's self-serve signup capacity for this hour is exhausted (this is a " +
3965
- "global service limit, not a problem with your machine). Try again in about an hour.");
4329
+ let challengeHandle;
4330
+ let challengeExpiresAt;
4331
+ let challengeEmail;
4332
+ if (liveChallenge) {
4333
+ // Phase 1 SKIPPED. Resuming an unexpired challenge: no request call, no
4334
+ // resend call, and no second email.
4335
+ challengeHandle = liveChallenge.handle;
4336
+ challengeExpiresAt = liveChallenge.expiresAt;
4337
+ challengeEmail = liveChallenge.email;
4338
+ log(` ${INSTALL_BRIDGE_RESUME_VERIFICATION_HEADING}`);
4339
+ log(` We already emailed a code to ${maskSignupEmail(challengeEmail)}.`);
4340
+ }
4341
+ else {
4342
+ const requested = await requestSelfServeChallenge(deps, baseUrl, signupEmail);
4343
+ if (!requested.ok) {
4344
+ if (requested.category === "rate-limited") {
4345
+ // Deliberately does NOT name a limiter bucket. The server refuses to
4346
+ // say which ceiling was reached, and repeating a guess here would
4347
+ // undo that.
4348
+ reportFatal(`Error: ${INSTALL_BRIDGE_RESEND_THROTTLED}`);
4349
+ }
4350
+ else if (requested.category === "unavailable") {
4351
+ // R-4's fail-closed branch: nothing was created, and there is no
4352
+ // suggestion anywhere that verification can be skipped.
4353
+ reportFatal(INSTALL_BRIDGE_SIGNUP_UNAVAILABLE);
4354
+ }
4355
+ else if (requested.category === "invalid") {
4356
+ reportFatal("Error: Self-serve setup could not be requested. Check the email value and try again.");
4357
+ }
4358
+ else {
4359
+ // R-2: a malformed or tokenless-unexpected response is a PROTOCOL
4360
+ // mismatch, not a network problem. Saying "check connectivity" here
4361
+ // was the old behavior and is exactly what this ticket replaces.
4362
+ reportFatal(INSTALL_BRIDGE_SIGNUP_UPGRADE_REQUIRED);
4363
+ }
4364
+ return false;
3966
4365
  }
3967
- else if (mint.category === "invalid") {
3968
- reportFatal("Error: Self-serve setup could not be requested. Check the email value and try again.");
4366
+ challengeHandle = requested.handle;
4367
+ challengeExpiresAt = requested.expiresAt;
4368
+ challengeEmail = signupEmail;
4369
+ // Phase 2, first half — durable BEFORE the prompt. A failure here stops
4370
+ // the run: continuing would prompt for a code whose challenge could not
4371
+ // be resumed if anything interrupted the terminal.
4372
+ const recorded = await deps.prepareSelfServeBootstrapChallenge({
4373
+ repoName,
4374
+ signupEmail: challengeEmail,
4375
+ challengeHandle,
4376
+ challengeExpiresAt,
4377
+ }, credentialWriteDeps);
4378
+ if (!recorded.ok) {
4379
+ reportFatal(`Error: could not record the signup attempt (${recorded.kind}). ${recorded.error} ` +
4380
+ "No workspace has been set up — fix the problem and re-run.");
4381
+ return false;
3969
4382
  }
3970
- else {
3971
- reportFatal("Error: Unable to complete self-serve setup. Check connectivity and retry.");
4383
+ log(` We emailed a code to ${maskSignupEmail(challengeEmail)}.`);
4384
+ }
4385
+ log(` Enter the ${SIGNUP_VERIFICATION_CODE_LENGTH}-digit code to finish setting up your workspace.`);
4386
+ const remaining = formatChallengeRemaining(challengeExpiresAt, deps.now());
4387
+ if (remaining)
4388
+ log(` ${remaining}`);
4389
+ // Phase 2, second half, and phase 3. One resend is offered on the recovery
4390
+ // path only — never proactively — so the budget is spent on the user's
4391
+ // explicit request rather than by the CLI's own retry logic.
4392
+ let resendOffered = false;
4393
+ // BOUNDED, not `for (;;)`. AC-4 makes a wrong code and a dead challenge
4394
+ // byte-identical, so the CLI cannot detect that a challenge has been killed
4395
+ // — an unbounded loop would re-prompt forever against something that can
4396
+ // never succeed, with Ctrl-C as the only way out.
4397
+ let rejections = 0;
4398
+ for (;;) {
4399
+ const code = await promptForVerificationCode(deps, log);
4400
+ if (code === null) {
4401
+ reportFatal("Error: No verification code entered — nothing has been set up. Re-run this command " +
4402
+ "to continue; the code already emailed to you stays valid until it expires.");
4403
+ return false;
4404
+ }
4405
+ const verified = await verifySelfServeChallenge(deps, baseUrl, challengeHandle, code);
4406
+ if (verified.ok) {
4407
+ inviteToken = verified.token;
4408
+ break;
4409
+ }
4410
+ if (verified.category === "rate-limited") {
4411
+ // NOT the resend wording: a throttle here comes from the invite-mint
4412
+ // ceiling and can fire AFTER a perfectly correct code, so telling the
4413
+ // user to fall back on an email they already have is backwards.
4414
+ reportFatal(`Error: ${INSTALL_BRIDGE_VERIFY_THROTTLED}`);
4415
+ return false;
4416
+ }
4417
+ if (verified.category === "server-error") {
4418
+ reportFatal(INSTALL_BRIDGE_VERIFY_SERVER_ERROR);
4419
+ return false;
4420
+ }
4421
+ if (verified.category === "failed") {
4422
+ // Only a genuine protocol mismatch reaches here now: a malformed body,
4423
+ // a missing token, a network error, or a 4xx this client does not
4424
+ // expect. R-2's upgrade instruction is the right answer for those.
4425
+ reportFatal(INSTALL_BRIDGE_SIGNUP_UPGRADE_REQUIRED);
4426
+ return false;
4427
+ }
4428
+ // `rejected` — the server's UNIFORM outcome. One message, no speculation
4429
+ // about which of the four states it was.
4430
+ rejections += 1;
4431
+ if (rejections >= INSTALL_BRIDGE_MAX_CODE_REJECTIONS) {
4432
+ // Still state-neutral: this says the attempt is over, never WHY.
4433
+ reportFatal(INSTALL_BRIDGE_VERIFICATION_GIVING_UP);
4434
+ return false;
4435
+ }
4436
+ errorLog(INSTALL_BRIDGE_VERIFICATION_REJECTED);
4437
+ if (!resendOffered && deps.isTTY && deps.promptLine) {
4438
+ resendOffered = true;
4439
+ const answer = await requestCredentialRouteSwitchConfirmation(deps, "Send a new code? [y/N]: ");
4440
+ if (answer) {
4441
+ const resent = await resendSelfServeChallenge(deps, baseUrl, challengeHandle);
4442
+ if (resent.ok) {
4443
+ log(` ${INSTALL_BRIDGE_RESEND_SENT}`);
4444
+ }
4445
+ else if (resent.category === "cooldown") {
4446
+ log(` ${INSTALL_BRIDGE_RESEND_COOLDOWN}`);
4447
+ }
4448
+ else if (resent.category === "rate-limited") {
4449
+ log(` ${INSTALL_BRIDGE_RESEND_THROTTLED}`);
4450
+ }
4451
+ else if (resent.category === "rejected") {
4452
+ // The challenge is no longer eligible — the same uniform outcome
4453
+ // verification reports. Stop rather than loop on a dead challenge.
4454
+ reportFatal(`Error: ${INSTALL_BRIDGE_VERIFICATION_REJECTED}`);
4455
+ return false;
4456
+ }
4457
+ else {
4458
+ log(` ${INSTALL_BRIDGE_RESEND_THROTTLED}`);
4459
+ }
4460
+ }
3972
4461
  }
3973
- return false;
3974
4462
  }
3975
- // Treated identically to a manually supplied invite token from here on.
3976
- inviteToken = mint.token;
4463
+ // From here on the token is treated identically to a manually supplied
4464
+ // invite — the load-bearing region below is completely unchanged.
3977
4465
  inviteFingerprint = fingerprintBootstrapInvite(inviteToken);
3978
4466
  const prepareSelfServePending = (allowOverwrite) => deps.prepareBootstrapPending({
3979
4467
  repoName,