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.
Files changed (76) hide show
  1. package/README.md +7 -1
  2. package/lib/check-license.d.ts.map +1 -1
  3. package/lib/check-license.js +5 -4
  4. package/lib/check-license.js.map +1 -1
  5. package/lib/create-namespace.d.ts.map +1 -1
  6. package/lib/create-namespace.js +3 -1
  7. package/lib/create-namespace.js.map +1 -1
  8. package/lib/get.js +5 -5
  9. package/lib/get.js.map +1 -1
  10. package/lib/http.d.ts +32 -0
  11. package/lib/http.d.ts.map +1 -0
  12. package/lib/http.js +170 -0
  13. package/lib/http.js.map +1 -0
  14. package/lib/index.d.ts +0 -3
  15. package/lib/index.d.ts.map +1 -1
  16. package/lib/index.js +0 -3
  17. package/lib/index.js.map +1 -1
  18. package/lib/main.js +50 -61
  19. package/lib/main.js.map +1 -1
  20. package/lib/oidc.d.ts.map +1 -1
  21. package/lib/oidc.js +5 -47
  22. package/lib/oidc.js.map +1 -1
  23. package/lib/pat.d.ts +6 -1
  24. package/lib/pat.d.ts.map +1 -1
  25. package/lib/pat.js +16 -7
  26. package/lib/pat.js.map +1 -1
  27. package/lib/publish.d.ts +6 -0
  28. package/lib/publish.d.ts.map +1 -1
  29. package/lib/publish.js +147 -40
  30. package/lib/publish.js.map +1 -1
  31. package/lib/registry.d.ts +50 -12
  32. package/lib/registry.d.ts.map +1 -1
  33. package/lib/registry.js +131 -126
  34. package/lib/registry.js.map +1 -1
  35. package/lib/trusted-publishing-options.d.ts +5 -0
  36. package/lib/trusted-publishing-options.d.ts.map +1 -1
  37. package/lib/trusted-publishing.js +28 -4
  38. package/lib/trusted-publishing.js.map +1 -1
  39. package/lib/unpublish.d.ts.map +1 -1
  40. package/lib/unpublish.js +5 -8
  41. package/lib/unpublish.js.map +1 -1
  42. package/lib/util.d.ts +19 -15
  43. package/lib/util.d.ts.map +1 -1
  44. package/lib/util.js +51 -71
  45. package/lib/util.js.map +1 -1
  46. package/lib/verify-pat.d.ts.map +1 -1
  47. package/lib/verify-pat.js +3 -1
  48. package/lib/verify-pat.js.map +1 -1
  49. package/lib/verify.js +2 -2
  50. package/lib/verify.js.map +1 -1
  51. package/lib/zip.d.ts.map +1 -1
  52. package/lib/zip.js +2 -10
  53. package/lib/zip.js.map +1 -1
  54. package/package.json +19 -14
  55. package/src/check-license.ts +5 -4
  56. package/src/create-namespace.ts +3 -1
  57. package/src/get.ts +6 -6
  58. package/src/http.ts +176 -0
  59. package/src/index.ts +0 -3
  60. package/src/main.ts +54 -63
  61. package/src/oidc.ts +5 -47
  62. package/src/pat.ts +22 -9
  63. package/src/publish.ts +187 -44
  64. package/src/registry.ts +153 -126
  65. package/src/trusted-publishing-options.ts +5 -0
  66. package/src/trusted-publishing.ts +33 -5
  67. package/src/unpublish.ts +4 -7
  68. package/src/util.ts +47 -73
  69. package/src/verify-pat.ts +3 -1
  70. package/src/verify.ts +3 -3
  71. package/src/zip.ts +2 -12
  72. package/lib/version.d.ts +0 -2
  73. package/lib/version.d.ts.map +0 -1
  74. package/lib/version.js +0 -5
  75. package/lib/version.js.map +0 -1
  76. 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 { pipeline, Writable } from 'stream';
14
- import * as followRedirects from 'follow-redirects';
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, withStatus } from './util';
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'], { token: pat });
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
- return this.getJson(this.getUrl(['api', namespace, 'verify-pat'], { token: pat }));
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
- return this.getJson(this.getUrl(['api', 'version']));
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
- publish(file: string, pat: string): Promise<Extension> {
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'], { token: pat });
85
- return this.postFile(file, url, {
86
- 'Content-Type': 'application/octet-stream'
87
- }, this.maxPublishSize);
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
- requestTrustedPublishingToken(namespace: string, extension: string, idToken: string): Promise<AccessToken> {
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 url = this.getUrl(['api', namespace, extension, 'delete'], { token: pat, allVersions: 'true' });
118
- return this.post('', url, undefined, DEFAULT_DELETE_SIZE);
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'], { token: pat });
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
- const requestOptions = this.getRequestOptions();
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
- const requestOptions = this.getRequestOptions();
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 requestOptions = this.getRequestOptions('POST', headers, maxBodyLength);
285
- const request = this.getProtocol(url)
286
- .request(url, requestOptions, this.getJsonResponse<T>(resolve, reject));
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
- const stream = fs.createReadStream(file);
297
- const requestOptions = this.getRequestOptions('POST', headers, maxBodyLength);
298
- const request = this.getProtocol(url)
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 getProtocol(url: URL) {
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
- const audience = options.oidcAudience ?? registry.url;
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
- // `import * as isCI from 'is-ci'` would bind to a namespace object instead of the boolean `is-ci`
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
- options.pat = await getPAT(namespace, { ...options, namespace }, false);
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
  }