@vzn/vx-devtools 0.0.651 → 0.0.653

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
@@ -25,8 +25,9 @@ option.
25
25
  `public`, `named` when ALLOW names it, or `undefined` when none is named.
26
26
  - `errorJson(status, message)` is every server's refusal, `{ code, message }`
27
27
  (Turbo's spec's `Error`); `notAllowed(allow)` its `405`, naming the methods in `Allow`.
28
- - `hashPart(text)` is what a server stores in place of an issuer, a repository name or a scope: the first
29
- 128 bits of its SHA-256 in hex (Web Crypto, so Bun and Workers agree), never the plain value.
28
+ - `hashPart(text)` is what a request names its issuer and repository by (`vx-issuer`, `vx-repository`):
29
+ the first 128 bits of its SHA-256 in hex (Web Crypto, so Bun and Workers agree). A server stores
30
+ them plain: a cache object's name and a history row hold the issuer, repository and ref.
30
31
  - `statusLine(tone, name, text)` is the line a server prints when it starts (`'ok'`) or refuses to
31
32
  (`'err'`): `✓ vx-cache-server listening on …`, painted as vx's own status lines (NO_COLOR, then
32
33
  FORCE_COLOR, then a terminal whose `TERM` is not `dumb`), with any lines after the first dim.
@@ -95,7 +96,7 @@ export function cachedFetchJson(
95
96
  checkout's origin names (2026-10-25). One `ALLOW` does not name is
96
97
  `403`, and so is one other than the request names.
97
98
  - The token's workflow (`job_workflow_ref` on GitHub, `ci_config_ref_uri`
98
- on GitLab, up to the `@` before its ref) must be one an entry names for that
99
+ on GitLab, up to its last `@`, before the ref or SHA) must be one an entry names for that
99
100
  repository: any other is `403`.
100
101
  - With `readAccess: 'public'` (`READ_ACCESS`), a request with no token
101
102
  reads the repository it names when `ALLOW` names it (else `403`), and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-devtools",
3
- "version": "0.0.651",
3
+ "version": "0.0.653",
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/hash-part.ts CHANGED
@@ -1,6 +1,7 @@
1
- // What a server stores in place of an issuer, a repository name or a scope
2
- // (2026-10-25: "issuer and scope should be hashed"): a cache object's
3
- // name and a history row hold no plain value of a token's.
1
+ // What a request names its issuer and repository by (`vx-issuer`,
2
+ // `vx-repository`, 2026-10-25), never their plain values. What a server
3
+ // stores is plain: a cache object's name and a history row hold the
4
+ // issuer, repository and ref as they are (2026-10-27, 2026-11-03).
4
5
 
5
6
  const parts = new Map<string, Promise<string>>()
6
7
 
package/src/oidc.ts CHANGED
@@ -155,7 +155,7 @@ function branchOf(iss: string, claims: JWTPayload): string | undefined {
155
155
  export interface OidcOptions {
156
156
  /** `ALLOW` (`parseAllow`): who may take part. */
157
157
  readonly allow: readonly Allowed[]
158
- /** The `aud` a token must name; unset, the origin the server is reached at. */
158
+ /** The `aud` a token must name, read as its origin (a URL that is not one throws); unset, the origin the server is reached at. */
159
159
  readonly audience?: string
160
160
  /**
161
161
  * Reads a URL as JSON (`cachedFetchJson` on a Worker); tests serve keys
@@ -291,9 +291,7 @@ const bearerOf = (req: Request): string | undefined =>
291
291
 
292
292
  /** A token checked: jose's checks, then ALLOW's repository and workflow, and the branch it may write. */
293
293
  function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Promise<Access> {
294
- // A blank one is no origin a token names: every token was "wrong audience".
295
- if (opts.audience !== undefined && opts.audience.trim() === '')
296
- throw new Error('AUDIENCE: the aud CI tokens name, or unset, not blank')
294
+ const own = opts.audience === undefined ? undefined : originOf(opts.audience)
297
295
  const fetchJson = opts.fetchJson ?? defaultFetchJson
298
296
  const now = opts.now ?? (() => Date.now() / 1000)
299
297
  // jose keeps each issuer's keys, and refetches them for a key it lacks.
@@ -360,7 +358,7 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
360
358
  : 'the token names no issuer (iss)',
361
359
  )
362
360
  }
363
- const audience = opts.audience ?? new URL(req.url).origin
361
+ const audience = own ?? new URL(req.url).origin
364
362
  let claims: JWTPayload
365
363
  try {
366
364
  claims = (
@@ -390,9 +388,13 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
390
388
  const wclaim = workflowClaimOf(iss)
391
389
  const ran = claims[wclaim]
392
390
  if (typeof ran !== 'string') throw new Refusal(403, `no ${wclaim} claim`)
393
- // Cut at the `@` before the ref: GitLab's CI file may itself hold one
394
- // (`.gitlab-ci.yml@group/project`, a file kept in another project).
395
- const workflow = ran.replace(/@(?:refs\/.*)?$/, '')
391
+ // Cut at the last `@`: the ref after it is `refs/…` or a SHA (a reusable
392
+ // workflow pinned by one), and GitLab's CI file may itself hold one
393
+ // (`.gitlab-ci.yml@group/project`, a file kept in another project, which
394
+ // a cut at the first `@refs/` matched as this project's own when that
395
+ // project was `refs/…`). A ref holding an `@` is refused, never widened.
396
+ const at = ran.lastIndexOf('@')
397
+ const workflow = at < 0 ? ran : ran.slice(0, at)
396
398
  if (!ofRepo.some((a) => same(workflow, workflowPath(iss, a.repository, a.workflow))))
397
399
  throw new Refusal(403, `${wclaim} ${workflow} is not in this server's ALLOW`)
398
400
  const repo = { issuer: iss, repository }
@@ -401,6 +403,22 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
401
403
  }
402
404
  }
403
405
 
406
+ /**
407
+ * `AUDIENCE` as tokens name it: the origin, which vxCache() and vxHistory()
408
+ * ask CI for, so `https://c/` or a URL with a path refused every token.
409
+ */
410
+ function originOf(audience: string): string {
411
+ const said = audience.trim()
412
+ // A blank one is no origin a token names: every token was "wrong audience".
413
+ if (said === '') throw new Error('AUDIENCE: the aud CI tokens name, or unset, not blank')
414
+ const url = URL.parse(said)
415
+ if (url === null || (url.protocol !== 'https:' && url.protocol !== 'http:'))
416
+ throw new Error(
417
+ `AUDIENCE: the origin clients reach this server at (https://cache.example.com), not ${said}`,
418
+ )
419
+ return url.origin
420
+ }
421
+
404
422
  /** An issuer as a key names it: its URL, no trailing slash. */
405
423
  const issuerOf = (iss: string): string => iss.replace(/\/$/, '')
406
424
 
@@ -469,11 +487,14 @@ export function cachedFetchJson(
469
487
  const res = await (fetchImpl ?? fetch)(url, { signal: AbortSignal.timeout(10_000) })
470
488
  if (!res.ok) throw new Error(`${url}: ${res.status}`)
471
489
  const body = await res.text()
490
+ // Parsed before it is kept: a 200 that is not JSON (a proxy's page) is
491
+ // not served to every token for the next ten minutes.
492
+ const json: unknown = JSON.parse(body)
472
493
  await cache.put(
473
494
  url,
474
495
  new Response(body, { headers: { 'cache-control': `max-age=${KEYS_TTL_S}` } }),
475
496
  )
476
- return JSON.parse(body)
497
+ return json
477
498
  }
478
499
  }
479
500