@browserstack/mcp-server 2.0.0 → 2.2.0-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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
3
+ *
4
+ * WHAT IS MINTED IS THE CALLER, NOT A SERVICE ACCOUNT. The exchange takes the user's own
5
+ * credentials and returns a JWT whose `user` claim carries their `user_id`/`group_id`, so a
6
+ * product authorises against signed claims rather than against anything we put in a request
7
+ * body. Scopes narrow it further. The property this preserves is the one the whole surface
8
+ * rests on: an agent can never do what the user could not.
9
+ *
10
+ * SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
11
+ * it the hard way: "The body can echo the credential back on some errors, so it is NOT
12
+ * logged or raised — only the status." Neither the access key nor the minted token is ever
13
+ * logged, returned in an error, or put in a message. Only a status code is.
14
+ *
15
+ * THIS MODULE IS THE MECHANISM AND NOTHING ELSE. It throws `CentralAuthError` carrying a
16
+ * `kind`, and each caller words its own message from that: `ask-browserstack` tells the
17
+ * reader nothing reached the agent, the capability registry tells them which product could
18
+ * not be reached. Sharing the mechanism is what stops a second, subtly different minter
19
+ * existing; sharing the prose would put Atlas's wording in front of a registry user.
20
+ */
21
+ export interface Credentials {
22
+ username: string;
23
+ accessKey: string;
24
+ }
25
+ export interface TokenResponse {
26
+ status: number;
27
+ body: unknown;
28
+ /** Only when there was no response at all to speak for itself. */
29
+ error?: string;
30
+ }
31
+ export type TokenTransport = (url: string, form: Record<string, string>) => Promise<TokenResponse>;
32
+ /** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
33
+ export declare const REQUESTED_EXPIRES_IN = 3600;
34
+ /**
35
+ * The token endpoint gets its own, short budget — it is not the call being made.
36
+ *
37
+ * READ AT CALL TIME, NEVER IN A DEFAULT PARAMETER — see `fetchTokenTransport`.
38
+ */
39
+ export declare const TOKEN_TIMEOUT_MS = 15000;
40
+ /**
41
+ * How a mint failed, in the terms a reader can act on.
42
+ *
43
+ * `refused_scope` is a provisioning problem, `refused_credentials` is "your password is
44
+ * wrong", `server_error` is "auth is down" and `unreachable` is "we could not get there".
45
+ * Four different fixes, so they must not collapse into one — routing a 503 to "your
46
+ * credentials were rejected" has already sent someone to audit env vars that had worked
47
+ * minutes earlier.
48
+ */
49
+ export type AuthFailureKind = "no_credentials" | "unreachable" | "server_error" | "refused_scope" | "refused_credentials" | "unusable";
50
+ /** Carries the classification and the status. NEVER any part of the response body. */
51
+ export declare class CentralAuthError extends Error {
52
+ readonly kind: AuthFailureKind;
53
+ readonly status: number;
54
+ readonly scope: string;
55
+ constructor(kind: AuthFailureKind, status: number, scope: string);
56
+ }
57
+ /**
58
+ * Was this refusal about the SCOPE or about the CREDENTIAL?
59
+ *
60
+ * The two need completely different fixes — provisioning versus a password — so collapsing
61
+ * them into one message sends someone to the wrong place entirely. Our form has five fields
62
+ * and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
63
+ * only really be about the scope.
64
+ *
65
+ * Nothing from the body is ever surfaced; the code is used to classify and then discarded.
66
+ */
67
+ export declare function refusalIsAboutScope(status: number, body: unknown): boolean;
68
+ /**
69
+ * One canonical string for a scope set.
70
+ *
71
+ * `["a","b"]` and `["b","a"]` are the same grant, and without this they are two cache keys
72
+ * and two mints for it. Sorted and deduped so the key is a property of the grant rather
73
+ * than of how someone happened to write it down.
74
+ */
75
+ export declare function normaliseScopes(scopes: readonly string[]): string;
76
+ /** The exact form body of the `client_credentials` grant. */
77
+ export declare function mintForm(credentials: Credentials, scope: string, expiresIn?: number): Record<string, string>;
78
+ /**
79
+ * The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
80
+ *
81
+ * `raise_error: false` keeps the status-first contract this transport has always had: the
82
+ * caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
83
+ * so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
84
+ *
85
+ * `timeoutMs` IS RESOLVED INSIDE THE CLOSURE, NOT AS A DEFAULT PARAMETER, and that is load
86
+ * bearing.
87
+ *
88
+ * This module sits inside an import cycle — `lib/apiClient` -> `lib/utils` -> `src/index`,
89
+ * the entry — so its top-level bindings are evaluated AFTER the entry starts running. Tool
90
+ * registration calls `fetchTokenTransport()` during that window: the function itself is
91
+ * hoisted and callable, but a default parameter reading `TOKEN_TIMEOUT_MS` is still in its
92
+ * temporal dead zone and threw `Cannot access 'TOKEN_TIMEOUT_MS' before initialization` on
93
+ * every server start. Reading it when a token is actually minted defers it past init.
94
+ *
95
+ * Anything else added here must follow the same rule until that cycle is broken.
96
+ */
97
+ export declare function fetchTokenTransport(timeoutMs?: number): TokenTransport;
98
+ /** Drop every cached token. For tests, and for a credential rotation. */
99
+ export declare function resetTokenCache(): void;
100
+ export interface MintOptions {
101
+ url: string;
102
+ /** Already normalised — `normaliseScopes` at the edge, not here. */
103
+ scope: string;
104
+ credentials: Credentials;
105
+ transport: TokenTransport;
106
+ /**
107
+ * Treat a token as stale this long before it actually expires.
108
+ *
109
+ * It must cover everything the token has to outlive, not merely clock skew: a caller that
110
+ * holds it for the length of a run needs a skew at least that long, or a human approves a
111
+ * write and the egress that follows fails on an expired credential.
112
+ */
113
+ refreshSkewMs: number;
114
+ /**
115
+ * False in a multi-tenant process. These are per-user attested credentials and the hosted
116
+ * process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
117
+ * data in module-level state there, so hosted mode mints per call. The cache key already
118
+ * means one user can never be SERVED another's token, but containment is not the
119
+ * contract; not holding it at all is.
120
+ */
121
+ cacheEnabled: boolean;
122
+ onMinted?: (lifetimeMs: number) => void;
123
+ now?: number;
124
+ }
125
+ /**
126
+ * Return a valid token, minting one only when the cache has nothing fresh.
127
+ *
128
+ * Minting per call would add a round trip to every request and make the token endpoint a hot
129
+ * dependency of the whole surface.
130
+ */
131
+ export declare function mintCentralToken(options: MintOptions): Promise<string>;
132
+ /** Drop the cached token for exactly these credentials — used after an upstream 401. */
133
+ export declare function invalidateToken(url: string, scope: string, credentials: Credentials): void;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
3
+ *
4
+ * WHAT IS MINTED IS THE CALLER, NOT A SERVICE ACCOUNT. The exchange takes the user's own
5
+ * credentials and returns a JWT whose `user` claim carries their `user_id`/`group_id`, so a
6
+ * product authorises against signed claims rather than against anything we put in a request
7
+ * body. Scopes narrow it further. The property this preserves is the one the whole surface
8
+ * rests on: an agent can never do what the user could not.
9
+ *
10
+ * SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
11
+ * it the hard way: "The body can echo the credential back on some errors, so it is NOT
12
+ * logged or raised — only the status." Neither the access key nor the minted token is ever
13
+ * logged, returned in an error, or put in a message. Only a status code is.
14
+ *
15
+ * THIS MODULE IS THE MECHANISM AND NOTHING ELSE. It throws `CentralAuthError` carrying a
16
+ * `kind`, and each caller words its own message from that: `ask-browserstack` tells the
17
+ * reader nothing reached the agent, the capability registry tells them which product could
18
+ * not be reached. Sharing the mechanism is what stops a second, subtly different minter
19
+ * existing; sharing the prose would put Atlas's wording in front of a registry user.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { apiClient } from "./apiClient.js";
23
+ /** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
24
+ export const REQUESTED_EXPIRES_IN = 3600;
25
+ /**
26
+ * The token endpoint gets its own, short budget — it is not the call being made.
27
+ *
28
+ * READ AT CALL TIME, NEVER IN A DEFAULT PARAMETER — see `fetchTokenTransport`.
29
+ */
30
+ export const TOKEN_TIMEOUT_MS = 15_000;
31
+ /** Carries the classification and the status. NEVER any part of the response body. */
32
+ export class CentralAuthError extends Error {
33
+ kind;
34
+ status;
35
+ scope;
36
+ constructor(kind, status, scope) {
37
+ super(`central auth ${kind} (HTTP ${status})`);
38
+ this.kind = kind;
39
+ this.status = status;
40
+ this.scope = scope;
41
+ this.name = "CentralAuthError";
42
+ }
43
+ }
44
+ /**
45
+ * The OAuth2 error codes we are willing to read out of a failure body.
46
+ *
47
+ * `error` is a fixed enum token in the spec, so it cannot carry a credential;
48
+ * `error_description` is free text and demonstrably CAN ("access_key <key> is invalid"),
49
+ * which is why only the code is ever looked at and only when it is one of these. Anything
50
+ * else is ignored entirely and the classification falls back to the status.
51
+ */
52
+ const SCOPE_ERROR_CODES = [
53
+ "invalid_scope",
54
+ "unauthorized_client",
55
+ "invalid_request",
56
+ ];
57
+ const CREDENTIAL_ERROR_CODES = [
58
+ "invalid_client",
59
+ "invalid_grant",
60
+ "access_denied",
61
+ ];
62
+ /**
63
+ * Was this refusal about the SCOPE or about the CREDENTIAL?
64
+ *
65
+ * The two need completely different fixes — provisioning versus a password — so collapsing
66
+ * them into one message sends someone to the wrong place entirely. Our form has five fields
67
+ * and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
68
+ * only really be about the scope.
69
+ *
70
+ * Nothing from the body is ever surfaced; the code is used to classify and then discarded.
71
+ */
72
+ export function refusalIsAboutScope(status, body) {
73
+ const payload = typeof body === "object" && body !== null
74
+ ? body
75
+ : {};
76
+ const code = typeof payload.error === "string" ? payload.error : "";
77
+ if (SCOPE_ERROR_CODES.includes(code))
78
+ return true;
79
+ if (CREDENTIAL_ERROR_CODES.includes(code))
80
+ return false;
81
+ // No usable code. OAuth2 answers a bad REQUEST with 400 and a bad CLIENT with 401/403, so
82
+ // the status is the next best evidence.
83
+ return status === 400;
84
+ }
85
+ /**
86
+ * One canonical string for a scope set.
87
+ *
88
+ * `["a","b"]` and `["b","a"]` are the same grant, and without this they are two cache keys
89
+ * and two mints for it. Sorted and deduped so the key is a property of the grant rather
90
+ * than of how someone happened to write it down.
91
+ */
92
+ export function normaliseScopes(scopes) {
93
+ return [
94
+ ...new Set(scopes.flatMap((scope) => scope.split(/\s+/)).filter(Boolean)),
95
+ ]
96
+ .sort()
97
+ .join(" ");
98
+ }
99
+ /** The exact form body of the `client_credentials` grant. */
100
+ export function mintForm(credentials, scope, expiresIn = REQUESTED_EXPIRES_IN) {
101
+ return {
102
+ grant_type: "client_credentials",
103
+ username: credentials.username,
104
+ access_key: credentials.accessKey,
105
+ scope,
106
+ expires_in: String(expiresIn),
107
+ };
108
+ }
109
+ /**
110
+ * The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
111
+ *
112
+ * `raise_error: false` keeps the status-first contract this transport has always had: the
113
+ * caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
114
+ * so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
115
+ *
116
+ * `timeoutMs` IS RESOLVED INSIDE THE CLOSURE, NOT AS A DEFAULT PARAMETER, and that is load
117
+ * bearing.
118
+ *
119
+ * This module sits inside an import cycle — `lib/apiClient` -> `lib/utils` -> `src/index`,
120
+ * the entry — so its top-level bindings are evaluated AFTER the entry starts running. Tool
121
+ * registration calls `fetchTokenTransport()` during that window: the function itself is
122
+ * hoisted and callable, but a default parameter reading `TOKEN_TIMEOUT_MS` is still in its
123
+ * temporal dead zone and threw `Cannot access 'TOKEN_TIMEOUT_MS' before initialization` on
124
+ * every server start. Reading it when a token is actually minted defers it past init.
125
+ *
126
+ * Anything else added here must follow the same rule until that cycle is broken.
127
+ */
128
+ export function fetchTokenTransport(timeoutMs) {
129
+ return async (url, form) => {
130
+ try {
131
+ const response = await apiClient.post({
132
+ url,
133
+ headers: {
134
+ "Content-Type": "application/x-www-form-urlencoded",
135
+ Accept: "application/json",
136
+ },
137
+ body: new URLSearchParams(form).toString(),
138
+ timeout: timeoutMs ?? TOKEN_TIMEOUT_MS,
139
+ raise_error: false,
140
+ });
141
+ return { status: response.status, body: response.data ?? null };
142
+ }
143
+ catch {
144
+ // DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
145
+ // carried: it can name the URL and, on some stacks, echo the request body.
146
+ return { status: 0, body: null, error: "auth could not be reached" };
147
+ }
148
+ };
149
+ }
150
+ const cache = new Map();
151
+ /** Drop every cached token. For tests, and for a credential rotation. */
152
+ export function resetTokenCache() {
153
+ cache.clear();
154
+ }
155
+ /**
156
+ * The cache key.
157
+ *
158
+ * Every part of it is something that changes WHICH token is correct:
159
+ *
160
+ * url a different issuer is a different token
161
+ * scope a token minted for one scope set must never be served for another
162
+ * username the identity the JWT attests
163
+ * digest so ROTATING the access key mints immediately rather than leaving a revoked
164
+ * credential working until expiry — hashed, never the value, so the secret is
165
+ * not left sitting in a map key for the life of the process
166
+ *
167
+ * The PRODUCT is deliberately absent: the token is the caller's identity, not a product's,
168
+ * so two products on the same endpoint and scopes share one mint.
169
+ */
170
+ function cacheKey(url, scope, credentials) {
171
+ const digest = createHash("sha256")
172
+ .update(credentials.accessKey)
173
+ .digest("hex");
174
+ return `${url} ${credentials.username} ${scope} ${digest}`;
175
+ }
176
+ async function mintOnce(url, scope, credentials, transport) {
177
+ const response = await transport(url, mintForm(credentials, scope));
178
+ if (response.status === 0)
179
+ throw new CentralAuthError("unreachable", 0, scope);
180
+ // 5xx BEFORE the refusal branch: a server error is not a refusal, and reading it as one
181
+ // is worse than saying nothing — it names the caller's credentials as the fault.
182
+ if (response.status >= 500)
183
+ throw new CentralAuthError("server_error", response.status, scope);
184
+ if (response.status !== 200) {
185
+ // ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a
186
+ // credential one, and nothing out of it is ever put in the error.
187
+ throw new CentralAuthError(refusalIsAboutScope(response.status, response.body)
188
+ ? "refused_scope"
189
+ : "refused_credentials", response.status, scope);
190
+ }
191
+ const body = typeof response.body === "object" && response.body !== null
192
+ ? response.body
193
+ : {};
194
+ const token = body.access_token;
195
+ if (typeof token !== "string" || !token)
196
+ throw new CentralAuthError("unusable", response.status, scope);
197
+ // Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and
198
+ // caching for the requested hour when it granted less would hand out a dead token.
199
+ const granted = Number(body.expires_in);
200
+ const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN;
201
+ return { token, lifetimeMs: seconds * 1000 };
202
+ }
203
+ /**
204
+ * Return a valid token, minting one only when the cache has nothing fresh.
205
+ *
206
+ * Minting per call would add a round trip to every request and make the token endpoint a hot
207
+ * dependency of the whole surface.
208
+ */
209
+ export async function mintCentralToken(options) {
210
+ const { url, scope, credentials, transport, cacheEnabled } = options;
211
+ const now = options.now ?? Date.now();
212
+ // Refused before any network call, and by name: these ARE the auth credential now, not
213
+ // merely attribution, so an empty one is our missing configuration rather than the user's
214
+ // rejected password, and must not read like one.
215
+ if (!credentials?.username || !credentials?.accessKey)
216
+ throw new CentralAuthError("no_credentials", 0, scope);
217
+ const finish = ({ token, lifetimeMs, }) => {
218
+ options.onMinted?.(lifetimeMs);
219
+ return token;
220
+ };
221
+ if (!cacheEnabled)
222
+ return mintOnce(url, scope, credentials, transport).then(finish);
223
+ const key = cacheKey(url, scope, credentials);
224
+ const entry = cache.get(key);
225
+ if (entry && entry.token && now < entry.expiresAt - options.refreshSkewMs)
226
+ return entry.token;
227
+ // Double-checked through a shared promise: concurrent callers await the same mint.
228
+ if (entry?.inflight)
229
+ return entry.inflight;
230
+ const pending = mintOnce(url, scope, credentials, transport)
231
+ .then(({ token, lifetimeMs }) => {
232
+ cache.set(key, { token, expiresAt: now + lifetimeMs });
233
+ return finish({ token, lifetimeMs });
234
+ })
235
+ .catch((error) => {
236
+ // Never leave a rejected promise cached, or every later call inherits this failure.
237
+ cache.delete(key);
238
+ throw error;
239
+ });
240
+ cache.set(key, { token: "", expiresAt: 0, inflight: pending });
241
+ return pending;
242
+ }
243
+ /** Drop the cached token for exactly these credentials — used after an upstream 401. */
244
+ export function invalidateToken(url, scope, credentials) {
245
+ cache.delete(cacheKey(url, scope, credentials));
246
+ }
@@ -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,20 +17,17 @@
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 { Credentials } from "./egress.js";
21
+ import { Credentials, mintCentralToken as mintShared } from "../../lib/central-oauth.js";
22
+ export { fetchTokenTransport, refusalIsAboutScope, REQUESTED_EXPIRES_IN, resetTokenCache, TOKEN_TIMEOUT_MS, type TokenResponse, type TokenTransport, } from "../../lib/central-oauth.js";
17
23
  /**
18
24
  * BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE.
19
25
  *
20
26
  * `oauth_user_profile` stays because it is what makes the pair obtainable through the
21
- * username+access_key flow at all. `ai_agent_notify` is what Atlas matches on
22
- * (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim in
23
- * `web/oauth.py`); both halves move together with Atlas.
27
+ * username+access_key flow at all — measured, not assumed: the paired scope mints, and the
28
+ * other half alone comes back `400 invalid_request`. `ai_agent_notify` is what Atlas matches
29
+ * on (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim
30
+ * in `web/oauth.py`); both halves move together with Atlas.
24
31
  *
25
32
  * THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than
26
33
  * discovering. From the merged `browserstack/railsApp#175367` (2026-08-24):
@@ -42,10 +49,11 @@ import { Credentials } from "./egress.js";
42
49
  * application — and it is reported as one, naming the scope. It is never retried with a
43
50
  * different scope: a silent downgrade to a different authorization is exactly the kind of
44
51
  * thing nobody notices until it matters.
52
+ *
53
+ * Named here rather than taken from the shared default, so that changing the registry's
54
+ * scopes can never silently change what Atlas is authorised for.
45
55
  */
