@bridge_gpt/mcp-server 0.2.32 → 0.2.34

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.
@@ -252,36 +252,52 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
252
252
  ...INSTALL_BRIDGE_ONBOARDING_CHOICES.map((choice) => ` ${choice}`),
253
253
  "There is NO default — pressing Enter selects nothing; you must type 1, 2, or 3",
254
254
  "(one blank or invalid answer re-prompts once, then exits with guidance). Option 1",
255
- "is the existing-key flow below, option 2 is the bootstrap-invite flow, and option 3",
256
- "prompts for an email and creates a brand-new Bridge workspace for you (the",
257
- "self-serve flow). That question is asked ONLY for a bare interactive run: passing",
255
+ "is the bootstrap-invite flow, option 2 prompts for an email and creates a",
256
+ "brand-new Bridge workspace for you (the self-serve flow), and option 3 is the",
257
+ "existing-project API-key flow below. The project-CREATING routes lead",
258
+ "deliberately. That question is asked ONLY for a bare interactive run: passing",
258
259
  "ANY flag, setting BAPI_API_KEY, or running without an interactive terminal keeps",
259
260
  "the existing deterministic behavior and no prompt.",
260
261
  "",
262
+ "Entered the wrong kind of credential? Both directions are detected by SHAPE",
263
+ "alone — no probe request is sent — and neither switches routes in silence:",
264
+ " - a bootstrap invite (bapi_inv_…) entered as an API key is announced, then",
265
+ " confirmed on a terminal before it is redeemed; a non-interactive run keeps",
266
+ " today's automatic switch but prints the notice.",
267
+ " - an API-key-shaped value entered as an invite is announced and may switch in",
268
+ " place only after you confirm on a terminal — non-interactive runs exit with",
269
+ " guidance instead, so a script is never silently redirected. The check runs",
270
+ " BEFORE any project-name prompt, credential write, or network call.",
271
+ "A value that is neither shape is left on the route you chose.",
272
+ "",
261
273
  "Inputs (the only two irreducible ones):",
262
274
  " --api-key <key> Bridge API key OR bootstrap invite. Falls back to the",
263
275
  " BAPI_API_KEY env var, then an interactive (no-echo) prompt.",
264
276
  ` Generate a key at ${setupUrl} (Security page) — this`,
265
277
  " command consumes a key, it does not create one. A value",
266
278
  " detected as a bootstrap invite (bapi_inv_…), from any of the",
267
- " three sources above, is instead redeemed to CREATE a new",
268
- " project and its first admin key — the same as --invite —",
269
- " so it skips repository lookup entirely (--email and --invite",
270
- " remain the explicit, preferred entry points for a new project).",
271
- " NEVER printed or logged.",
279
+ " three sources above, is announced and instead redeemed to",
280
+ " CREATE a new project and its first admin key — the same as",
281
+ " --invite — so it skips repository lookup entirely (--email",
282
+ " and --invite remain the explicit, preferred entry points for",
283
+ " a new project). NEVER printed or logged.",
272
284
  " --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
273
285
  " priority and short-circuit before any network call. When",
274
286
  " neither is set, a compatible server resolves the unique",
275
- " repository from your existing API key automatically; if the",
276
- " server is older, the key is unresolvable, or resolution",
277
- " fails, it falls back to an inferred default you confirm",
278
- " interactively (and to a required --repo when stdin is",
279
- " non-interactive). In the existing-key flow it MUST match the",
280
- " server-side repo registration (it keys the credential store",
281
- " as bapi:<repo>). In either new-project flow (--email,",
282
- " --invite, or chooser option 2 or 3 above) it",
283
- " instead NAMES the project this run creates, so you are asked",
284
- " to name a new project rather than match an existing one; the",
287
+ " repository from your existing API key automatically. If the",
288
+ " key does not identify exactly one project, the run STOPS —",
289
+ " it never guesses — and asks you to re-run with --repo <name>,",
290
+ ` or to rotate the key at ${setupUrl} (Security page).`,
291
+ " If instead the server is older or resolution fails",
292
+ " transiently, you are asked to type the registered name, with",
293
+ " NO pre-filled default (and",
294
+ " --repo is required when stdin is non-interactive). In the",
295
+ " existing-project flow it MUST match the server-side repo",
296
+ " registration (it keys the credential store as bapi:<repo>).",
297
+ " In either new-project flow (--email, --invite, or chooser",
298
+ " option 1 or 2 above) it instead NAMES the project this run",
299
+ " creates, so you are asked to name a new project — with a",
300
+ " suggested default — rather than match an existing one; the",
285
301
  " name must be globally unique.",
286
302
  "",
