@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 +53 -21
- package/build/credential-store.js +302 -18
- package/build/doctor.js +80 -35
- package/build/index.js +17 -16
- package/build/install-bridge.js +1477 -440
- package/build/install-doctor.js +37 -4
- package/build/readme.generated.js +1 -1
- package/build/start-tickets.js +12 -1
- package/build/version.generated.js +1 -1
- package/package.json +3 -2
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
|
-
**`
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- **
|
|
43
|
-
Bridge
|
|
44
|
-
|
|
45
|
-
|
|
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**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
564
|
-
function
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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.
|