@typeship-ax/cli 0.22.0 → 0.23.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 (138) hide show
  1. package/AGENTS.md +12 -8
  2. package/README.md +14 -27
  3. package/api.json +8898 -9026
  4. package/api.md +369 -327
  5. package/dist/arguments.d.ts +47 -0
  6. package/dist/arguments.d.ts.map +1 -0
  7. package/dist/arguments.js +254 -0
  8. package/dist/cli-agent.d.ts +31 -8
  9. package/dist/cli-agent.d.ts.map +1 -1
  10. package/dist/cli-agent.js +146 -28
  11. package/dist/cli.js +483 -237
  12. package/dist/core/http.d.ts +162 -19
  13. package/dist/core/http.d.ts.map +1 -1
  14. package/dist/core/http.js +381 -48
  15. package/dist/core/pagination.d.ts +42 -6
  16. package/dist/core/pagination.d.ts.map +1 -1
  17. package/dist/core/pagination.js +111 -17
  18. package/dist/credential-storage.d.ts +10 -3
  19. package/dist/credential-storage.d.ts.map +1 -1
  20. package/dist/credential-storage.js +15 -6
  21. package/dist/dates.d.ts +1 -1
  22. package/dist/dates.js +1 -1
  23. package/dist/errors.d.ts +20 -84
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +20 -108
  26. package/dist/fields.d.ts +29 -0
  27. package/dist/fields.d.ts.map +1 -0
  28. package/dist/fields.js +101 -0
  29. package/dist/index.d.ts +28 -18
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +35 -25
  32. package/dist/named-credentials.d.ts +19 -0
  33. package/dist/named-credentials.d.ts.map +1 -1
  34. package/dist/named-credentials.js +81 -1
  35. package/dist/oauth-login.d.ts +8 -2
  36. package/dist/oauth-login.d.ts.map +1 -1
  37. package/dist/oauth-login.js +31 -19
  38. package/dist/oauth-request.d.ts +7 -1
  39. package/dist/oauth-request.d.ts.map +1 -1
  40. package/dist/oauth-request.js +26 -4
  41. package/dist/oauth-session.d.ts +13 -1
  42. package/dist/oauth-session.d.ts.map +1 -1
  43. package/dist/oauth-session.js +34 -18
  44. package/dist/ops.d.ts +53 -5
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +49 -40
  47. package/dist/polling-login.d.ts +8 -2
  48. package/dist/polling-login.d.ts.map +1 -1
  49. package/dist/polling-login.js +25 -11
  50. package/dist/resources/api-keys.d.ts +10 -7
  51. package/dist/resources/api-keys.d.ts.map +1 -1
  52. package/dist/resources/api-keys.js +10 -31
  53. package/dist/resources/deliveries.d.ts +88 -4
  54. package/dist/resources/deliveries.d.ts.map +1 -1
  55. package/dist/resources/deliveries.js +95 -18
  56. package/dist/resources/drafts.d.ts +15 -15
  57. package/dist/resources/drafts.d.ts.map +1 -1
  58. package/dist/resources/drafts.js +11 -64
  59. package/dist/resources/files.d.ts +4 -4
  60. package/dist/resources/files.d.ts.map +1 -1
  61. package/dist/resources/files.js +3 -12
  62. package/dist/resources/generations.d.ts +14 -14
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +21 -45
  65. package/dist/resources/organization.d.ts +4 -4
  66. package/dist/resources/organization.d.ts.map +1 -1
  67. package/dist/resources/organization.js +3 -10
  68. package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
  69. package/dist/resources/packages.d.ts.map +1 -0
  70. package/dist/resources/{generate.js → packages.js} +13 -29
  71. package/dist/resources/projects.d.ts +50 -50
  72. package/dist/resources/projects.d.ts.map +1 -1
  73. package/dist/resources/projects.js +60 -116
  74. package/dist/resources/releases.d.ts +21 -16
  75. package/dist/resources/releases.d.ts.map +1 -1
  76. package/dist/resources/releases.js +18 -39
  77. package/dist/resources/spec-revisions.d.ts +15 -6
  78. package/dist/resources/spec-revisions.d.ts.map +1 -1
  79. package/dist/resources/spec-revisions.js +6 -28
  80. package/dist/resources/specs.d.ts +7 -7
  81. package/dist/resources/specs.d.ts.map +1 -1
  82. package/dist/resources/specs.js +6 -34
  83. package/dist/resources/targets.d.ts +48 -48
  84. package/dist/resources/targets.d.ts.map +1 -1
  85. package/dist/resources/targets.js +58 -114
  86. package/dist/schemas.d.ts.map +1 -1
  87. package/dist/schemas.js +83 -81
  88. package/dist/search.d.ts +54 -0
  89. package/dist/search.d.ts.map +1 -0
  90. package/dist/search.js +421 -0
  91. package/dist/types.d.ts +499 -339
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/types.js +18 -18
  94. package/package.json +5 -2
  95. package/src/arguments.ts +242 -0
  96. package/src/cli-agent.ts +156 -30
  97. package/src/cli.ts +444 -211
  98. package/src/core/http.ts +457 -58
  99. package/src/core/pagination.ts +129 -18
  100. package/src/credential-storage.ts +16 -6
  101. package/src/dates.ts +1 -1
  102. package/src/errors.ts +46 -115
  103. package/src/fields.ts +91 -0
  104. package/src/index.ts +45 -28
  105. package/src/named-credentials.ts +66 -1
  106. package/src/oauth-login.ts +36 -21
  107. package/src/oauth-request.ts +32 -6
  108. package/src/oauth-session.ts +37 -19
  109. package/src/ops.ts +82 -44
  110. package/src/polling-login.ts +24 -11
  111. package/src/resources/api-keys.ts +34 -48
  112. package/src/resources/deliveries.ts +211 -30
  113. package/src/resources/drafts.ts +60 -107
  114. package/src/resources/files.ts +19 -20
  115. package/src/resources/generations.ts +57 -75
  116. package/src/resources/organization.ts +11 -16
  117. package/src/resources/{generate.ts → packages.ts} +43 -51
  118. package/src/resources/projects.ts +145 -200
  119. package/src/resources/releases.ts +48 -65
  120. package/src/resources/spec-revisions.ts +38 -47
  121. package/src/resources/specs.ts +39 -59
  122. package/src/resources/targets.ts +143 -193
  123. package/src/schemas.ts +83 -81
  124. package/src/search.ts +434 -0
  125. package/src/types.ts +538 -357
  126. package/dist/console-login-check.d.ts +0 -21
  127. package/dist/console-login-check.d.ts.map +0 -1
  128. package/dist/console-login-check.js +0 -107
  129. package/dist/console-login-contract.d.ts +0 -45
  130. package/dist/console-login-contract.d.ts.map +0 -1
  131. package/dist/console-login-contract.js +0 -40
  132. package/dist/resources/generate.d.ts.map +0 -1
  133. package/dist/resources/publications.d.ts +0 -47
  134. package/dist/resources/publications.d.ts.map +0 -1
  135. package/dist/resources/publications.js +0 -70
  136. package/src/console-login-check.ts +0 -88
  137. package/src/console-login-contract.ts +0 -65
  138. package/src/resources/publications.ts +0 -140
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- // typeship — client entry point.
2
- // Generated by typeship — https://typeship.dev
1
+ // Typeship — client entry point.
2
+ // Generated by Typeship — https://typeship.dev
3
3
 
