@typeship-ax/mcp 0.8.0 → 0.10.0

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 (103) hide show
  1. package/AGENTS.md +31 -0
  2. package/README.md +66 -9
  3. package/api.json +3153 -761
  4. package/api.md +9796 -382
  5. package/dist/api-identity.d.ts +40 -0
  6. package/dist/api-identity.d.ts.map +1 -0
  7. package/dist/api-identity.js +128 -0
  8. package/dist/auth-profiles.d.ts +30 -0
  9. package/dist/auth-profiles.d.ts.map +1 -0
  10. package/dist/auth-profiles.js +138 -0
  11. package/dist/core/http.d.ts +17 -2
  12. package/dist/core/http.d.ts.map +1 -1
  13. package/dist/core/http.js +78 -17
  14. package/dist/credential-storage.d.ts +24 -0
  15. package/dist/credential-storage.d.ts.map +1 -0
  16. package/dist/credential-storage.js +207 -0
  17. package/dist/docs.d.ts +25 -0
  18. package/dist/docs.d.ts.map +1 -1
  19. package/dist/docs.js +144 -0
  20. package/dist/errors.d.ts +18 -10
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +24 -14
  23. package/dist/index.d.ts +10 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +20 -4
  26. package/dist/mcp-authorization.d.ts +52 -0
  27. package/dist/mcp-authorization.d.ts.map +1 -0
  28. package/dist/mcp-authorization.js +232 -0
  29. package/dist/mcp-protocol.d.ts +51 -2
  30. package/dist/mcp-protocol.d.ts.map +1 -1
  31. package/dist/mcp-protocol.js +249 -37
  32. package/dist/mcp.d.ts +21 -3
  33. package/dist/mcp.d.ts.map +1 -1
  34. package/dist/mcp.js +185 -68
  35. package/dist/named-credentials.d.ts +21 -0
  36. package/dist/named-credentials.d.ts.map +1 -0
  37. package/dist/named-credentials.js +86 -0
  38. package/dist/oauth-request.d.ts +21 -0
  39. package/dist/oauth-request.d.ts.map +1 -0
  40. package/dist/oauth-request.js +119 -0
  41. package/dist/oauth-session.d.ts +106 -0
  42. package/dist/oauth-session.d.ts.map +1 -0
  43. package/dist/oauth-session.js +244 -0
  44. package/dist/ops.d.ts +14 -1
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +34 -30
  47. package/dist/resources/account.d.ts +2 -2
  48. package/dist/resources/account.d.ts.map +1 -1
  49. package/dist/resources/account.js +1 -0
  50. package/dist/resources/api-keys.d.ts +3 -3
  51. package/dist/resources/api-keys.d.ts.map +1 -1
  52. package/dist/resources/api-keys.js +2 -0
  53. package/dist/resources/definition-revisions.d.ts +5 -5
  54. package/dist/resources/definition-revisions.d.ts.map +1 -1
  55. package/dist/resources/definition-revisions.js +4 -0
  56. package/dist/resources/definitions.d.ts +15 -4
  57. package/dist/resources/definitions.d.ts.map +1 -1
  58. package/dist/resources/definitions.js +11 -2
  59. package/dist/resources/generate.d.ts +14 -3
  60. package/dist/resources/generate.d.ts.map +1 -1
  61. package/dist/resources/generate.js +10 -2
  62. package/dist/resources/generations.d.ts +3 -3
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +2 -0
  65. package/dist/resources/projects.d.ts +56 -20
  66. package/dist/resources/projects.d.ts.map +1 -1
  67. package/dist/resources/projects.js +40 -4
  68. package/dist/resources/targets.d.ts +84 -10
  69. package/dist/resources/targets.d.ts.map +1 -1
  70. package/dist/resources/targets.js +127 -2
  71. package/dist/schemas.d.ts.map +1 -1
  72. package/dist/schemas.js +55 -26
  73. package/dist/types.d.ts +602 -123
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +24 -0
  76. package/dist/worker.js +4 -4
  77. package/package.json +11 -1
  78. package/server.json +42 -0
  79. package/src/api-identity.ts +98 -0
  80. package/src/auth-profiles.ts +114 -0
  81. package/src/core/http.ts +88 -19
  82. package/src/credential-storage.ts +183 -0
  83. package/src/docs.ts +138 -0
  84. package/src/errors.ts +26 -15
  85. package/src/index.ts +29 -4
  86. package/src/mcp-authorization.ts +211 -0
  87. package/src/mcp-protocol.ts +287 -38
  88. package/src/mcp.ts +186 -72
  89. package/src/named-credentials.ts +74 -0
  90. package/src/oauth-request.ts +90 -0
  91. package/src/oauth-session.ts +258 -0
  92. package/src/ops.ts +48 -31
  93. package/src/resources/account.ts +3 -0
  94. package/src/resources/api-keys.ts +5 -0
  95. package/src/resources/definition-revisions.ts +9 -0
  96. package/src/resources/definitions.ts +25 -0
  97. package/src/resources/generate.ts +23 -0
  98. package/src/resources/generations.ts +5 -0
  99. package/src/resources/projects.ts +95 -7
  100. package/src/resources/targets.ts +241 -0
  101. package/src/schemas.ts +55 -26
  102. package/src/types.ts +640 -123
  103. package/src/worker.ts +4 -4