287
303
  "Self-serve onboarding (no account, no API key, no pre-issued invite):",
@@ -290,7 +306,7 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
290
306
  " It requests a fresh workspace for that email, then creates",
291
307
  " the project and mints your own admin API key in one command.",
292
308
  " Falls back to the BAPI_SIGNUP_EMAIL env var, then a visible",
293
- " interactive prompt — which is also what chooser option 3 on a",
309
+ " interactive prompt — which is also what chooser option 2 on a",
294
310
  " bare run reaches. The email is NOT a",
295
311
  " secret (it is shown as you type), but it is never printed to",
296
312
  " a log. Mutually",
@@ -997,11 +1013,24 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
997
1013
  * numbered options once, accept ONE strict line.
998
1014
  */
999
1015
  export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "How would you like to connect to Bridge API?";
1000
- /** The chooser's numbered options, in render order. Index + 1 is the accepted token. */
1016
+ /**
1017
+ * The chooser's numbered options, in render order. Index + 1 is the accepted token.
1018
+ *
1019
+ * BAPI-687 (A-1) put the two PROJECT-CREATING routes first. The previous order led
1020
+ * with the existing-project key path, labelled only "I have a Bridge API key" — which
1021
+ * reads as the default for anyone holding any credential, so a user with a valid
1022
+ * invite picked it and dead-ended at a repo-not-registered 403 with no route to the
1023
+ * path that would have created their project. Position was doing the opposite of the
1024
+ * intended routing.
1025
+ *
1026
+ * Option 3's label carries its own scope ("for an existing project") rather than
1027
+ * relying on position (A-3): a stale screenshot or a habituated user must still be
1028
+ * able to read the right choice off the label itself.
1029
+ */
1001
1030
  export const INSTALL_BRIDGE_ONBOARDING_CHOICES = [
1002
- "1. I have a Bridge API key",
1003
- "2. I have an invite token",
1004
- "3. I'm new set me up with just my email",
1031
+ "1. I have an invite token",
1032
+ "2. I'm new set me up with just my email",
1033
+ "3. I have an API key for an existing project",
1005
1034
  ];
1006
1035
  /** The single-line answer prompt drawn after the options (no bracketed default). */
1007
1036
  export const INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT = "Enter 1, 2, or 3: ";
@@ -1017,7 +1046,7 @@ export const INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT = "Enter 1, 2, or 3.";
1017
1046
  */
1018
1047
  export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install-bridge and choose an option: pass --api-key <key> if you have a Bridge API " +
1019
1048
  "key (or set BAPI_API_KEY), --invite if you were sent an invite token, or --email <addr> " +
1020
- "to sign up with just an email — on a bare interactive run, re-run and choose option 3 to " +
1049
+ "to sign up with just an email — on a bare interactive run, re-run and choose option 2 to " +
1021
1050
  "sign up with just an email.";
1022
1051
  /**
1023
1052
  * Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
@@ -1042,10 +1071,16 @@ export const INSTALL_BRIDGE_ENTRY_ROUTES_ADVICE = "Re-run install-bridge and cho
1042
1071
  *
1043
1072
  * BAPI-667 replaced the Y/n question with a three-option chooser. Two properties
1044
1073
  * are load-bearing: there is NO default (a bare Enter can no longer route a
1045
- * brand-new user into the hidden key prompt), and option 3 names the self-serve
1046
- * route explicitly so it is discoverable without reading `--help`. Validation is
1074
+ * brand-new user into the hidden key prompt), and the self-serve route is named
1075
+ * explicitly so it is discoverable without reading `--help`. Validation is
1047
1076
  * bounded to ONE re-prompt — an unanswerable prompt that loops is the same
1048
1077
  * dead-end in a different costume.
1078
+ *
1079
+ * BAPI-687 reordered the options so the project-CREATING routes lead (1 = invite,
1080
+ * 2 = self-serve email, 3 = existing-project API key). Only the labels and the
1081
+ * dispatch moved: the heading, the `Enter 1, 2, or 3: ` prompt, the accepted-token
1082
+ * set, the no-default rule, EOF handling, and every flag/env/non-TTY bypass are
1083
+ * byte-for-byte unchanged.
1049
1084
  */
