@browserstack/mcp-server 2.2.0 → 2.2.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/capability/tm.capability-index.json +2 -1
  2. package/capability/tra.capability-index.json +31709 -0
  3. package/dist/lib/apiClient.d.ts +2 -1
  4. package/dist/lib/apiClient.js +8 -1
  5. package/dist/lib/central-oauth.d.ts +133 -0
  6. package/dist/lib/central-oauth.js +246 -0
  7. package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
  8. package/dist/tools/ask-browserstack/central-oauth.js +55 -177
  9. package/dist/tools/capability-registry/bind.js +43 -7
  10. package/dist/tools/capability-registry/discovery.js +3 -1
  11. package/dist/tools/capability-registry/egress.d.ts +12 -5
  12. package/dist/tools/capability-registry/egress.js +24 -1
  13. package/dist/tools/capability-registry/oauth.d.ts +63 -0
  14. package/dist/tools/capability-registry/oauth.js +176 -0
  15. package/dist/tools/capability-registry/register.js +289 -41
  16. package/dist/tools/capability-registry/resolve.d.ts +1 -1
  17. package/dist/tools/capability-registry/resolve.js +24 -3
  18. package/dist/tools/capability-registry/routing-token.d.ts +25 -0
  19. package/dist/tools/capability-registry/routing-token.js +87 -0
  20. package/dist/tools/capability-registry/search.d.ts +28 -1
  21. package/dist/tools/capability-registry/search.js +202 -4
  22. package/dist/tools/capability-registry/types.d.ts +42 -3
  23. package/dist/tools/failurelogs-utils/app-automate.js +4 -1
  24. package/dist/tools/failurelogs-utils/automate.js +5 -2
  25. package/dist/tools/failurelogs-utils/utils.d.ts +1 -0
  26. package/dist/tools/failurelogs-utils/utils.js +7 -0
  27. package/dist/tools/testmanagement-utils/TCG-utils/helpers.js +1 -1
  28. package/dist/tools/testmanagement.js +5 -5
  29. package/dist/tools/tool-handoff.d.ts +15 -12
  30. package/dist/tools/tool-handoff.js +21 -18
  31. package/package.json +2 -1
@@ -1,5 +1,15 @@
1
1
  /**
2
- * Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
2
+ * Atlas's view of the central-OAuth mint.
3
+ *
4
+ * The MECHANISM — the form, the transport, the classification, the cache — moved to
5
+ * `src/lib/central-oauth.ts` when the capability registry needed the same exchange for
6
+ * products whose APIs take a Bearer JWT rather than `Api-Token`. Two minters would have
7
+ * drifted, and the one that drifted would have been the one with fewer tests.
8
+ *
9
+ * What stays here is everything ATLAS-SPECIFIC and nothing else: the scope pair, the skew
10
+ * that exists because Atlas holds the token for a whole run, and the messages — which say
11
+ * "nothing reached the agent" and name `ai_agent_notify`, and would be actively wrong in
12
+ * front of a capability-registry user.
3
13
  *
4
14
  * This replaces a shared delegation token, and the upgrade is not cosmetic.
5
15
  * `validate_delegation_token` refuses any token without `user.user_id`/`user.group_id`, so
@@ -7,24 +17,20 @@
7
17
  * user from signed claims rather than from anything we put in the request body, and reuses
8
18
  * this same JWT as its `egress_token` — so the product call a human approves runs as that
9
19
  * human, not as a shared service account.
10
- *
11
- * SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
12
- * it the hard way: "The body can echo the credential back on some errors, so it is NOT
13
- * logged or raised — only the status." Neither the access key nor the minted token is ever
14
- * logged, returned, or put in an error message. Only a status code is.
15
20
  */
16
- import { createHash } from "node:crypto";
17
- import { apiClient } from "../../lib/apiClient.js";
21
+ import { CentralAuthError, mintCentralToken as mintShared, mintForm as mintFormShared, REQUESTED_EXPIRES_IN, } from "../../lib/central-oauth.js";
18
22
  import appConfig from "../../config.js";
19
23
  import logger from "../../logger.js";
20
24
  import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
