ovsx 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/lib/check-license.d.ts.map +1 -1
- package/lib/check-license.js +5 -4
- package/lib/check-license.js.map +1 -1
- package/lib/create-namespace.d.ts.map +1 -1
- package/lib/create-namespace.js +3 -1
- package/lib/create-namespace.js.map +1 -1
- package/lib/get.js +5 -5
- package/lib/get.js.map +1 -1
- package/lib/http.d.ts +32 -0
- package/lib/http.d.ts.map +1 -0
- package/lib/http.js +170 -0
- package/lib/http.js.map +1 -0
- package/lib/index.d.ts +0 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +0 -3
- package/lib/index.js.map +1 -1
- package/lib/main.js +50 -61
- package/lib/main.js.map +1 -1
- package/lib/oidc.d.ts.map +1 -1
- package/lib/oidc.js +5 -47
- package/lib/oidc.js.map +1 -1
- package/lib/pat.d.ts +6 -1
- package/lib/pat.d.ts.map +1 -1
- package/lib/pat.js +16 -7
- package/lib/pat.js.map +1 -1
- package/lib/publish.d.ts +6 -0
- package/lib/publish.d.ts.map +1 -1
- package/lib/publish.js +147 -40
- package/lib/publish.js.map +1 -1
- package/lib/registry.d.ts +50 -12
- package/lib/registry.d.ts.map +1 -1
- package/lib/registry.js +131 -126
- package/lib/registry.js.map +1 -1
- package/lib/trusted-publishing-options.d.ts +5 -0
- package/lib/trusted-publishing-options.d.ts.map +1 -1
- package/lib/trusted-publishing.js +28 -4
- package/lib/trusted-publishing.js.map +1 -1
- package/lib/unpublish.d.ts.map +1 -1
- package/lib/unpublish.js +5 -8
- package/lib/unpublish.js.map +1 -1
- package/lib/util.d.ts +19 -15
- package/lib/util.d.ts.map +1 -1
- package/lib/util.js +51 -71
- package/lib/util.js.map +1 -1
- package/lib/verify-pat.d.ts.map +1 -1
- package/lib/verify-pat.js +3 -1
- package/lib/verify-pat.js.map +1 -1
- package/lib/verify.js +2 -2
- package/lib/verify.js.map +1 -1
- package/lib/zip.d.ts.map +1 -1
- package/lib/zip.js +2 -10
- package/lib/zip.js.map +1 -1
- package/package.json +19 -14
- package/src/check-license.ts +5 -4
- package/src/create-namespace.ts +3 -1
- package/src/get.ts +6 -6
- package/src/http.ts +176 -0
- package/src/index.ts +0 -3
- package/src/main.ts +54 -63
- package/src/oidc.ts +5 -47
- package/src/pat.ts +22 -9
- package/src/publish.ts +187 -44
- package/src/registry.ts +153 -126
- package/src/trusted-publishing-options.ts +5 -0
- package/src/trusted-publishing.ts +33 -5
- package/src/unpublish.ts +4 -7
- package/src/util.ts +47 -73
- package/src/verify-pat.ts +3 -1
- package/src/verify.ts +3 -3
- package/src/zip.ts +2 -12
- package/lib/version.d.ts +0 -2
- package/lib/version.d.ts.map +0 -1
- package/lib/version.js +0 -5
- package/lib/version.js.map +0 -1
- package/src/version.ts +0 -1
package/src/registry.ts
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import * as http from 'http';
|
|
12
12
|
import * as fs from 'fs';
|
|
13
|
-
import
|
|
14
|
-
import
|
|
13
|
+
import * as semver from 'semver';
|
|
14
|
+
import { pipeline } from 'stream';
|
|
15
|
+
import { jsonResponse, send } from './http';
|
|
15
16
|
import { RegistryOptions } from './registry-options';
|
|
16
|
-
import { DEFAULT_TIMEOUT, redactUrl, rejectError, statusError
|
|
17
|
+
import { DEFAULT_TIMEOUT, redactUrl, rejectError, statusError } from './util';
|
|
17
18
|
|
|
18
19
|
export const DEFAULT_URL = 'https://open-vsx.org';
|
|
19
20
|
export const DEFAULT_NAMESPACE_SIZE = 1024;
|
|
@@ -22,6 +23,18 @@ export { DEFAULT_TIMEOUT };
|
|
|
22
23
|
export const DEFAULT_TOKEN_REQUEST_SIZE = 8 * 1024;
|
|
23
24
|
export const DEFAULT_DELETE_SIZE = 64 * 1024;
|
|
24
25
|
|
|
26
|
+
// Fallback only, for when Authorization is already claimed by Basic auth to a fronting proxy (see
|
|
27
|
+
// tokenHeaders/getRequestOptions).
|
|
28
|
+
// Must stay in the credential headers http.ts drops on a cross-origin redirect.
|
|
29
|
+
const TOKEN_HEADER = 'X-OpenVSX-Token';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Oldest registry version that resolves the personal access token from a header (see #1344).
|
|
33
|
+
* Registries older than this only look at the `token` query parameter, so `tokenQuery` keeps
|
|
34
|
+
* sending it to anything below this version.
|
|
35
|
+
*/
|
|
36
|
+
export const MIN_TOKEN_HEADER_REGISTRY_VERSION = '1.3.0';
|
|
37
|
+
|
|
25
38
|
export class Registry {
|
|
26
39
|
|
|
27
40
|
readonly url: string;
|
|
@@ -30,6 +43,10 @@ export class Registry {
|
|
|
30
43
|
readonly timeout: number;
|
|
31
44
|
readonly username?: string;
|
|
32
45
|
readonly password?: string;
|
|
46
|
+
/** Whether the caller pinned a publish size, which then wins over any limit the registry reports. */
|
|
47
|
+
private readonly publishSizePinned: boolean;
|
|
48
|
+
private registryVersion?: Promise<RegistryVersion>;
|
|
49
|
+
private tokenHeaderSupport?: Promise<boolean>;
|
|
33
50
|
|
|
34
51
|
constructor(options: RegistryOptions = {}) {
|
|
35
52
|
if (options.registryUrl?.endsWith('/'))
|
|
@@ -40,6 +57,7 @@ export class Registry {
|
|
|
40
57
|
this.url = DEFAULT_URL;
|
|
41
58
|
|
|
42
59
|
this.maxNamespaceSize = options.maxNamespaceSize ?? DEFAULT_NAMESPACE_SIZE;
|
|
60
|
+
this.publishSizePinned = options.maxPublishSize !== undefined;
|
|
43
61
|
this.maxPublishSize = options.maxPublishSize ?? DEFAULT_PUBLISH_SIZE;
|
|
44
62
|
this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
45
63
|
this.username = options.username;
|
|
@@ -51,49 +69,101 @@ export class Registry {
|
|
|
51
69
|
return url.hostname === 'open-vsx.org' || url.hostname.endsWith('.open-vsx.org');
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
createNamespace(name: string, pat: string): Promise<Response> {
|
|
72
|
+
async createNamespace(name: string, pat: string): Promise<Response> {
|
|
55
73
|
try {
|
|
56
|
-
const url = this.getUrl(['api', '-', 'namespace', 'create'],
|
|
74
|
+
const url = this.getUrl(['api', '-', 'namespace', 'create'], await this.tokenQuery(pat));
|
|
57
75
|
const namespace = { name };
|
|
58
|
-
return this.post(JSON.stringify(namespace), url, {
|
|
59
|
-
'Content-Type': 'application/json'
|
|
76
|
+
return await this.post(JSON.stringify(namespace), url, {
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
...this.tokenHeaders(pat)
|
|
60
79
|
}, this.maxNamespaceSize);
|
|
61
80
|
} catch (err) {
|
|
62
81
|
return rejectError(err);
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
verifyPat(namespace: string, pat: string): Promise<Response> {
|
|
85
|
+
async verifyPat(namespace: string, pat: string): Promise<Response> {
|
|
86
|
+
try {
|
|
87
|
+
const url = this.getUrl(['api', namespace, 'verify-pat'], await this.tokenQuery(pat));
|
|
88
|
+
return await this.getJson(url, this.tokenHeaders(pat));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return rejectError(err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The size limit that applies to this namespace/extension, which a size override can raise above
|
|
96
|
+
* the registry default. Neither needs to exist yet - a first publish asks about both before
|
|
97
|
+
* creating either.
|
|
98
|
+
*/
|
|
99
|
+
async getSizeLimit(namespace: string, extension: string, pat: string): Promise<SizeLimit> {
|
|
67
100
|
try {
|
|
68
|
-
|
|
101
|
+
const query = { namespace, extension, ...(await this.tokenQuery(pat)) };
|
|
102
|
+
const url = this.getUrl(['api', '-', 'size-limit'], query);
|
|
103
|
+
return await this.getJson(url, this.tokenHeaders(pat));
|
|
69
104
|
} catch (err) {
|
|
70
105
|
return rejectError(err);
|
|
71
106
|
}
|
|
72
107
|
}
|
|
73
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Cached per `Registry` instance once it has succeeded - callers like `tokenQuery` and `unpublish`'s own version check
|
|
111
|
+
* would otherwise each fetch it separately, doubling the round trip for a single command.
|
|
112
|
+
*/
|
|
74
113
|
getRegistryVersion(): Promise<RegistryVersion> {
|
|
75
114
|
try {
|
|
76
|
-
|
|
115
|
+
if (this.registryVersion) {
|
|
116
|
+
return this.registryVersion;
|
|
117
|
+
}
|
|
118
|
+
const pending: Promise<RegistryVersion> = this.getJson(this.getUrl(['api', 'version']));
|
|
119
|
+
this.registryVersion = pending;
|
|
120
|
+
// A failure is not remembered, so a later caller asks again rather than inheriting it.
|
|
121
|
+
pending.catch(() => {
|
|
122
|
+
if (this.registryVersion === pending) {
|
|
123
|
+
this.registryVersion = undefined;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return pending;
|
|
77
127
|
} catch (err) {
|
|
78
128
|
return rejectError(err);
|
|
79
129
|
}
|
|
80
130
|
}
|
|
81
131
|
|
|
82
|
-
|
|
132
|
+
/**
|
|
133
|
+
* `sizeLimit` is the limit the registry reports for this package, when it could report one. The
|
|
134
|
+
* transport body cap is raised to it, because a namespace granted more than the default would
|
|
135
|
+
* otherwise have its upload refused here - by the body size check, before anything reached the
|
|
136
|
+
* registry that allowed it.
|
|
137
|
+
*/
|
|
138
|
+
async publish(file: string, pat: string, sizeLimit?: number): Promise<Extension> {
|
|
83
139
|
try {
|
|
84
|
-
const url = this.getUrl(['api', '-', 'publish'],
|
|
85
|
-
return this.postFile(file, url, {
|
|
86
|
-
'Content-Type': 'application/octet-stream'
|
|
87
|
-
|
|
140
|
+
const url = this.getUrl(['api', '-', 'publish'], await this.tokenQuery(pat));
|
|
141
|
+
return await this.postFile(file, url, {
|
|
142
|
+
'Content-Type': 'application/octet-stream',
|
|
143
|
+
...this.tokenHeaders(pat)
|
|
144
|
+
}, this.publishBodyLimit(sizeLimit));
|
|
88
145
|
} catch (err) {
|
|
89
146
|
return rejectError(err);
|
|
90
147
|
}
|
|
91
148
|
}
|
|
92
149
|
|
|
93
|
-
|
|
150
|
+
/** The body cap for a publish. A pinned `maxPublishSize` wins; otherwise it never sits below the registry's own limit. */
|
|
151
|
+
publishBodyLimit(sizeLimit?: number): number {
|
|
152
|
+
if (this.publishSizePinned || sizeLimit === undefined) {
|
|
153
|
+
return this.maxPublishSize;
|
|
154
|
+
}
|
|
155
|
+
return Math.max(this.maxPublishSize, sizeLimit);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
requestTrustedPublishingToken(
|
|
159
|
+
namespace: string,
|
|
160
|
+
extension: string,
|
|
161
|
+
idToken: string,
|
|
162
|
+
registrationCode?: string
|
|
163
|
+
): Promise<AccessToken> {
|
|
94
164
|
try {
|
|
95
165
|
const url = this.getUrl(['api', '-', 'trusted-publishing', 'token']);
|
|
96
|
-
const request = { namespace, extension, token: idToken };
|
|
166
|
+
const request = { namespace, extension, token: idToken, registrationCode };
|
|
97
167
|
return this.post(JSON.stringify(request), url, {
|
|
98
168
|
'Content-Type': 'application/json'
|
|
99
169
|
}, DEFAULT_TOKEN_REQUEST_SIZE);
|
|
@@ -106,7 +176,7 @@ export class Registry {
|
|
|
106
176
|
* Deletes extension versions. Omitting `targetVersions` deletes the extension as a whole,
|
|
107
177
|
* i.e. all versions the personal access token's user is allowed to delete.
|
|
108
178
|
*/
|
|
109
|
-
deleteExtension(
|
|
179
|
+
async deleteExtension(
|
|
110
180
|
namespace: string,
|
|
111
181
|
extension: string,
|
|
112
182
|
targetVersions: TargetPlatformVersion[] | undefined,
|
|
@@ -114,13 +184,15 @@ export class Registry {
|
|
|
114
184
|
): Promise<Response> {
|
|
115
185
|
try {
|
|
116
186
|
if (!targetVersions) {
|
|
117
|
-
const
|
|
118
|
-
|
|
187
|
+
const query = { ...await this.tokenQuery(pat), allVersions: 'true' };
|
|
188
|
+
const url = this.getUrl(['api', namespace, extension, 'delete'], query);
|
|
189
|
+
return await this.post('', url, this.tokenHeaders(pat), DEFAULT_DELETE_SIZE);
|
|
119
190
|
}
|
|
120
191
|
|
|
121
|
-
const url = this.getUrl(['api', namespace, extension, 'delete'],
|
|
122
|
-
return this.post(JSON.stringify(targetVersions), url, {
|
|
123
|
-
'Content-Type': 'application/json'
|
|
192
|
+
const url = this.getUrl(['api', namespace, extension, 'delete'], await this.tokenQuery(pat));
|
|
193
|
+
return await this.post(JSON.stringify(targetVersions), url, {
|
|
194
|
+
'Content-Type': 'application/json',
|
|
195
|
+
...this.tokenHeaders(pat)
|
|
124
196
|
}, DEFAULT_DELETE_SIZE);
|
|
125
197
|
} catch (err) {
|
|
126
198
|
return rejectError(err);
|
|
@@ -232,11 +304,12 @@ export class Registry {
|
|
|
232
304
|
fs.rm(partial, { force: true }, () => reject(err));
|
|
233
305
|
};
|
|
234
306
|
|
|
235
|
-
|
|
236
|
-
const request = this.getProtocol(url)
|
|
237
|
-
.request(url, requestOptions, response => {
|
|
307
|
+
send(url, this.getRequestOptions(), undefined, response => {
|
|
238
308
|
if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
|
|
239
309
|
response.resume();
|
|
310
|
+
// Claimed here so a timeout or reset while the error body drains does not run fail().
|
|
311
|
+
// Nothing was written yet, so there is nothing to clean up.
|
|
312
|
+
failed = true;
|
|
240
313
|
reject(statusError(response));
|
|
241
314
|
return;
|
|
242
315
|
}
|
|
@@ -261,55 +334,64 @@ export class Registry {
|
|
|
261
334
|
fs.rename(partial, file, renameErr => renameErr ? fail(renameErr) : resolve());
|
|
262
335
|
}
|
|
263
336
|
});
|
|
264
|
-
});
|
|
265
|
-
request.on('error', (err: Error) => fail(err));
|
|
266
|
-
this.failOnTimeout(request, url, fail);
|
|
267
|
-
request.end();
|
|
337
|
+
}, fail);
|
|
268
338
|
});
|
|
269
339
|
}
|
|
270
340
|
|
|
271
|
-
getJson<T extends Response>(url: URL): Promise<T> {
|
|
341
|
+
getJson<T extends Response>(url: URL, headers?: http.OutgoingHttpHeaders): Promise<T> {
|
|
272
342
|
return new Promise((resolve, reject) => {
|
|
273
|
-
|
|
274
|
-
const request = this.getProtocol(url)
|
|
275
|
-
.request(url, requestOptions, this.getJsonResponse<T>(resolve, reject));
|
|
276
|
-
request.on('error', reject);
|
|
277
|
-
this.failOnTimeout(request, url, reject);
|
|
278
|
-
request.end();
|
|
343
|
+
send(url, this.getRequestOptions('GET', headers), undefined, jsonResponse<T>(resolve, reject), reject);
|
|
279
344
|
});
|
|
280
345
|
}
|
|
281
346
|
|
|
282
347
|
post<T extends Response>(content: string | Buffer | Uint8Array, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
|
|
283
348
|
return new Promise((resolve, reject) => {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
request.on('error', reject);
|
|
288
|
-
this.failOnTimeout(request, url, reject);
|
|
289
|
-
request.write(content);
|
|
290
|
-
request.end();
|
|
349
|
+
const size = typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
|
|
350
|
+
checkBodySize(size, maxBodyLength);
|
|
351
|
+
send(url, this.getRequestOptions('POST', headers), () => content, jsonResponse<T>(resolve, reject), reject);
|
|
291
352
|
});
|
|
292
353
|
}
|
|
293
354
|
|
|
294
|
-
postFile<T extends Response>(file: string, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
|
|
355
|
+
async postFile<T extends Response>(file: string, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
|
|
356
|
+
checkBodySize((await fs.promises.stat(file)).size, maxBodyLength);
|
|
295
357
|
return new Promise((resolve, reject) => {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
.request(url, requestOptions, this.getJsonResponse<T>(resolve, reject));
|
|
300
|
-
stream.on('error', (err: Error) => {
|
|
301
|
-
request.destroy();
|
|
302
|
-
reject(err);
|
|
303
|
-
});
|
|
304
|
-
request.on('error', (err: Error) => {
|
|
305
|
-
stream.close();
|
|
306
|
-
reject(err);
|
|
307
|
-
});
|
|
308
|
-
this.failOnTimeout(request, url, reject);
|
|
309
|
-
stream.on('open', () => stream.pipe(request));
|
|
358
|
+
// a stream can be read once, so each redirect hop opens the file again
|
|
359
|
+
send(url, this.getRequestOptions('POST', headers), () => fs.createReadStream(file),
|
|
360
|
+
jsonResponse<T>(resolve, reject), reject);
|
|
310
361
|
});
|
|
311
362
|
}
|
|
312
363
|
|
|
364
|
+
/**
|
|
365
|
+
* The header a personal access token travels in. `Authorization: Bearer` is standard and what
|
|
366
|
+
* log/proxy redaction and secret scanners already expect, so it's preferred - except when
|
|
367
|
+
* `username`/`password` are set, where `Authorization` is already claimed by Basic auth to a
|
|
368
|
+
* fronting reverse proxy (see `getRequestOptions`) and the token falls back to `TOKEN_HEADER`.
|
|
369
|
+
*/
|
|
370
|
+
private tokenHeaders(pat: string): http.OutgoingHttpHeaders {
|
|
371
|
+
return (this.username && this.password)
|
|
372
|
+
? { [TOKEN_HEADER]: pat }
|
|
373
|
+
: { Authorization: `Bearer ${pat}` };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The `token` query parameter to add alongside the header, for registries that predate header
|
|
378
|
+
* support (see #1344) - empty once the registry is known to be new enough to not need it.
|
|
379
|
+
*
|
|
380
|
+
* Best-effort and cached per `Registry` instance: a registry that doesn't expose `/api/version`,
|
|
381
|
+
* or reports a version that doesn't parse as semver, is assumed too old to know about the header,
|
|
382
|
+
* same as before this method existed - the query parameter is kept rather than risking a request
|
|
383
|
+
* that only carries a header such a registry never looks at.
|
|
384
|
+
*/
|
|
385
|
+
private tokenQuery(pat: string): Promise<Record<string, string>> {
|
|
386
|
+
return (this.tokenHeaderSupport ??= this.getRegistryVersion()
|
|
387
|
+
.then(({ version }) => {
|
|
388
|
+
const parsed = semver.coerce(version);
|
|
389
|
+
return !!parsed && semver.gte(parsed, MIN_TOKEN_HEADER_REGISTRY_VERSION);
|
|
390
|
+
})
|
|
391
|
+
.catch(() => false)
|
|
392
|
+
).then(supported => supported ? {} : { token: pat } as Record<string, string>);
|
|
393
|
+
}
|
|
394
|
+
|
|
313
395
|
private getUrl(segments: string[], query?: Record<string, string>): URL {
|
|
314
396
|
const url = new URL(this.url);
|
|
315
397
|
const basePath = url.pathname.replace(/\/+$/, '');
|
|
@@ -321,33 +403,7 @@ export class Registry {
|
|
|
321
403
|
return url;
|
|
322
404
|
}
|
|
323
405
|
|
|
324
|
-
private
|
|
325
|
-
return url.protocol === 'https:' ? followRedirects.https : followRedirects.http;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Node's `timeout` option only raises an event - the request stays open, which is why a server
|
|
330
|
-
* that accepts a connection and then says nothing used to hold a command open indefinitely.
|
|
331
|
-
* Destroying the request with an error routes through the error handling each caller already
|
|
332
|
-
* has, so a stalled request rejects the way any other failure does. With a timeout of zero the
|
|
333
|
-
* event never fires and this does nothing.
|
|
334
|
-
*/
|
|
335
|
-
// Typed on Writable because both http.ClientRequest and follow-redirects' wrapper are ones, and
|
|
336
|
-
// all this needs is the timeout event and destroy.
|
|
337
|
-
private failOnTimeout(request: Writable, url: URL, fail: (err: Error) => void): void {
|
|
338
|
-
request.on('timeout', () => {
|
|
339
|
-
// Reported before the request is torn down, rather than by destroying it with the error.
|
|
340
|
-
// Destroying raises errors of its own - ECONNRESET on the response, a premature close
|
|
341
|
-
// from pipeline - and node guarantees no order between those and the request's own. In
|
|
342
|
-
// practice the request's arrives first, so this is belt and braces rather than the thing
|
|
343
|
-
// that stops the wrong error being reported; that is the caller claiming the failure
|
|
344
|
-
// before it does any asynchronous cleanup.
|
|
345
|
-
fail(new Error(`No response from ${redactUrl(url)} for ${this.timeout} ms.`));
|
|
346
|
-
request.destroy();
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
private getRequestOptions(method?: string, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): http.RequestOptions {
|
|
406
|
+
private getRequestOptions(method?: string, headers?: http.OutgoingHttpHeaders): http.RequestOptions {
|
|
351
407
|
if (this.username && this.password) {
|
|
352
408
|
headers ??= {};
|
|
353
409
|
const credentials = Buffer.from(this.username + ':' + this.password).toString('base64');
|
|
@@ -356,52 +412,19 @@ export class Registry {
|
|
|
356
412
|
return {
|
|
357
413
|
method,
|
|
358
414
|
headers,
|
|
359
|
-
maxBodyLength,
|
|
360
415
|
timeout: this.timeout
|
|
361
|
-
} as http.RequestOptions;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
private getJsonResponse<T extends Response>(resolve: (value: T) => void, reject: (reason: any) => void): (res: http.IncomingMessage) => void {
|
|
365
|
-
return response => {
|
|
366
|
-
response.setEncoding('utf-8');
|
|
367
|
-
let json = '';
|
|
368
|
-
// A connection lost after the headers have arrived ends the response without 'end' ever
|
|
369
|
-
// firing, so without this the promise is never settled and the command waits on a body
|
|
370
|
-
// that is not coming. The configured timeout would eventually rescue it, but rejecting
|
|
371
|
-
// here reports what actually happened instead of thirty seconds of silence.
|
|
372
|
-
response.on('error', reject);
|
|
373
|
-
response.on('data', chunk => json += chunk);
|
|
374
|
-
response.on('end', () => {
|
|
375
|
-
if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
|
|
376
|
-
if (json.startsWith('{')) {
|
|
377
|
-
try {
|
|
378
|
-
const parsed = JSON.parse(json) as ErrorResponse;
|
|
379
|
-
const message = parsed.message || parsed.error;
|
|
380
|
-
if (message) {
|
|
381
|
-
// keep the status: the message alone cannot say whether retrying is worth it
|
|
382
|
-
reject(withStatus(new Error(message), response.statusCode));
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
} catch (err) {
|
|
386
|
-
// Ignore the error and reject with response status
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
reject(statusError(response));
|
|
390
|
-
} else if (json.startsWith('<!DOCTYPE html>')) {
|
|
391
|
-
reject(json);
|
|
392
|
-
} else {
|
|
393
|
-
try {
|
|
394
|
-
resolve(JSON.parse(json));
|
|
395
|
-
} catch (err) {
|
|
396
|
-
reject(err);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
416
|
};
|
|
401
417
|
}
|
|
402
418
|
|
|
403
419
|
}
|
|
404
420
|
|
|
421
|
+
/** Refuses a request body larger than `maxBodyLength` before anything is sent. */
|
|
422
|
+
function checkBodySize(size: number, maxBodyLength?: number): void {
|
|
423
|
+
if (maxBodyLength !== undefined && size > maxBodyLength) {
|
|
424
|
+
throw new Error(`The request body of ${size} bytes is larger than the limit of ${maxBodyLength} bytes.`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
405
428
|
export interface Response {
|
|
406
429
|
success?: string;
|
|
407
430
|
warning?: string;
|
|
@@ -466,6 +489,10 @@ export interface RegistryVersion extends Response {
|
|
|
466
489
|
trustedPublishingAudience?: string;
|
|
467
490
|
}
|
|
468
491
|
|
|
492
|
+
export interface SizeLimit extends Response {
|
|
493
|
+
maxSize: number;
|
|
494
|
+
}
|
|
495
|
+
|
|
469
496
|
export interface AccessToken extends Response {
|
|
470
497
|
id: number;
|
|
471
498
|
value?: string;
|
|
@@ -27,4 +27,9 @@ export interface TrustedPublishingOptions {
|
|
|
27
27
|
* the audience the registry expects.
|
|
28
28
|
*/
|
|
29
29
|
oidcAudience?: string;
|
|
30
|
+
/**
|
|
31
|
+
* The registration code shown when the trusted publisher of a private repository was registered.
|
|
32
|
+
* Only the first publish needs it: it completes the registration, and the code is used up.
|
|
33
|
+
*/
|
|
34
|
+
registrationCode?: string;
|
|
30
35
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { getIdToken, hasIdTokenSource } from './oidc';
|
|
15
15
|
import { AccessToken, Registry } from './registry';
|
|
16
|
-
import { StatusError } from './util';
|
|
16
|
+
import { StatusError, redactUrl } from './util';
|
|
17
17
|
import { TrustedPublishingOptions } from './trusted-publishing-options';
|
|
18
18
|
|
|
19
19
|
const tokens = new Map<string, Promise<string>>();
|
|
@@ -100,12 +100,13 @@ async function requestToken(
|
|
|
100
100
|
extension: string,
|
|
101
101
|
options: TrustedPublishingOptions
|
|
102
102
|
): Promise<string> {
|
|
103
|
-
|
|
103
|
+
requireSecureRegistry(registry);
|
|
104
|
+
const audience = options.oidcAudience ?? await expectedAudience(registry);
|
|
104
105
|
const idToken = await getIdToken(audience, options);
|
|
105
106
|
|
|
106
107
|
let result: AccessToken;
|
|
107
108
|
try {
|
|
108
|
-
result = await exchangeWithRetry(registry, namespace, extension, idToken);
|
|
109
|
+
result = await exchangeWithRetry(registry, namespace, extension, idToken, options.registrationCode);
|
|
109
110
|
} catch (err) {
|
|
110
111
|
throw new Error(`${err.message}\n${registrationHint(registry, namespace, extension)}`);
|
|
111
112
|
}
|
|
@@ -125,11 +126,12 @@ async function exchangeWithRetry(
|
|
|
125
126
|
registry: Registry,
|
|
126
127
|
namespace: string,
|
|
127
128
|
extension: string,
|
|
128
|
-
idToken: string
|
|
129
|
+
idToken: string,
|
|
130
|
+
registrationCode?: string
|
|
129
131
|
): Promise<AccessToken> {
|
|
130
132
|
for (let attempt = 0; ; attempt++) {
|
|
131
133
|
try {
|
|
132
|
-
return await registry.requestTrustedPublishingToken(namespace, extension, idToken);
|
|
134
|
+
return await registry.requestTrustedPublishingToken(namespace, extension, idToken, registrationCode);
|
|
133
135
|
} catch (err) {
|
|
134
136
|
const status = (err as StatusError)?.status;
|
|
135
137
|
if (attempt >= RETRY_DELAYS_MS.length || status === undefined || !RETRYABLE_STATUS.includes(status)) {
|
|
@@ -142,6 +144,32 @@ async function exchangeWithRetry(
|
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The ID token is a bearer credential for publishing, and the registry also chooses the audience it
|
|
149
|
+
* is minted for, so both only travel over https. A loopback registry is exempt, as for a browser's
|
|
150
|
+
* secure contexts: nothing leaves the machine.
|
|
151
|
+
*/
|
|
152
|
+
function requireSecureRegistry(registry: Registry): void {
|
|
153
|
+
const { protocol, hostname } = new URL(registry.url);
|
|
154
|
+
if (protocol === 'https:' || hostname === 'localhost' || hostname === '[::1]' || /^127\./.test(hostname)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Trusted publishing sends an ID token to the registry, so the registry URL must use https, `
|
|
159
|
+
+ `but it is ${redactUrl(registry.url)}. Use an https URL, or publish with an access token.`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The audience a registry validates is its own configuration, defaulting to the URL of its web UI
|
|
165
|
+
* rather than the API URL this CLI talks to, so it is read from where the registry publishes it.
|
|
166
|
+
* A registry that reports nothing there is asked for the registry URL.
|
|
167
|
+
*/
|
|
168
|
+
async function expectedAudience(registry: Registry): Promise<string> {
|
|
169
|
+
const reported = await registry.getRegistryVersion().catch(() => undefined);
|
|
170
|
+
return reported?.trustedPublishingAudience ?? registry.url;
|
|
171
|
+
}
|
|
172
|
+
|
|
145
173
|
function registrationHint(registry: Registry, namespace: string, extension: string): string {
|
|
146
174
|
return `Check the trusted publishers registered for '${namespace}.${extension}' at `
|
|
147
175
|
+ `${registry.url}/user-settings/trusted-publishers.`;
|
package/src/unpublish.ts
CHANGED
|
@@ -12,12 +12,7 @@
|
|
|
12
12
|
*****************************************************************************/
|
|
13
13
|
|
|
14
14
|
import { confirm } from '@inquirer/prompts';
|
|
15
|
-
|
|
16
|
-
// exports whenever the importer applies ES module interop (e.g. a bundler, or vitest's own
|
|
17
|
-
// transform), making the truthiness check below always true. `import = require` always binds
|
|
18
|
-
// directly to the exported value, regardless of how the importing tool handles interop.
|
|
19
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
20
|
-
import isCI = require('is-ci');
|
|
15
|
+
import { isCI } from 'ci-info';
|
|
21
16
|
import * as semver from 'semver';
|
|
22
17
|
import { getPAT } from './pat';
|
|
23
18
|
import { Registry, TargetPlatformVersion } from './registry';
|
|
@@ -48,7 +43,8 @@ export async function unpublish(options: UnpublishOptions = {}): Promise<void> {
|
|
|
48
43
|
const registry = new Registry(options);
|
|
49
44
|
await ensureUnpublishSupported(registry);
|
|
50
45
|
|
|
51
|
-
|
|
46
|
+
const { pat, save } = await getPAT(namespace, { ...options, namespace }, false);
|
|
47
|
+
options.pat = pat;
|
|
52
48
|
|
|
53
49
|
const targetVersions = getTargetVersions(options);
|
|
54
50
|
await confirmUnpublish(`${namespace}.${extension}`, targetVersions, registry.url, options.force);
|
|
@@ -57,6 +53,7 @@ export async function unpublish(options: UnpublishOptions = {}): Promise<void> {
|
|
|
57
53
|
if (result.error) {
|
|
58
54
|
throw new Error(result.error);
|
|
59
55
|
}
|
|
56
|
+
await save();
|
|
60
57
|
// the registry reports one line per deleted version, and nothing at all if there was nothing to delete
|
|
61
58
|
console.log(`🗑 ${result.success || `Nothing to delete for ${namespace}.${extension}`}`);
|
|
62
59
|
}
|