@bridge_gpt/mcp-server 0.2.31 → 0.2.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,18 +31,29 @@ npx -y @bridge_gpt/mcp-server@latest install-bridge
31
31
  ```
32
32
 
33
33
  Run bare like that in a terminal and it starts by asking
34
- **`Do you have a Bridge API key or invite? [Y/n]`**:
35
-
36
- - **Yes** (or just press Enter) — the existing-key flow. It asks for your **API key**
37
- (generate one on the Bridge API web UI **Security** page) and a **repo name**
38
- matching your server-side registration; everything else is derived. A
39
- `bapi_inv_…` credential entered here instead of a full API key is automatically
40
- detected and redeemed as a **bootstrap invite** — it creates a brand-new project
41
- and mints your admin API key rather than looking up an existing repository.
42
- - **No** — the **self-serve** flow. It asks for an **email**, then a name for your new
43
- Bridge project, and creates the workspace and your own admin API key for you. No
44
- account, no key, and no invite needed beforehand. Same as passing
45
- `--email you@example.com` (see below).
34
+ **`How would you like to connect to Bridge API?`** with three numbered choices:
35
+
36
+ ```
37
+ 1. I have a Bridge API key
38
+ 2. I have an invite token
39
+ 3. I'm new set me up with just my email
40
+ ```
41
+
42
+ - **1** — the existing-key flow. It asks for your **API key** (generate one on the
43
+ Bridge API web UI **Security** page) and a **repo name** matching your server-side
44
+ registration; everything else is derived. A `bapi_inv_…` credential entered here
45
+ instead of a full API key is automatically detected and redeemed as a **bootstrap
46
+ invite** — it creates a brand-new project and mints your admin API key rather than
47
+ looking up an existing repository.
48
+ - **2** — the **bootstrap-invite** flow. It asks for the invite token you were given,
49
+ with echo suppressed. Same as passing `--invite` (see below).
50
+ - **3** — the **self-serve** flow, and the right answer if you have nothing yet. It
51
+ asks for an **email**, then a name for your new Bridge project, and creates the
52
+ workspace and your own admin API key for you. No account, no key, and no invite
53
+ needed beforehand. Same as passing `--email you@example.com` (see below).
54
+
55
+ There is **no default**: pressing Enter selects nothing. A blank or invalid answer
56
+ re-prompts once with a hint, then exits with guidance naming all three routes.
46
57
 
47
58
  That question is asked only for a *bare interactive* run. Passing any flag, setting
48
59
  `BAPI_API_KEY`, or running without an interactive terminal skips it and keeps the
@@ -51,11 +62,12 @@ existing deterministic behavior.
51
62
  From there `install-bridge` scaffolds the project, writes your editor's MCP config
52
63
  with real values, verifies connectivity, persists your API key to the user-scoped
53
64
  credential store, and opens a fresh agent session that runs `/install-bridge` to
54
- derive and apply the remaining config, presents a **capability report** (what you can
55
- use now and what you'll unlock), and closes by asking whether to index the
56
- repository. It does **not** automatically run `/learn-repository` or index without
57
- your consent both remain available as separate steps. Add `--dry-run` to preview
58
- every step without writing, pinging, or spawning anything.
65
+ derive and apply the remaining config, presents a concise **capability report**
66
+ ("What Bridge can help with"), and recommends `/learn-repository` as your next step.
67
+ It does **not** run `/learn-repository` itself that stays your next explicit
68
+ invocation. There is **no indexing question**: indexing starts automatically
69
+ server-side once the repository reaches full parse readiness. Add `--dry-run` to
70
+ preview every step without writing, pinging, or spawning anything.
59
71
 
60
72
  **Were you sent a bootstrap invite?** Then you don't need an API key or the web UI
61
73
  at all — run the command your operator gave you:
@@ -148,12 +160,30 @@ shown**.
148
160
  The email may instead come from the `BAPI_SIGNUP_EMAIL` environment variable or a
149
161
  **visible** interactive prompt (email is not a secret, so it is echoed as you type —
150
162
  unlike the API key and the invite token, which use a hidden prompt). That prompt is
151
- what answering **no** to `Do you have a Bridge API key or invite? [Y/n]` on a bare run reaches,
152
- so `install-bridge --email you@example.com` and a bare `install-bridge` + "no" land
153
- in the same place. The email is still **never written to a log line**. No email
163
+ what **option 3** of the bare-run chooser reaches, so
164
+ `install-bridge --email you@example.com` and a bare `install-bridge` + `3` land in
165
+ the same place. The email is still **never written to a log line**. No email
154
166
  verification is performed and no message is sent to the address — it only labels your
155
167
  new workspace. `--email` is mutually exclusive with `--api-key` and `--invite`.
156
168
 
169
+ ##### Self-serve retries resume automatically
170
+
171
+ A self-serve run that fails part-way through — a network blip on the exchange, a
172
+ failed connectivity check, an interrupted credential write — saves its signup state
173
+ under `bootstrap-pending:<repo>` in the credential store (mode `0600`, fsync'd, the
174
+ same record that already holds your `key_secret`).
175
+
176
+ **Just re-run the self-serve flow.** It detects that saved attempt, prints
177
+ `resuming your previous signup attempt for <repo>`, and re-drives the *same*
178
+ exchange — it does **not** sign up again, so a retry never creates a second
179
+ workspace. This is why the self-serve record stores the minted invite token: you
180
+ were never shown that token, so nothing else could re-present it.
181
+
182
+ Do **not** copy, display, or hand-remove that record. If the saved invite has
183
+ genuinely expired, the CLI says so and **asks for confirmation** before discarding
184
+ it and starting fresh — it never discards it silently, because a record whose
185
+ exchange already succeeded is the only trace of a live admin key.
186
+
157
187
  Because this flow *creates* the project, it asks you to **name a new project**
158
188
  (`Name your new Bridge project [<inferred>]: `) rather than to match an existing
159
189
  server-side registration. The name must be globally unique; if it's taken, you're
@@ -689,12 +719,14 @@ Per-OS prerequisites: macOS `wt`, `git`, `osascript`; Windows `git-wt`, Git for
689
719
 
690
720
  ### `doctor`
691
721
 
692
- The package also ships a strictly **read-only** `doctor` subcommand that diagnoses the `start-tickets` prerequisites for the current OS without changing anything:
722
+ The package also ships a strictly **read-only** `doctor` subcommand titled **`bridge doctor — read-only diagnostics`** that diagnoses your whole Bridge install without changing anything:
693
723
 
694
724
  ```