25
+ export { fetchTokenTransport, refusalIsAboutScope, REQUESTED_EXPIRES_IN, resetTokenCache, TOKEN_TIMEOUT_MS, } from "../../lib/central-oauth.js";
21
26
  /**
22
27
  * BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE.
23
28
  *
24
29
  * `oauth_user_profile` stays because it is what makes the pair obtainable through the
25
- * username+access_key flow at all. `ai_agent_notify` is what Atlas matches on
26
- * (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim in
27
- * `web/oauth.py`); both halves move together with Atlas.
30
+ * username+access_key flow at all — measured, not assumed: the paired scope mints, and the
31
+ * other half alone comes back `400 invalid_request`. `ai_agent_notify` is what Atlas matches
32
+ * on (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim
33
+ * in `web/oauth.py`); both halves move together with Atlas.
28
34
  *
29
35
  * THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than
30
36
  * discovering. From the merged `browserstack/railsApp#175367` (2026-08-24):
@@ -46,10 +52,11 @@ import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
46
52
  * application — and it is reported as one, naming the scope. It is never retried with a
47
53
  * different scope: a silent downgrade to a different authorization is exactly the kind of
48
54
  * thing nobody notices until it matters.
55
+ *
56
+ * Named here rather than taken from the shared default, so that changing the registry's
57
+ * scopes can never silently change what Atlas is authorised for.
49
58
  */
50
59
  export const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
51
- /** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
52
- export const REQUESTED_EXPIRES_IN = 3600;
53
60
  /**
54
61
  * Treat a token as stale this long before it actually expires.
55
62
  *
@@ -60,49 +67,6 @@ export const REQUESTED_EXPIRES_IN = 3600;
60
67
  * expired credential, which is the exact mid-flight expiry this cache exists to prevent.
61
68
  */
62
69
  export const REFRESH_SKEW_MS = AGENT_TIMEOUT_MS + 60_000;
