@vltpkg/registry-client 1.0.0-rc.22 → 1.0.0-rc.24

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 (43) hide show
  1. package/dist/add-header.d.ts +1 -0
  2. package/dist/add-header.js +26 -0
  3. package/dist/auth.d.ts +15 -0
  4. package/dist/auth.js +53 -0
  5. package/dist/cache-entry.d.ts +140 -0
  6. package/dist/cache-entry.js +517 -0
  7. package/dist/cache-revalidate.d.ts +1 -0
  8. package/dist/cache-revalidate.js +56 -0
  9. package/dist/delete-header.d.ts +1 -0
  10. package/dist/delete-header.js +31 -0
  11. package/dist/env.d.ts +6 -0
  12. package/dist/env.js +12 -0
  13. package/dist/get-header.d.ts +1 -0
  14. package/dist/get-header.js +36 -0
  15. package/dist/handle-304-response.d.ts +3 -0
  16. package/dist/handle-304-response.js +8 -0
  17. package/dist/index.d.ts +145 -0
  18. package/dist/index.js +326 -0
  19. package/dist/is-cacheable.d.ts +4 -0
  20. package/dist/is-cacheable.js +9 -0
  21. package/dist/is-iterable.d.ts +1 -0
  22. package/dist/is-iterable.js +1 -0
  23. package/dist/oidc.d.ts +17 -0
  24. package/dist/oidc.js +151 -0
  25. package/dist/otplease.d.ts +3 -0
  26. package/dist/otplease.js +62 -0
  27. package/dist/raw-header.d.ts +8 -0
  28. package/dist/raw-header.js +33 -0
  29. package/dist/redirect.d.ts +22 -0
  30. package/dist/redirect.js +64 -0
  31. package/dist/revalidate.d.ts +4 -0
  32. package/dist/revalidate.js +50 -0
  33. package/dist/set-cache-headers.d.ts +3 -0
  34. package/dist/set-cache-headers.js +16 -0
  35. package/dist/set-raw-header.d.ts +5 -0
  36. package/dist/set-raw-header.js +20 -0
  37. package/dist/string-encoding.d.ts +8 -0
  38. package/dist/string-encoding.js +24 -0
  39. package/dist/token-response.d.ts +4 -0
  40. package/dist/token-response.js +7 -0
  41. package/dist/web-auth-challenge.d.ts +5 -0
  42. package/dist/web-auth-challenge.js +13 -0
  43. package/package.json +12 -12
