@typeship-ax/cli 0.8.0 → 0.9.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.
- package/AGENTS.md +31 -0
- package/README.md +26 -9
- package/api.json +1875 -684
- package/api.md +352 -500
- package/dist/api-identity.d.ts +40 -0
- package/dist/api-identity.d.ts.map +1 -0
- package/dist/api-identity.js +128 -0
- package/dist/auth-profiles.d.ts +30 -0
- package/dist/auth-profiles.d.ts.map +1 -0
- package/dist/auth-profiles.js +138 -0
- package/dist/cli.js +522 -355
- package/dist/console-login-check.d.ts +21 -0
- package/dist/console-login-check.d.ts.map +1 -0
- package/dist/console-login-check.js +107 -0
- package/dist/console-login-contract.d.ts +45 -0
- package/dist/console-login-contract.d.ts.map +1 -0
- package/dist/console-login-contract.js +40 -0
- package/dist/core/http.d.ts +17 -2
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +78 -17
- package/dist/credential-storage.d.ts +24 -0
- package/dist/credential-storage.d.ts.map +1 -0
- package/dist/credential-storage.js +207 -0
- package/dist/docs.d.ts +25 -0
- package/dist/docs.d.ts.map +1 -1
- package/dist/docs.js +144 -0
- package/dist/errors.d.ts +18 -10
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +24 -14
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/named-credentials.d.ts +21 -0
- package/dist/named-credentials.d.ts.map +1 -0
- package/dist/named-credentials.js +86 -0
- package/dist/oauth-login.d.ts +39 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +171 -0
- package/dist/oauth-request.d.ts +21 -0
- package/dist/oauth-request.d.ts.map +1 -0
- package/dist/oauth-request.js +119 -0
- package/dist/oauth-session.d.ts +106 -0
- package/dist/oauth-session.d.ts.map +1 -0
- package/dist/oauth-session.js +244 -0
- package/dist/ops.d.ts +14 -1
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +30 -30
- package/dist/polling-login.d.ts +57 -0
- package/dist/polling-login.d.ts.map +1 -0
- package/dist/polling-login.js +204 -0
- package/dist/resources/account.d.ts +2 -2
- package/dist/resources/account.d.ts.map +1 -1
- package/dist/resources/account.js +1 -0
- package/dist/resources/api-keys.d.ts +3 -3
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +2 -0
- package/dist/resources/definition-revisions.d.ts +5 -5
- package/dist/resources/definition-revisions.d.ts.map +1 -1
- package/dist/resources/definition-revisions.js +4 -0
- package/dist/resources/definitions.d.ts +15 -4
- package/dist/resources/definitions.d.ts.map +1 -1
- package/dist/resources/definitions.js +11 -2
- package/dist/resources/generate.d.ts +14 -3
- package/dist/resources/generate.d.ts.map +1 -1
- package/dist/resources/generate.js +10 -2
- package/dist/resources/generations.d.ts +3 -3
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +2 -0
- package/dist/resources/projects.d.ts +56 -20
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +40 -4
- package/dist/resources/targets.d.ts +20 -9
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +14 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +38 -22
- package/dist/types.d.ts +385 -119
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +11 -0
- package/package.json +2 -1
- package/src/api-identity.ts +98 -0
- package/src/auth-profiles.ts +114 -0
- package/src/cli.ts +444 -332
- package/src/console-login-check.ts +88 -0
- package/src/console-login-contract.ts +65 -0
- package/src/core/http.ts +88 -19
- package/src/credential-storage.ts +183 -0
- package/src/docs.ts +138 -0
- package/src/errors.ts +26 -15
- package/src/index.ts +29 -4
- package/src/named-credentials.ts +74 -0
- package/src/oauth-login.ts +184 -0
- package/src/oauth-request.ts +90 -0
- package/src/oauth-session.ts +258 -0
- package/src/ops.ts +44 -31
- package/src/polling-login.ts +165 -0
- package/src/resources/account.ts +3 -0
- package/src/resources/api-keys.ts +5 -0
- package/src/resources/definition-revisions.ts +9 -0
- package/src/resources/definitions.ts +25 -0
- package/src/resources/generate.ts +23 -0
- package/src/resources/generations.ts +5 -0
- package/src/resources/projects.ts +95 -7
- package/src/resources/targets.ts +32 -0
- package/src/schemas.ts +38 -22
- package/src/types.ts +404 -119
package/src/docs.ts
CHANGED
|
@@ -55,6 +55,144 @@ function safeHttpUrl(value: string | null): string | null {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface GuideMatch {
|
|
59
|
+
title: string;
|
|
60
|
+
section: string | null;
|
|
61
|
+
/** Kept for callers that displayed the original heading-only results. */
|
|
62
|
+
heading: string;
|
|
63
|
+
excerpt: string;
|
|
64
|
+
url: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface GuidePage { title: string; url: string; description: string }
|
|
68
|
+
|
|
69
|
+
/** Links in an index follow URL semantics, including site-root relative links. */
|
|
70
|
+
function docsLink(base: string | null, indexUrl: string | null, value: string): string | null {
|
|
71
|
+
const index = resolveDocsContentUrl(base, indexUrl, "llms.txt");
|
|
72
|
+
if (!index) return null;
|
|
73
|
+
try { return resolveDocsContentUrl(base, indexUrl, new URL(value, index).toString()); }
|
|
74
|
+
catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function docsIndexPages(index: string | null, base: string | null, indexUrl: string | null): GuidePage[] {
|
|
78
|
+
const pages = new Map<string, GuidePage>();
|
|
79
|
+
for (const line of (index ?? "").split("\n")) {
|
|
80
|
+
for (const match of line.matchAll(/\[([^\]]+)\]\(([^\s)]+)\)/g)) {
|
|
81
|
+
const url = docsLink(base, indexUrl, match[2]!);
|
|
82
|
+
if (!url || /\/llms(?:-full)?\.txt(?:[?#]|$)/.test(url)) continue;
|
|
83
|
+
const description = line.slice(match.index! + match[0].length).replace(/^\s*:\s*/, "").trim();
|
|
84
|
+
if (!pages.has(url)) pages.set(url, { title: match[1]!, url, description: cleanDocsText(description) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...pages.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cleanDocsText(text: string): string {
|
|
91
|
+
return text.replace(/^\s*(?:>\s*)+/, "").replace(/!?\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/<[^>]*>/g, "").replace(/[*`]/g, "").replace(/^\s*[-*>]\s*/, "").replace(/\s+/g, " ").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function docsTerms(query: string): string[] {
|
|
95
|
+
return [...new Set(query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length >= 2))];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Rank sections, then keep the strongest section of each real page. A heading
|
|
99
|
+
* match must have content: blank lines, fences, and source markers are not hits. */
|
|
100
|
+
export function searchGuidePages(
|
|
101
|
+
prose: string | null, index: string | null, base: string | null, indexUrl: string | null, query: string,
|
|
102
|
+
): GuideMatch[] {
|
|
103
|
+
const pages = docsIndexPages(index, base, indexUrl);
|
|
104
|
+
const terms = docsTerms(query);
|
|
105
|
+
if (terms.length === 0) return [];
|
|
106
|
+
const phrase = query.trim().toLowerCase();
|
|
107
|
+
const candidates: { match: GuideMatch; score: number }[] = [];
|
|
108
|
+
const add = (title: string, section: string | null, url: string | null, lines: string[]) => {
|
|
109
|
+
if (!url) return;
|
|
110
|
+
const content = lines.map(cleanDocsText).filter((line) => line.length > 0);
|
|
111
|
+
if (content.length === 0) return;
|
|
112
|
+
const heading = (title + " " + (section ?? "")).toLowerCase();
|
|
113
|
+
const body = content.join(" ").toLowerCase();
|
|
114
|
+
const matched = terms.filter((term) => heading.includes(term) || body.includes(term));
|
|
115
|
+
if (matched.length === 0) return;
|
|
116
|
+
const score = matched.length * 10 + (matched.length === terms.length ? 50 : 0)
|
|
117
|
+
+ (heading.includes(phrase) ? 35 : body.includes(phrase) ? 25 : 0)
|
|
118
|
+
+ (title.toLowerCase().includes(phrase) ? 25 : 0)
|
|
119
|
+
+ terms.filter((term) => title.toLowerCase().includes(term)).length * 5
|
|
120
|
+
+ terms.filter((term) => heading.includes(term)).length * 5
|
|
121
|
+
+ terms.filter((term) => section?.toLowerCase().includes(term)).length * 3;
|
|
122
|
+
const excerpt = [...content].sort((a, b) => {
|
|
123
|
+
const rank = (line: string) => terms.filter((term) => line.toLowerCase().includes(term)).length;
|
|
124
|
+
return rank(b) - rank(a);
|
|
125
|
+
})[0]!.slice(0, 240);
|
|
126
|
+
candidates.push({ match: { title, section, heading: section ?? title, excerpt, url }, score });
|
|
127
|
+
};
|
|
128
|
+
let title = "";
|
|
129
|
+
let section: string | null = null;
|
|
130
|
+
let url: string | null = null;
|
|
131
|
+
let lines: string[] = [];
|
|
132
|
+
let fence: string | null = null;
|
|
133
|
+
const flush = () => { add(title, section, url, lines); lines = []; };
|
|
134
|
+
for (const line of (prose ?? "").split("\n")) {
|
|
135
|
+
const fenced = line.trim().match(/^(`{3,}|~{3,})/);
|
|
136
|
+
if (fenced) { if (fence === null) fence = fenced[1]![0]!; else if (fenced[1]![0] === fence) fence = null; continue; }
|
|
137
|
+
if (fence !== null) { if (line.trim()) lines.push(line); continue; }
|
|
138
|
+
const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*$/);
|
|
139
|
+
if (heading) {
|
|
140
|
+
flush();
|
|
141
|
+
if (heading[1] === "#") {
|
|
142
|
+
title = cleanDocsText(heading[2]!);
|
|
143
|
+
section = null;
|
|
144
|
+
const link = heading[2]!.match(/\[[^\]]+\]\(([^\s)]+)\)/);
|
|
145
|
+
url = link ? docsLink(base, indexUrl, link[1]!) : pages.find((page) => page.title.toLowerCase() === title.toLowerCase())?.url ?? null;
|
|
146
|
+
} else section = cleanDocsText(heading[2]!);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const source = line.match(/^Source:\s*(?:\[[^\]]*\]\()?<?(https?:\/\/[^\s)>]+)>?\)?\s*$/i);
|
|
150
|
+
if (source) {
|
|
151
|
+
url = docsLink(base, indexUrl, source[1]!);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// Markdown callouts are blockquotes; keep their text for search and excerpts.
|
|
155
|
+
if (!line.trim() || /^\s*(?:---+|\|[\s:|-]+\|)\s*$/.test(line) || /^\s*</.test(line)) continue;
|
|
156
|
+
lines.push(line);
|
|
157
|
+
}
|
|
158
|
+
flush();
|
|
159
|
+
// An index is useful without llms-full.txt, and may include additional pages.
|
|
160
|
+
const described = new Set(candidates.map(({ match }) => match.url.replace(/\.md(?=[?#]|$)/, "")));
|
|
161
|
+
for (const page of pages) {
|
|
162
|
+
if (!described.has(page.url.replace(/\.md(?=[?#]|$)/, ""))) add(page.title, null, page.url, [page.description || page.title]);
|
|
163
|
+
}
|
|
164
|
+
candidates.sort((a, b) => b.score - a.score || a.match.title.localeCompare(b.match.title) || a.match.url.localeCompare(b.match.url));
|
|
165
|
+
const distinct = new Map<string, GuideMatch>();
|
|
166
|
+
for (const { match } of candidates) {
|
|
167
|
+
const key = match.url.replace(/\.md(?=[?#]|$)/, "");
|
|
168
|
+
if (!distinct.has(key)) distinct.set(key, match);
|
|
169
|
+
}
|
|
170
|
+
return [...distinct.values()];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function searchConnectedGuides(
|
|
174
|
+
base: string | null, indexUrl: string | null, fetchText: (path: string) => Promise<string | null>, query: string,
|
|
175
|
+
): Promise<{ guides: GuideMatch[]; status: "not_configured" | "unavailable" | "ok" }> {
|
|
176
|
+
if (!base && !indexUrl) return { guides: [], status: "not_configured" };
|
|
177
|
+
const [index, prose] = await Promise.all([fetchText("llms.txt"), fetchText("llms-full.txt")]);
|
|
178
|
+
return { guides: searchGuidePages(prose, index, base, indexUrl, query), status: index === null && prose === null ? "unavailable" : "ok" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function docsReadTarget(index: string | null, base: string | null, indexUrl: string | null, page: string): string {
|
|
182
|
+
if (/^https?:\/\//.test(page)) return page;
|
|
183
|
+
const pages = docsIndexPages(index, base, indexUrl);
|
|
184
|
+
const term = page.toLowerCase();
|
|
185
|
+
const exact = pages.find((item) => item.title.toLowerCase() === term || new URL(item.url).pathname.toLowerCase() === term);
|
|
186
|
+
if (exact) return exact.url;
|
|
187
|
+
const matches = pages.filter((item) => item.url.toLowerCase().includes(term));
|
|
188
|
+
return matches.length === 1 ? matches[0]!.url : page;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One shell argument, including URLs with quotes, query strings, or fragments. */
|
|
192
|
+
export function docsReadCommand(bin: string, url: string): string {
|
|
193
|
+
return bin + " docs read '" + url.replace(/'/g, "'\\''") + "'";
|
|
194
|
+
}
|
|
195
|
+
|
|
58
196
|
/** Markdown-preferred fetch with same-origin redirects, one deadline, and a
|
|
59
197
|
* streaming byte cap. Returns null for every invalid or failed read. */
|
|
60
198
|
export async function fetchDocsText(url: string): Promise<string | null> {
|
package/src/errors.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { ApiError, type ResponseMeta } from "./core/http.js";
|
|
5
5
|
import type { ErrorModel, ErrorModelRead } from "./types.js";
|
|
6
6
|
|
|
7
|
-
export { ApiError, TransportError, UnexpectedApiError, ValidationError, type Violation, unwrap } from "./core/http.js";
|
|
7
|
+
export { ApiError, ResponseParseError, TransportError, UnexpectedApiError, ValidationError, type Violation, unwrap } from "./core/http.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The request body, Definition source, target selection, or package name is invalid.
|
|
@@ -36,6 +36,16 @@ export class ForbiddenError extends ApiError<403, ErrorModelRead> {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The key identifies changed intent.
|
|
41
|
+
* Raised for HTTP 409 responses.
|
|
42
|
+
*/
|
|
43
|
+
export class ConflictError extends ApiError<409, ErrorModelRead> {
|
|
44
|
+
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
45
|
+
super("The key identifies changed intent.", 409, body, response);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/**
|
|
40
50
|
* Spec exceeds the 10MB limit.
|
|
41
51
|
* Raised for HTTP 413 responses.
|
|
@@ -57,12 +67,13 @@ export class UnprocessableEntityError extends ApiError<422, ErrorModelRead> {
|
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
/**
|
|
60
|
-
* Too many requests. Wait for Retry-After before
|
|
70
|
+
* Too many requests, or an identical write is still in progress. Wait for Retry-After before
|
|
71
|
+
* retrying.
|
|
61
72
|
* Raised for HTTP 429 responses.
|
|
62
73
|
*/
|
|
63
74
|
export class RateLimitedError extends ApiError<429, ErrorModelRead> {
|
|
64
75
|
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
65
|
-
super("Too many requests. Wait for Retry-After before retrying.", 429, body, response);
|
|
76
|
+
super("Too many requests, or an identical write is still in progress. Wait for Retry-After before retrying.", 429, body, response);
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
79
|
|
|
@@ -87,22 +98,12 @@ export class PaymentRequiredError extends ApiError<402, ErrorModelRead> {
|
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
/**
|
|
90
|
-
*
|
|
91
|
-
* Raised for HTTP 409 responses.
|
|
92
|
-
*/
|
|
93
|
-
export class ConflictError extends ApiError<409, ErrorModelRead> {
|
|
94
|
-
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
95
|
-
super("The Idempotency-Key was already used with different request parameters.", 409, body, response);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The original idempotent request is temporarily unavailable.
|
|
101
|
+
* Project setup failed unexpectedly; the key reservation is released.
|
|
101
102
|
* Raised for HTTP 500 responses.
|
|
102
103
|
*/
|
|
103
104
|
export class InternalServerError extends ApiError<500, ErrorModelRead> {
|
|
104
105
|
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
105
|
-
super("
|
|
106
|
+
super("Project setup failed unexpectedly; the key reservation is released.", 500, body, response);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -115,3 +116,13 @@ export class NotFoundError extends ApiError<404, ErrorModelRead> {
|
|
|
115
116
|
super("No such resource in this account.", 404, body, response);
|
|
116
117
|
}
|
|
117
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The Project was saved, but an obsolete release pull request could not be retired.
|
|
122
|
+
* Raised for HTTP 502 responses.
|
|
123
|
+
*/
|
|
124
|
+
export class BadGatewayError extends ApiError<502, ErrorModelRead> {
|
|
125
|
+
constructor(body: ErrorModelRead, response: ResponseMeta) {
|
|
126
|
+
super("The Project was saved, but an obsolete release pull request could not be retired.", 502, body, response);
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
HttpCore,
|
|
6
6
|
formatDebugEvent,
|
|
7
7
|
type AuthValue,
|
|
8
|
+
type SecurityCredential,
|
|
8
9
|
type DebugEvent,
|
|
9
10
|
type RequestContext,
|
|
10
11
|
bearerAuth,
|
|
@@ -21,8 +22,8 @@ import { AccountResource } from "./resources/account.js";
|
|
|
21
22
|
import { ApiKeysResource } from "./resources/api-keys.js";
|
|
22
23
|
|
|
23
24
|
/** This package's version, also sent as the `User-Agent`. */
|
|
24
|
-
export const VERSION = "0.
|
|
25
|
-
const USER_AGENT = "@typeship-ax/cli/0.
|
|
25
|
+
export const VERSION = "0.9.1";
|
|
26
|
+
const USER_AGENT = "@typeship-ax/cli/0.9.1 (typeship)";
|
|
26
27
|
|
|
27
28
|
export interface ClientOptions {
|
|
28
29
|
/** Override the server URL. Default: `https://typeship.dev/api/v1` */
|
|
@@ -32,6 +33,13 @@ export interface ClientOptions {
|
|
|
32
33
|
* before every attempt.
|
|
33
34
|
*/
|
|
34
35
|
bearerToken?: string | (() => string | Promise<string>);
|
|
36
|
+
/**
|
|
37
|
+
* Credentials keyed by the Definition's security scheme names. Only one complete alternative is
|
|
38
|
+
* sent for each operation. Named values take precedence over convenience options.
|
|
39
|
+
*/
|
|
40
|
+
credentials?: {
|
|
41
|
+
"apiKey"?: AuthValue;
|
|
42
|
+
};
|
|
35
43
|
/** Per-attempt timeout in milliseconds. Default: 30000. */
|
|
36
44
|
timeoutMs?: number;
|
|
37
45
|
/** Retries after the first attempt (retryable failures only). Default: 2. */
|
|
@@ -71,7 +79,7 @@ export interface ClientOptions {
|
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
/**
|
|
74
|
-
* typeship — v0.
|
|
82
|
+
* typeship — v0.9.1
|
|
75
83
|
*
|
|
76
84
|
* Resolve an OpenAPI or GraphQL Definition, diagnose it, and keep every
|
|
77
85
|
* selected SDK, CLI, and MCP Target current.
|
|
@@ -105,7 +113,23 @@ export class TypeshipClient {
|
|
|
105
113
|
? (event: DebugEvent) => console.error(formatDebugEvent("typeship", event))
|
|
106
114
|
: undefined;
|
|
107
115
|
const query: Record<string, AuthValue> = {};
|
|
108
|
-
|
|
116
|
+
const authHeaders: Record<string, AuthValue> = {};
|
|
117
|
+
const authQuery: Record<string, AuthValue> = {};
|
|
118
|
+
|
|
119
|
+
let bearerCredential: AuthValue | undefined;
|
|
120
|
+
|
|
121
|
+
if (options.bearerToken !== undefined) bearerCredential = bearerAuth(options.bearerToken);
|
|
122
|
+
|
|
123
|
+
const allowedCredentials = new Set<string>(["apiKey"]);
|
|
124
|
+
for (const name of Object.keys(options.credentials ?? {})) {
|
|
125
|
+
if (!allowedCredentials.has(name)) throw new Error("Unknown security scheme: " + name);
|
|
126
|
+
}
|
|
127
|
+
const credentials: Record<string, SecurityCredential> = Object.create(null);
|
|
128
|
+
{
|
|
129
|
+
const named = options.credentials && Object.hasOwn(options.credentials, "apiKey") ? options.credentials["apiKey"] : undefined;
|
|
130
|
+
const value = named !== undefined ? bearerAuth(named) : bearerCredential;
|
|
131
|
+
if (value !== undefined) credentials["apiKey"] = { headers: { ["Authorization"]: value } };
|
|
132
|
+
}
|
|
109
133
|
const v = options.validate;
|
|
110
134
|
const validate = v
|
|
111
135
|
? {
|
|
@@ -118,6 +142,7 @@ export class TypeshipClient {
|
|
|
118
142
|
baseUrl: options.baseUrl ?? "https://typeship.dev/api/v1",
|
|
119
143
|
headers,
|
|
120
144
|
query,
|
|
145
|
+
credentials,
|
|
121
146
|
fetch: options.fetch ?? fetch,
|
|
122
147
|
timeoutMs: options.timeoutMs ?? 30_000,
|
|
123
148
|
maxRetries: options.maxRetries ?? 2,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Runtime credentials keyed by the API's exact security-scheme names. */
|
|
4
|
+
export type NamedCredential = string | { username: string; password: string };
|
|
5
|
+
export type NamedCredentials = Record<string, NamedCredential>;
|
|
6
|
+
export type CredentialSchemes = Record<string, { kind: string; options: string[] }>;
|
|
7
|
+
|
|
8
|
+
export function parseNamedCredentials(value: unknown, schemes: CredentialSchemes): NamedCredentials {
|
|
9
|
+
if (typeof value === "string") {
|
|
10
|
+
if (new TextEncoder().encode(value).byteLength > 1_048_576) throw new Error("Named credentials exceed the 1 MiB limit.");
|
|
11
|
+
try { value = JSON.parse(value); } catch { throw new Error("Named credentials must be a JSON object keyed by security scheme. Credential contents are not included in errors."); }
|
|
12
|
+
}
|
|
13
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Named credentials must be a JSON object keyed by security scheme.");
|
|
14
|
+
const result: NamedCredentials = Object.create(null);
|
|
15
|
+
for (const [name, credential] of Object.entries(value)) {
|
|
16
|
+
if (!Object.hasOwn(schemes, name)) throw new Error("Named credentials contain an unknown or unsupported security scheme. Check the names listed by login --help.");
|
|
17
|
+
if (schemes[name]!.kind === "basic") {
|
|
18
|
+
if (!credential || typeof credential !== "object" || Array.isArray(credential) || Object.keys(credential).some((key) => key !== "username" && key !== "password") ||
|
|
19
|
+
typeof credential.username !== "string" || typeof credential.password !== "string" || !credential.username || !credential.password || credential.username.includes(":")) {
|
|
20
|
+
throw new Error("A named Basic credential requires a nonempty username and password; the username cannot contain a colon.");
|
|
21
|
+
}
|
|
22
|
+
result[name] = { username: credential.username, password: credential.password };
|
|
23
|
+
} else {
|
|
24
|
+
if (typeof credential !== "string" || !credential || /[\x00-\x1f\x7f]/.test(credential) || (schemes[name]!.kind === "bearer" && credential.includes(" "))) throw new Error("A named token or API key must be a nonempty string without control characters; bearer tokens cannot contain spaces.");
|
|
25
|
+
result[name] = credential;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Low-to-high source order. Within a source, exact names beat convenience options. */
|
|
32
|
+
export function resolveNamedCredentials(schemes: CredentialSchemes, layers: { named?: NamedCredentials; options?: Record<string, unknown> }[]): NamedCredentials {
|
|
33
|
+
const resolved: NamedCredentials = Object.create(null);
|
|
34
|
+
for (const layer of layers) {
|
|
35
|
+
for (const [name, scheme] of Object.entries(schemes)) {
|
|
36
|
+
for (const option of scheme.options) {
|
|
37
|
+
if (option === "clientCredentials") continue;
|
|
38
|
+
const value = layer.options?.[option];
|
|
39
|
+
if (value !== undefined) resolved[name] = value as NamedCredential;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (layer.named) Object.assign(resolved, parseNamedCredentials(layer.named, schemes));
|
|
43
|
+
}
|
|
44
|
+
return resolved;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Make convenience and named inputs comparable without expanding combinations. */
|
|
48
|
+
export function namedCredentialAvailability(schemes: CredentialSchemes, options: Set<string>, named: NamedCredentials): Set<string> {
|
|
49
|
+
for (const [name, scheme] of Object.entries(schemes)) {
|
|
50
|
+
if (Object.hasOwn(named, name) || scheme.options.some((option) => options.has(option))) options.add("credentials." + name);
|
|
51
|
+
}
|
|
52
|
+
return options;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bound file/stdin reads before allocating a full credential document. */
|
|
56
|
+
export function readNamedCredentialsFile(input: string, schemes: CredentialSchemes): NamedCredentials {
|
|
57
|
+
let fd: number | undefined;
|
|
58
|
+
const chunks: Buffer[] = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
try {
|
|
61
|
+
fd = input === "-" ? 0 : openSync(input.slice(1), "r");
|
|
62
|
+
while (true) {
|
|
63
|
+
const buffer = Buffer.alloc(Math.min(32_768, 1_048_577 - total));
|
|
64
|
+
const size = readSync(fd, buffer, 0, buffer.length, null);
|
|
65
|
+
if (!size) break;
|
|
66
|
+
total += size;
|
|
67
|
+
if (total > 1_048_576) throw new Error("size");
|
|
68
|
+
chunks.push(buffer.subarray(0, size));
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error("Cannot read named credentials. Supply a readable JSON file or stdin input of at most 1 MiB.");
|
|
72
|
+
} finally { if (fd !== undefined && input !== "-") closeSync(fd); }
|
|
73
|
+
return parseNamedCredentials(Buffer.concat(chunks).toString("utf8"), schemes);
|
|
74
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { oauthJsonRequest, OAuthResponseError } from "./oauth-request.js";
|
|
4
|
+
|
|
5
|
+
export interface OAuthLoginConfig {
|
|
6
|
+
issuer: string;
|
|
7
|
+
clientId: string;
|
|
8
|
+
discoveryUrl?: string;
|
|
9
|
+
authorizationUrl?: string;
|
|
10
|
+
tokenUrl?: string;
|
|
11
|
+
/** Register http://127.0.0.1/callback; an omitted port uses a random port. */
|
|
12
|
+
redirectUri?: string;
|
|
13
|
+
scopes?: string[];
|
|
14
|
+
audience?: string;
|
|
15
|
+
resource?: string;
|
|
16
|
+
/** Explicit runtime selection. The CLI verifies this ID through the API. */
|
|
17
|
+
organization?: { parameter: "organization" | "organization_id"; id: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OAuthLoginSession {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken?: string;
|
|
23
|
+
expiresAt?: number;
|
|
24
|
+
issuer: string;
|
|
25
|
+
clientId: string;
|
|
26
|
+
tokenUrl: string;
|
|
27
|
+
/** The actual native callback used for this exchange, including its port. */
|
|
28
|
+
redirectUri: string;
|
|
29
|
+
revocationUrl?: string;
|
|
30
|
+
scopes?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OAuthLoginInteraction {
|
|
34
|
+
/** Open or display the URL. Never send the verifier or returned tokens. */
|
|
35
|
+
authorize(url: string): void | Promise<void>;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function endpoint(value: string): URL {
|
|
41
|
+
const url = new URL(value);
|
|
42
|
+
const loopback = ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname);
|
|
43
|
+
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.");
|
|
45
|
+
}
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function metadata(config: OAuthLoginConfig, signal: AbortSignal) {
|
|
50
|
+
const issuer = endpoint(config.issuer);
|
|
51
|
+
if (issuer.search) throw new Error("The OAuth issuer must not contain a query.");
|
|
52
|
+
const path = issuer.pathname.replace(/\/$/, "");
|
|
53
|
+
const urls = config.discoveryUrl ? [config.discoveryUrl] : [
|
|
54
|
+
issuer.origin + "/.well-known/oauth-authorization-server" + path,
|
|
55
|
+
issuer.origin + path + "/.well-known/openid-configuration",
|
|
56
|
+
];
|
|
57
|
+
for (const url of urls) {
|
|
58
|
+
let response: Awaited<ReturnType<typeof oauthJsonRequest>>;
|
|
59
|
+
try { response = await oauthJsonRequest(endpoint(url), { headers: { Accept: "application/json" }, signal }, 10_000); }
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (!signal.aborted && error instanceof OAuthResponseError && error.code === "request_failed") continue;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
if (response.status !== 200) continue;
|
|
65
|
+
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.");
|
|
67
|
+
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.");
|
|
69
|
+
}
|
|
70
|
+
const authorizationUrl = config.authorizationUrl ?? data.authorization_endpoint;
|
|
71
|
+
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.");
|
|
73
|
+
return {
|
|
74
|
+
authorizationUrl: endpoint(authorizationUrl), tokenUrl: endpoint(tokenUrl).href,
|
|
75
|
+
...(typeof data.revocation_endpoint === "string" ? { revocationUrl: endpoint(data.revocation_endpoint).href } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Some custom providers publish no metadata. Their explicit endpoints are
|
|
79
|
+
// owner configuration, not endpoints accepted from an unverified document.
|
|
80
|
+
if (config.authorizationUrl && config.tokenUrl) return {
|
|
81
|
+
authorizationUrl: endpoint(config.authorizationUrl), tokenUrl: endpoint(config.tokenUrl).href,
|
|
82
|
+
};
|
|
83
|
+
throw new Error("Could not discover OAuth endpoints. Check the issuer or provide explicit authorization and token URLs.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Authorization Code + S256 PKCE for a public native client. The listener
|
|
87
|
+
* binds only a loopback IP, verifies state/issuer, and closes on every path. */
|
|
88
|
+
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.");
|
|
91
|
+
const signal = AbortSignal.any([interaction.signal ?? new AbortController().signal, AbortSignal.timeout(interaction.timeoutMs ?? 600_000)]);
|
|
92
|
+
signal.throwIfAborted();
|
|
93
|
+
const endpoints = await metadata(config, signal);
|
|
94
|
+
const redirect = new URL(config.redirectUri ?? "http://127.0.0.1/callback");
|
|
95
|
+
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.");
|
|
97
|
+
}
|
|
98
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
99
|
+
const state = randomBytes(32).toString("base64url");
|
|
100
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
101
|
+
let resolveCode!: (code: string) => void;
|
|
102
|
+
let rejectCode!: (error: Error) => void;
|
|
103
|
+
const codeResult = new Promise<string>((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
|
|
104
|
+
// Observe rejection even if opening the browser or starting the server fails.
|
|
105
|
+
void codeResult.catch(() => undefined);
|
|
106
|
+
let accepted = false;
|
|
107
|
+
const server = createServer((request, response) => {
|
|
108
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
109
|
+
response.setHeader("Cache-Control", "no-store");
|
|
110
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
111
|
+
response.setHeader("Content-Security-Policy", "default-src 'none'");
|
|
112
|
+
let incoming: URL;
|
|
113
|
+
try { incoming = new URL(request.url ?? "/", redirect.origin); } catch { response.writeHead(400).end("Invalid callback URL."); return; }
|
|
114
|
+
if (accepted || request.method !== "GET" || request.headers.host !== redirect.host || incoming.pathname !== redirect.pathname) {
|
|
115
|
+
response.writeHead(404).end("Not found."); return;
|
|
116
|
+
}
|
|
117
|
+
const returnedState = Buffer.from(incoming.searchParams.get("state") ?? "");
|
|
118
|
+
const expectedState = Buffer.from(state);
|
|
119
|
+
if (incoming.searchParams.getAll("state").length !== 1 || returnedState.length !== expectedState.length || !timingSafeEqual(returnedState, expectedState)) {
|
|
120
|
+
response.writeHead(400).end("Invalid login state. Return to the terminal and try again."); return;
|
|
121
|
+
}
|
|
122
|
+
const returnedIssuer = incoming.searchParams.get("iss");
|
|
123
|
+
if (returnedIssuer !== null && (incoming.searchParams.getAll("iss").length !== 1 || returnedIssuer !== config.issuer)) {
|
|
124
|
+
response.writeHead(400).end("The login issuer did not match."); return;
|
|
125
|
+
}
|
|
126
|
+
accepted = true;
|
|
127
|
+
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.")));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const code = incoming.searchParams.get("code");
|
|
132
|
+
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.")));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
response.end("Authorization received. Return to the terminal to finish signing in.", () => resolveCode(code));
|
|
137
|
+
});
|
|
138
|
+
server.headersTimeout = 10_000;
|
|
139
|
+
server.requestTimeout = 10_000;
|
|
140
|
+
const aborted = () => rejectCode(new Error("Browser login timed out or was cancelled. Run login again to retry."));
|
|
141
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
142
|
+
try {
|
|
143
|
+
await new Promise<void>((resolve, reject) => {
|
|
144
|
+
server.once("error", reject);
|
|
145
|
+
server.listen(Number(redirect.port) || 0, redirect.hostname === "[::1]" ? "::1" : "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
|
146
|
+
});
|
|
147
|
+
const address = server.address();
|
|
148
|
+
if (!address || typeof address === "string") throw new Error("Could not start the OAuth callback listener.");
|
|
149
|
+
redirect.port = String(address.port);
|
|
150
|
+
signal.throwIfAborted();
|
|
151
|
+
const authorization = new URL(endpoints.authorizationUrl);
|
|
152
|
+
if (config.organization) {
|
|
153
|
+
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.");
|
|
155
|
+
authorization.searchParams.set(config.organization.parameter, config.organization.id);
|
|
156
|
+
}
|
|
157
|
+
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);
|
|
158
|
+
await interaction.authorize(authorization.href);
|
|
159
|
+
const code = await codeResult;
|
|
160
|
+
signal.throwIfAborted();
|
|
161
|
+
const response = await oauthJsonRequest(endpoints.tokenUrl, {
|
|
162
|
+
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, redirect: "error",
|
|
163
|
+
body: new URLSearchParams({ grant_type: "authorization_code", client_id: config.clientId, redirect_uri: redirect.href, code, code_verifier: verifier, ...(config.audience ? { audience: config.audience } : {}), ...(config.resource ? { resource: config.resource } : {}) }),
|
|
164
|
+
signal,
|
|
165
|
+
});
|
|
166
|
+
const data = response.data;
|
|
167
|
+
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.`);
|
|
169
|
+
}
|
|
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.");
|
|
171
|
+
return {
|
|
172
|
+
accessToken: data.access_token,
|
|
173
|
+
...(typeof data.refresh_token === "string" && data.refresh_token ? { refreshToken: data.refresh_token } : {}),
|
|
174
|
+
...(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,
|
|
176
|
+
...(endpoints.revocationUrl ? { revocationUrl: endpoints.revocationUrl } : {}),
|
|
177
|
+
...(typeof data.scope === "string" ? { scopes: data.scope.split(/\s+/).filter(Boolean) } : {}),
|
|
178
|
+
};
|
|
179
|
+
} finally {
|
|
180
|
+
signal.removeEventListener("abort", aborted);
|
|
181
|
+
server.closeAllConnections();
|
|
182
|
+
if (server.listening) await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|