63
- /** The token endpoint gets its own, much shorter budget than `/agent`. */
64
- export const TOKEN_TIMEOUT_MS = 15_000;
65
- /**
66
- * The OAuth2 error codes we are willing to read out of a failure body.
67
- *
68
- * `error` is a fixed enum token in the spec, so it cannot carry a credential; `error_description`
69
- * is free text and demonstrably CAN ("access_key <key> is invalid"), which is why only the
70
- * code is ever looked at and only when it is one of these. Anything else is ignored entirely
71
- * and the classification falls back to the status.
72
- */
73
- const SCOPE_ERROR_CODES = [
74
- "invalid_scope",
75
- "unauthorized_client",
76
- "invalid_request",
77
- ];
78
- const CREDENTIAL_ERROR_CODES = [
79
- "invalid_client",
80
- "invalid_grant",
81
- "access_denied",
82
- ];
83
- /**
84
- * Was this refusal about the SCOPE or about the CREDENTIAL?
85
- *
86
- * The two need completely different fixes — provisioning versus a password — so collapsing
87
- * them into one message sends someone to the wrong place entirely. Our form has five fields
88
- * and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
89
- * only really be about the scope.
90
- *
91
- * Nothing from the body is ever surfaced; the code is used to classify and then discarded.
92
- */
93
- export function refusalIsAboutScope(status, body) {
94
- const payload = typeof body === "object" && body !== null
95
- ? body
96
- : {};
97
- const code = typeof payload.error === "string" ? payload.error : "";
98
- if (SCOPE_ERROR_CODES.includes(code))
99
- return true;
100
- if (CREDENTIAL_ERROR_CODES.includes(code))
101
- return false;
102
- // No usable code. OAuth2 answers a bad REQUEST with 400 and a bad CLIENT with 401/403, so
103
- // the status is the next best evidence.
104
- return status === 400;
105
- }
106
70
  /**
107
71
  * The ways authentication can fail, kept apart because a user cannot act on them otherwise.
108
72
  *
@@ -141,137 +105,51 @@ export const AUTH_SERVER_ERROR_DETAIL = (status) => `BrowserStack auth is unavai
141
105
  `BrowserStack auth is back.`;
142
106
  export const AUTH_UNUSABLE_DETAIL = (status) => `BrowserStack auth answered HTTP ${status} without issuing a token. NOTHING REACHED THE ` +
143
107
  `AGENT — no request was made, no prompt appeared and nothing was changed.`;
144
- const cache = new Map();
145
- /** Drop every cached token. For tests, and for a credential rotation. */
146
- export function resetTokenCache() {
147
- cache.clear();
148
- }
149
- /**
150
- * The cache key.
151
- *
152
- * Keyed on the access key so that ROTATING it mints immediately rather than leaving a
153
- * revoked credential working until expiry — but on a SHA-256 of it, never the value, so the
154
- * secret is not left sitting in a map key for the life of the process.
155
- */
156
- function cacheKey(url, credentials) {
157
- const digest = createHash("sha256")
158
- .update(credentials.accessKey)
159
- .digest("hex");
160
- return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`;
161
- }
162
- /**
163
- * The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
164
- *
165
- * `raise_error: false` keeps the status-first contract this transport has always had: the
166
- * caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
167
- * so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
168
- */
169
- export function fetchTokenTransport(timeoutMs = TOKEN_TIMEOUT_MS) {
170
- return async (url, form) => {
171
- try {
172
- const response = await apiClient.post({
173
- url,
174
- headers: {
175
- "Content-Type": "application/x-www-form-urlencoded",
176
- Accept: "application/json",
177
- },
178
- body: new URLSearchParams(form).toString(),
179
- timeout: timeoutMs,
180
- raise_error: false,
181
- });
182
- return { status: response.status, body: response.data ?? null };
183
- }
184
- catch {
185
- // DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
186
- // carried: it can name the URL and, on some stacks, echo the request body.
187
- return { status: 0, body: null, error: "auth could not be reached" };
188
- }
189
- };
190
- }
191
- /** The exact form body of the `client_credentials` grant. */
108
+ /** The exact form body of the `client_credentials` grant, at Atlas's scope. */
192
109
  export function mintForm(credentials) {
193
- return {
194
- grant_type: "client_credentials",
195
- username: credentials.username,
196
- access_key: credentials.accessKey,
197
- scope: CENTRAL_SCOPE,
198
- expires_in: String(REQUESTED_EXPIRES_IN),
199
- };
110
+ return mintFormShared(credentials, CENTRAL_SCOPE, REQUESTED_EXPIRES_IN);
200
111
  }
201
- async function mintOnce(url, credentials, transport) {
202
- const response = await transport(url, mintForm(credentials));
203
- if (response.status === 0)
204
- throw new AskError(AUTH_UNREACHABLE_DETAIL);
205
- // 5xx BEFORE the refusal branch: a server error is not a refusal, and reading it as one
206
- // is worse than saying nothing — it names the caller's credentials as the fault.
207
- if (response.status >= 500) {
208
- throw new AskError(AUTH_SERVER_ERROR_DETAIL(response.status));
112
+ /** Every failure kind gets Atlas's own sentence; none of them carries the response body. */
113
+ function toAskError(error) {
114
+ if (!(error instanceof CentralAuthError))
115
+ return error;
116
+ switch (error.kind) {
117
+ case "no_credentials":
118
+ return new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
119
+ "BROWSERSTACK_ACCESS_KEY are required to sign in");
120
+ case "unreachable":
121
+ return new AskError(AUTH_UNREACHABLE_DETAIL);
122
+ case "server_error":
123
+ return new AskError(AUTH_SERVER_ERROR_DETAIL(error.status));
124
+ case "refused_scope":
125
+ return new AskError(AUTH_SCOPE_REFUSED_DETAIL(error.status));
126
+ case "refused_credentials":
127
+ return new AskError(AUTH_REJECTED_DETAIL(error.status));
128
+ default:
129
+ return new AskError(AUTH_UNUSABLE_DETAIL(error.status));
209
130
  }
210
- if (response.status !== 200) {
211
- // ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a
212
- // credential one, and nothing out of it is ever put in the message — a non-200 body can
213
- // echo the access key straight back.
214
- throw new AskError(refusalIsAboutScope(response.status, response.body)
215
- ? AUTH_SCOPE_REFUSED_DETAIL(response.status)
216
- : AUTH_REJECTED_DETAIL(response.status));
217
- }
218
- const body = typeof response.body === "object" && response.body !== null
219
- ? response.body
220
- : {};
221
- const token = body.access_token;
222
- if (typeof token !== "string" || !token) {
223
- throw new AskError(AUTH_UNUSABLE_DETAIL(response.status));
224
- }
225
- // Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and
226
- // caching for the requested hour when it granted less would hand out a dead token.
227
- const granted = Number(body.expires_in);
228
- const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN;
229
- return { token, lifetimeMs: seconds * 1000 };
230
131
  }
231
132
  /**
232
133
  * Return a valid token, minting one only when the cache has nothing fresh.
233
134
  *
234
- * Minting per tool call would add a round trip to every request and make the token endpoint
235
- * a hot dependency of the whole surface.
135
+ * NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
136
+ * process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
137
+ * data in module-level state there, so remote mode mints per call.
236
138
  */
237
139
  export async function mintCentralToken(url, credentials, transport, now = Date.now()) {
238
- // Refused before any network call, and by name: these ARE the auth credential now, not
239
- // merely attribution, so an empty one is our missing configuration rather than the user's
240
- // rejected password, and must not read like one.
241
- if (!credentials?.username || !credentials?.accessKey) {
242
- throw new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
243
- "BROWSERSTACK_ACCESS_KEY are required to sign in");
244
- }
245
- // NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
246
- // process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
247
- // data in module-level state there, so remote mode mints per call. Keying on
248
- // username + sha256(accessKey) already means one user can never be SERVED another's token,
249
- // but containment is not the contract; not holding it at all is.
250
- if (appConfig.REMOTE_MCP) {
251
- return mintOnce(url, credentials, transport).then(({ token }) => {
252
- logger.info("askBrowserStackAI: signed in as %s", credentials.username);
253
- return token;
140
+ try {
141
+ return await mintShared({
142
+ url,
143
+ scope: CENTRAL_SCOPE,
144
+ credentials,
145
+ transport,
146
+ refreshSkewMs: REFRESH_SKEW_MS,
147
+ cacheEnabled: !appConfig.REMOTE_MCP,
148
+ now,
149
+ onMinted: (lifetimeMs) => logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000)),
254
150
  });
255
151
  }
256
- const key = cacheKey(url, credentials);
257
- const entry = cache.get(key);
258
- if (entry && entry.token && now < entry.expiresAt - REFRESH_SKEW_MS) {
259
- return entry.token;
152
+ catch (error) {
153
+ throw toAskError(error);
260
154
  }
261
- // Double-checked through a shared promise: concurrent callers await the same mint.
262
- if (entry?.inflight)
263
- return entry.inflight;
264
- const pending = mintOnce(url, credentials, transport)
265
- .then(({ token, lifetimeMs }) => {
266
- cache.set(key, { token, expiresAt: now + lifetimeMs });
267
- logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000));
268
- return token;
269
- })
270
- .catch((error) => {
271
- // Never leave a rejected promise cached, or every later call inherits this failure.
272
- cache.delete(key);
273
- throw error;
274
- });
275
- cache.set(key, { token: "", expiresAt: 0, inflight: pending });
276
- return pending;
277
155
  }
@@ -230,6 +230,15 @@ function coerceType(value, param, label = param.name) {
230
230
  if (expected === "integer" && !Number.isInteger(parsed)) {
231
231
  throw new InvocationError(`'${label}' must be a whole number, not ${JSON.stringify(raw)}`);
232
232
  }
233
+ // Past 2^53 `Number` rounds: TRA's 17-digit unique-error cluster ids came back as a
234
+ // neighbouring id, so the call silently targeted the wrong cluster. Keep the caller's
235
+ // exact digits instead — path and query serialise a string identically, and a JSON body
236
+ // carrying a digit string still binds to a Long on the server.
237
+ if (expected === "integer" &&
238
+ !Number.isSafeInteger(parsed) &&
239
+ /^-?\d+$/.test(raw)) {
240
+ return raw;
241
+ }
233
242
  return parsed;
234
243
  }
235
244
  if (expected === "boolean") {
@@ -280,6 +289,12 @@ function place(root, pointer, value) {
280
289
  }
281
290
  cursor[segments[segments.length - 1]] = value;
282
291
  }
292
+ const MAP_KEY = /^[A-Za-z0-9][\w.-]*$/;
293
+ function keyedParam(group, params) {
294
+ if (group !== "body")
295
+ return undefined;
296
+ return params.find((param) => /^\{\w+\}$/.test(param.name));
297
+ }
283
298
  const GROUPS = [
284
299
  { group: "path_params", declared: "path_params" },
285
300
  { group: "query", declared: "query" },
@@ -298,9 +313,10 @@ export function bind(capability, args) {
298
313
  }
299
314
  const params = capability[declared] || [];
300
315
  const byName = new Map(params.map((param) => [param.name, param]));
316
+ const keyed = keyedParam(group, params);
301
317
  // Unknown arguments are an error rather than being dropped: silently ignoring a
302
318
  // misspelled filter would return a larger result set that looks like a correct answer.
303
- const unknown = Object.keys(supplied).filter((name) => !byName.has(name));
319
+ const unknown = Object.keys(supplied).filter((name) => !byName.has(name) && !keyed);
304
320
  if (unknown.length > 0) {
305
321
  // A CALLER WHO SENT THE WRAPPER WE BUILD DESERVES TO BE TOLD SO.
306
322
  //
@@ -331,7 +347,10 @@ export function bind(capability, args) {
331
347
  : ""));
332
348
  }
333
349
  for (const [name, raw] of Object.entries(supplied)) {
334
- const param = byName.get(name);
350
+ const param = byName.get(name) ?? keyed;
351
+ if (param === keyed && !MAP_KEY.test(name)) {
352
+ throw new InvocationError(`'${name}' is not a usable ${keyed.name} key: use the id exactly as a read returned it`);
353
+ }
335
354
  const value = coerce(raw, param);
336
355
  if (group === "path_params") {
337
356
  // Encode with nothing exempt: a `/` inside a path value would otherwise rewrite the
@@ -339,7 +358,10 @@ export function bind(capability, args) {
339
358
  path = path.replaceAll(`{${name}}`, encodeURIComponent(pathSegment(value, name)));
340
359
  }
341
360
  else if (group === "body") {
342
- place(body, param.json_path || `/${name}`, value);
361
+ const pointer = param.json_path || `/${param.name}`;
362
+ place(body, param === keyed && !byName.has(name)
363
+ ? pointer.replace(keyed.name, name)
364
+ : pointer, value);
343
365
  }
344
366
  else {
345
367
  query[name] = value;
@@ -354,9 +376,14 @@ export function bind(capability, args) {
354
376
  if (group === "query")
355
377
  continue;
356
378
  const supplied = args[group] || {};
357
- for (const param of capability[declared] ||
358
- []) {
359
- if (param.required && !(param.name in supplied))
379
+ const params = capability[declared] || [];
380
+ const keyed = keyedParam(group, params);
381
+ const declaredNames = new Set(params.map((param) => param.name));
382
+ for (const param of params) {
383
+ const present = param.name in supplied ||
384
+ (param === keyed &&
385
+ Object.keys(supplied).some((name) => !declaredNames.has(name)));
386
+ if (param.required && !present)
360
387
  missing.push(param.name);
361
388
  }
362
389
  }
@@ -367,5 +394,14 @@ export function bind(capability, args) {
367
394
  if (leftover) {
368
395
  throw new InvocationError(`path placeholder(s) not supplied: ${leftover.join(", ")}`);
369
396
  }
370
- return { path, query, body: Object.keys(body).length > 0 ? body : undefined };
397
+ // An explicitly supplied empty body is still a body. Dropping it sent no request body at
398
+ // all, and a route with a required @RequestBody (TRA close_build, whose guidance says to
399
+ // send `{}`) answered 400 "Invalid request payload". GET/HEAD never carry one.
400
+ const sentEmptyBody = args.body !== undefined &&
401
+ !["GET", "HEAD"].includes(capability.method.toUpperCase());
402
+ return {
403
+ path,
404
+ query,
405
+ body: Object.keys(body).length > 0 || sentEmptyBody ? body : undefined,
406
+ };
371
407
  }
@@ -79,7 +79,9 @@ export async function discoverBaseUrl(product, source, credentials, transport) {
79
79
  `set probe_path in its index`);
80
80
  }
81
81
  // The same scheme the invocation will use, so a host that answers here answers there.
82
- const headers = authHeaders(credentials, source.auth);
82
+ // Minted ONCE for the whole probe, not per candidate: the token is the caller's identity
83
+ // and does not vary by which region answers.
84
+ const headers = await authHeaders(credentials, source.auth, product);
83
85
  const failures = [];
84
86
  for (const candidate of candidates) {
85
87
  const response = await transport("GET", `${candidate}${path}`, headers, {});
@@ -16,11 +16,10 @@
16
16
  * Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
17
17
  * credentials are wrong.
18
18
  */
19
+ import { TokenTransport } from "../../lib/central-oauth.js";
19
20
  import { AuthScheme } from "./types.js";
20
- export interface Credentials {
21
- username: string;
22
- accessKey: string;
23
- }
21
+ export type { Credentials } from "../../lib/central-oauth.js";
22
+ import type { Credentials } from "../../lib/central-oauth.js";
24
23
  export interface HttpResponse {
25
24
  status: number;
26
25
  body: unknown;
@@ -39,6 +38,14 @@ export declare const DEFAULT_AUTH: AuthScheme;
39
38
  * the common case, not a hypothetical.
40
39
  */
41
40
  export declare function renderTemplate(template: string, credentials: Credentials): string;
42
- export declare function authHeaders(credentials: Credentials, auth?: AuthScheme): Record<string, string>;
41
+ /**
42
+ * The headers for one outbound call.
43
+ *
44
+ * ASYNC BECAUSE ONE SCHEME NEEDS A ROUND TRIP. `apiKey` and `http` are pure string work, but
45
+ * `oauth2` exchanges the caller's credentials for a JWT before anything can be sent, and
46
+ * there was nowhere to put that while this was synchronous. Both call sites were already
47
+ * inside async functions, so the cost is two `await`s.
48
+ */
49
+ export declare function authHeaders(credentials: Credentials, auth?: AuthScheme, product?: string, tokenTransport?: TokenTransport): Promise<Record<string, string>>;
43
50
  /** A fetch-based transport. Redirects are NOT followed. */
44
51
  export declare function fetchTransport(timeoutMs?: number): Transport;
@@ -16,7 +16,9 @@
16
16
  * Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
17
17
  * credentials are wrong.
18
18
  */
19
+ import { fetchTokenTransport, } from "../../lib/central-oauth.js";
19
20
  import { InvocationError } from "./index-loader.js";
21
+ import { bearerToken } from "./oauth.js";
20
22
  /** What this server can put into a credential template. Nothing else is fillable. */
21
23
  const PLACEHOLDERS = ["username", "access_key"];
22
24
  /** The historical default, and what an index without an `auth` block still gets. */
@@ -48,7 +50,15 @@ export function renderTemplate(template, credentials) {
48
50
  .replaceAll("{username}", credentials.username)
49
51
  .replaceAll("{access_key}", credentials.accessKey);
50
52
  }
51
- export function authHeaders(credentials, auth = DEFAULT_AUTH) {
53
+ /**
54
+ * The headers for one outbound call.
55
+ *
56
+ * ASYNC BECAUSE ONE SCHEME NEEDS A ROUND TRIP. `apiKey` and `http` are pure string work, but
57
+ * `oauth2` exchanges the caller's credentials for a JWT before anything can be sent, and
58
+ * there was nowhere to put that while this was synchronous. Both call sites were already
59
+ * inside async functions, so the cost is two `await`s.
60
+ */
61
+ export async function authHeaders(credentials, auth = DEFAULT_AUTH, product = "this product", tokenTransport = fetchTokenTransport()) {
52
62
  if (!credentials?.username || !credentials?.accessKey) {
53
63
  // Refusing here beats sending unauthenticated and surfacing the product's 401, which
54
64
  // reads like the user's problem when it is our missing configuration.
@@ -59,6 +69,19 @@ export function authHeaders(credentials, auth = DEFAULT_AUTH) {
59
69
  "request-source": "ai-chatbot",
60
70
  "Content-Type": "application/json",
61
71
  };
72
+ if (auth.type === "oauth2") {
73
+ // Refused rather than ignored, which is the same call `in: cookie` and `in: query` get
74
+ // below: a field that looks like it selects behaviour and does not is how someone
75
+ // spends an afternoon on a 401 they were never going to be able to explain.
76
+ const stray = ["in", "name", "template"].filter((field) => auth[field] !== undefined);
77
+ if (stray.length > 0) {
78
+ throw new InvocationError(`oauth2 auth does not take ${stray.join(", ")}: a bearer token's presentation is ` +
79
+ `fixed at 'Authorization: Bearer' (RFC 6750). Remove ${stray.length > 1 ? "them" : "it"}, ` +
80
+ `or use apiKey if this product really does read a different header.`);
81
+ }
82
+ const token = await bearerToken(product, auth, credentials, tokenTransport);
83
+ return { Authorization: `Bearer ${token}`, ...common };
84
+ }
62
85
  const value = renderTemplate(auth.template || DEFAULT_AUTH.template, credentials);