695
725
  npx -y @bridge_gpt/mcp-server doctor [--agent <name>]
696
726
  ```
697
727
 
728
+ The report always leads with the advisory **`Install status`** section (repo identity, credential resolution, server connectivity, bootstrap-field completeness, integration credentials, indexing state) **before** the `start-tickets` prerequisite diagnostics; the launcher-cache and MCP tool-surface sections follow. `Install status` is read-only GETs only and never affects the exit code.
729
+
698
730
  It is **read-only**: it never installs anything, modifies your system, adds an npm `postinstall`, spawns a terminal, or starts the MCP server, and there is no `--fix`. For each prerequisite it prints found/missing and, when missing, the exact per-OS install command **as a manual instruction you run yourself**. The checked set is the `start-tickets` preflight prerequisites **plus `uv`** **plus the selected agent's command** (`claude` by default, or `cursor-agent` with `--agent cursor-agent`). The Worktrunk binary is probed via the resolved name (honoring `BAPI_WORKTRUNK_BIN`), not a hard-coded one. **Exit code:** `0` when all required prerequisites are present, non-zero when any is missing or the platform is unsupported. A failing `start-tickets` preflight now hints you to run `doctor` for an actionable diagnostics report.
699
731
 
700
732
  ### `setup-epic`
@@ -10,7 +10,8 @@
10
10
  * creates or initializes credential files. The mutation primitives — the
11
11
  * best-effort {@link upsertBapiCredential} and the fail-closed bootstrap-invite
12
12
  * pending-state operations ({@link prepareBootstrapPendingCredential},
13
- * {@link repointBootstrapPendingCredential}, {@link promoteBootstrapPendingCredential})
13
+ * {@link repointBootstrapPendingCredential}, {@link promoteBootstrapPendingCredential},
14
+ * {@link discardBootstrapPendingCredential})
14
15
  * — are the ONLY writers: they mutate the user-scoped primary store from explicit
15
16
  * install/migration code paths, preserve every other entry, and never touch
16
17
  * project/worktree config files (those remain secret-free). Every mutation runs
@@ -489,12 +490,40 @@ export async function durablyReplaceCredentialStoreJson(primaryPath, value, deps
489
490
  * durably saved but NOT yet redeemed. It is a separate top-level key from
490
491
  * `bapi:<repo>`, so the resolver never serves an unredeemed secret as a
491
492
  * credential, and a failed redemption never leaves a broken `bapi:<repo>` entry.
493
+ *
494
+ * A pending record has TWO shapes (BAPI-667), distinguished only by whether
495
+ * {@link BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD} is present:
496
+ *
497
+ * - ORDINARY INVITE — `key_secret` + invite fingerprint. The token stays off
498
+ * disk because the user holds it and can re-present it on a retry.
499
+ * - SELF-SERVE — the same two fields PLUS the minted invite token, because the
500
+ * token was never shown to the user and therefore cannot be re-presented.
501
+ *
502
+ * Both shapes are read by the same helpers; the ordinary shape is the historical
503
+ * one and remains fully valid, so records written before BAPI-667 keep working.
492
504
  */
493
505
  export const BOOTSTRAP_PENDING_TARGET_PREFIX = "bootstrap-pending:";
494
506
  /** Secret name holding the client-generated `key_secret` inside a pending record. */
495
507
  export const BOOTSTRAP_PENDING_SECRET_FIELD = "BAPI_API_KEY";
496
508
  /** Secret name holding the invite FINGERPRINT (a SHA-256 hex digest, not the token). */
497
509
  export const BOOTSTRAP_PENDING_FINGERPRINT_FIELD = "BOOTSTRAP_INVITE_FINGERPRINT";
510
+ /**
511
+ * Secret name holding the INTERNALLY MINTED self-serve invite token (BAPI-667).
512
+ *
513
+ * SELF-SERVE RECORDS ONLY. An ordinary invite (`--invite` / `BAPI_INVITE` / an
514
+ * invite pasted into the ordinary credential input) is supplied BY the user, so a
515
+ * retry can re-present it and the record needs only the one-way fingerprint. A
516
+ * self-serve token is minted internally and never shown, so a fingerprint alone
517
+ * makes replay impossible BY CONSTRUCTION: every retry would mint a fresh token,
518
+ * present a fresh fingerprint, and hard-fail against its own pending record.
519
+ * Storing the token is what makes the self-serve arm retryable at all.
520
+ *
521
+ * This adds no new exposure class: the record already holds the strictly more
522
+ * sensitive `key_secret` and is written 0600 through the same locked, fsync'd
523
+ * {@link durablyReplaceCredentialStoreJson} path. Like every other value here it
524
+ * is NEVER logged, printed, or placed in an error string.
525
+ */
526
+ export const BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD = "BOOTSTRAP_SELF_SERVE_INVITE";
498
527
  /** Pending target for a repo: `bootstrap-pending:<repo>`. */
499
528
  export function getBootstrapPendingTarget(repoName) {
500
529
  return `${BOOTSTRAP_PENDING_TARGET_PREFIX}${(repoName ?? "").trim()}`;
@@ -521,7 +550,19 @@ function hasExistingBapiKey(store, repoName) {
521
550
  const entry = store[getBapiTarget(repoName)];
522
551
  return (!!entry && typeof entry.BAPI_API_KEY === "string" && entry.BAPI_API_KEY.trim().length > 0);
523
552
  }
524
- /** Read a pending record whose fingerprint matches; `null` when absent/mismatched. */
553
+ /** Trimmed string value of a pending-record field, or `""` when absent/blank. */
554
+ function readPendingField(entry, field) {
555
+ const value = entry?.[field];
556
+ return typeof value === "string" ? value.trim() : "";
557
+ }
558
+ /**
559
+ * Read a pending record whose fingerprint matches; `null` when absent/mismatched.
560
+ *
561
+ * `replayToken` is present ONLY for a self-serve record (BAPI-667). An ordinary
562
+ * invite record predates that field and legitimately omits it, so its absence is
563
+ * NEVER a validity problem here — it just means "not resumable without the user
564
+ * re-presenting the token", which is exactly the ordinary-invite contract.
565
+ */
525
566
  function readMatchingPending(store, repoName, inviteFingerprint) {
526
567
  const entry = store[getBootstrapPendingTarget(repoName)];
527
568
  if (!entry)
@@ -532,7 +573,30 @@ function readMatchingPending(store, repoName, inviteFingerprint) {
532
573
  return null;
533
574
  if (fingerprint !== inviteFingerprint)
534
575
  return null;
535
- return { keySecret: secret };
576
+ const replayToken = readPendingField(entry, BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD);
577
+ return replayToken.length > 0 ? { keySecret: secret, replayToken } : { keySecret: secret };
578
+ }
579
+ /**
580
+ * Build the pending-record entry written by prepare / repoint.
581
+ *
582
+ * Sibling secret names on the existing entry are preserved, but the replay-token
583
+ * field is set EXPLICITLY or removed — never inherited. Inheriting it would let a
584
+ * stale token from an earlier record ride along with a new `key_secret`,
585
+ * producing a record whose two halves belong to different redemptions.
586
+ */
587
+ function buildPendingEntry(existing, keySecret, inviteFingerprint, replayToken) {
588
+ const next = {
589
+ ...(existing ?? {}),
590
+ [BOOTSTRAP_PENDING_SECRET_FIELD]: keySecret,
591
+ [BOOTSTRAP_PENDING_FINGERPRINT_FIELD]: inviteFingerprint,
592
+ };
593
+ if (replayToken && replayToken.length > 0) {
594
+ next[BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD] = replayToken;
595
+ }
596
+ else {
597
+ delete next[BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD];
598
+ }
599
+ return next;
536
600
  }
537
601
  /**
538
602
  * Does `bootstrap-pending:<repo>` hold a secret belonging to a DIFFERENT invite?
@@ -560,8 +624,29 @@ function hasConflictingPending(store, repoName, inviteFingerprint) {
560
624
  return false;
561
625
  return entry[BOOTSTRAP_PENDING_FINGERPRINT_FIELD] !== inviteFingerprint;
562
626
  }
563
- /** One wording for the conflict, so `prepare` and `repoint` cannot drift apart. */
564
- function pendingConflictError(target, primaryPath) {
627
+ /** Does the stored (conflicting) pending record carry usable self-serve replay material? */
628
+ function storedPendingIsSelfServe(store, repoName) {
629
+ const entry = store[getBootstrapPendingTarget(repoName)];
630
+ return readPendingField(entry, BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD).length > 0;
631
+ }
632
+ /**
633
+ * One wording for the conflict, so `prepare` and `repoint` cannot drift apart.
634
+ *
635
+ * MODE-AWARE (BAPI-667). A conflicting record that carries self-serve replay
636
+ * material is recoverable WITHOUT the user touching the store: re-running the
637
+ * self-serve flow resumes it. Telling that user to "remove the entry by hand" is
638
+ * the single worst instruction available — if its exchange already succeeded, the
639
+ * record is the only trace of a live admin key. A genuine cross-invite conflict
640
+ * (an ordinary, fingerprint-only record) keeps the original fail-closed guidance,
641
+ * because there the CLI genuinely cannot replay it.
642
+ */
643
+ function pendingConflictError(target, primaryPath, storedIsSelfServe) {
644
+ if (storedIsSelfServe) {
645
+ return (`A pending self-serve signup for a DIFFERENT invite already exists at ${target} in ` +
646
+ `${primaryPath}. It is the only proof that can replay that signup, so it will not be ` +
647
+ "overwritten. Re-run install-bridge and choose the email option — it will resume that " +
648
+ "signup automatically. Do NOT remove the entry by hand.");
649
+ }
565
650
  return (`A pending bootstrap-invite credential for a DIFFERENT invite already exists at ${target} ` +
566
651
  `in ${primaryPath}. It is the only proof that can replay that redemption, so it will not be ` +
567
652
  "overwritten. Complete that redemption first, or — only if you are certain its invite was " +
@@ -579,12 +664,20 @@ function pendingConflictError(target, primaryPath) {
579
664
  * A fresh record is written through {@link durablyReplaceCredentialStoreJson}, so
580
665
  * a failed write or fsync produces a failure result and NO success value — the
581
666
  * caller cannot proceed to the exchange with a secret that is not on disk.
667
+ *
668
+ * BAPI-667: an optional {@link PrepareBootstrapPendingParams.selfServeReplayToken}
669
+ * is persisted in the SAME durable write, making the record self-serve-resumable.
670
+ * Omitting it (every `--invite` caller) preserves the fingerprint-only record shape
671
+ * exactly as before.
582
672
  */
583
673
  export async function prepareBootstrapPendingCredential(params, deps) {
584
674
  const primaryPath = getPrimaryCredentialStorePath(deps);
585
675
  const repoName = (params.repoName ?? "").trim();
586
676
  const fingerprint = (params.inviteFingerprint ?? "").trim();
587
677
  const target = getBootstrapPendingTarget(repoName);
678
+ // `undefined` = ordinary invite (fingerprint-only). A supplied value is trimmed
679
+ // and validated below; its CONTENTS never reach an error string.
680
+ const replayToken = params.selfServeReplayToken === undefined ? undefined : params.selfServeReplayToken.trim();
588
681
  if (repoName.length === 0) {
589
682
  return {
590
683
  ok: false,
@@ -603,6 +696,15 @@ export async function prepareBootstrapPendingCredential(params, deps) {
603
696
  error: "Cannot prepare a bootstrap-invite credential: the invite fingerprint was empty.",
604
697
  };
605
698
  }
699
+ if (replayToken !== undefined && replayToken.length === 0) {
700
+ return {
701
+ ok: false,
702
+ path: primaryPath,
703
+ target,
704
+ kind: "invalid-replay-token",
705
+ error: "Cannot prepare a self-serve bootstrap credential: the supplied replay material was empty.",
706
+ };
707
+ }
606
708
  return withCredentialStoreLock(deps, async () => {
607
709
  const loaded = await loadStoreForMutation(deps);
608
710
  if (!loaded.ok) {
@@ -622,10 +724,24 @@ export async function prepareBootstrapPendingCredential(params, deps) {
622
724
  };
623
725
  }
624
726
  const existing = readMatchingPending(base, repoName, fingerprint);
625
- if (existing) {
727
+ if (existing && (replayToken === undefined || existing.replayToken === replayToken)) {
626
728
  // Reuse verbatim — this exact value is the replay proof. Do not regenerate.
627
729
  return { ok: true, path: primaryPath, target, keySecret: existing.keySecret, reused: true };
628
730
  }
731
+ if (existing) {
732
+ // Same invite + same repo, but the record predates (or lost) its replay
733
+ // material. Keep the `key_secret` — it is still the replay proof — and
734
+ // durably attach the token so the NEXT retry can resume without minting.
735
+ const upgraded = {
736
+ ...base,
737
+ [target]: buildPendingEntry(base[target], existing.keySecret, fingerprint, replayToken),
738
+ };
739
+ const rewritten = await durablyReplaceCredentialStoreJson(primaryPath, upgraded, deps);
740
+ if (!rewritten.ok) {
741
+ return { ok: false, path: primaryPath, target, kind: rewritten.kind, error: rewritten.error };
742
+ }
743
+ return { ok: true, path: primaryPath, target, keySecret: existing.keySecret, reused: true };
744
+ }
629
745
  // Not ours, but someone's: redeeming a second invite against a repo that
630
746
  // still holds a pending record for a first one would overwrite that record —
631
747
  // and if the first exchange already succeeded, its admin key becomes
@@ -636,17 +752,13 @@ export async function prepareBootstrapPendingCredential(params, deps) {
636
752
  path: primaryPath,
637
753
  target,
638
754
  kind: "pending-conflict",
639
- error: pendingConflictError(target, primaryPath),
755
+ error: pendingConflictError(target, primaryPath, storedPendingIsSelfServe(base, repoName)),
640
756
  };
641
757
  }
642
758
  const keySecret = params.generateKeySecret();
643
759
  const next = {
644
760
  ...base,
645
- [target]: {
646
- ...(base[target] ?? {}),
647
- [BOOTSTRAP_PENDING_SECRET_FIELD]: keySecret,
648
- [BOOTSTRAP_PENDING_FINGERPRINT_FIELD]: fingerprint,
649
- },
761
+ [target]: buildPendingEntry(base[target], keySecret, fingerprint, replayToken),
650
762
  };
651
763
  const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
652
764
  if (!written.ok) {
@@ -716,16 +828,16 @@ export async function repointBootstrapPendingCredential(params, deps) {
716
828
  path: primaryPath,
717
829
  target,
718
830
  kind: "pending-conflict",
719
- error: pendingConflictError(target, primaryPath),
831
+ error: pendingConflictError(target, primaryPath, storedPendingIsSelfServe(base, toRepo)),
720
832
  };
721
833
  }
722
834
  const next = { ...base };
723
835
  delete next[getBootstrapPendingTarget(fromRepo)];
724
- next[target] = {
725
- ...(destination ?? {}),
726
- [BOOTSTRAP_PENDING_SECRET_FIELD]: pending.keySecret,
727
- [BOOTSTRAP_PENDING_FINGERPRINT_FIELD]: fingerprint,
728
- };
836
+ // BAPI-667: the replay token moves WITH the `key_secret`. A rename that kept
837
+ // the secret but dropped the token would silently downgrade a resumable
838
+ // self-serve record into an unreplayable one at exactly the moment (a 409
839
+ // rename loop) a retry is most likely.
840
+ next[target] = buildPendingEntry(destination, pending.keySecret, fingerprint, pending.replayToken);
729
841
  const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
730
842
  if (!written.ok) {
731
843
  return { ok: false, path: primaryPath, target, kind: written.kind, error: written.error };
@@ -744,6 +856,11 @@ export async function repointBootstrapPendingCredential(params, deps) {
744
856
  *
745
857
  * A failed promotion leaves the pending record intact, so the same replay proof is
746
858
  * still available to a later run.
859
+ *
860
+ * BAPI-667: the pending record is removed WHOLE, so a self-serve replay token is
861
+ * discarded here rather than copied forward. Only the stored `key_secret` becomes
862
+ * `bapi:<repo>.BAPI_API_KEY` — the token has served its only purpose (replaying an
863
+ * unfinished exchange) and a completed redemption must not leave it on disk.
747
864
  */
748
865
  export async function promoteBootstrapPendingCredential(params, deps) {
749
866
  const primaryPath = getPrimaryCredentialStorePath(deps);
@@ -796,6 +913,173 @@ export async function promoteBootstrapPendingCredential(params, deps) {
796
913
  return { ok: true, path: primaryPath, target, action: hadKey ? "updated" : "created" };
797
914
  }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
798
915
  }
916
+ /**
917
+ * Look up the SELF-SERVE pending record for a repo (BAPI-667) — the read that
918
+ * makes a self-serve retry resume instead of minting a second workspace.
919
+ *
920
+ * SECRET-BEARING on `state: "resumable"`: the result carries the stored invite
921
+ * token AND the stored `key_secret`. Neither may be logged, printed, embedded in
922
+ * an error, or shown in a dry-run preview; they exist only to be re-POSTed to the
923
+ * exchange. Every failure variant is deliberately secret-free — it names the
924
+ * logical target and the store path and nothing else.
925
+ *
926
+ * States:
927
+ * - `none` — no record, or a record with nothing at stake (no usable secret and
928
+ * no replay material). The caller mints normally.
929
+ * - `resumable` — a complete self-serve record. The caller MUST reuse it and MUST
930
+ * NOT mint.
931
+ * - `pending-not-self-serve` — a real pending record with a `key_secret` but no
932
+ * replay material: an ordinary `--invite` redemption in flight. Not ours to
933
+ * touch.
934
+ * - `pending-malformed` — replay material present but the record is incomplete
935
+ * (missing `key_secret` or fingerprint). FAIL CLOSED: a half-written record may
936
+ * still correspond to a live admin key, so it must never be silently discarded
937
+ * or trigger a fresh mint.
938
+ * - store failures (`read-error` / `parse-error` / `lock-error`) — propagated so
939
+ * an unreadable store never masquerades as "no record, safe to mint".
940
+ */
941
+ export async function lookupSelfServeBootstrapPendingCredential(params, deps) {
942
+ const primaryPath = getPrimaryCredentialStorePath(deps);
943
+ const repoName = (params.repoName ?? "").trim();
944
+ const target = getBootstrapPendingTarget(repoName);
945
+ if (repoName.length === 0) {
946
+ return {
947
+ ok: false,
948
+ path: primaryPath,
949
+ target,
950
+ kind: "invalid-repo",
951
+ error: "Cannot look up a bootstrap-invite credential: a non-empty repo name is required.",
952
+ };
953
+ }
954
+ // Read under the SHARED LOCK, even though the store is replaced by an atomic
955
+ // rename and a lock-free read could never see a half-written file. The lock is
956
+ // about the read-then-act window, not the read: without it a second install
957
+ // running concurrently for the same repo could observe the pending record an
958
+ // instant before the first run promotes it, then replay an exchange for a
959
+ // redemption that has already completed. Serializing against prepare / repoint /
960
+ // promote makes the record either wholly present or wholly gone.
961
+ //
962
+ // A lock failure is therefore FAIL-CLOSED: it is reported rather than degraded
963
+ // into `state: "none"`, because "I could not check" must never be answered by
964
+ // minting a second workspace.
965
+ return withCredentialStoreLock(deps, async () => {
966
+ const loaded = await loadStoreForMutation(deps);
967
+ if (!loaded.ok) {
968
+ return { ok: false, path: primaryPath, target, kind: loaded.kind, error: loaded.error };
969
+ }
970
+ const entry = loaded.base[target];
971
+ const keySecret = readPendingField(entry, BOOTSTRAP_PENDING_SECRET_FIELD);
972
+ const fingerprint = readPendingField(entry, BOOTSTRAP_PENDING_FINGERPRINT_FIELD);
973
+ const replayToken = readPendingField(entry, BOOTSTRAP_PENDING_REPLAY_TOKEN_FIELD);
974
+ if (replayToken.length === 0) {
975
+ // No replay material: either nothing is stored, or an ordinary invite
976
+ // redemption owns this target. Only the latter is a conflict.
977
+ if (keySecret.length === 0) {
978
+ return { ok: true, state: "none", path: primaryPath, target };
979
+ }
980
+ return {
981
+ ok: false,
982
+ path: primaryPath,
983
+ target,
984
+ kind: "pending-not-self-serve",
985
+ error: `A pending bootstrap-invite credential already exists at ${target} in ${primaryPath}. ` +
986
+ "It belongs to an invite redemption, not a self-serve signup, so this run will not " +
987
+ "touch it. Finish that redemption by re-running install-bridge with the same invite.",
988
+ };
989
+ }
990
+ if (keySecret.length === 0 || fingerprint.length === 0) {
991
+ return {
992
+ ok: false,
993
+ path: primaryPath,
994
+ target,
995
+ kind: "pending-malformed",
996
+ error: `The pending self-serve record at ${target} in ${primaryPath} is incomplete, so it ` +
997
+ "cannot be replayed and will not be discarded automatically (it may correspond to a " +
998
+ "key that was already created). Ask your Bridge API operator to recover it.",
999
+ };
1000
+ }
1001
+ return {
1002
+ ok: true,
1003
+ state: "resumable",
1004
+ path: primaryPath,
1005
+ target,
1006
+ keySecret,
1007
+ replayToken,
1008
+ inviteFingerprint: fingerprint,
1009
+ };
1010
+ }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
1011
+ }
1012
+ /**
1013
+ * Discard a SELF-SERVE pending record (BAPI-667) — the consent-gated exit from a
1014
+ * signup whose stored invite the server has conclusively rejected (401).
1015
+ *
1016
+ * This is the ONLY destructive operation on a pending record other than promotion,
1017
+ * so it is guarded three ways: the record must exist, its fingerprint must match
1018
+ * what the caller expects, and it must carry self-serve replay material. Those
1019
+ * guards are what keep it from becoming the `--force` that BAPI-606 deliberately
1020
+ * refused to add — an ordinary invite's record can never be reached through here,
1021
+ * and neither can a self-serve record the caller has not actually replayed.
1022
+ *
1023
+ * Returns secret-free path/target metadata only.
1024
+ */
1025
+ export async function discardBootstrapPendingCredential(params, deps) {
1026
+ const primaryPath = getPrimaryCredentialStorePath(deps);
1027
+ const repoName = (params.repoName ?? "").trim();
1028
+ const fingerprint = (params.inviteFingerprint ?? "").trim();
1029
+ const target = getBootstrapPendingTarget(repoName);
1030
+ if (repoName.length === 0) {
1031
+ return {
1032
+ ok: false,
1033
+ path: primaryPath,
1034
+ target,
1035
+ kind: "invalid-repo",
1036
+ error: "Cannot discard a bootstrap-invite credential: a non-empty repo name is required.",
1037
+ };
1038
+ }
1039
+ if (fingerprint.length === 0) {
1040
+ return {
1041
+ ok: false,
1042
+ path: primaryPath,
1043
+ target,
1044
+ kind: "invalid-fingerprint",
1045
+ error: "Cannot discard a bootstrap-invite credential: the invite fingerprint was empty.",
1046
+ };
1047
+ }
1048
+ return withCredentialStoreLock(deps, async () => {
1049
+ const loaded = await loadStoreForMutation(deps);
1050
+ if (!loaded.ok) {
1051
+ return { ok: false, path: primaryPath, target, kind: loaded.kind, error: loaded.error };
1052
+ }
1053
+ const base = loaded.base;
1054
+ const pending = readMatchingPending(base, repoName, fingerprint);
1055
+ if (!pending) {
1056
+ return {
1057
+ ok: false,
1058
+ path: primaryPath,
1059
+ target,
1060
+ kind: "pending-missing",
1061
+ error: `No matching pending bootstrap-invite credential for ${target} in ${primaryPath}.`,
1062
+ };
1063
+ }
1064
+ if (!pending.replayToken) {
1065
+ return {
1066
+ ok: false,
1067
+ path: primaryPath,
1068
+ target,
1069
+ kind: "pending-not-self-serve",
1070
+ error: `The pending record at ${target} in ${primaryPath} carries no self-serve replay ` +
1071
+ "material, so it will not be discarded automatically.",
1072
+ };
1073
+ }
1074
+ const next = { ...base };
1075
+ delete next[target];
1076
+ const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
1077
+ if (!written.ok) {
1078
+ return { ok: false, path: primaryPath, target, kind: written.kind, error: written.error };
1079
+ }
1080
+ return { ok: true, path: primaryPath, target };
1081
+ }, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
1082
+ }
799
1083
  /**
800
1084
  * Upsert `BAPI_API_KEY` for `bapi:<repoName>` into the user-scoped PRIMARY
801
1085
  * credential store, atomically (temp file + rename) and secret-safely.