@bridge_gpt/mcp-server 0.2.23 → 0.2.25

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.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Bridge API access + polling primitives for the sessionless GitHub connect flow
3
+ * (BAPI-631).
4
+ *
5
+ * Split out from `connect-github.ts` so the standalone command and `install-bridge`
6
+ * share one implementation of the request contract, the lifecycle vocabulary, and the
7
+ * polling policy — rather than each growing its own slightly-different copy.
8
+ *
9
+ * Two rules shape everything here:
10
+ *
11
+ * 1. **Nothing secret is ever returned in a message.** Failures collapse to coarse,
12
+ * enumerated categories. Fetch exception text, response bodies, headers, the state
13
+ * nonce, the install URL, and the API key never reach a string a caller might print.
14
+ * 2. **A malformed success is a failure.** Responses are validated against explicit
15
+ * unions; a 200 whose body does not match is rejected rather than guessed at, so a
16
+ * server change can never be silently reinterpreted as a connection outcome.
17
+ */
18
+ /** Statuses that end the flow — polling past one of these is pointless. */
19
+ const TERMINAL_STATUSES = new Set([
20
+ "staged",
21
+ "awaiting-organization-approval",
22
+ "connected",
23
+ "expired",
24
+ "invalid",
25
+ "verification-failed",
26
+ "no-repositories",
27
+ "conflict",
28
+ "failed",
29
+ ]);
30
+ const ALL_STATUSES = new Set([
31
+ "waiting",
32
+ ...TERMINAL_STATUSES,
33
+ ]);
34
+ /** Per-request timeout. Generous enough for a cold server, short enough to retry. */
35
+ const REQUEST_TIMEOUT_MS = 15_000;
36
+ /**
37
+ * POST JSON to a Bridge endpoint.
38
+ *
39
+ * The state nonce travels in the BODY, never the query string: query strings land in
40
+ * server access logs, proxy logs, and browser history in a way request bodies do not.
41
+ */
42
+ async function postJson(deps, path, payload) {
43
+ let resp;
44
+ try {
45
+ resp = await deps.fetch(`${deps.baseUrl}${path}`, {
46
+ method: "POST",
47
+ headers: {
48
+ "Content-Type": "application/json",
49
+ "X-API-Key": deps.apiKey,
50
+ },
51
+ body: JSON.stringify(payload),
52
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
53
+ });
54
+ }
55
+ catch (e) {
56
+ // Deliberately does not forward `e`: fetch exception text can echo the URL, which
57
+ // carries the state nonce.
58
+ const isTimeout = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError");
59
+ return { ok: false, kind: isTimeout ? "timeout" : "network" };
60
+ }
61
+ let body = null;
62
+ try {
63
+ body = await resp.json();
64
+ }
65
+ catch {
66
+ /* Non-JSON body. The status still classifies the outcome. */
67
+ }
68
+ return {
69
+ ok: true,
70
+ value: { status: resp.status, body, retryAfter: resp.headers.get("Retry-After") },
71
+ };
72
+ }
73
+ /** Map a non-2xx status to a coarse category. */
74
+ function classifyStatus(status) {
75
+ if (status === 401 || status === 403)
76
+ return "unauthorized";
77
+ if (status === 404)
78
+ return "not-found";
79
+ return "server";
80
+ }
81
+ function asRecord(value) {
82
+ return value && typeof value === "object" && !Array.isArray(value)
83
+ ? value
84
+ : null;
85
+ }
86
+ function asString(value) {
87
+ return typeof value === "string" && value.length > 0 ? value : null;
88
+ }
89
+ function asNullableString(value) {
90
+ return typeof value === "string" && value.length > 0 ? value : null;
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // Retry-After
94
+ // ---------------------------------------------------------------------------
95
+ /**
96
+ * Parse a `Retry-After` header to milliseconds.
97
+ *
98
+ * Handles both RFC forms — delta-seconds and an HTTP-date — and clamps the result to
99
+ * `remainingMs` so a server (or a bogus far-future date) can never push a wait past the
100
+ * caller's own deadline. Returns null for absent/unparseable/negative values.
101
+ */
102
+ export function parseRetryAfterMs(header, remainingMs, nowMs) {
103
+ if (!header)
104
+ return null;
105
+ const trimmed = header.trim();
106
+ if (!trimmed)
107
+ return null;
108
+ const clamp = (ms) => {
109
+ if (!Number.isFinite(ms) || ms < 0)
110
+ return null;
111
+ return Math.min(ms, Math.max(0, remainingMs));
112
+ };
113
+ // delta-seconds
114
+ if (/^\d+$/.test(trimmed)) {
115
+ return clamp(Number(trimmed) * 1_000);
116
+ }
117
+ // HTTP-date
118
+ const parsed = Date.parse(trimmed);
119
+ if (Number.isNaN(parsed))
120
+ return null;
121
+ return clamp(parsed - nowMs);
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Endpoints
125
+ // ---------------------------------------------------------------------------
126
+ /** Mint a CLI-origin connection code and its install URL. */
127
+ export async function mintGithubConnection(deps, repoName) {
128
+ const res = await postJson(deps, "/setup/github/cli/connection-code", {
129
+ repo_name: repoName,
130
+ });
131
+ if (!res.ok)
132
+ return res;
133
+ if (res.value.status !== 200) {
134
+ return { ok: false, kind: classifyStatus(res.value.status) };
135
+ }
136
+ const body = asRecord(res.value.body);
137
+ const state = asString(body?.state);
138
+ const installUrl = asString(body?.install_url);
139
+ const ttlSeconds = body?.ttl_seconds;
140
+ if (!body || !state || !installUrl || typeof ttlSeconds !== "number") {
141
+ return { ok: false, kind: "malformed" };
142
+ }
143
+ return { ok: true, value: { state, installUrl, ttlSeconds } };
144
+ }
145
+ function parseCandidates(value) {
146
+ if (!Array.isArray(value))
147
+ return null;
148
+ const out = [];
149
+ for (const raw of value) {
150
+ const rec = asRecord(raw);
151
+ const id = asString(rec?.github_repository_id);
152
+ const name = asString(rec?.github_repo_name);
153
+ // A candidate without a usable identity is a malformed response, not a candidate to
154
+ // silently drop — dropping it would show the user an incomplete picker.
155
+ if (!rec || !id || !name)
156
+ return null;
157
+ out.push({
158
+ github_repository_id: id,
159
+ github_repo_name: name,
160
+ github_repo_full_name: asNullableString(rec.github_repo_full_name),
161
+ owner: asNullableString(rec.owner),
162
+ });
163
+ }
164
+ return out;
165
+ }
166
+ /** Read one connection code's current lifecycle state. */
167
+ export async function fetchGithubConnectionStatus(deps, repoName, state, nowMs = Date.now()) {
168
+ const res = await postJson(deps, "/setup/github/cli/status", {
169
+ repo_name: repoName,
170
+ state,
171
+ });
172
+ if (!res.ok)
173
+ return res;
174
+ if (res.value.status !== 200) {
175
+ return { ok: false, kind: classifyStatus(res.value.status) };
176
+ }
177
+ const body = asRecord(res.value.body);
178
+ const status = asString(body?.status);
179
+ if (!body || !status || !ALL_STATUSES.has(status)) {
180
+ return { ok: false, kind: "malformed" };
181
+ }
182
+ const candidates = parseCandidates(body.candidates ?? []);
183
+ if (candidates === null)
184
+ return { ok: false, kind: "malformed" };
185
+ return {
186
+ ok: true,
187
+ value: {
188
+ status: status,
189
+ candidates,
190
+ githubRepoName: asNullableString(body.github_repo_name),
191
+ // A single read has no deadline of its own to clamp against; the poller applies
192
+ // its own bound. POLL_DEADLINE_MS is the widest a caller could honor.
193
+ retryAfterMs: parseRetryAfterMs(res.value.retryAfter, POLL_DEADLINE_MS, nowMs),
194
+ },
195
+ };
196
+ }
197
+ /** Bind one chosen repository. Sends no installation id — the server owns that. */
198
+ export async function confirmGithubConnection(deps, repoName, state, githubRepositoryId) {
199
+ const res = await postJson(deps, "/setup/github/cli/confirm", {
200
+ repo_name: repoName,
201
+ state,
202
+ github_repository_id: githubRepositoryId,
203
+ });
204
+ if (!res.ok)
205
+ return res;
206
+ if (res.value.status !== 200) {
207
+ return { ok: false, kind: classifyStatus(res.value.status) };
208
+ }
209
+ const body = asRecord(res.value.body);
210
+ const name = asString(body?.github_repo_name);
211
+ if (!body || body.status !== "connected" || !name) {
212
+ return { ok: false, kind: "malformed" };
213
+ }
214
+ return {
215
+ ok: true,
216
+ value: {
217
+ githubRepoName: name,
218
+ githubRepoFullName: asNullableString(body.github_repo_full_name),
219
+ },
220
+ };
221
+ }
222
+ /**
223
+ * Read whether GitHub is already configured for a project.
224
+ *
225
+ * Uses the existing authenticated install-manifest/capability surface — read-only, and
226
+ * unrelated to any in-flight connection attempt. `unavailable` is a real answer and is
227
+ * deliberately distinct from `unconfigured`: callers must be able to skip rather than
228
+ * fabricate "not configured" from a probe that simply failed.
229
+ */
230
+ export async function fetchGithubConfigurationState(deps, repoName) {
231
+ let resp;
232
+ try {
233
+ resp = await deps.fetch(`${deps.baseUrl}/jira/config/install-manifest?repo_name=${encodeURIComponent(repoName)}`, {
234
+ headers: { "X-API-Key": deps.apiKey },
235
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
236
+ });
237
+ }
238
+ catch {
239
+ return "unavailable";
240
+ }
241
+ if (resp.status !== 200)
242
+ return "unavailable";
243
+ let body;
244
+ try {
245
+ body = await resp.json();
246
+ }
247
+ catch {
248
+ return "unavailable";
249
+ }
250
+ const integrations = asRecord(body)?.integrations;
251
+ if (!Array.isArray(integrations))
252
+ return "unavailable";
253
+ for (const raw of integrations) {
254
+ const rec = asRecord(raw);
255
+ if (rec?.id !== "github_app")
256
+ continue;
257
+ if (typeof rec.is_configured !== "boolean")
258
+ return "unavailable";
259
+ return rec.is_configured ? "configured" : "unconfigured";
260
+ }
261
+ // The checklist is provider-scoped: GitHub is legitimately absent for a Bitbucket
262
+ // project. Absent is not "unconfigured" — there is nothing here to connect.
263
+ return "unavailable";
264
+ }
265
+ /**
266
+ * Slightly longer than the server's 15-minute code TTL, so an expiry is reported by the
267
+ * server as `expired` rather than guessed at by a local timeout.
268
+ */
269
+ export const POLL_DEADLINE_MS = 15.5 * 60 * 1_000;
270
+ /** Back off gently: quick early (the common case is fast), then settle. */
271
+ const POLL_DELAYS_MS = [2_000, 3_000, 5_000];
272
+ const MAX_JITTER_MS = 400;
273
+ /**
274
+ * TRANSPORT faults worth retrying — the request never produced a response at all.
275
+ *
276
+ * Deliberately does NOT list "server": `postJson` only ever reports these two kinds,
277
+ * because any HTTP response (500 included) comes back as `ok: true` with a status. HTTP
278
+ * statuses are classified by {@link isRetryableStatus} instead; conflating the two is
279
+ * what previously let a plain 500 kill a poll.
280
+ */
281
+ const RETRYABLE_TRANSPORT = new Set([
282
+ "network",
283
+ "timeout",
284
+ ]);
285
+ /**
286
+ * Is this HTTP status worth retrying rather than surfacing?
287
+ *
288
+ * Every 5xx, not just 502/503: a 500 from a transient server-side hiccup (the /cli/status
289
+ * handler raises a bare 500 on a DB read failure) and a 504 gateway timeout are exactly
290
+ * as transient as a dropped packet, and the user's alternative is restarting the whole
291
+ * mint → browser → poll flow. 429 is retried because it is literally a request to retry.
292
+ * 4xx is terminal: the request itself is the problem, so repeating it cannot help.
293
+ */
294
+ function isRetryableStatus(status) {
295
+ return status === 429 || status >= 500;
296
+ }
297
+ /**
298
+ * Poll until a terminal status, an unrecoverable failure, or the deadline.
299
+ *
300
+ * Terminal statuses return immediately — including the failure ones. Waiting out a
301
+ * 15-minute deadline on a code the server already called `expired` would be theatre.
302
+ *
303
+ * Transient transport faults (network blips, timeouts, 502/503) are retried, because a
304
+ * dropped packet is not a failed connection. `Retry-After` is honored when the server
305
+ * sends it, including on a 429.
306
+ */
307
+ export async function pollGithubConnection(deps, poll, repoName, state) {
308
+ const started = poll.now();
309
+ let attempt = 0;
310
+ for (;;) {
311
+ const elapsed = poll.now() - started;
312
+ const remaining = POLL_DEADLINE_MS - elapsed;
313
+ if (remaining <= 0)
314
+ return { ok: false, kind: "deadline" };
315
+ const res = await postJson(deps, "/setup/github/cli/status", {
316
+ repo_name: repoName,
317
+ state,
318
+ });
319
+ let waitMs = null;
320
+ if (!res.ok) {
321
+ if (!RETRYABLE_TRANSPORT.has(res.kind))
322
+ return { ok: false, kind: res.kind };
323
+ }
324
+ else if (res.value.status === 200) {
325
+ const body = asRecord(res.value.body);
326
+ const status = asString(body?.status);
327
+ if (!body || !status || !ALL_STATUSES.has(status)) {
328
+ return { ok: false, kind: "malformed" };
329
+ }
330
+ const candidates = parseCandidates(body.candidates ?? []);
331
+ if (candidates === null)
332
+ return { ok: false, kind: "malformed" };
333
+ const typed = status;
334
+ if (TERMINAL_STATUSES.has(typed)) {
335
+ return {
336
+ ok: true,
337
+ value: {
338
+ status: typed,
339
+ candidates,
340
+ githubRepoName: asNullableString(body.github_repo_name),
341
+ retryAfterMs: null,
342
+ },
343
+ };
344
+ }
345
+ // `waiting` — honor server pacing if offered.
346
+ waitMs = parseRetryAfterMs(res.value.retryAfter, remaining, poll.now());
347
+ }
348
+ else if (isRetryableStatus(res.value.status)) {
349
+ waitMs = parseRetryAfterMs(res.value.retryAfter, remaining, poll.now());
350
+ }
351
+ else {
352
+ return { ok: false, kind: classifyStatus(res.value.status) };
353
+ }
354
+ if (waitMs === null) {
355
+ const base = POLL_DELAYS_MS[Math.min(attempt, POLL_DELAYS_MS.length - 1)];
356
+ waitMs = base + Math.floor(poll.jitter() * MAX_JITTER_MS);
357
+ }
358
+ attempt += 1;
359
+ // Never sleep past the deadline.
360
+ const capped = Math.min(waitMs, Math.max(0, POLL_DEADLINE_MS - (poll.now() - started)));
361
+ if (capped <= 0)
362
+ return { ok: false, kind: "deadline" };
363
+ await poll.sleep(capped);
364
+ }
365
+ }