@@ -0,0 +1,145 @@
1
+ import { Cache } from '@vltpkg/cache';
2
+ import type { Integrity } from '@vltpkg/types';
3
+ import type { Dispatcher } from 'undici';
4
+ import { RetryAgent } from 'undici';
5
+ import type { Token } from './auth.ts';
6
+ import { clearRuntimeTokens, deleteToken, getKC, isToken, keychains, runtimeTokens, setRuntimeToken, setToken } from './auth.ts';
7
+ import type { JSONObj } from './cache-entry.ts';
8
+ import { CacheEntry } from './cache-entry.ts';
9
+ import type { TokenResponse } from './token-response.ts';
10
+ import type { WebAuthChallenge } from './web-auth-challenge.ts';
11
+ import { oidc } from './oidc.ts';
12
+ import type { OidcOptions } from './oidc.ts';
13
+ export { CacheEntry, clearRuntimeTokens, deleteToken, getKC, isToken, keychains, oidc, runtimeTokens, setRuntimeToken, setToken, type JSONObj, type OidcOptions, type Token, type TokenResponse, type WebAuthChallenge, };
14
+ export type CacheableMethod = 'GET' | 'HEAD';
15
+ export declare const isCacheableMethod: (m: unknown) => m is CacheableMethod;
16
+ export type RegistryClientOptions = {
17
+ /**
18
+ * Path on disk where the cache should be stored
19
+ *
20
+ * Defaults to the XDG cache folder for `vlt/registry-client`
21
+ */
22
+ cache?: string;
23
+ /**
24
+ * Number of retries to perform when encountering network errors or
25
+ * likely-transient errors from git hosts.
26
+ */
27
+ 'fetch-retries'?: number;
28
+ /** The exponential backoff factor to use when retrying git hosts */
29
+ 'fetch-retry-factor'?: number;
30
+ /** Number of milliseconds before starting first retry */
31
+ 'fetch-retry-mintimeout'?: number;
32
+ /** Maximum number of milliseconds between two retries */
33
+ 'fetch-retry-maxtimeout'?: number;
34
+ /** the identity to use for storing auth tokens */
35
+ identity?: string;
36
+ /**
37
+ * If the server does not serve a `stale-while-revalidate` value in the
38
+ * `cache-control` header, then this multiplier is applied to the `max-age`
39
+ * or `s-maxage` values.
40
+ *
41
+ * By default, this is `60`, so for example a response that is cacheable for
42
+ * 5 minutes will allow a stale response while revalidating for up to 5
43
+ * hours.
44
+ *
45
+ * If the server *does* provide a `stale-while-revalidate` value, then that
46
+ * is always used.
47
+ *
48
+ * Set to 0 to prevent any `stale-while-revalidate` behavior unless
49
+ * explicitly allowed by the server's `cache-control` header.
50
+ */
51
+ 'stale-while-revalidate-factor'?: number;
52
+ };
53
+ export type RegistryClientRequestOptions = Omit<Dispatcher.RequestOptions, 'method' | 'path'> & {
54
+ /**
55
+ * `path` should not be set when using the RegistryClient.
56
+ * It will be overwritten with the path on the URL being requested.
57
+ * This only here for compliance with the DispatchOptions base type.
58
+ * @deprecated
59
+ */
60
+ path?: string;
61
+ /**
62
+ * Method is optional, defaults to 'GET'
63
+ */
64
+ method?: Dispatcher.DispatchOptions['method'];
65
+ /**
66
+ * Provide an SRI string to verify integrity of the item being fetched.
67
+ *
68
+ * This is only relevant when it must make a request to the registry. Once in
69
+ * the local disk cache, items are assumed to be trustworthy.
70
+ */
71
+ integrity?: Integrity;
72
+ /**
73
+ * Set to true if the integrity should be trusted implicitly without
74
+ * a recalculation, for example if it comes from a trusted registry that
75
+ * also serves the tarball itself.
76
+ */
77
+ trustIntegrity?: boolean;
78
+ /**
79
+ * Follow up to 10 redirections by default. Set this to 0 to just return
80
+ * the 3xx response. If the max redirections are expired, and we still get
81
+ * a redirection response, then fail the request. Redirection cycles are
82
+ * always treated as an error.
83
+ */
84
+ maxRedirections?: number;
85
+ /**
86
+ * the number of redirections that have already been seen. This is used
87
+ * internally, and should always start at 0.
88
+ * @internal
89
+ */
90
+ redirections?: Set<string>;
91
+ /**
92
+ * Set to `false` to suppress ANY lookups from cache. This will also
93
+ * prevent storing the result to the cache.
94
+ */
95
+ useCache?: false;
96
+ /**
97
+ * Set to pass an `npm-otp` header on the request.
98
+ *
99
+ * This should not be set except by the RegistryClient itself, when
100
+ * we receive a 401 response with an OTP challenge.
101
+ * @internal
102
+ */
103
+ otp?: string;
104
+ /**
105
+ * Set to false to explicitly prevent `stale-while-revalidate` behavior,
106
+ * for use in revalidating while stale.
107
+ * @internal
108
+ */
109
+ staleWhileRevalidate?: false;
110
+ };
111
+ export declare const userAgent: string;
112
+ export declare class RegistryClient {
113
+ #private;
114
+ agent: RetryAgent;
115
+ cache: Cache;
116
+ identity: string;
117
+ staleWhileRevalidateFactor: number;
118
+ constructor(options: RegistryClientOptions);
119
+ /**
120
+ * Fetch the entire set of a paginated list of objects
121
+ */
122
+ scroll<T>(url: URL | string, options?: RegistryClientRequestOptions, seek?: (obj: T) => boolean): Promise<T[]>;
123
+ /**
124
+ * find a given item in a paginated set
125
+ */
126
+ seek<T>(url: URL | string, seek: (obj: T) => boolean, options?: RegistryClientRequestOptions): Promise<T | undefined>;
127
+ /**
128
+ * Log out from the registry specified, attempting to destroy the
129
+ * token if the registry supports that endpoint.
130
+ */
131
+ logout(registry: string): Promise<void>;
132
+ /**
133
+ * Log into the registry specified
134
+ *
135
+ * Does not return the token or expose it, just saves to the auth keychain
136
+ * and returns void if it worked. Otherwise, error is raised.
137
+ */
138
+ login(registry: string): Promise<void>;
139
+ /**
140
+ * Given a {@link WebAuthChallenge}, open the `authUrl` in a browser and
141
+ * hang on the `doneUrl` until it returns a {@link TokenResponse} object.
142
+ */
143
+ webAuthOpener({ doneUrl, authUrl }: WebAuthChallenge): Promise<TokenResponse>;
144
+ request(url: URL | string, options?: RegistryClientRequestOptions): Promise<CacheEntry>;
145
+ }
package/dist/index.js ADDED
@@ -0,0 +1,326 @@
1
+ import { Cache } from '@vltpkg/cache';
2
+ import { register as cacheUnzipRegister } from '@vltpkg/cache-unzip';
3
+ import { error } from '@vltpkg/error-cause';
4
+ import { asError } from '@vltpkg/types';
5
+ import { logRequest } from '@vltpkg/output';
6
+ import { urlOpen } from '@vltpkg/url-open';
7
+ import { XDG } from '@vltpkg/xdg';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { setTimeout } from 'node:timers/promises';
10
+ import { loadPackageJson } from 'package-json-from-dist';
11
+ import { Agent, RetryAgent } from 'undici';
12
+ import { addHeader } from "./add-header.js";
13
+ import { clearRuntimeTokens, deleteToken, getKC, getToken, isToken, keychains, runtimeTokens, setRuntimeToken, setToken, } from "./auth.js";
14
+ import { CacheEntry } from "./cache-entry.js";
15
+ import { register } from "./cache-revalidate.js";
16
+ import { bun, deno, node } from "./env.js";
17
+ import { handleCacheHitResponse } from "./handle-304-response.js";
18
+ import { otplease } from "./otplease.js";
19
+ import { isRedirect, redirect } from "./redirect.js";
20
+ import { setCacheHeaders } from "./set-cache-headers.js";
21
+ import { getTokenResponse } from "./token-response.js";
22
+ import { getWebAuthChallenge } from "./web-auth-challenge.js";
23
+ import { getEncondedValue } from "./string-encoding.js";
24
+ import { oidc } from "./oidc.js";
25
+ export { CacheEntry, clearRuntimeTokens, deleteToken, getKC, isToken, keychains, oidc, runtimeTokens, setRuntimeToken, setToken, };
26
+ export const isCacheableMethod = (m) => m === 'GET' || m === 'HEAD';
27
+ const { version } = loadPackageJson(import.meta.filename, process.env.__VLT_INTERNAL_REGISTRY_CLIENT_PACKAGE_JSON);
28
+ const nua = globalThis.navigator?.userAgent ??
29
+ (bun ? `Bun/${bun}`
30
+ : deno ? `Deno/${deno}`
31
+ : node ? `Node.js/${node}`
32
+ : '(unknown platform)');
33
+ export const userAgent = `@vltpkg/registry-client/${version} ${nua}`;
34
+ const agentOptions = {
35
+ bodyTimeout: 600_000,
36
+ headersTimeout: 600_000,
37
+ keepAliveMaxTimeout: 1_200_000,
38
+ keepAliveTimeout: 600_000,
39
+ keepAliveTimeoutThreshold: 30_000,
40
+ connect: {
41
+ timeout: 600_000,
42
+ keepAlive: true,
43
+ keepAliveInitialDelay: 30_000,
44
+ sessionTimeout: 600,
45
+ },
46
+ connections: 128,
47
+ pipelining: 10,
48
+ };
49
+ const xdg = new XDG('vlt');
50
+ export class RegistryClient {
51
+ agent;
52
+ cache;
53
+ identity;
54
+ staleWhileRevalidateFactor;
55
+ constructor(options) {
56
+ const { cache = xdg.cache(), 'fetch-retry-factor': timeoutFactor = 2, 'fetch-retry-mintimeout': minTimeout = 0, 'fetch-retry-maxtimeout': maxTimeout = 30_000, 'fetch-retries': maxRetries = 3, identity = '', 'stale-while-revalidate-factor': staleWhileRevalidateFactor = 576, // 48h for a 5min cache
57
+ } = options;
58
+ this.identity = identity;
59
+ this.staleWhileRevalidateFactor = staleWhileRevalidateFactor;
60
+ const path = resolve(cache, 'registry-client');
61
+ this.cache = new Cache({
62
+ path,
63
+ onDiskWrite(_path, key, data) {
64
+ if (CacheEntry.isGzipEntry(data)) {
65
+ cacheUnzipRegister(path, key);
66
+ }
67
+ },
68
+ });
69
+ const dispatch = new Agent(agentOptions);
70
+ this.agent = new RetryAgent(dispatch, {
71
+ maxRetries,
72
+ timeoutFactor,
73
+ minTimeout,
74
+ maxTimeout,
75
+ retryAfter: true,
76
+ errorCodes: [
77
+ 'ECONNREFUSED',
78
+ 'ECONNRESET',
79
+ 'EHOSTDOWN',
80
+ 'ENETDOWN',
81
+ 'ENETUNREACH',
82
+ 'ENOTFOUND',
83
+ 'EPIPE',
84
+ 'UND_ERR_SOCKET',
85
+ ],
86
+ });
87
+ }
88
+ /**
89
+ * Fetch the entire set of a paginated list of objects
90
+ */
91
+ async scroll(url, options = {}, seek) {
92
+ const resp = await this.request(url, options);
93
+ const { objects, urls } = resp.json();
94
+ // if we have more, and haven't found our target, fetch more
95
+ return urls.next && !(seek && objects.some(seek)) ?
96
+ objects.concat(await this.scroll(urls.next, options, seek))
97
+ : objects;
98
+ }
99
+ /**
100
+ * find a given item in a paginated set
101
+ */
102
+ async seek(url, seek, options = {}) {
103
+ return (await this.scroll(url, options, seek)).find(seek);
104
+ }
105
+ /**
106
+ * Log out from the registry specified, attempting to destroy the
107
+ * token if the registry supports that endpoint.
108
+ */
109
+ async logout(registry) {
110
+ // if we have no token for that registry, nothing to do
111
+ const tok = await getToken(registry, this.identity);
112
+ if (!tok)
113
+ return;
114
+ const s = tok.replace(/^(Bearer|Basic) /i, '');
115
+ const tokensUrl = new URL('-/npm/v1/tokens', registry);
116
+ const record = await this.seek(tokensUrl, ({ token }) => s.startsWith(token), {
117
+ useCache: false,
118
+ }).catch(() => undefined);
119
+ if (record) {
120
+ const { key } = record;
121
+ await this.request(new URL(`-/npm/v1/tokens/token/${key}`, registry), { useCache: false, method: 'DELETE' });
122
+ }
123
+ await deleteToken(registry, this.identity);
124
+ }
125
+ /**
126
+ * Log into the registry specified
127
+ *
128
+ * Does not return the token or expose it, just saves to the auth keychain
129
+ * and returns void if it worked. Otherwise, error is raised.
130
+ */
131
+ async login(registry) {
132
+ // - make POST to '/-/v1/login'
133
+ // - include a body of {} and npm-auth-type:web
134
+ // - get a {doneUrl, authUrl}
135
+ // - open the authUrl
136
+ // - hang on the doneUrl until done
137
+ //
138
+ // if that fails: fall back to couchdb login
139
+ const webLoginURL = new URL('-/v1/login', registry);
140
+ const response = await this.request(webLoginURL, {
141
+ method: 'POST',
142
+ useCache: false,
143
+ headers: {
144
+ 'content-type': 'application/json',
145
+ 'npm-auth-type': 'web',
146
+ },
147
+ body: '{}',
148
+ });
149
+ if (response.statusCode === 200) {
150
+ const challenge = getWebAuthChallenge(response.json());
151
+ if (challenge) {
152
+ const result = await this.webAuthOpener(challenge);
153
+ await setToken(registry, `Bearer ${result.token}`, this.identity);
154
+ return;
155
+ }
156
+ }
157
+ /* c8 ignore start */
158
+ // TODO: fall back to username/password login, and/or couchdb PUT login
159
+ throw error('Failed to perform web login', { response });
160
+ }
161
+ /* c8 ignore stop */
162
+ /**
163
+ * Given a {@link WebAuthChallenge}, open the `authUrl` in a browser and
164
+ * hang on the `doneUrl` until it returns a {@link TokenResponse} object.
165
+ */
166
+ async webAuthOpener({ doneUrl, authUrl }) {
167
+ const ac = new AbortController();
168
+ const { signal } = ac;
169
+ /* c8 ignore start - race condition */
170
+ const [result] = await Promise.all([
171
+ this.#checkLogin(doneUrl, { signal }).then(result => {
172
+ ac.abort();
173
+ return result;
174
+ }),
175
+ urlOpen(authUrl, { signal }).catch((er) => {
176
+ if (asError(er).name === 'AbortError')
177
+ return;
178
+ ac.abort();
179
+ throw er;
180
+ }),
181
+ ]);
182
+ /* c8 ignore stop */
183
+ return result;
184
+ }
185
+ async #checkLogin(url, options = {}) {
186
+ const response = await this.request(url, {
187
+ ...options,
188
+ useCache: false,
189
+ });
190
+ const { signal } = options;
191
+ if (response.statusCode === 202) {
192
+ const rt = response.getHeaderString('retry-after');
193
+ const retryAfter = rt ? Number(rt) : -1;
194
+ if (retryAfter > 0) {
195
+ await setTimeout(retryAfter * 1000, null, { signal });
196
+ }
197
+ return await this.#checkLogin(url, options);
198
+ }
199
+ if (response.statusCode === 200) {
200
+ const token = getTokenResponse(response.json());
201
+ if (token)
202
+ return token;
203
+ }
204
+ throw error('Invalid response from web login endpoint', {
205
+ response,
206
+ });
207
+ }
208
+ async request(url, options = {}) {
209
+ const u = typeof url === 'string' ? new URL(url) : url;
210
+ const { method = 'GET', integrity, redirections = new Set(), signal, otp = (process.env.VLT_OTP ?? '').trim(), staleWhileRevalidate = true, } = options;
211
+ let { trustIntegrity } = options;
212
+ const m = isCacheableMethod(method) ? method : undefined;
213
+ const { useCache = !!m } = options;
214
+ signal?.throwIfAborted();
215
+ // first, try to get from the cache before making any request.
216
+ const { origin } = u;
217
+ const key = `${method !== 'GET' ? method + ' ' : ''}${u}`;
218
+ const buffer = useCache ?
219
+ await this.cache.fetch(key, { context: { integrity } })
220
+ : undefined;
221
+ const entry = buffer ? CacheEntry.decode(buffer) : undefined;
222
+ if (entry?.valid) {
223
+ logRequest(url, 'cache');
224
+ return entry;
225
+ }
226
+ if (staleWhileRevalidate && entry?.staleWhileRevalidate && m) {
227
+ // revalidate while returning the stale entry
228
+ register(dirname(this.cache.path()), m, url);
229
+ logRequest(url, 'stale');
230
+ return entry;
231
+ }
232
+ logRequest(url, 'start');
233
+ // either no cache entry, or need to revalidate before use.
234
+ setCacheHeaders(options, entry);
235
+ redirections.add(String(url));
236
+ Object.assign(options, {
237
+ path: u.pathname.replace(/\/+$/, '') + u.search,
238
+ ...agentOptions,
239
+ });
240
+ options.origin = u.origin;
241
+ options.headers = addHeader(addHeader(options.headers, 'accept-encoding', 'gzip;q=1.0, identity;q=0.5'), 'user-agent', userAgent);
242
+ if (otp) {
243
+ options.headers = addHeader(options.headers, 'npm-otp', otp);
244
+ }
245
+ if (integrity) {
246
+ options.headers = addHeader(options.headers, 'accept-integrity', integrity);
247
+ }
248
+ options.method = options.method ?? 'GET';
249
+ // will remove if we don't have a token.
250
+ options.headers = addHeader(options.headers, 'authorization', await getToken(origin, this.identity));
251
+ let response = null;
252
+ try {
253
+ response = await this.agent.request(options);
254
+ /* c8 ignore start */
255
+ }
256
+ catch (er) {
257
+ // Rethrow so we get a better stack trace
258
+ throw error('Request failed', {
259
+ code: 'EREQUEST',
260
+ cause: er,
261
+ url,
262
+ method,
263
+ });
264
+ }
265
+ /* c8 ignore stop */
266
+ const result = await this.#handleResponse(u, options, response, entry);
267
+ if (result.getHeader('integrity')) {
268
+ trustIntegrity = true;
269
+ }
270
+ if (result.isGzip && !trustIntegrity) {
271
+ result.checkIntegrity({ url });
272
+ }
273
+ if (useCache) {
274
+ // Get the encoded buffer from the cache entry
275
+ const buffer = result.encode();
276
+ this.cache.set(key, Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength), {
277
+ integrity: result.integrity,
278
+ });
279
+ }
280
+ return result;
281
+ }
282
+ async #handleResponse(url, options, response, entry) {
283
+ if (handleCacheHitResponse(response, entry))
284
+ return entry;
285
+ if (response.statusCode === 401) {
286
+ const repeatRequest = await otplease(this, options, response);
287
+ if (repeatRequest)
288
+ return await this.request(url, repeatRequest);
289
+ }
290
+ const h = [];
291
+ for (const [key, value] of Object.entries(response.headers)) {
292
+ /* c8 ignore start - theoretical */
293
+ if (Array.isArray(value)) {
294
+ h.push(getEncondedValue(key), getEncondedValue(value.join(', ')));
295
+ /* c8 ignore stop */
296
+ }
297
+ else if (typeof value === 'string') {
298
+ h.push(getEncondedValue(key), getEncondedValue(value));
299
+ }
300
+ }
301
+ const { integrity, trustIntegrity } = options;
302
+ const result = new CacheEntry(
303
+ /* c8 ignore next - should always have a status code */
304
+ response.statusCode || 200, h, {
305
+ integrity,
306
+ trustIntegrity,
307
+ 'stale-while-revalidate-factor': this.staleWhileRevalidateFactor,
308
+ contentLength: response.headers['content-length'] ?
309
+ Number(response.headers['content-length'])
310
+ : /* c8 ignore next */ undefined,
311
+ });
312
+ if (isRedirect(result)) {
313
+ response.body.resume();
314
+ const [nextURL, nextOptions] = redirect(options, result, url);
315
+ if (nextOptions && nextURL) {
316
+ return await this.request(nextURL, nextOptions);
317
+ }
318
+ return result;
319
+ }
320
+ response.body.on('data', (chunk) => result.addBody(chunk));
321
+ return await new Promise((res, rej) => {
322
+ response.body.on('error', rej);
323
+ response.body.on('end', () => res(result));
324
+ });
325
+ }
326
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Determine whether we're allowed to cache it, based on status code
3
+ */
4
+ export declare const isCacheable: (statusCode: number) => boolean;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Determine whether we're allowed to cache it, based on status code
3
+ */
4
+ export const isCacheable = (statusCode) => statusCode < 200 ? false
5
+ : statusCode < 300 ? true
6
+ : statusCode === 301 ? true
7
+ : statusCode === 308 ? true
8
+ : statusCode === 410 ? true
9
+ : false;
@@ -0,0 +1 @@
1
+ export declare const isIterable: <T>(o: unknown) => o is Iterable<T>;
@@ -0,0 +1 @@
1
+ export const isIterable = (o) => !!o && typeof o === 'object' && Symbol.iterator in o;
package/dist/oidc.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { Token } from './auth.ts';
2
+ export type OidcOptions = {
3
+ /** The package name being published, e.g. `@scope/name` */
4
+ packageName: string;
5
+ /** The full registry URL, e.g. `https://registry.npmjs.org/` */
6
+ registry: string;
7
+ };
8
+ /**
9
+ * Detect CI environment and exchange an OIDC ID token for a
10
+ * registry auth token. Sets the token into the runtime token
11
+ * store so subsequent requests are authenticated.
12
+ *
13
+ * This function **never throws**. OIDC is always optional — if
14
+ * it is unavailable or any step fails the function returns
15
+ * `undefined` silently.
16
+ */
17
+ export declare const oidc: (opts: OidcOptions) => Promise<Token | undefined>;
package/dist/oidc.js ADDED
@@ -0,0 +1,151 @@
1
+ import { request } from 'undici';
2
+ import { setRuntimeToken } from "./auth.js";
3
+ const log = (...args) =>
4
+ // eslint-disable-next-line no-console
5
+ console.error('[vlt:oidc]', ...args);
6
+ /**
7
+ * Safely parse a JSON response body, returning undefined on failure.
8
+ */
9
+ const safeJson = async (res) => {
10
+ try {
11
+ return (await res.body.json());
12
+ /* c8 ignore next 3 - defensive: non-JSON response body */
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ };
18
+ /**
19
+ * Keys that are always replaced with '[REDACTED]' before logging.
20
+ */
21
+ const SENSITIVE_KEYS = new Set(['token', 'value', 'secret', 'key']);
22
+ /**
23
+ * Recursively redact sensitive fields from a value before logging.
24
+ * Works as a JSON.stringify replacer so nested objects are handled
25
+ * automatically — no sensitive value is ever serialised.
26
+ */
27
+ const redact = (obj) => JSON.parse(JSON.stringify(obj, (k, v) => SENSITIVE_KEYS.has(k) ? '[REDACTED]' : v));
28
+ /**
29
+ * Detect CI environment and exchange an OIDC ID token for a
30
+ * registry auth token. Sets the token into the runtime token
31
+ * store so subsequent requests are authenticated.
32
+ *
33
+ * This function **never throws**. OIDC is always optional — if
34
+ * it is unavailable or any step fails the function returns
35
+ * `undefined` silently.
36
+ */
37
+ export const oidc = async (opts) => {
38
+ try {
39
+ return await _oidc(opts);
40
+ }
41
+ catch (err) /* c8 ignore start - defensive */ {
42
+ log('Unexpected error:', err instanceof Error ? err.message : String(err));
43
+ return undefined;
44
+ } /* c8 ignore stop */
45
+ };
46
+ const _oidc = async ({ packageName, registry, }) => {
47
+ const isGitHub = process.env.GITHUB_ACTIONS === 'true';
48
+ const isGitLab = process.env.GITLAB_CI === 'true';
49
+ const isCircle = process.env.CIRCLECI === 'true';
50
+ log(`CI detected: github=${isGitHub} gitlab=${isGitLab} circle=${isCircle}`);
51
+ if (!isGitHub && !isGitLab && !isCircle) {
52
+ log('Not in a supported CI environment — skipping');
53
+ return undefined;
54
+ }
55
+ // NPM_ID_TOKEN is supported as an override in all CI environments
56
+ let idToken = process.env.NPM_ID_TOKEN;
57
+ log(`NPM_ID_TOKEN: ${idToken ? `set (length=${idToken.length})` : 'not set'}`);
58
+ if (!idToken && isGitHub) {
59
+ idToken = await fetchGitHubIdToken(registry);
60
+ }
61
+ if (!idToken) {
62
+ log('No ID token available — skipping');
63
+ return undefined;
64
+ }
65
+ const token = await exchangeToken(idToken, packageName, registry);
66
+ if (token) {
67
+ setRuntimeToken(registry, token);
68
+ log(`Token set for registry ${registry}`);
69
+ }
70
+ else {
71
+ log('Token exchange did not return a token');
72
+ }
73
+ return token;
74
+ };
75
+ /**
76
+ * Fetch an OIDC ID token from GitHub Actions.
77
+ * Requires `id-token: write` permission in the workflow.
78
+ */
79
+ const fetchGitHubIdToken = async (registry) => {
80
+ const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
81
+ const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
82
+ log(`ACTIONS_ID_TOKEN_REQUEST_URL: ${requestUrl ? `set (length=${requestUrl.length})` : 'not set — skipping (missing id-token permissions?)'}`);
83
+ log(`ACTIONS_ID_TOKEN_REQUEST_TOKEN: ${requestToken ? 'set' : 'not set'}`);
84
+ if (!requestUrl || !requestToken) {
85
+ return undefined;
86
+ }
87
+ const audience = `npm:${new URL(registry).hostname}`;
88
+ const url = new URL(requestUrl);
89
+ url.searchParams.append('audience', audience);
90
+ log(`Fetching GitHub ID token with audience ${audience}`);
91
+ try {
92
+ const res = await request(url, {
93
+ method: 'GET',
94
+ headers: {
95
+ accept: 'application/json',
96
+ authorization: `Bearer ${requestToken}`,
97
+ },
98
+ });
99
+ log(`GitHub ID token response: status=${res.statusCode}`);
100
+ if (res.statusCode < 200 || res.statusCode >= 300) {
101
+ const body = await safeJson(res);
102
+ log(`GitHub ID token error body: ${body ? JSON.stringify(redact(body)) : '(non-JSON response)'}`);
103
+ return undefined;
104
+ }
105
+ const json = await safeJson(res);
106
+ const value = json?.value;
107
+ log(`GitHub ID token: hasValue=${!!value}`);
108
+ return value || undefined;
109
+ }
110
+ catch (err) /* c8 ignore start - network error */ {
111
+ log('GitHub ID token fetch error:', err instanceof Error ? err.message : String(err));
112
+ return undefined;
113
+ } /* c8 ignore stop */
114
+ };
115
+ /**
116
+ * Exchange an OIDC ID token for an npm auth token via the
117
+ * registry token exchange endpoint.
118
+ */
119
+ const exchangeToken = async (idToken, packageName, registry) => {
120
+ // npm escaping: @scope/name → @scope%2Fname
121
+ const escapedName = packageName.replace('/', '%2F');
122
+ const exchangeUrl = new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedName}`, registry);
123
+ log(`Exchanging token for package: ${packageName}`);
124
+ log(`Exchange URL: ${exchangeUrl.href}`);
125
+ try {
126
+ const res = await request(exchangeUrl, {
127
+ method: 'POST',
128
+ headers: {
129
+ 'content-type': 'application/json',
130
+ authorization: `Bearer ${idToken}`,
131
+ },
132
+ });
133
+ log(`Exchange response: status=${res.statusCode}`);
134
+ if (res.statusCode < 200 || res.statusCode >= 300) {
135
+ const body = await safeJson(res);
136
+ log(`Exchange error body: ${body ? JSON.stringify(redact(body)) : '(non-JSON response)'}`);
137
+ return undefined;
138
+ }
139
+ const json = await safeJson(res);
140
+ const hasToken = !!json?.token;
141
+ log(`Exchange result: hasToken=${hasToken}`);
142
+ if (!json?.token || typeof json.token !== 'string') {
143
+ return undefined;
144
+ }
145
+ return `Bearer ${json.token}`;
146
+ }
147
+ catch (err) /* c8 ignore start - network error */ {
148
+ log('Exchange request error:', err instanceof Error ? err.message : String(err));
149
+ return undefined;
150
+ } /* c8 ignore stop */
151
+ };
@@ -0,0 +1,3 @@
1
+ import type { Dispatcher } from 'undici';
2
+ import type { RegistryClient, RegistryClientRequestOptions } from './index.ts';
3
+ export declare const otplease: (client: RegistryClient, options: RegistryClientRequestOptions, response: Dispatcher.ResponseData) => Promise<RegistryClientRequestOptions | undefined>;