4
4
  import {
5
5
  HttpCore,
@@ -10,34 +10,33 @@ import {
10
10
  type RequestContext,
11
11
  bearerAuth,
12
12
  } from "./core/http.js";
13
- import { DEFS, SCHEMAS } from "./schemas.js";
14
13
 
15
- import { GenerateResource } from "./resources/generate.js";
16
14
  import { ProjectsResource } from "./resources/projects.js";
17
15
  import { SpecsResource } from "./resources/specs.js";
18
16
  import { SpecRevisionsResource } from "./resources/spec-revisions.js";
19
17
  import { TargetsResource } from "./resources/targets.js";
20
- import { DraftsResource } from "./resources/drafts.js";
21
- import { ReleasesResource } from "./resources/releases.js";
22
18
  import { DeliveriesResource } from "./resources/deliveries.js";
23
- import { PublicationsResource } from "./resources/publications.js";
24
19
  import { GenerationsResource } from "./resources/generations.js";
20
+ import { DraftsResource } from "./resources/drafts.js";
21
+ import { ReleasesResource } from "./resources/releases.js";
25
22
  import { FilesResource } from "./resources/files.js";
23
+ import { PackagesResource } from "./resources/packages.js";
26
24
  import { OrganizationResource } from "./resources/organization.js";
27
25
  import { ApiKeysResource } from "./resources/api-keys.js";
28
26
 
29
27
  /** This package's version, also sent as the `User-Agent`. */
30
- export const VERSION = "0.22.0";
31
- const USER_AGENT = "@typeship-ax/cli/0.22.0";
28
+ export const VERSION = "0.23.1";
29
+ const USER_AGENT = "@typeship-ax/cli/0.23.1";
32
30
 
33
31
  export interface ClientOptions {
34
32
  /** Override the server URL. Default: `https://typeship.dev/api/v1` */
35
33
  baseUrl?: string;
36
34
  /**
37
35
  * Sent as `Authorization: Bearer <token>`. Pass a callback for tokens that expire; it is resolved
38
- * before every attempt.
36
+ * before every attempt, and after a 401 it is called once more with `{ rejected: true }` so it
37
+ * can refresh instead of returning the rejected token.
39
38
  */
40
- bearerToken?: string | (() => string | Promise<string>);
39
+ bearerToken?: AuthValue;
41
40
  /**
42
41
  * Credentials keyed by the Spec's security scheme names. Only one complete alternative is sent
43
42
  * for each operation. Named values take precedence over convenience options.
@@ -45,10 +44,15 @@ export interface ClientOptions {
45
44
  credentials?: {
46
45
  "apiKey"?: AuthValue;
47
46
  };
48
- /** Per-attempt timeout in milliseconds. Default: 30000. */
47
+ /** Per-attempt timeout in milliseconds. Default: 60000. */
49
48
  timeoutMs?: number;
50
49
  /** Retries after the first attempt (retryable failures only). Default: 2. */
51
50
  maxRetries?: number;
51
+ /**
52
+ * The longest wait a server may request (Retry-After, x-ratelimit-reset) that a retry honors; a
53
+ * longer one fails the call with the reset time. Default: 60000.
54
+ */
55
+ maxRetryWaitMs?: number;
52
56
  /** Custom fetch implementation (proxies, testing, instrumentation). */
53
57
  fetch?: typeof fetch;
54
58
  /** Headers sent with every request. */
@@ -83,7 +87,7 @@ export interface ClientOptions {
83
87
  }
84
88
 
85
89
  /**
86
- * typeship — v0.22.0
90
+ * Typeship — v0.23.1
87
91
  *
88
92
  * Resolve an OpenAPI or GraphQL Spec, diagnose it, and keep every
89
93
  * selected CLI, MCP, and SDK Target current.
@@ -100,21 +104,22 @@ export interface ClientOptions {
100
104
  * petstore Spec is a runnable sample.
101
105
  */
102
106
  export class TypeshipClient {
103
- readonly generate: GenerateResource;
104
107
  readonly projects: ProjectsResource;
105
108
  readonly specs: SpecsResource;
106
109
  readonly specRevisions: SpecRevisionsResource;
107
110
  readonly targets: TargetsResource;
108
- readonly drafts: DraftsResource;
109
- readonly releases: ReleasesResource;
110
111
  readonly deliveries: DeliveriesResource;
111
- readonly publications: PublicationsResource;
112
112
  readonly generations: GenerationsResource;
113
+ readonly drafts: DraftsResource;
114
+ readonly releases: ReleasesResource;
113
115
  readonly files: FilesResource;
116
+ readonly packages: PackagesResource;
114
117
  readonly organization: OrganizationResource;
115
118
  readonly apiKeys: ApiKeysResource;
119
+ private readonly _options: ClientOptions;
116
120
 
117
121
  constructor(options: ClientOptions = {}) {
122
+ this._options = options;
118
123
  // Identifies this package to the API (ignored by browsers, which
119
124
  // control their own User-Agent); override via defaultHeaders.
120
125
  const headers: Record<string, AuthValue> = { "User-Agent": USER_AGENT, ...options.defaultHeaders };
@@ -157,30 +162,42 @@ export class TypeshipClient {
157
162
  query,
158
163
  credentials,
159
164
  fetch: options.fetch ?? fetch,
160
- timeoutMs: options.timeoutMs ?? 30_000,
165
+ timeoutMs: options.timeoutMs ?? 60_000,
161
166
  maxRetries: options.maxRetries ?? 2,
167
+ maxRetryWaitMs: options.maxRetryWaitMs,
162
168
  onRequest: options.onRequest,
163
169
  onResponse: options.onResponse,
164
170
  onError: options.onError,
165
171
  debug,
166
172
  validate,
167
- schemas: validate ? SCHEMAS : undefined,
168
- schemaDefs: validate ? DEFS : undefined,
173
+ // Loaded on the first validated request, so the schema table costs
174
+ // nothing when validation is off.
175
+ loadSchemas: validate ? () => import("./schemas.js") : undefined,
169
176
  });
170
- this.generate = new GenerateResource(core);
171
177
  this.projects = new ProjectsResource(core);
172
178
  this.specs = new SpecsResource(core);
173
179
  this.specRevisions = new SpecRevisionsResource(core);
174
180
  this.targets = new TargetsResource(core);
175
- this.drafts = new DraftsResource(core);
176
- this.releases = new ReleasesResource(core);
177
181
  this.deliveries = new DeliveriesResource(core);
178
- this.publications = new PublicationsResource(core);
179
182
  this.generations = new GenerationsResource(core);
183
+ this.drafts = new DraftsResource(core);
184
+ this.releases = new ReleasesResource(core);
180
185
  this.files = new FilesResource(core);
186
+ this.packages = new PackagesResource(core);
181
187
  this.organization = new OrganizationResource(core);
182
188
  this.apiKeys = new ApiKeysResource(core);
183
189
  }
190
+
191
+ /**
192
+ * A client with this client's configuration and only these credentials,
193
+ * for a server acting on behalf of many users. Nothing is inherited from
194
+ * this client's credentials. The copy shares the configured fetch.
195
+ */
196
+ withCredentials(credentials: Pick<ClientOptions, "bearerToken" | "credentials">): TypeshipClient {
197
+ const options: Record<string, unknown> = { ...this._options };
198
+ for (const key of ["bearerToken","credentials"]) delete options[key];
199
+ return new TypeshipClient({ ...options, ...credentials } as ClientOptions);
200
+ }
184
201
  }
185
202
 
186
203
  export * from "./types.js";
@@ -188,6 +205,7 @@ export * from "./errors.js";
188
205
  export {
189
206
  formatDebugEvent,
190
207
  type AuthValue,
208
+ type CredentialContext,
191
209
  type DebugEvent,
192
210
  type RequestContext,
193
211
  type RequestOptions,
@@ -195,16 +213,15 @@ export {
195
213
  } from "./core/http.js";
196
214
  export { Page, PagePromise } from "./core/pagination.js";
197
215
 
198
- export * from "./resources/generate.js";
199
216
  export * from "./resources/projects.js";
200
217
  export * from "./resources/specs.js";
201
218
  export * from "./resources/spec-revisions.js";
202
219
  export * from "./resources/targets.js";
203
- export * from "./resources/drafts.js";
204
- export * from "./resources/releases.js";
205
220
  export * from "./resources/deliveries.js";
206
- export * from "./resources/publications.js";
207
221
  export * from "./resources/generations.js";
222
+ export * from "./resources/drafts.js";
223
+ export * from "./resources/releases.js";
208
224
  export * from "./resources/files.js";
225
+ export * from "./resources/packages.js";
209
226
  export * from "./resources/organization.js";
210
227
  export * from "./resources/api-keys.js";
@@ -34,7 +34,7 @@ export function resolveNamedCredentials(schemes: CredentialSchemes, layers: { na
34
34
  for (const layer of layers) {
35
35
  for (const [name, scheme] of Object.entries(schemes)) {
36
36
  for (const option of scheme.options) {
37
- if (option === "clientCredentials") continue;
37
+ if (option === "clientCredentials" || option === "refreshToken") continue;
38
38
  const value = layer.options?.[option];
39
39
  if (value !== undefined) resolved[name] = value as NamedCredential;
40
40
  }
@@ -52,6 +52,28 @@ export function namedCredentialAvailability(schemes: CredentialSchemes, options:
52
52
  return options;
53
53
  }
54
54
 
55
+ /** The schemes an interactive OAuth login session authenticates by name. When
56
+ * the API also declares a separate non-OAuth bearer scheme, the convenience
57
+ * bearer token belongs to that scheme, so the session must be passed to each
58
+ * OAuth scheme by name. Empty: the session is the convenience bearer token. */
59
+ export function oauthSessionSchemes(schemes: CredentialSchemes): string[] {
60
+ return Object.entries(schemes).filter(([, scheme]) => (scheme.options.includes("clientCredentials") || scheme.options.includes("refreshToken")) && !scheme.options.includes("bearerToken")).map(([name]) => name);
61
+ }
62
+
63
+ /** Whether resolved client options satisfy one complete credential alternative
64
+ * of an operation. Returns null when satisfied (or when no credential is
65
+ * required); otherwise the named alternatives and the schemes each lacks. */
66
+ export function missingCredentials(schemes: CredentialSchemes, credentialOptions: string[][] | undefined, options: Record<string, unknown>): { alternatives: string[][]; missing: string[][] } | null {
67
+ const supplied = (key: string, value: unknown): boolean => typeof value === "function" || (typeof value === "string" && value.length > 0)
68
+ || (key === "clientCredentials" && value !== null && typeof value === "object")
69
+ || (value !== null && typeof value === "object" && "username" in value && "password" in value && typeof value.username === "string" && value.username.length > 0 && typeof value.password === "string" && value.password.length > 0);
70
+ const named = Object.fromEntries(Object.entries((options.credentials ?? {}) as Record<string, unknown>).filter(([name, value]) => supplied(name, value))) as NamedCredentials;
71
+ const available = namedCredentialAvailability(schemes, new Set(Object.keys(options).filter((key) => key !== "credentials" && supplied(key, options[key]))), named);
72
+ if (credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => available.has(option)))) return null;
73
+ const alternatives = (credentialOptions ?? []).filter((alternative) => alternative.length > 0 && alternative.every((option) => option.startsWith("credentials."))).map((alternative) => alternative.map((option) => option.slice("credentials.".length)));
74
+ return { alternatives, missing: alternatives.map((alternative) => alternative.filter((name) => !available.has("credentials." + name))) };
75
+ }
76
+
55
77
  /** Bound file/stdin reads before allocating a full credential document. */
56
78
  export function readNamedCredentialsFile(input: string, schemes: CredentialSchemes): NamedCredentials {
57
79
  let fd: number | undefined;
@@ -72,3 +94,46 @@ export function readNamedCredentialsFile(input: string, schemes: CredentialSchem
72
94
  } finally { if (fd !== undefined && input !== "-") closeSync(fd); }
73
95
  return parseNamedCredentials(Buffer.concat(chunks).toString("utf8"), schemes);
74
96
  }
97
+
98
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
99
+ const TRANSPORT_HEADERS = new Set(["host", "content-length", "content-type", "transfer-encoding", "connection"]);
100
+
101
+ /** Extra request headers from repeated `--header "Name: value"` flags and a
102
+ * `<PREFIX>_HEADERS` variable (a JSON object, or one `Name: value` per line).
103
+ * The escape hatch for credentials a spec does not declare. Flags win over
104
+ * the environment; values never appear in errors. */
105
+ export function parseExtraHeaders(environment: string | undefined, flags: readonly string[], variable: string): Record<string, string> {
106
+ const entries: [string, string][] = [];
107
+ const line = (text: string, source: string) => {
108
+ const colon = text.indexOf(":");
109
+ if (colon <= 0) throw new Error(source + " expects \"Name: value\".");
110
+ entries.push([text.slice(0, colon).trim(), text.slice(colon + 1).trim()]);
111
+ };
112
+ if (environment !== undefined && environment.trim()) {
113
+ const text = environment.trim();
114
+ if (text.startsWith("{")) {
115
+ let value: unknown;
116
+ try { value = JSON.parse(text); } catch { throw new Error(variable + " must be a JSON object of header names to values, or one \"Name: value\" per line."); }
117
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.values(value).some((entry) => typeof entry !== "string")) throw new Error(variable + " must map header names to string values.");
118
+ entries.push(...Object.entries(value as Record<string, string>));
119
+ } else for (const part of text.split(/\r?\n/)) if (part.trim()) line(part, variable);
120
+ }
121
+ for (const flag of flags) line(flag, "--header");
122
+ const headers: Record<string, string> = {};
123
+ for (const [name, value] of entries) {
124
+ if (!HEADER_NAME.test(name)) throw new Error("Header names may contain only token characters. Check --header and " + variable + ".");
125
+ if (TRANSPORT_HEADERS.has(name.toLowerCase())) throw new Error("The " + name + " header is set by the client and cannot be overridden.");
126
+ if (/[\r\n\0]/.test(value)) throw new Error("Header values cannot contain line breaks. Check --header and " + variable + ".");
127
+ for (const existing of Object.keys(headers)) if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing];
128
+ headers[name] = value;
129
+ }
130
+ return headers;
131
+ }
132
+
133
+ /** Apply extra headers last, replacing any header of the same name. */
134
+ export function applyExtraHeaders(target: Record<string, string>, extra: Record<string, string>): void {
135
+ for (const [name, value] of Object.entries(extra)) {
136
+ for (const existing of Object.keys(target)) if (existing.toLowerCase() === name.toLowerCase()) delete target[existing];
137
+ target[name] = value;
138
+ }
139
+ }
@@ -1,9 +1,16 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
- import { oauthJsonRequest, OAuthResponseError } from "./oauth-request.js";
3
+ import { oauthJsonRequest, OAuthResponseError, providerErrorOf } from "./oauth-request.js";
4
+
5
+ /** Browser OAuth login did not complete; the message names the cause. */
6
+ export class OAuthLoginError extends Error {
7
+ constructor(message?: string) { super(message); this.name = "OAuthLoginError"; }
8
+ }
4
9
 
5
10
  export interface OAuthLoginConfig {
6
- issuer: string;
11
+ /** Exact issuer, when the provider publishes metadata. Without one, the
12
+ * explicit authorization and token URLs from the API Spec are used. */
13
+ issuer?: string | null;
7
14
  clientId: string;
8
15
  discoveryUrl?: string;
9
16
  authorizationUrl?: string;
@@ -21,7 +28,7 @@ export interface OAuthLoginSession {
21
28
  accessToken: string;
22
29
  refreshToken?: string;
23
30
  expiresAt?: number;
24
- issuer: string;
31
+ issuer?: string;
25
32
  clientId: string;
26
33
  tokenUrl: string;
27
34
  /** The actual native callback used for this exchange, including its port. */
@@ -41,14 +48,19 @@ function endpoint(value: string): URL {
41
48
  const url = new URL(value);
42
49
  const loopback = ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname);
43
50
  if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password || url.hash) {
44
- throw new Error("OAuth endpoints require HTTPS without credentials or fragments; loopback HTTP is allowed for development.");
51
+ throw new OAuthLoginError("OAuth endpoints require HTTPS without credentials or fragments; loopback HTTP is allowed for development.");
45
52
  }
46
53
  return url;
47
54
  }
48
55
 
49
56
  async function metadata(config: OAuthLoginConfig, signal: AbortSignal) {
57
+ // The Spec's authorizationCode flow names both endpoints; no discovery.
58
+ if (!config.issuer) {
59
+ if (config.authorizationUrl && config.tokenUrl) return { authorizationUrl: endpoint(config.authorizationUrl), tokenUrl: endpoint(config.tokenUrl).href };
60
+ throw new OAuthLoginError("Browser login needs the provider's authorization and token URLs, or an issuer that publishes them.");
61
+ }
50
62
  const issuer = endpoint(config.issuer);
51
- if (issuer.search) throw new Error("The OAuth issuer must not contain a query.");
63
+ if (issuer.search) throw new OAuthLoginError("The OAuth issuer must not contain a query.");
52
64
  const path = issuer.pathname.replace(/\/$/, "");
53
65
  const urls = config.discoveryUrl ? [config.discoveryUrl] : [
54
66
  issuer.origin + "/.well-known/oauth-authorization-server" + path,
@@ -63,13 +75,13 @@ async function metadata(config: OAuthLoginConfig, signal: AbortSignal) {
63
75
  }
64
76
  if (response.status !== 200) continue;
65
77
  const data = response.data!;
66
- if (data.issuer !== config.issuer) throw new Error("OAuth discovery returned a different issuer. Check the issuer and discovery URL.");
78
+ if (data.issuer !== config.issuer) throw new OAuthLoginError("OAuth discovery returned a different issuer. Check the issuer and discovery URL.");
67
79
  if (Array.isArray(data.code_challenge_methods_supported) && !data.code_challenge_methods_supported.includes("S256")) {
68
- throw new Error("The provider does not advertise S256 PKCE support.");
80
+ throw new OAuthLoginError("The provider does not advertise S256 PKCE support.");
69
81
  }
70
82
  const authorizationUrl = config.authorizationUrl ?? data.authorization_endpoint;
71
83
  const tokenUrl = config.tokenUrl ?? data.token_endpoint;
72
- if (typeof authorizationUrl !== "string" || typeof tokenUrl !== "string") throw new Error("Browser login requires authorization and token endpoints.");
84
+ if (typeof authorizationUrl !== "string" || typeof tokenUrl !== "string") throw new OAuthLoginError("Browser login requires authorization and token endpoints.");
73
85
  return {
74
86
  authorizationUrl: endpoint(authorizationUrl), tokenUrl: endpoint(tokenUrl).href,
75
87
  ...(typeof data.revocation_endpoint === "string" ? { revocationUrl: endpoint(data.revocation_endpoint).href } : {}),
@@ -80,20 +92,20 @@ async function metadata(config: OAuthLoginConfig, signal: AbortSignal) {
80
92
  if (config.authorizationUrl && config.tokenUrl) return {
81
93
  authorizationUrl: endpoint(config.authorizationUrl), tokenUrl: endpoint(config.tokenUrl).href,
82
94
  };
83
- throw new Error("Could not discover OAuth endpoints. Check the issuer or provide explicit authorization and token URLs.");
95
+ throw new OAuthLoginError("Could not discover OAuth endpoints. Check the issuer or provide explicit authorization and token URLs.");
84
96
  }
85
97
 
86
98
  /** Authorization Code + S256 PKCE for a public native client. The listener
87
99
  * binds only a loopback IP, verifies state/issuer, and closes on every path. */
88
100
  export async function oauthBrowserLogin(config: OAuthLoginConfig, interaction: OAuthLoginInteraction): Promise<OAuthLoginSession> {
89
- if (!config.clientId.trim()) throw new Error("Browser login requires a public client ID.");
90
- if (config.organization && (!["organization", "organization_id"].includes(config.organization.parameter) || typeof config.organization.id !== "string" || !config.organization.id || config.organization.id.length > 512 || /\s|[\u0000-\u001F\u007F]/.test(config.organization.id))) throw new Error("Organization selection requires a supported parameter and a nonempty organization ID without whitespace.");
101
+ if (!config.clientId.trim()) throw new OAuthLoginError("Browser login requires a public client ID.");
102
+ if (config.organization && (!["organization", "organization_id"].includes(config.organization.parameter) || typeof config.organization.id !== "string" || !config.organization.id || config.organization.id.length > 512 || /\s|[\u0000-\u001F\u007F]/.test(config.organization.id))) throw new OAuthLoginError("Organization selection requires a supported parameter and a nonempty organization ID without whitespace.");
91
103
  const signal = AbortSignal.any([interaction.signal ?? new AbortController().signal, AbortSignal.timeout(interaction.timeoutMs ?? 600_000)]);
92
104
  signal.throwIfAborted();
93
105
  const endpoints = await metadata(config, signal);
94
106
  const redirect = new URL(config.redirectUri ?? "http://127.0.0.1/callback");
95
107
  if (redirect.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(redirect.hostname) || redirect.username || redirect.password || redirect.search || redirect.hash) {
96
- throw new Error("The OAuth redirect must be an HTTP loopback IP URL without credentials, query, or fragment.");
108
+ throw new OAuthLoginError("The OAuth redirect must be an HTTP loopback IP URL without credentials, query, or fragment.");
97
109
  }
98
110
  const verifier = randomBytes(32).toString("base64url");
99
111
  const state = randomBytes(32).toString("base64url");
@@ -120,24 +132,27 @@ export async function oauthBrowserLogin(config: OAuthLoginConfig, interaction: O
120
132
  response.writeHead(400).end("Invalid login state. Return to the terminal and try again."); return;
121
133
  }
122
134
  const returnedIssuer = incoming.searchParams.get("iss");
123
- if (returnedIssuer !== null && (incoming.searchParams.getAll("iss").length !== 1 || returnedIssuer !== config.issuer)) {
135
+ if (returnedIssuer !== null && config.issuer && (incoming.searchParams.getAll("iss").length !== 1 || returnedIssuer !== config.issuer)) {
124
136
  response.writeHead(400).end("The login issuer did not match."); return;
125
137
  }
126
138
  accepted = true;
127
139
  if (incoming.searchParams.has("error")) {
128
- response.writeHead(400).end("Login was not approved. Return to the terminal.", () => rejectCode(new Error("OAuth authorization was denied or cancelled. Run login again to retry.")));
140
+ const provider = providerErrorOf({ error: incoming.searchParams.get("error"), error_description: incoming.searchParams.get("error_description") ?? undefined });
141
+ const denied = incoming.searchParams.get("error") === "access_denied";
142
+ response.writeHead(400).end("Login was not approved. Return to the terminal.", () => rejectCode(new OAuthLoginError(
143
+ (provider ? "The provider returned " + provider + ". " : "") + (denied ? "OAuth authorization was denied or cancelled. Run login again to retry." : "Check the client ID, the registered redirect URI and the requested scopes, then run login again."))));
129
144
  return;
130
145
  }
131
146
  const code = incoming.searchParams.get("code");
132
147
  if (!code || incoming.searchParams.getAll("code").length !== 1) {
133
- response.writeHead(400).end("The provider returned no authorization code.", () => rejectCode(new Error("OAuth callback did not contain one authorization code.")));
148
+ response.writeHead(400).end("The provider returned no authorization code.", () => rejectCode(new OAuthLoginError("OAuth callback did not contain one authorization code.")));
134
149
  return;
135
150
  }
136
151
  response.end("Authorization received. Return to the terminal to finish signing in.", () => resolveCode(code));
137
152
  });
138
153
  server.headersTimeout = 10_000;
139
154
  server.requestTimeout = 10_000;
140
- const aborted = () => rejectCode(new Error("Browser login timed out or was cancelled. Run login again to retry."));
155
+ const aborted = () => rejectCode(new OAuthLoginError("Browser login timed out or was cancelled. Run login again to retry."));
141
156
  signal.addEventListener("abort", aborted, { once: true });
142
157
  try {
143
158
  await new Promise<void>((resolve, reject) => {
@@ -145,13 +160,13 @@ export async function oauthBrowserLogin(config: OAuthLoginConfig, interaction: O
145
160
  server.listen(Number(redirect.port) || 0, redirect.hostname === "[::1]" ? "::1" : "127.0.0.1", () => { server.off("error", reject); resolve(); });
146
161
  });
147
162
  const address = server.address();
148
- if (!address || typeof address === "string") throw new Error("Could not start the OAuth callback listener.");
163
+ if (!address || typeof address === "string") throw new OAuthLoginError("Could not start the OAuth callback listener.");
149
164
  redirect.port = String(address.port);
150
165
  signal.throwIfAborted();
151
166
  const authorization = new URL(endpoints.authorizationUrl);
152
167
  if (config.organization) {
153
168
  const other = config.organization.parameter === "organization" ? "organization_id" : "organization";
154
- if (authorization.searchParams.has(other)) throw new Error("The authorization URL contains a conflicting organization parameter. Update the provider configuration.");
169
+ if (authorization.searchParams.has(other)) throw new OAuthLoginError("The authorization URL contains a conflicting organization parameter. Update the provider configuration.");
155
170
  authorization.searchParams.set(config.organization.parameter, config.organization.id);
156
171
  }
157
172
  for (const [key, value] of Object.entries({ response_type: "code", client_id: config.clientId, redirect_uri: redirect.href, state, code_challenge: challenge, code_challenge_method: "S256", ...(config.scopes?.length ? { scope: config.scopes.join(" ") } : {}), ...(config.audience ? { audience: config.audience } : {}), ...(config.resource ? { resource: config.resource } : {}) })) authorization.searchParams.set(key, value);
@@ -165,14 +180,14 @@ export async function oauthBrowserLogin(config: OAuthLoginConfig, interaction: O
165
180
  });
166
181
  const data = response.data;
167
182
  if (response.status !== 200 || !data || typeof data.access_token !== "string" || !data.access_token || typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer") {
168
- throw new Error(`OAuth token exchange failed (HTTP ${response.status}). Check the public client registration and run login again.`);
183
+ throw new OAuthLoginError(`OAuth token exchange failed (HTTP ${response.status}${response.providerError ? ": " + response.providerError : ""}). Check the public client registration and run login again.`);
169
184
  }
170
- if (data.expires_in !== undefined && (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0)) throw new Error("OAuth provider returned an invalid token lifetime.");
185
+ if (data.expires_in !== undefined && (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0)) throw new OAuthLoginError("OAuth provider returned an invalid token lifetime.");
171
186
  return {
172
187
  accessToken: data.access_token,
173
188
  ...(typeof data.refresh_token === "string" && data.refresh_token ? { refreshToken: data.refresh_token } : {}),
174
189
  ...(typeof data.expires_in === "number" ? { expiresAt: Date.now() + data.expires_in * 1000 } : {}),
175
- issuer: config.issuer, clientId: config.clientId, tokenUrl: endpoints.tokenUrl, redirectUri: redirect.href,
190
+ ...(config.issuer ? { issuer: config.issuer } : {}), clientId: config.clientId, tokenUrl: endpoints.tokenUrl, redirectUri: redirect.href,
176
191
  ...(endpoints.revocationUrl ? { revocationUrl: endpoints.revocationUrl } : {}),
177
192
  ...(typeof data.scope === "string" ? { scopes: data.scope.split(/\s+/).filter(Boolean) } : {}),
178
193
  };
@@ -16,9 +16,23 @@ export class OAuthResponseError extends Error {
16
16
  }
17
17
 
18
18
  export type DeviceOAuthError = "authorization_pending" | "slow_down" | "access_denied" | "expired_token";
19
- interface AuthenticationResponse { status: number; data?: Record<string, unknown>; error?: DeviceOAuthError }
19
+ interface AuthenticationResponse {
20
+ status: number; data?: Record<string, unknown>; error?: DeviceOAuthError;
21
+ /** RFC 6749 `error` and `error_description` from a 400 or 401 body, as
22
+ * "code: description". Only those two fields, printable ASCII, bounded. */
23
+ providerError?: string;
24
+ }
25
+
26
+ /** The provider's standard error fields, never any other body content. */
27
+ export function providerErrorOf(data: Record<string, unknown>): string | undefined {
28
+ const code = typeof data.error === "string" && /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,64}$/.test(data.error) ? data.error : undefined;
29
+ if (!code) return undefined;
30
+ const description = typeof data.error_description === "string" ? data.error_description.replace(/[^\x20-\x7e]/g, " ").trim().slice(0, 300) : "";
31
+ return description ? code + ": " + description : code;
32
+ }
20
33
 
21
- /** Error bodies and transport causes may contain credentials and are discarded. */
34
+ /** Transport causes and error bodies beyond the standard OAuth `error` and
35
+ * `error_description` fields may contain credentials and are discarded. */
22
36
  export function oauthJsonRequest(url: string | URL, init: RequestInit = {}, timeoutMs = 30_000): Promise<AuthenticationResponse> {
23
37
  return boundedRequest(url, init, timeoutMs, "json");
24
38
  }
@@ -39,6 +53,9 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
39
53
  let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
40
54
  let timer: ReturnType<typeof setTimeout> | undefined;
41
55
  let interrupted: "timed_out" | "cancelled" | undefined;
56
+ // An error status is already an answer: its body only adds the provider's
57
+ // explanation, so a stalled or malformed error body never becomes a failure.
58
+ let errorStatus: number | undefined;
42
59
  let rejectDeadline!: (reason: Error) => void;
43
60
  const deadline = new Promise<never>((_, reject) => { rejectDeadline = reject; });
44
61
  const stop = (code: "timed_out" | "cancelled") => { interrupted ??= code; rejectDeadline(new OAuthResponseError(interrupted)); controller.abort(); };
@@ -56,7 +73,8 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
56
73
  }), deadline,
57
74
  ]);
58
75
  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 }; }