63
86
  if (auth.type === "apiKey") {
64
87
  // Header only. `cookie` needs a session this server does not have, and `query` would
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Bearer-JWT auth for products whose API does not accept `Api-Token`.
3
+ *
4
+ * The registry was built on "the caller's credentials, forwarded — never a token this server
5
+ * mints", and this is the first product that cannot work that way. The security property is
6
+ * unchanged: the exchange uses the caller's own username and access key, the JWT carries
7
+ * their `user_id`/`group_id` as signed claims, and the scopes narrow it BELOW what the raw
8
+ * credential could do. What changes is that a token is now minted rather than formatted, so
9
+ * the sentence to hold onto is "the server grants nothing beyond the caller's own identity",
10
+ * not "the server mints nothing".
11
+ *
12
+ * The mint itself lives in `lib/central-oauth.ts` and is shared with `askBrowserStackAI`.
13
+ * This module is only the part that is the registry's: where the endpoint and scopes come
14
+ * from, what a product is allowed to ask for, and how a failure is worded.
15
+ */
16
+ import { Credentials, TokenTransport } from "../../lib/central-oauth.js";
17
+ import { AuthScheme } from "./types.js";
18
+ /**
19
+ * Where a central-OAuth JWT is minted, when a product does not name its own.
20
+ *
21
+ * Staging, for anyone setting the override:
22
+ * CAPABILITY_REGISTRY_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
23
+ */
24
+ export declare const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
25
+ /**
26
+ * The default scope set, and why it is a PAIR.
27
+ *
28
+ * `ai_agent` is the scope a product's gate matches on. `oauth_user_profile` is not extra
29
+ * authority — it is what makes the scope obtainable through the username+access_key flow at
30
+ * all, and this is measured rather than assumed: `ai_agent` alone comes back
31
+ * `400 invalid_request`, the pair mints, and the granted token's `scopes` claim carries both.
32
+ *
33
+ * Deliberately NOT `ask-browserstack`'s `CENTRAL_SCOPE`, which pairs `ai_agent_notify`
34
+ * instead — Atlas names its own so that changing this one cannot silently change what Atlas
35
+ * is authorised for.
36
+ */
37
+ export declare const DEFAULT_SCOPES: readonly ["ai_agent", "oauth_user_profile"];
38
+ /**
39
+ * Small, because the registry uses the token for ONE HTTP call.
40
+ *
41
+ * Nothing here holds it for the length of a run the way Atlas does, so this only has to
42
+ * cover clock skew and the flight time of the request it opens.
43
+ */
44
+ export declare const REFRESH_SKEW_MS = 60000;
45
+ /** For tests, and for anything that legitimately re-resolves. */
46
+ export declare function resetOAuthAnnouncements(): void;
47
+ /**
48
+ * Three rungs: the operator's override, then the product's declaration, then the built-in.
49
+ *
50
+ * Matching `base_url`, where config is explicitly "a default, not the last word" — a
51
+ * deployment pointed at staging must not be overruled by whatever an index happens to carry.
52
+ */
53
+ export declare function resolveTokenUrl(product: string, auth?: AuthScheme): string;
54
+ /** Two rungs. A scope does not vary by deployment the way a host does, so there is no env one. */
55
+ export declare function resolveScope(auth?: AuthScheme): string;
56
+ /**
57
+ * The Bearer value for one invocation.
58
+ *
59
+ * Cached per user for the life of the token when this process serves one caller, and not at
60
+ * all when it serves many — `mintCentralToken` is handed `cacheEnabled` rather than deciding
61
+ * for itself, because the rule is about THIS deployment, not about the mint.
62
+ */
63
+ export declare function bearerToken(product: string, auth: AuthScheme | undefined, credentials: Credentials, transport: TokenTransport): Promise<string>;