@vzn/vx-devtools 0.0.649 → 0.0.651

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 CHANGED
@@ -51,7 +51,7 @@ export type Authorize = (req: Request) => Promise<Access>
51
51
  export interface OidcOptions {
52
52
  readonly allow: readonly Allowed[]
53
53
  readonly audience?: string
54
- readonly fetchJson?: (url: string) => Promise<unknown>
54
+ readonly fetchJson?: (url: string, fresh: boolean) => Promise<unknown>
55
55
  readonly now?: () => number
56
56
  readonly readAccess?: string
57
57
  }
@@ -64,13 +64,29 @@ export interface Repo {
64
64
  export function oidcTokenAuth(
65
65
  opts: OidcOptions,
66
66
  ): (req: Request, named?: Repo) => Promise<Access | undefined>
67
+ export interface KeysCache {
68
+ match(url: string): Promise<Response | undefined>
69
+ put(url: string, res: Response): Promise<void>
70
+ }
71
+ export function cachedFetchJson(
72
+ cache: KeysCache,
73
+ fetchImpl?: typeof fetch,
74
+ ): (url: string, fresh: boolean) => Promise<unknown>
67
75
  ```
68
76
 
77
+ - `fetchJson`'s `fresh` is true for a reload of keys already read. A reload
78
+ must reach the issuer. The first read of a URL may come from a cache.
79
+ - `cachedFetchJson(caches.default)` (any `KeysCache`) is how a Worker reads the keys: a fresh
80
+ isolate takes them from the cache its data center shares (10 minutes),
81
+ not from the issuer.
82
+
69
83
  ## How it decides
70
84
 
71
85
  - jose checks the JWT: `RS256`, the signature against the issuer's keys
72
86
  (found through its `/.well-known/openid-configuration`, kept and
73
87
  refetched by `createRemoteJWKSet`), `aud`, `exp` and `nbf` within 60 s.
88
+ When a refetch fails, the keys last read still check tokens. A key they
89
+ lack is then `502`, not a refused token.
74
90
  - With `oidcAuth`, every request names its repository: `vx-issuer` and `vx-repository`,
75
91
  the `hashPart`s of the issuer's URL (no trailing slash) and of its
76
92
  `org/name` lowercased; one missing or not a hash is `400`.
@@ -90,5 +106,6 @@ export function oidcTokenAuth(
90
106
  `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>` ref. A
91
107
  `pull_request_target`, `issue_comment` or `workflow_run` run writes
92
108
  nothing: its `ref` is the base while it may run a pull request's code.
93
- Nor does a GitLab merge request pipeline unless `source_project_path` (GitLab
94
- 19.5+) names its own project: a fork's runs in the parent project.
109
+ Nor does a GitLab run whose `source_project_path` or `job_project_path`
110
+ names another project (a fork's merge request, a child pipeline of one),
111
+ or a merge request pipeline naming no source project (GitLab before 19.5).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-devtools",
3
- "version": "0.0.649",
3
+ "version": "0.0.651",
4
4
  "license": "MIT",
5
5
  "description": "Code vx's servers and plugins share, never core: first, the CI OIDC token check.",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export {
6
6
  type Access,
7
7
  type Allowed,
8
8
  type Authorize,
9
+ cachedFetchJson,
10
+ type KeysCache,
9
11
  oidcAuth,
10
12
  oidcTokenAuth,
11
13
  type OidcOptions,
package/src/oidc.ts CHANGED
@@ -126,22 +126,23 @@ const ON_BEHALF = new Set(['pull_request_target', 'issue_comment', 'workflow_run
126
126
  * queue's (`merge_group`) run writes its base branch, which its code is
127
127
  * about to become (2026-10-25: "just strip it"); only its ref is renamed:
128
128
  * anyone can push a branch named like a queue ref. A run of an event in
129
- * `ON_BEHALF` writes nothing. A GitLab merge request pipeline writes its source branch
130
- * only when `source_project_path` names its own project: a fork's runs in
131
- * the parent project with the fork's branch as its `ref_path`, and a
132
- * GitLab before 19.5 names no source project.
129
+ * `ON_BEHALF` writes nothing. A GitLab run writes only when the code and
130
+ * the job are its project's own (`source_project_path`, `job_project_path`):
131
+ * a fork's merge request runs in the parent project with the fork's branch
132
+ * as its `ref_path`, and so may a child pipeline of one or a mirrored pull
133
+ * request, which were not checked. A merge request pipeline must name its
134
+ * source project: a GitLab before 19.5 names none.
133
135
  */
134
136
  function branchOf(iss: string, claims: JWTPayload): string | undefined {
135
- if (iss !== GITHUB && claims['pipeline_source'] === 'merge_request_event') {
136
- const own = (claim: string) => {
137
+ if (iss !== GITHUB) {
138
+ // Absent is its own, but where it must be named: a merge request's.
139
+ const own = (claim: string, required: boolean) => {
137
140
  const p = claims[claim]
141
+ if (p === undefined) return !required
138
142
  return typeof p === 'string' && same(p, String(claims['project_path']))
139
143
  }
140
- if (
141
- !own('source_project_path') ||
142
- (claims['job_project_path'] !== undefined && !own('job_project_path'))
143
- )
144
- return undefined
144
+ const mr = claims['pipeline_source'] === 'merge_request_event'
145
+ if (!own('source_project_path', mr) || !own('job_project_path', false)) return undefined
145
146
  }
146
147
  const event = claims['event_name']
147
148
  if (ON_BEHALF.has(event as string)) return undefined
@@ -156,8 +157,12 @@ export interface OidcOptions {
156
157
  readonly allow: readonly Allowed[]
157
158
  /** The `aud` a token must name; unset, the origin the server is reached at. */
158
159
  readonly audience?: string
159
- /** Reads a URL as JSON; tests serve keys without a network. */
160
- readonly fetchJson?: (url: string) => Promise<unknown>
160
+ /**
161
+ * Reads a URL as JSON (`cachedFetchJson` on a Worker); tests serve keys
162
+ * without a network. `fresh`: a reload of keys already read, which must
163
+ * reach the issuer; the first read of a URL may come from a cache.
164
+ */
165
+ readonly fetchJson?: (url: string, fresh: boolean) => Promise<unknown>
161
166
  /** Seconds since the epoch; tests move the clock. */
162
167
  readonly now?: () => number
163
168
  /** `READ_ACCESS`: `public` lets a request with no token read a repository ALLOW names. */
@@ -293,16 +298,38 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
293
298
  const now = opts.now ?? (() => Date.now() / 1000)
294
299
  // jose keeps each issuer's keys, and refetches them for a key it lacks.
295
300
  const keys = new Map<string, Promise<ReturnType<typeof createRemoteJWKSet>>>()
301
+ // The key set each URL last answered. jose refetches every 10 minutes and
302
+ // threw when that failed: one slow answer from GitHub's key endpoint failed
303
+ // an upload 502 and lost a DTE worker's entry (2026-10-29). Keys rotate
304
+ // rarely, so the last set answers until the issuer answers again.
305
+ const lastRead = new Map<string, unknown>()
306
+ // The issuers answered from lastRead since their last fetch: a kid that
307
+ // set lacks is then the issuer out of reach, not a token refused.
308
+ const stale = new Set<string>()
309
+ const keySet = async (iss: string, url: string): Promise<unknown> => {
310
+ try {
311
+ const json = await fetchJson(url, lastRead.has(url))
312
+ lastRead.set(url, json)
313
+ stale.delete(iss)
314
+ return json
315
+ } catch (err) {
316
+ if (!lastRead.has(url)) throw err
317
+ stale.add(iss)
318
+ return lastRead.get(url)
319
+ }
320
+ }
296
321
  const keysOf = (iss: string) => {
297
322
  let set = keys.get(iss)
298
323
  if (set === undefined) {
299
- set = fetchJson(`${iss.replace(/\/$/, '')}/.well-known/openid-configuration`).then((c) => {
300
- const uri = (c as { jwks_uri?: unknown }).jwks_uri
301
- if (typeof uri !== 'string') throw new Error('no jwks_uri')
302
- return createRemoteJWKSet(new URL(uri), {
303
- [customFetch]: async (url: string) => Response.json(await fetchJson(url)),
304
- })
305
- })
324
+ set = fetchJson(`${iss.replace(/\/$/, '')}/.well-known/openid-configuration`, false).then(
325
+ (c) => {
326
+ const uri = (c as { jwks_uri?: unknown }).jwks_uri
327
+ if (typeof uri !== 'string') throw new Error('no jwks_uri')
328
+ return createRemoteJWKSet(new URL(uri), {
329
+ [customFetch]: async (url: string) => Response.json(await keySet(iss, url)),
330
+ })
331
+ },
332
+ )
306
333
  keys.set(iss, set)
307
334
  // A failed discovery is not kept: the next token tries again.
308
335
  // quiet: the awaited set rethrows it; this only forgets it
@@ -346,6 +373,8 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
346
373
  })
347
374
  ).payload
348
375
  } catch (err) {
376
+ if (err instanceof errors.JWKSNoMatchingKey && stale.has(iss))
377
+ throw keysUnread(iss, new Error('a key the keys last read lack, and a refetch failed'))
349
378
  throw refusal(err, iss, audience)
350
379
  }
351
380
  const claim = repoClaimOf(iss)
@@ -413,6 +442,41 @@ const keysUnread = (iss: string, err: unknown): Refusal =>
413
442
  `could not read ${iss}'s keys: ${err instanceof Error ? err.message : String(err)}`,
414
443
  )