76
+ if (mode === "status" || response.status !== 200 && response.status !== 400 && response.status !== 401) { void response.body?.cancel().catch(() => {}); return { status: response.status }; }
77
+ if (response.status !== 200) errorStatus = response.status;
60
78
  if (!response.body) throw new OAuthResponseError("invalid_response");
61
79
  reader = response.body.getReader();
62
80
  let size = 0, text = "";
@@ -72,14 +90,22 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
72
90
  try { text += decoder.decode(); }
73
91
  catch { throw new OAuthResponseError("invalid_response"); }
74
92
  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");
93
+ try { data = JSON.parse(text); } catch {
94
+ if (response.status !== 200) return { status: response.status };
95
+ throw new OAuthResponseError("invalid_response");
96
+ }
97
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
98
+ if (response.status !== 200) return { status: response.status };
99
+ throw new OAuthResponseError("invalid_response");
100
+ }
77
101
  if (response.status !== 200) {
78
102
  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 } : {}) };
103
+ const providerError = providerErrorOf(data as Record<string, unknown>);
104
+ return { status: response.status, ...(providerError ? { providerError } : {}), ...(mode === "device" && ["authorization_pending", "slow_down", "access_denied", "expired_token"].includes(String(error)) && typeof error === "string" ? { error: error as DeviceOAuthError } : {}) };
80
105
  }