1050
1085
  export async function resolveInstallBridgeOnboardingBranchForRun(options, deps, argv) {
1051
1086
  const branch = resolveInstallBridgeOnboardingBranch(options, deps.env);
@@ -1070,15 +1105,18 @@ export async function resolveInstallBridgeOnboardingBranchForRun(options, deps,
1070
1105
  // it can never hang or silently select a branch.
1071
1106
  for (let attempt = 0; attempt < 2; attempt += 1) {
1072
1107
  const answer = (await promptLine(INSTALL_BRIDGE_ONBOARDING_CHOICE_PROMPT)).trim();
1073
- // Option 1 stays credential-agnostic: a `bapi_inv_…` value pasted into the
1074
- // hidden key prompt is still reclassified downstream (BAPI-661).
1075
- if (answer === "1")
1076
- return { ok: true, branch: { kind: "have-key" } };
1077
- if (answer === "2") {
1108
+ // BAPI-687 (A-2): the dispatch moved WITH the labels — the accepted tokens are
1109
+ // still exactly 1/2/3 (no letters, no aliases), so the one-strict-line input
1110
+ // contract BAPI-667 established is unchanged. Either branch stays
1111
+ // credential-agnostic: a value pasted onto the wrong path is detected and
1112
+ // re-routed downstream rather than silently accepted (B-2 / B-3).
1113
+ if (answer === "1") {
1078
1114
  return { ok: true, branch: { kind: "need-key", method: "bootstrap-invite" } };
1079
1115
  }
1080
- if (answer === "3")
1116
+ if (answer === "2")
1081
1117
  return { ok: true, branch: { kind: "need-key", method: "self-serve" } };
1118
+ if (answer === "3")
1119
+ return { ok: true, branch: { kind: "have-key" } };
1082
1120
  if (attempt === 0)
1083
1121
  deps.log(INSTALL_BRIDGE_ONBOARDING_CHOICE_HINT);
1084
1122
  }
@@ -1112,23 +1150,30 @@ export function resolveConfiguredRepoName(options, env) {
1112
1150
  return undefined;
1113
1151
  }
1114
1152
  /**
1115
- * Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → inferred default
1116
- * (from .bridge/config, else the cwd basename) confirmed interactively. Fails
1117
- * fast (no inference) when neither is supplied and stdin is non-interactive —
1118
- * the repo identity keys the credential store, so it is never silently inferred
1153
+ * Resolve the repo name: `--repo` → `BAPI_REPO_NAME` env → an interactive prompt.
1154
+ * Fails fast when neither is supplied and stdin is non-interactive — the repo
1155
+ * identity keys the credential store, so it is never silently inferred
1119
1156
  * non-interactively.
1120
1157
  *
1121
- * `mode` selects only the WORDING (prompt and non-interactive error); resolution
1122
- * order, inference, and validation are identical in both. It defaults to
1123
- * `existing-registration` to preserve the behaviour of pre-BAPI-626 callers.
1158
+ * `mode` selects the WORDING **and, since BAPI-687 (C-3), whether a default is
1159
+ * inferred at all**:
1160
+ *
1161
+ * - `new-project` — unchanged. The project does not exist yet, so a suggested
1162
+ * name (from `.bridge/config`, else the cwd basename) is genuinely helpful and
1163
+ * a bare Enter accepts it.
1164
+ * - `existing-registration` — NO inference and NO bracketed default. The name
1165
+ * must match a registration the user already has server-side, where the cwd
1166
+ * basename is essentially never right; BAPI-661 already tried warning harder
1167
+ * and the 2026-07-21 run shows the user accepted the guess anyway, then took a
1168
+ * 403 two steps later. A pre-filled wrong answer is the defect, so the user now
1169
+ * types the registered name deliberately.
1124
1170
  */
1125
1171
  export async function resolveRepoName(options, deps, mode = "existing-registration") {
1126
1172
  const configured = resolveConfiguredRepoName(options, deps.env);
1127
1173
  if (configured !== undefined) {
1128
1174
  return { ok: true, value: configured };
1129
1175
  }
1130
- // Non-interactive: fail fast and require --repo. Inference is an interactive
1131
- // convenience only (the user must confirm it).
1176
+ // Non-interactive: fail fast and require --repo.
1132
1177
  if (!deps.isTTY || !deps.promptLine) {
1133
1178
  return {
1134
1179
  ok: false,
@@ -1137,10 +1182,19 @@ export async function resolveRepoName(options, deps, mode = "existing-registrati
1137
1182
  "variable (no interactive terminal is available to confirm an inferred name). It " +
1138
1183
  "names the new Bridge project this run creates and must be globally unique."
1139
1184
  : "A repo name is required. Pass --repo or set the BAPI_REPO_NAME environment variable " +
1140
- "(no interactive terminal is available to confirm an inferred name). It must match the " +
1141
- "server-side repository registration.",
1185
+ "(no interactive terminal is available to prompt for it). It must match the " +
1186
+ "server-side repository registration exactly.",
1142
1187
  };
1143
1188
  }
1189
+ // BAPI-687 (C-3): only the new-project mode infers. The existing-registration
1190
+ // prompt is rendered with NO bracketed default, so a bare Enter cannot commit a
1191
+ // guess the user never chose.
1192
+ if (mode !== "new-project") {
1193
+ const answer = (await deps.promptLine("Repo name (must match server-side registration): ")).trim();
1194
+ if (answer.length > 0)
1195
+ return { ok: true, value: answer };
1196
+ return { ok: false, error: "No repo name provided." };
1197
+ }
1144
1198
  // Infer a sensible default: existing .bridge/config, else the cwd basename.
1145
1199
  let inferred = await resolveStartTicketsRepoName({
1146
1200
  env: deps.env,
@@ -1153,19 +1207,13 @@ export async function resolveRepoName(options, deps, mode = "existing-registrati
1153
1207
  inferred = validated.value;
1154
1208
  }
1155
1209
  if (inferred) {
1156
- const promptText = mode === "new-project"
1157
- ? `Name your new Bridge project [${inferred}]: `
1158
- : `Repo name [${inferred}] (must match server-side registration): `;
1159
- const answer = (await deps.promptLine(promptText)).trim();
1210
+ const answer = (await deps.promptLine(`Name your new Bridge project [${inferred}]: `)).trim();
1160
1211
  const chosen = answer.length > 0 ? answer : inferred;
1161
1212
  if (chosen.length > 0)
1162
1213
  return { ok: true, value: chosen };
1163
1214
  }
1164
1215
  else {
1165
- const promptText = mode === "new-project"
1166
- ? "Name your new Bridge project: "
1167
- : "Repo name (must match server-side registration): ";
1168
- const answer = (await deps.promptLine(promptText)).trim();
1216
+ const answer = (await deps.promptLine("Name your new Bridge project: ")).trim();
1169
1217
  if (answer.length > 0)
1170
1218
  return { ok: true, value: answer };
1171
1219
  }
@@ -1986,6 +2034,23 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey, apiKey
1986
2034
  message: `Connectivity check failed (HTTP ${resp.status}). Verify your repo, API key, and BAPI_BASE_URL.`,
1987
2035
  };
1988
2036
  }
2037
+ /**
2038
+ * The `unresolved` (409) hard stop (BAPI-687, C-2). Names both recoveries; the
2039
+ * caller appends the address-bearing rotation line so this string stays a constant.
2040
+ * Secret-free: no key, no HTTP status, no server detail.
2041
+ */
2042
+ export const INSTALL_BRIDGE_UNRESOLVED_KEY_ERROR = "Error: that API key does not identify exactly one Bridge project, so the repository could " +
2043
+ "not be resolved from it. Re-run with --repo <name> (or set BAPI_REPO_NAME) to name the " +
2044
+ "registered project explicitly.";
2045
+ /**
2046
+ * The surviving `not-deployed` (404) / `error` fallback notice (BAPI-687, C-5).
2047
+ *
2048
+ * Re-worded for a path that no longer OFFERS a name: the old copy said the CLI was
2049
+ * "falling back to a guessed name" and told the user to confirm it against the setup
2050
+ * UI, which is now false in both halves — there is nothing pre-filled to confirm.
2051
+ */
2052
+ export const INSTALL_BRIDGE_RESOLUTION_UNAVAILABLE_NOTICE = "Automatic repository resolution isn't available for this run. Enter the name your project " +
2053
+ "is registered under (or re-run with --repo <name>).";
1989
2054
  /** The resolve endpoint: `<base>/setup/resolve-repo`. No query string — ever. */
1990
2055
  export function buildResolveRepoUrl(baseUrl) {
1991
2056
  return `${baseUrl.replace(/\/+$/, "")}/setup/resolve-repo`;
@@ -2166,6 +2231,118 @@ export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
2166
2231
  export function classifyEnteredCredential(value) {
2167
2232
  return value.trim().startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX) ? "invite" : "api-key";
2168
2233
  }
2234
+ // ---------------------------------------------------------------------------
2235
+ // Cross-credential detection (BAPI-687, Part B)
2236
+ // ---------------------------------------------------------------------------
2237
+ /** The exact encoded length of `secrets.token_urlsafe(32)`: 32 bytes, unpadded base64url. */
2238
+ export const USER_ACCESS_KEY_ENCODED_LENGTH = 43;
2239
+ /** The exact decoded length the encoded form must yield. */
2240
+ export const USER_ACCESS_KEY_DECODED_BYTES = 32;
2241
+ /** Strict base64url alphabet, anchored to the exact issuance length. */
2242
+ const USER_ACCESS_KEY_SHAPE_PATTERN = new RegExp(`^[A-Za-z0-9_-]{${USER_ACCESS_KEY_ENCODED_LENGTH}}$`);
2243
+ /**
2244
+ * POSITIVE test for the project's API-key shape — the TypeScript port of the
2245
+ * server's `is_high_entropy_user_access_key_shape`
2246
+ * (`src/python/library/auth/user_access_lookup.py`). Detection had to become
2247
+ * positive: "is an API key" was previously only the NEGATIVE of the `bapi_inv_`
2248
+ * prefix check, which would confidently mis-announce a typo'd invite as an API key.
2249
+ *
2250
+ * Admits ONLY `secrets.token_urlsafe(32)`: exactly 43 characters from the strict
2251
+ * base64url alphabet, decoding to exactly 32 bytes, AND canonical — the input must
2252
+ * equal the unpadded base64url re-encoding of those bytes. The canonical check is
2253
+ * not decoration: the final 6-bit group of a 32-byte value carries 2 unused low
2254
+ * bits, so several distinct 43-character strings decode to the same 32 bytes and
2255
+ * length-only validation would admit the aliases. It deliberately does NOT admit
2256
+ * shorter legacy keys — a legacy credential misses detection rather than being
2257
+ * re-routed on a guess.
2258
+ *
2259
+ * Pure and total: no logging, no trimming (the caller owns normalization), no
2260
+ * throw, and the input is never placed in a returned or thrown value. Every
2261
+ * failure path returns `false`.
2262
+ */
2263
+ export function isHighEntropyApiKeyShape(value) {
2264
+ if (typeof value !== "string" || !USER_ACCESS_KEY_SHAPE_PATTERN.test(value))
2265
+ return false;
2266
+ let decoded;
2267
+ try {
2268
+ // The alphabet was already validated above, so this decode cannot silently
2269
+ // discard characters the way a lenient base64url decode otherwise would.
2270
+ decoded = Buffer.from(value, "base64url");
2271
+ }
2272
+ catch {
2273
+ return false;
2274
+ }
2275
+ if (decoded.length !== USER_ACCESS_KEY_DECODED_BYTES)
2276
+ return false;
2277
+ return decoded.toString("base64url") === value;
2278
+ }
2279
+ /**
2280
+ * Notice printed when a bootstrap invite is detected on the API-key path (B-2).
2281
+ *
2282
+ * The reclassification itself is not new (BAPI-661) — the SILENCE was. A user who
2283
+ * called their value an "API key" was branch-switched with no indication, which is
2284
+ * half of why the reported run was opaque to everyone reading the transcript.
2285
+ *
2286
+ * Secret discipline: names the credential TYPE and the destination ROUTE only.
2287
+ * No part of the value — no prefix excerpt, no length, no fingerprint.
2288
+ */
2289
+ export const INSTALL_BRIDGE_INVITE_ON_KEY_PATH_NOTICE = "That looks like a bootstrap invite token, not a Bridge API key. An invite CREATES a new " +
2290
+ "Bridge project and mints your first admin key, instead of connecting to a project that " +
2291
+ "already exists.";
2292
+ /** TTY confirmation for the invite-detected-on-the-key-path switch. Default No. */
2293
+ export const INSTALL_BRIDGE_INVITE_ON_KEY_PATH_PROMPT = "Redeem it as an invite and create a new project? [y/N]: ";
2294
+ /** Non-TTY form: the switch still happens automatically, but it is announced (B-2). */
2295
+ export const INSTALL_BRIDGE_INVITE_ON_KEY_PATH_AUTO_NOTICE = "Redeeming it as a bootstrap invite (non-interactive — no confirmation is possible here).";
2296
+ /** Printed on decline. Names the other route rather than leaving the user stranded. */
2297
+ export const INSTALL_BRIDGE_INVITE_ON_KEY_PATH_DECLINED = "Error: cancelled — nothing was created. Re-run install-bridge with an API key for an " +
2298
+ "existing project (--api-key <key>, or BAPI_API_KEY), or re-run and confirm to redeem the " +
2299
+ "invite and create a new project.";
2300
+ /**
2301
+ * Notice printed when an API-key-shaped value is detected on the invite path (B-3).
2302
+ *
2303
+ * This direction had NO detection at all, and the cost of that was severe: the run
2304
+ * prompted for a new project name, fsynced a durable `bootstrap-pending:<repo>`
2305
+ * record, POSTed the exchange, took a 401, and printed the "you cannot recover it
2306
+ * yourself — ask your Bridge API operator" message. For a wrong-credential-type
2307
+ * paste. The orphaned pending record then blocked the next attempt.
2308
+ *
2309
+ * Secret discipline: credential TYPE and destination ROUTE only.
2310
+ */
2311
+ export const INSTALL_BRIDGE_KEY_ON_INVITE_PATH_NOTICE = "That looks like a Bridge API key, not a bootstrap invite token. An API key connects to a " +
2312
+ "project that already exists, instead of creating a new one.";
2313
+ /** TTY confirmation for the key-detected-on-the-invite-path switch. Default No. */
2314
+ export const INSTALL_BRIDGE_KEY_ON_INVITE_PATH_PROMPT = "Use it to connect to your existing project instead? [y/N]: ";
2315
+ /**
2316
+ * Printed when the switch is declined, unconfirmable, or non-interactive.
2317
+ *
2318
+ * Non-TTY does NOT auto-switch here, and the asymmetry is deliberate: the invite
2319
+ * direction merely continues a path BAPI-661 already established, while this
2320
+ * direction would silently redirect a script away from the project-creating run it
2321
+ * asked for. A script gets a non-zero exit and the exact flag it should have passed.
2322
+ */
2323
+ export const INSTALL_BRIDGE_KEY_ON_INVITE_PATH_DECLINED = "Error: cancelled — nothing was created and no invite was spent. Re-run install-bridge with " +
2324
+ "--api-key <key> (or BAPI_API_KEY) to connect to your existing project, or supply a bootstrap " +
2325
+ "invite token (bapi_inv_…) to create a new one.";
2326
+ /**
2327
+ * Shared default-No confirmation for a cross-credential route switch (B-2 / B-3).
2328
+ *
2329
+ * Accepts only an explicit `y`/`yes` (case-insensitive, trimmed); EVERY other
2330
+ * answer — including a prompt that throws or an EOF-resolved empty line — is a
2331
+ * decline, because an unreadable prompt is not consent. The credential value is
2332
+ * never passed in, so neither the prompt text nor a caught exception can carry it.
2333
+ */
2334
+ export async function requestCredentialRouteSwitchConfirmation(deps, promptText) {
2335
+ if (!deps.isTTY || !deps.promptLine)
2336
+ return false;
2337
+ try {
2338
+ const answer = (await deps.promptLine(promptText)).trim().toLowerCase();
2339
+ return answer === "y" || answer === "yes";
2340
+ }
2341
+ catch {
2342
+ // Secret-free by construction: the caught value is never surfaced or inspected.
2343
+ return false;
2344
+ }
2345
+ }
2169
2346
  /** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
2170
2347
  export function buildSelfServeMintUrl(baseUrl) {
2171
2348
  return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
@@ -2344,10 +2521,15 @@ export function buildDryRunPreview(plan) {
2344
2521
  if (plan.bootstrapInvite)
2345
2522
  return buildBootstrapDryRunPreview(plan);
2346
2523
  return [
2347
- plan.attemptedServerResolution
2524
+ // Any defined status means the read-only GET really happened, so the header is
2525
+ // truthful for every outcome — but ONLY `resolved` may claim the name came back
2526
+ // from the server (BAPI-687, C-4).
2527
+ plan.serverRepoResolutionStatus !== undefined
2348
2528
  ? "install-bridge --dry-run (one read-only repository-resolution GET may already have occurred; no writes, no state-changing requests, no spawns)"
2349
2529
  : "install-bridge --dry-run (no writes, no network, no spawns)",
2350
- `Repo name: ${plan.repoName}${plan.attemptedServerResolution ? " (resolved server-side from your API key)" : ""}`,
2530
+ `Repo name: ${plan.repoName}${plan.serverRepoResolutionStatus === "resolved"
2531
+ ? " (resolved server-side from your API key)"
2532
+ : ""}`,
2351
2533
  `Base URL (ping): ${plan.baseUrl}`,
2352
2534
  `Docs dir: ${plan.docsDir}`,
2353
2535
  `Agent: ${describePlannedLaunchAgent(plan.launch)}`,
@@ -2933,6 +3115,14 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2933
3115
  // downstream that branches on these two flags then follows the existing
2934
3116
  // bootstrap-invite protocol automatically. Explicit `--invite`/`BAPI_INVITE` and
2935
3117
  // `--email`/`BAPI_SIGNUP_EMAIL` never pass through this reclassification.
3118
+ //
3119
+ // BAPI-687 (Part B) makes the reclassification BIDIRECTIONAL and, in both
3120
+ // directions, ANNOUNCED. The two directions are deliberately asymmetric on a
3121
+ // non-TTY: an invite found on the key path still switches automatically (a script
3122
+ // that worked yesterday must not gain a prompt) but now prints a notice, whereas
3123
+ // an API key found on the invite path exits non-zero, because auto-switching there
3124
+ // would silently redirect a script away from the project-creating run it asked
3125
+ // for. Detection is by SHAPE ONLY — no probe request, no credential-store read.
2936
3126
  let bootstrapInviteMode = branch.kind === "need-key";
2937
3127
  let selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
2938
3128
  // ---- Resolve inputs (may prompt when interactive) ----
@@ -2965,6 +3155,37 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2965
3155
  return 1;
2966
3156
  }
2967
3157
  inviteToken = inviteResult.value;
3158
+ // BAPI-687 (B-3/B-4): an API-key-shaped value pasted onto the invite path is
3159
+ // caught HERE — before the project-name prompt, before `prepareBootstrapPending`
3160
+ // writes a durable `bootstrap-pending:<repo>` record, and before any fetch. That
3161
+ // ordering IS the fix: today's failure leaves exactly that debris behind, and the
3162
+ // orphaned pending record is what turns a wrong-credential paste into an
3163
+ // unrecoverable "ask your Bridge API operator" message plus a `pending-conflict`
3164
+ // on the next attempt.
3165
+ //
3166
+ // Guarded by BOTH conditions (B-5): not `bapi_inv_`-prefixed AND a positive
3167
+ // API-key shape. A value that is neither — a typo'd invite, say — is not
3168
+ // reclassified in either direction; it stays on the branch the user chose and
3169
+ // fails there exactly as it does today.
3170
+ if (classifyEnteredCredential(inviteToken) !== "invite" &&
3171
+ isHighEntropyApiKeyShape(inviteToken)) {
3172
+ log(INSTALL_BRIDGE_KEY_ON_INVITE_PATH_NOTICE);
3173
+ const switchToKeyPath = await requestCredentialRouteSwitchConfirmation(deps, INSTALL_BRIDGE_KEY_ON_INVITE_PATH_PROMPT);
3174
+ if (!switchToKeyPath) {
3175
+ // Decline, an unreadable prompt, and a non-TTY run all land here — nothing
3176
+ // has been prompted for, written, or sent, so the exit costs the user only
3177
+ // the re-run named in the guidance.
3178
+ errorLog(INSTALL_BRIDGE_KEY_ON_INVITE_PATH_DECLINED);
3179
+ return 1;
3180
+ }
3181
+ apiKey = inviteToken;
3182
+ inviteToken = "";
3183
+ bootstrapInviteMode = false;
3184
+ selfServeSignupMode = false;
3185
+ // Deliberately unattributed: the value came through the INVITE input seam, so
3186
+ // naming `--api-key` or `BAPI_API_KEY` on a later 401 would simply be false.
3187
+ apiKeySource = undefined;
3188
+ }
2968
3189
  }
2969
3190
  else {
2970
3191
  const keyResult = await resolveApiKey(options, deps);
@@ -2978,6 +3199,22 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2978
3199
  // resolution is what lets the rest of the function reuse the existing
2979
3200
  // bootstrap-invite protocol verbatim.
2980
3201
  if (classifyEnteredCredential(keyResult.value) === "invite") {
3202
+ // BAPI-687 (B-2): the switch survives, the SILENCE does not. The user is told
3203
+ // which credential type was detected and what it will do, and on a TTY is
3204
+ // asked to confirm before the branch changes. A non-TTY run keeps today's
3205
+ // automatic switch — a script must not gain a prompt — but still prints the
3206
+ // notice, so the transcript explains itself.
3207
+ log(INSTALL_BRIDGE_INVITE_ON_KEY_PATH_NOTICE);
3208
+ if (deps.isTTY && deps.promptLine) {
3209
+ const switchToInvite = await requestCredentialRouteSwitchConfirmation(deps, INSTALL_BRIDGE_INVITE_ON_KEY_PATH_PROMPT);
3210
+ if (!switchToInvite) {
3211
+ errorLog(INSTALL_BRIDGE_INVITE_ON_KEY_PATH_DECLINED);
3212
+ return 1;
3213
+ }
3214
+ }
3215
+ else {
3216
+ log(INSTALL_BRIDGE_INVITE_ON_KEY_PATH_AUTO_NOTICE);
3217
+ }
2981
3218
  inviteToken = keyResult.value.trim();
2982
3219
  apiKey = "";
2983
3220
  bootstrapInviteMode = true;
@@ -2997,13 +3234,25 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2997
3234
  // ---- Resolve the repository name ----
2998
3235
  // Need-key (invite or self-serve): choose-a-name for the project this run is
2999
3236
  // about to create, so the prompt says exactly that. Have-key:
3000
- // `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve
3001
- // it server-side from the API key (BAPI-616), and on ANY non-resolution outcome
3002
- // (unresolved / not-deployed / error) fall back to the existing local
3003
- // prompt/inference never a hard failure. That fallback keeps the
3004
- // existing-registration wording: there the name must match a real project.
3237
+ // `--repo`/`BAPI_REPO_NAME` short-circuit deterministically; otherwise resolve it
3238
+ // server-side from the API key (BAPI-616).
3239
+ //
3240
+ // BAPI-687 (C-2) TIERS the non-`resolved` outcomes instead of collapsing them:
3241
+ //
3242
+ // - `unresolved` (409) → HARD STOP. The key does not identify exactly one
3243
+ // project, and that answer is real rather than a deployment artifact:
3244
+ // `user_access.api_key_lookup` cannot be backfilled offline, but
3245
+ // `verify_repo_access` lazily populates it on any successful authentication,
3246
+ // so the permanently-unresolvable population is only pre-migration keys that
3247
+ // have never authenticated since — small and shrinking. Guessing a name here
3248
+ // buys nothing but a 403 two steps later.
3249
+ // - `not-deployed` (404) / `error` → still recoverable, and must stay that way:
3250
+ // 404 is the BAPI-616 feature-detect for an older or self-hosted server, and
3251
+ // `error` covers a transient network failure or a 401. Collapsing these into
3252
+ // the hard stop would make a blip unrecoverable.
3005
3253
  let repoName;
3006
- let attemptedServerResolution = false;
3254
+ /** The resolver's OUTCOME (C-4), or undefined when it was never called. */
3255
+ let serverRepoResolutionStatus;
3007
3256
  if (bootstrapInviteMode) {
3008
3257
  const repoResult = await resolveRepoName(options, deps, "new-project");
3009
3258
  if (!repoResult.ok) {
@@ -3026,20 +3275,24 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
3026
3275
  repoName = configured;
3027
3276
  }
3028
3277
  else {
3029
- attemptedServerResolution = true;
3030
3278
  log("Resolving repository…");
3031
3279
  const resolution = await deps.resolveRepoViaServer(baseUrl, apiKey);
3280
+ serverRepoResolutionStatus = resolution.status;
3032
3281
  if (resolution.status === "resolved") {
3033
3282
  repoName = resolution.repoName;
3034
3283
  }
3284
+ else if (resolution.status === "unresolved") {
3285
+ // C-2: a 409 means the key does not identify exactly one project. Stop here,
3286
+ // before any prompt, ping, scaffold, persist, or spawn — and name BOTH
3287
+ // recoveries rather than inferring anything.
3288
+ return fatal(INSTALL_BRIDGE_UNRESOLVED_KEY_ERROR, ` Or rotate the key at ${setupUrl} (Security page) if it should identify exactly one project.`);
3289
+ }
3035
3290
  else {
3036
- // Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
3037
- // client-scoped), and network/other errors all fall back to the existing
3038
- // local resolution WITHOUT a cause-specific message or leaked detail, but
3039
- // (BAPI-661) WITH a visible signal that the fallback is happening, so a
3040
- // guessed name is never confirmed silently as if it had been verified.
3041
- log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
3042
- `it matches ${setupUrl} (setup UI).`);
3291
+ // Feature-detect + degrade: 404 (old server) and network/other errors keep
3292
+ // the local fallback prompt WITHOUT a cause-specific message or leaked
3293
+ // detail. C-3/C-5: it no longer offers a name, so it no longer says
3294
+ // "guessed" or asks the user to confirm a value against the setup UI.
3295
+ log(INSTALL_BRIDGE_RESOLUTION_UNAVAILABLE_NOTICE);
3043
3296
  const repoResult = await resolveRepoName(options, deps, "existing-registration");
3044
3297
  if (!repoResult.ok) {
3045
3298
  errorLog(`Error: ${repoResult.error}`);
@@ -3105,7 +3358,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
3105
3358
  ? { bootstrapInvite: true, exchangeUrl: buildBootstrapExchangeUrl(baseUrl) }
3106
3359
  : {}),
3107
3360
  ...(selfServeSignupMode ? { selfServeSignup: true } : {}),
3108
- ...(attemptedServerResolution ? { attemptedServerResolution: true } : {}),
3361
+ ...(serverRepoResolutionStatus !== undefined
3362
+ ? { serverRepoResolutionStatus }
3363
+ : {}),
3109
3364
  };
3110
3365
  // ---- --dry-run: preview every step, strictly no side effects ----
3111
3366
  // Positioned BEFORE the CSPRNG, the pending write, the exchange, the scaffold,