415
444
 
445
+ /** A cache a Worker's isolates share (`caches.default`), as `cachedFetchJson` uses it. */
446
+ export interface KeysCache {
447
+ match(url: string): Promise<Response | undefined>
448
+ put(url: string, res: Response): Promise<void>
449
+ }
450
+
451
+ /** How long a shared cache holds an issuer's answer for the next isolate. */
452
+ const KEYS_TTL_S = 600
453
+
454
+ /**
455
+ * `fetchJson` through a cache isolates share: a Worker's fresh isolate read
456
+ * the issuer on its first token, and one slow answer from GitHub failed it
457
+ * 502 (2026-10-29), as the DTE relay found (2026-10-22). A fresh read (a
458
+ * reload, maybe for a rotated key) asks the issuer, and refills the cache.
459
+ */
460
+ export function cachedFetchJson(
461
+ cache: KeysCache,
462
+ fetchImpl?: typeof fetch,
463
+ ): (url: string, fresh: boolean) => Promise<unknown> {
464
+ return async (url, fresh) => {
465
+ const hit = fresh ? undefined : await cache.match(url)
466
+ if (hit !== undefined) return hit.json()
467
+ // The global read at each call: one taken when the module loaded missed
468
+ // a fetch set after it (a test's issuer reached the network).
469
+ const res = await (fetchImpl ?? fetch)(url, { signal: AbortSignal.timeout(10_000) })
470
+ if (!res.ok) throw new Error(`${url}: ${res.status}`)
471
+ const body = await res.text()
472
+ await cache.put(
473
+ url,
474
+ new Response(body, { headers: { 'cache-control': `max-age=${KEYS_TTL_S}` } }),
475
+ )
476
+ return JSON.parse(body)
477
+ }
478
+ }
479
+
416
480
  async function defaultFetchJson(url: string): Promise<unknown> {
417
481
  const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
418
482
  if (!res.ok) throw new Error(`${url}: ${res.status}`)