@@ -0,0 +1,90 @@
1
+
2
+ export class OAuthResponseError extends Error {
3
+ readonly code: "request_failed" | "timed_out" | "cancelled" | "response_too_large" | "invalid_response";
4
+ constructor(code: OAuthResponseError["code"]) {
5
+ const messages = {
6
+ request_failed: "OAuth request failed. Check the configured provider endpoint.",
7
+ timed_out: "OAuth request timed out. Run login again to retry.",
8
+ cancelled: "OAuth request was cancelled.",
9
+ response_too_large: "OAuth provider response exceeded 1 MiB.",
10
+ invalid_response: "OAuth provider returned an invalid JSON response.",
11
+ };
12
+ super(messages[code]);
13
+ this.code = code;
14
+ this.name = "OAuthResponseError";
15
+ }
16
+ }
17
+
18
+ export type DeviceOAuthError = "authorization_pending" | "slow_down" | "access_denied" | "expired_token";
19
+ interface AuthenticationResponse { status: number; data?: Record<string, unknown>; error?: DeviceOAuthError }
20
+
21
+ /** Error bodies and transport causes may contain credentials and are discarded. */
22
+ export function oauthJsonRequest(url: string | URL, init: RequestInit = {}, timeoutMs = 30_000): Promise<AuthenticationResponse> {
23
+ return boundedRequest(url, init, timeoutMs, "json");
24
+ }
25
+ /** Device polling needs four protocol errors. Return only their exact names,
26
+ * never descriptions, error URIs, arbitrary error codes or other body fields. */
27
+ export function oauthDeviceRequest(url: string | URL, init: RequestInit = {}, timeoutMs = 30_000): Promise<AuthenticationResponse> {
28
+ return boundedRequest(url, init, timeoutMs, "device");
29
+ }
30
+ /** Revocation may return an empty success body. Do not wait for or parse it. */
31
+ export function oauthStatusRequest(url: string | URL, init: RequestInit = {}, timeoutMs = 30_000): Promise<{ status: number }> {
32
+ return boundedRequest(url, init, timeoutMs, "status");
33
+ }
34
+
35
+ /** Bound the entire exchange, including decoded response bytes. */
36
+ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: number, mode: "json" | "device" | "status"): Promise<AuthenticationResponse> {
37
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 30_000) throw new OAuthResponseError("request_failed");
38
+ const controller = new AbortController();
39
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
40
+ let timer: ReturnType<typeof setTimeout> | undefined;
41
+ let interrupted: "timed_out" | "cancelled" | undefined;
42
+ let rejectDeadline!: (reason: Error) => void;
43
+ const deadline = new Promise<never>((_, reject) => { rejectDeadline = reject; });
44
+ const stop = (code: "timed_out" | "cancelled") => { interrupted ??= code; rejectDeadline(new OAuthResponseError(interrupted)); controller.abort(); };
45
+ const cancel = () => stop("cancelled");
46
+ init.signal?.addEventListener("abort", cancel, { once: true });
47
+ timer = setTimeout(() => stop("timed_out"), timeoutMs);
48
+ try {
49
+ if (init.signal?.aborted) throw new OAuthResponseError("cancelled");
50
+ const request: RequestInit & { cache: "no-store" } = { ...init, redirect: "error", credentials: "omit", cache: "no-store", signal: controller.signal };
51
+ const response = await Promise.race([
52
+ fetch(url, request).then((value) => {
53
+ // Also dispose a late response from a transport that ignored abort.
54
+ if (controller.signal.aborted) void value.body?.cancel().catch(() => {});
55
+ return value;
56
+ }), deadline,
57
+ ]);
58
+ if (response.redirected) { void response.body?.cancel().catch(() => {}); throw new OAuthResponseError("request_failed"); }
59
+ if (mode === "status" || response.status !== 200 && !(mode === "device" && response.status === 400)) { void response.body?.cancel().catch(() => {}); return { status: response.status }; }
60
+ if (!response.body) throw new OAuthResponseError("invalid_response");
61
+ reader = response.body.getReader();
62
+ let size = 0, text = "";
63
+ const decoder = new TextDecoder("utf-8", { fatal: true });
64
+ while (true) {
65
+ const chunk = await Promise.race([reader.read(), deadline]);
66
+ if (chunk.done) break;
67
+ size += chunk.value.byteLength;
68
+ if (size > 1_048_576) throw new OAuthResponseError("response_too_large");
69
+ try { text += decoder.decode(chunk.value, { stream: true }); }
70
+ catch { throw new OAuthResponseError("invalid_response"); }
71
+ }
72
+ try { text += decoder.decode(); }
73
+ catch { throw new OAuthResponseError("invalid_response"); }
74
+ let data: unknown;
75
+ try { data = JSON.parse(text); } catch { throw new OAuthResponseError("invalid_response"); }
76
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new OAuthResponseError("invalid_response");
77
+ if (response.status !== 200) {
78
+ const error = (data as Record<string, unknown>).error;
79
+ return { status: response.status, ...(["authorization_pending", "slow_down", "access_denied", "expired_token"].includes(String(error)) && typeof error === "string" ? { error: error as DeviceOAuthError } : {}) };
80
+ }
81
+ return { status: response.status, data: data as Record<string, unknown> };
82
+ } catch (error) {
83
+ if (interrupted) throw new OAuthResponseError(interrupted);
84
+ if (error instanceof OAuthResponseError) throw error;
85
+ throw new OAuthResponseError("request_failed");
86
+ } finally {
87
+ clearTimeout(timer); init.signal?.removeEventListener("abort", cancel); controller.abort();
88
+ if (reader) { void reader.cancel().catch(() => {}); reader.releaseLock(); }
89
+ }
90
+ }
@@ -0,0 +1,258 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { assertApiIdentity, type IdentityConfiguration, type VerifiedIdentity } from "./api-identity.js";
5
+ import { oauthJsonRequest } from "./oauth-request.js";
6
+
7
+ export interface StoredOAuthSession {
8
+ accessToken: string;
9
+ refreshToken?: string;
10
+ expiresAt?: number;
11
+ tokenUrl?: string;
12
+ issuer?: string;
13
+ clientId?: string;
14
+ revocationUrl?: string;
15
+ apiBaseUrl?: string;
16
+ configuredClientId?: string | null;
17
+ scopes?: string[];
18
+ /** New login identity, independent of rotating tokens. */
19
+ sessionId: string;
20
+ binding: string;
21
+ /** Persisted before exchange: an interrupted exchange must not replay a rotating token. */
22
+ refreshPending?: boolean;
23
+ }
24
+
25
+ export interface CredentialDestination { apiBaseUrl: string; environment?: string; profile?: string }
26
+
27
+ export function assertCredentialDestination(credentials: StoredCredentials, destination: CredentialDestination): void {
28
+ const saved = credentials.destination;
29
+ if (!saved) throw new Error("Saved credentials have no API and profile binding. Log in again before using them.");
30
+ if (new URL(saved.apiBaseUrl).href !== new URL(destination.apiBaseUrl).href) throw new Error("The API destination changed. Log in again before using saved credentials.");
31
+ if ((saved.environment ?? null) !== (destination.environment ?? null) || (saved.profile ?? null) !== (destination.profile ?? null)) throw new Error("The credential environment or profile changed. Log in again before using saved credentials.");
32
+ }
33
+
34
+ export interface StoredCredentials {
35
+ destination?: CredentialDestination;
36
+ named?: Record<string, string | { username: string; password: string }>;
37
+ scalars?: Record<string, string>;
38
+ basic?: { username: string; password: string };
39
+ oauth?: StoredOAuthSession;
40
+ identity?: VerifiedIdentity & { binding: string };
41
+ minted?: { via: "browser"; key_name: string; org_id?: string; revocationUrl?: string };
42
+ }
43
+
44
+ export interface SessionConfiguration extends CredentialDestination {
45
+ identity?: IdentityConfiguration;
46
+ verifyIdentity?: (accessToken: string) => Promise<VerifiedIdentity>;
47
+ clientId?: string | null;
48
+ issuer?: string | null;
49
+ tokenUrl?: string | null;
50
+ discoveryUrl?: string | null;
51
+ authorizationUrl?: string | null;
52
+ scopes?: string[];
53
+ audience?: string;
54
+ resource?: string;
55
+ organizationParameter?: "organization" | "organization_id";
56
+ }
57
+
58
+ /** Bind minimal identity evidence to this credential set and its configured read. */
59
+ export function credentialIdentityBinding(credentials: StoredCredentials, identity: IdentityConfiguration): string {
60
+ return createHash("sha256").update(JSON.stringify({
61
+ destination: credentials.destination, scalars: credentials.scalars, named: credentials.named, basic: credentials.basic,
62
+ oauth: credentials.oauth ? { sessionId: credentials.oauth.sessionId, binding: credentials.oauth.binding } : undefined,
63
+ operation: identity.operation, fields: Object.entries(identity.fields).sort(([a], [b]) => a.localeCompare(b)),
64
+ })).digest("hex");
65
+ }
66
+
67
+ export function assertStoredIdentity(credentials: StoredCredentials, identity?: IdentityConfiguration): void {
68
+ if (!identity) return;
69
+ if (!credentials.identity?.values || credentials.identity.binding !== credentialIdentityBinding(credentials, identity) || Object.keys(identity.fields).some((kind) => !Object.hasOwn(credentials.identity!.values, kind))) {
70
+ throw new Error("The saved login has no matching API identity verification. Sign in again to verify the current account and organization.");
71
+ }
72
+ }
73
+
74
+ /** Bind the login to effective nonsecret settings. Scope ordering is irrelevant. */
75
+ export function sessionBinding(config: SessionConfiguration): string {
76
+ const url = (value?: string | null) => value ? new URL(value).href : null;
77
+ return createHash("sha256").update(JSON.stringify({
78
+ apiBaseUrl: url(config.apiBaseUrl), environment: config.environment ?? null, profile: config.profile ?? null,
79
+ clientId: config.clientId ?? null, issuer: config.issuer ?? null,
80
+ tokenUrl: url(config.tokenUrl), discoveryUrl: url(config.discoveryUrl), authorizationUrl: url(config.authorizationUrl),
81
+ scopes: [...new Set(config.scopes ?? [])].sort(), audience: config.audience ?? null, resource: config.resource ?? null,
82
+ organizationParameter: config.organizationParameter ?? null,
83
+ identity: config.identity ? { operation: config.identity.operation, fields: Object.entries(config.identity.fields).sort(([a], [b]) => a.localeCompare(b)) } : null,
84
+ })).digest("hex");
85
+ }
86
+
87
+ interface LockedCredentials {
88
+ read(): StoredCredentials | null;
89
+ write(credentials: StoredCredentials | null): void;
90
+ }
91
+
92
+ export interface CredentialStore {
93
+ read(): StoredCredentials | null;
94
+ withLock<T>(work: (store: LockedCredentials) => Promise<T>): Promise<T>;
95
+ }
96
+
97
+ function code(error: unknown): string | undefined { return (error as NodeJS.ErrnoException)?.code; }
98
+ function alive(pid: number): boolean {
99
+ try { process.kill(pid, 0); return true; } catch (error) { return code(error) !== "ESRCH"; }
100
+ }
101
+
102
+ export class CredentialStorageError extends Error {}
103
+
104
+ export interface CredentialCodec {
105
+ readonly name: string;
106
+ prepare(): void;
107
+ encode(plaintext: string): string;
108
+ decode(encoded: string): string;
109
+ }
110
+
111
+ /** Private file adapter. All writers (login, refresh and logout) use the same
112
+ * cross-process lock. Credential storage can be replaced independently of OAuth. */
113
+ export class FileCredentialStore implements CredentialStore {
114
+ constructor(readonly path: string, private readonly lockTimeoutMs = 40_000, private readonly codec?: CredentialCodec) {}
115
+
116
+ get backend(): string { return this.codec?.name ?? "private plaintext file"; }
117
+
118
+ /** Check storage access before starting an interactive login. */
119
+ async prepare(): Promise<void> { await this.withLock(async () => { this.codec?.prepare(); }); }
120
+
121
+ read(): StoredCredentials | null {
122
+ try {
123
+ const encoded = readFileSync(this.path, "utf8");
124
+ const data: unknown = JSON.parse(this.codec ? this.codec.decode(encoded) : encoded);
125
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error();
126
+ return data as StoredCredentials;
127
+ } catch (error) {
128
+ if (code(error) === "ENOENT") return null;
129
+ if (error instanceof CredentialStorageError) throw error;
130
+ throw new Error("Cannot read saved credentials. Check the credential store before logging in again.");
131
+ }
132
+ }
133
+
134
+ private write(credentials: StoredCredentials | null): void {
135
+ if (credentials === null) { rmSync(this.path, { force: true }); return; }
136
+ const temporary = this.path + "." + randomBytes(16).toString("hex") + ".tmp";
137
+ try {
138
+ const plaintext = JSON.stringify(credentials, null, 2);
139
+ writeFileSync(temporary, (this.codec ? this.codec.encode(plaintext) : plaintext) + "\n", { mode: 0o600, flag: "wx", flush: true });
140
+ renameSync(temporary, this.path);
141
+ } finally { rmSync(temporary, { force: true }); }
142
+ }
143
+
144
+ async withLock<T>(work: (store: LockedCredentials) => Promise<T>): Promise<T> {
145
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
146
+ const lock = this.path + ".lock";
147
+ const owner = { pid: process.pid, nonce: randomBytes(16).toString("hex") };
148
+ const claim = lock + "." + owner.nonce;
149
+ // Hard-link a complete owner record: no empty-file window if the process dies.
150
+ writeFileSync(claim, JSON.stringify(owner), { mode: 0o600, flag: "wx" });
151
+ let acquired = false;
152
+ const deadline = Date.now() + this.lockTimeoutMs;
153
+ try {
154
+ while (!acquired) {
155
+ try { linkSync(claim, lock); acquired = true; }
156
+ catch (error) {
157
+ if (code(error) !== "EEXIST") throw new Error("Cannot lock the credential store.");
158
+ this.recoverDeadOwner(lock);
159
+ if (Date.now() >= deadline) throw new Error("The credential store is busy. Wait for the other login or refresh to finish and retry. If its process stopped, inspect " + lock + ".");
160
+ await new Promise((resolve) => setTimeout(resolve, 25));
161
+ }
162
+ }
163
+ return await work({ read: () => this.read(), write: (value) => this.write(value) });
164
+ } finally {
165
+ if (acquired) rmSync(lock, { force: true });
166
+ rmSync(claim, { force: true });
167
+ }
168
+ }
169
+
170
+ private recoverDeadOwner(lock: string): void {
171
+ let previous: { pid?: unknown; nonce?: unknown };
172
+ try { previous = JSON.parse(readFileSync(lock, "utf8")); } catch { return; }
173
+ if (!Number.isSafeInteger(previous?.pid) || (previous.pid as number) <= 0 || typeof previous.nonce !== "string" || !/^[a-f0-9]{32}$/.test(previous.nonce) || alive(previous.pid as number)) return;
174
+ // One reclaimer per dead owner. Keep this empty marker: deleting it lets a
175
+ // delayed contender remove a later owner's lock (the ABA race). A double
176
+ // crash during reclamation fails closed with the bounded busy diagnostic.
177
+ const marker = lock + ".reaped-" + previous.nonce;
178
+ try { closeSync(openSync(marker, "wx", 0o600)); } catch { return; }
179
+ try {
180
+ const current = JSON.parse(readFileSync(lock, "utf8")) as { nonce?: string };
181
+ if (current.nonce === previous.nonce) rmSync(lock);
182
+ } catch { /* another process already completed recovery */ }
183
+ }
184
+
185
+ async update(change: (current: StoredCredentials) => StoredCredentials): Promise<void> {
186
+ await this.withLock(async (store) => { store.write(change(store.read() ?? {})); });
187
+ }
188
+
189
+ /** Explicit local-only logout can remove an unreadable encrypted session. */
190
+ async clear(): Promise<boolean> {
191
+ return this.withLock(async (store) => { const existed = existsSync(this.path); store.write(null); return existed; });
192
+ }
193
+
194
+ /** Remove under the refresh lock, then revoke the latest token outside it. */
195
+ async take(): Promise<StoredCredentials | null> {
196
+ return this.withLock(async (store) => { const current = store.read(); store.write(null); return current; });
197
+ }
198
+ }
199
+
200
+ function validSession(credentials: StoredCredentials | null, config: SessionConfiguration, sessionId: string, allowPending = false): StoredOAuthSession {
201
+ const session = credentials?.oauth;
202
+ if (!session || !session.sessionId || session.sessionId !== sessionId) throw new Error("The saved OAuth session changed or was logged out. Run login again and retry the request.");
203
+ if (session.apiBaseUrl && new URL(session.apiBaseUrl).href !== new URL(config.apiBaseUrl).href) throw new Error("The API destination changed. Log in again.");
204
+ if (session.issuer && config.issuer && session.issuer !== config.issuer) throw new Error("The configured issuer changed. Log in again.");
205
+ if (session.configuredClientId !== (config.clientId ?? null)) throw new Error("The OAuth client changed. Log in again.");
206
+ if (!session.binding || session.binding !== sessionBinding(config)) throw new Error("The API or OAuth configuration changed. Log in again before using saved credentials.");
207
+ if (session.refreshPending && !allowPending) throw new Error("The previous OAuth refresh did not finish safely. Log in again; its refresh token will not be reused.");
208
+ if (typeof session.accessToken !== "string" || !session.accessToken) throw new Error("The saved OAuth session is invalid. Log in again.");
209
+ assertStoredIdentity(credentials!, config.identity);
210
+ return session;
211
+ }
212
+
213
+ /** Re-read on every request attempt; only one process may rotate the token.
214
+ * A failed or interrupted exchange requires login, since the server may have
215
+ * consumed its refresh token even when no response reached this process. */
216
+ export async function oauthSessionToken(store: CredentialStore, config: SessionConfiguration, sessionId: string, tokenParams: Record<string, string> = {}): Promise<string> {
217
+ const fresh = (session: StoredOAuthSession) => session.expiresAt === undefined || (Number.isFinite(session.expiresAt) && session.expiresAt > Date.now() + 60_000);
218
+ const first = validSession(store.read(), config, sessionId, true);
219
+ if (!first.refreshPending && fresh(first)) return first.accessToken;
220
+ return store.withLock(async (locked) => {
221
+ const credentials = locked.read();
222
+ const session = validSession(credentials, config, sessionId);
223
+ if (fresh(session)) return session.accessToken;
224
+ if (!session.refreshToken || !session.tokenUrl || !session.clientId) throw new Error("The OAuth session expired and cannot be refreshed. Log in again.");
225
+ const endpoint = new URL(session.tokenUrl);
226
+ if ((endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname))) || endpoint.username || endpoint.password || endpoint.hash) throw new Error("The OAuth token endpoint requires HTTPS; loopback HTTP is allowed for development.");
227
+ locked.write({ ...credentials, oauth: { ...session, refreshPending: true } });
228
+ try {
229
+ const response = await oauthJsonRequest(endpoint, {
230
+ method: "POST",
231
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
232
+ body: new URLSearchParams({ ...tokenParams, grant_type: "refresh_token", refresh_token: session.refreshToken, client_id: session.clientId }),
233
+ });
234
+ const body = response.data;
235
+ if (response.status !== 200 || !body || typeof body.access_token !== "string" || !body.access_token || typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer") throw new Error();
236
+ if (body.expires_in !== undefined && (typeof body.expires_in !== "number" || !Number.isFinite(body.expires_in) || body.expires_in <= 0)) throw new Error();
237
+ if (body.refresh_token !== undefined && (typeof body.refresh_token !== "string" || !body.refresh_token)) throw new Error();
238
+ if (body.scope !== undefined && typeof body.scope !== "string") throw new Error();
239
+ const next: StoredOAuthSession = {
240
+ ...session, accessToken: body.access_token, refreshToken: body.refresh_token as string | undefined ?? session.refreshToken,
241
+ expiresAt: typeof body.expires_in === "number" ? Date.now() + body.expires_in * 1000 : undefined,
242
+ ...(typeof body.scope === "string" ? { scopes: body.scope.split(/\s+/).filter(Boolean) } : {}), refreshPending: undefined,
243
+ };
244
+ let identity = credentials?.identity;
245
+ if (config.identity) {
246
+ if (!config.verifyIdentity || !identity) throw new Error();
247
+ const verified = await config.verifyIdentity(next.accessToken);
248
+ assertApiIdentity(identity.values, verified.values);
249
+ identity = { ...identity, ...verified };
250
+ }
251
+ locked.write({ ...credentials, oauth: next, ...(identity ? { identity } : {}) });
252
+ return next.accessToken;
253
+ } catch {
254
+ // Never expose an error body, a refresh token, or fetch's nested cause.
255
+ throw new Error("The OAuth session could not be refreshed safely. Log in again.");
256
+ }
257
+ });
258
+ }