@vzn/vx-devtools 0.0.650 → 0.0.652

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,8 +64,22 @@ 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
@@ -92,5 +106,6 @@ export function oidcTokenAuth(
92
106
  `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>` ref. A
93
107
  `pull_request_target`, `issue_comment` or `workflow_run` run writes
94
108
  nothing: its `ref` is the base while it may run a pull request's code.
95
- Nor does a GitLab merge request pipeline unless `source_project_path` (GitLab
96
- 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.650",
3
+ "version": "0.0.652",
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. */
@@ -303,7 +308,7 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
303
308
  const stale = new Set<string>()
304
309
  const keySet = async (iss: string, url: string): Promise<unknown> => {
305
310
  try {
306
- const json = await fetchJson(url)
311
+ const json = await fetchJson(url, lastRead.has(url))
307
312
  lastRead.set(url, json)
308
313
  stale.delete(iss)
309
314
  return json
@@ -316,13 +321,15 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
316
321
  const keysOf = (iss: string) => {
317
322
  let set = keys.get(iss)
318
323
  if (set === undefined) {
319
- set = fetchJson(`${iss.replace(/\/$/, '')}/.well-known/openid-configuration`).then((c) => {
320
- const uri = (c as { jwks_uri?: unknown }).jwks_uri
321
- if (typeof uri !== 'string') throw new Error('no jwks_uri')
322
- return createRemoteJWKSet(new URL(uri), {
323
- [customFetch]: async (url: string) => Response.json(await keySet(iss, url)),
324
- })
325
- })
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
+ )
326
333
  keys.set(iss, set)
327
334
  // A failed discovery is not kept: the next token tries again.
328
335
  // quiet: the awaited set rethrows it; this only forgets it
@@ -435,6 +442,41 @@ const keysUnread = (iss: string, err: unknown): Refusal =>
435
442
  `could not read ${iss}'s keys: ${err instanceof Error ? err.message : String(err)}`,
436
443
  )
437
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
+
438
480
  async function defaultFetchJson(url: string): Promise<unknown> {
439
481
  const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
440
482
  if (!res.ok) throw new Error(`${url}: ${res.status}`)