46
56
  export declare const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
47
- /** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
48
- export declare const REQUESTED_EXPIRES_IN = 3600;
49
57
  /**
50
58
  * Treat a token as stale this long before it actually expires.
51
59
  *
@@ -56,26 +64,6 @@ export declare const REQUESTED_EXPIRES_IN = 3600;
56
64
  * expired credential, which is the exact mid-flight expiry this cache exists to prevent.
57
65
  */
58
66
  export declare const REFRESH_SKEW_MS: number;
59
- /** The token endpoint gets its own, much shorter budget than `/agent`. */
60
- export declare const TOKEN_TIMEOUT_MS = 15000;
61
- export interface TokenResponse {
62
- status: number;
63
- body: unknown;
64
- /** Only when there was no response at all to speak for itself. */
65
- error?: string;
66
- }
67
- export type TokenTransport = (url: string, form: Record<string, string>) => Promise<TokenResponse>;
68
- /**
69
- * Was this refusal about the SCOPE or about the CREDENTIAL?
70
- *
71
- * The two need completely different fixes — provisioning versus a password — so collapsing
72
- * them into one message sends someone to the wrong place entirely. Our form has five fields
73
- * and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
74
- * only really be about the scope.
75
- *
76
- * Nothing from the body is ever surfaced; the code is used to classify and then discarded.
77
- */
78
- export declare function refusalIsAboutScope(status: number, body: unknown): boolean;
79
67
  /**
80
68
  * The ways authentication can fail, kept apart because a user cannot act on them otherwise.
81
69
  *
@@ -99,22 +87,13 @@ export declare const AUTH_UNREACHABLE_DETAIL: string;
99
87
  */