81
106
  return { status: response.status, data: data as Record<string, unknown> };
82
107
  } catch (error) {
108
+ if (errorStatus !== undefined && interrupted !== "cancelled") return { status: errorStatus };
83
109
  if (interrupted) throw new OAuthResponseError(interrupted);
84
110
  if (error instanceof OAuthResponseError) throw error;
85
111
  throw new OAuthResponseError("request_failed");
@@ -26,9 +26,9 @@ export interface CredentialDestination { apiBaseUrl: string; environment?: strin
26
26
 
27
27
  export function assertCredentialDestination(credentials: StoredCredentials, destination: CredentialDestination): void {
28
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.");
29
+ if (!saved) throw new OAuthSessionError("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 OAuthSessionError("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 OAuthSessionError("The credential environment or profile changed. Log in again before using saved credentials.");
32
32
  }
33
33
 
34
34
  export interface StoredCredentials {
@@ -67,7 +67,7 @@ export function credentialIdentityBinding(credentials: StoredCredentials, identi
67
67
  export function assertStoredIdentity(credentials: StoredCredentials, identity?: IdentityConfiguration): void {
68
68
  if (!identity) return;
69
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.");
70
+ throw new OAuthSessionError("The saved login has no matching API identity verification. Sign in again to verify the current account and organization.");
71
71
  }
72
72
  }
73
73
 
@@ -99,7 +99,14 @@ function alive(pid: number): boolean {
99
99
  try { process.kill(pid, 0); return true; } catch (error) { return code(error) !== "ESRCH"; }
100
100
  }
101
101
 
102
- export class CredentialStorageError extends Error {}
102
+ /** The OS credential store or the saved credential file cannot be used. */
103
+ export class CredentialStorageError extends Error {
104
+ constructor(message?: string) { super(message); this.name = "CredentialStorageError"; }
105
+ }
106
+ /** A saved login cannot be used as is; the remedy is to sign in again. */
107
+ export class OAuthSessionError extends Error {
108
+ constructor(message?: string) { super(message); this.name = "OAuthSessionError"; }
109
+ }
103
110
 
104
111
  export interface CredentialCodec {
105
112
  readonly name: string;
@@ -127,7 +134,7 @@ export class FileCredentialStore implements CredentialStore {
127
134
  } catch (error) {
128
135
  if (code(error) === "ENOENT") return null;
129
136
  if (error instanceof CredentialStorageError) throw error;
130
- throw new Error("Cannot read saved credentials. Check the credential store before logging in again.");
137
+ throw new CredentialStorageError("Cannot read saved credentials. Check the credential store before logging in again.");
131
138
  }
132
139
  }
133
140
 
@@ -154,9 +161,9 @@ export class FileCredentialStore implements CredentialStore {
154
161
  while (!acquired) {
155
162
  try { linkSync(claim, lock); acquired = true; }
156
163
  catch (error) {
157
- if (code(error) !== "EEXIST") throw new Error("Cannot lock the credential store.");
164
+ if (code(error) !== "EEXIST") throw new CredentialStorageError("Cannot lock the credential store.");
158
165
  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 + ".");
166
+ if (Date.now() >= deadline) throw new CredentialStorageError("The credential store is busy. Wait for the other login or refresh to finish and retry. If its process stopped, inspect " + lock + ".");
160
167
  await new Promise((resolve) => setTimeout(resolve, 25));
161
168
  }
162
169
  }
@@ -199,13 +206,13 @@ export class FileCredentialStore implements CredentialStore {
199
206
 
200
207
  function validSession(credentials: StoredCredentials | null, config: SessionConfiguration, sessionId: string, allowPending = false): StoredOAuthSession {
201
208
  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
+ if (!session || !session.sessionId || session.sessionId !== sessionId) throw new OAuthSessionError("The saved OAuth session changed or was logged out. Run login again and retry the request.");
210
+ if (session.apiBaseUrl && new URL(session.apiBaseUrl).href !== new URL(config.apiBaseUrl).href) throw new OAuthSessionError("The API destination changed. Log in again.");
211
+ if (session.issuer && config.issuer && session.issuer !== config.issuer) throw new OAuthSessionError("The configured issuer changed. Log in again.");
212
+ if (session.configuredClientId !== (config.clientId ?? null)) throw new OAuthSessionError("The OAuth client changed. Log in again.");
213
+ if (!session.binding || session.binding !== sessionBinding(config)) throw new OAuthSessionError("The API or OAuth configuration changed. Log in again before using saved credentials.");
214
+ if (session.refreshPending && !allowPending) throw new OAuthSessionError("The previous OAuth refresh did not finish safely. Log in again; its refresh token will not be reused.");
215
+ if (typeof session.accessToken !== "string" || !session.accessToken) throw new OAuthSessionError("The saved OAuth session is invalid. Log in again.");
209
216
  assertStoredIdentity(credentials!, config.identity);
210
217
  return session;
211
218
  }
@@ -213,15 +220,17 @@ function validSession(credentials: StoredCredentials | null, config: SessionConf
213
220
  /** Re-read on every request attempt; only one process may rotate the token.
214
221
  * A failed or interrupted exchange requires login, since the server may have
215
222
  * 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);
223
+ export async function oauthSessionToken(store: CredentialStore, config: SessionConfiguration, sessionId: string, tokenParams: Record<string, string> = {}, rejected?: string): Promise<string> {
224
+ // A token the API just rejected (401) is refreshed once even if unexpired;
225
+ // another process may already have replaced it, which is fine.
226
+ const fresh = (session: StoredOAuthSession) => session.accessToken !== rejected && (session.expiresAt === undefined || (Number.isFinite(session.expiresAt) && session.expiresAt > Date.now() + 60_000));
218
227
  const first = validSession(store.read(), config, sessionId, true);
219
228
  if (!first.refreshPending && fresh(first)) return first.accessToken;
220
229
  return store.withLock(async (locked) => {
221
230
  const credentials = locked.read();
222
231
  const session = validSession(credentials, config, sessionId);
223
232
  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.");
233
+ if (!session.refreshToken || !session.tokenUrl || !session.clientId) throw new OAuthSessionError(session.accessToken === rejected ? "The API rejected the saved OAuth session and it has no refresh token. Log in again." : "The OAuth session expired and cannot be refreshed. Log in again.");
225
234
  const endpoint = new URL(session.tokenUrl);
226
235
  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
236
  locked.write({ ...credentials, oauth: { ...session, refreshPending: true } });
@@ -252,7 +261,16 @@ export async function oauthSessionToken(store: CredentialStore, config: SessionC
252
261
  return next.accessToken;
253
262
  } catch {
254
263
  // 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.");
264
+ throw new OAuthSessionError("The OAuth session could not be refreshed safely. Log in again.");
256
265
  }
257
266
  });
258
267
  }
268
+
269
+ /** A saved-session token for the request runtime: resolved before every
270
+ * attempt, and after a 401 (invalidate) the next resolve forces one refresh
271
+ * of the token the API rejected. */
272
+ export function sessionCredential(resolve: (rejected?: string) => Promise<string>): (() => Promise<string>) & { invalidate(): void } {
273
+ let last: string | undefined, rejected: string | undefined;
274
+ const token = async () => { const previous = rejected; rejected = undefined; last = await resolve(previous); return last; };
275
+ return Object.assign(token, { invalidate() { rejected = last; } });
276
+ }