100
88
  export declare const AUTH_SERVER_ERROR_DETAIL: (status: number) => string;
101
89
  export declare const AUTH_UNUSABLE_DETAIL: (status: number) => string;
102
- /** Drop every cached token. For tests, and for a credential rotation. */
103
- export declare function resetTokenCache(): void;
104
- /**
105
- * The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
106
- *
107
- * `raise_error: false` keeps the status-first contract this transport has always had: the
108
- * caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
109
- * so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
110
- */
111
- export declare function fetchTokenTransport(timeoutMs?: number): TokenTransport;
112
- /** The exact form body of the `client_credentials` grant. */
90
+ /** The exact form body of the `client_credentials` grant, at Atlas's scope. */
113
91
  export declare function mintForm(credentials: Credentials): Record<string, string>;
114
92
  /**
115
93
  * Return a valid token, minting one only when the cache has nothing fresh.
116
94
  *
117
- * Minting per tool call would add a round trip to every request and make the token endpoint
118
- * a hot dependency of the whole surface.
95
+ * NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
96
+ * process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
97
+ * data in module-level state there, so remote mode mints per call.
119
98
  */
120
- export declare function mintCentralToken(url: string, credentials: Credentials, transport: TokenTransport, now?: number): Promise<string>;
99
+ export declare function mintCentralToken(url: string, credentials: Credentials, transport: Parameters<typeof mintShared>[0]["transport"], now?: number): Promise<string>;