@vzn/vx-devtools 0.0.0 → 0.0.648

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VZN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,94 @@
1
1
  # @vzn/vx-devtools
2
2
 
3
- Placeholder. Part of [vx](https://github.com/vznjs/vx).
3
+ Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
4
+
5
+ Code vx's servers and plugins share. Core never imports it.
6
+
7
+ ## Purpose
8
+
9
+ First, the CI tokens vx's servers take. `@vzn/vx-cache-server` and
10
+ `@vzn/vx-history-server` take one kind of token: a CI job's OIDC token
11
+ (GitHub Actions, GitLab CI). This package is the check both share. It imports only jose, so a server on Workers
12
+ bundles it alone. Clients never import it: each plugin asks its CI for
13
+ its own token. The token alone decides
14
+ what a request reads and where it writes, never a header or a client
15
+ option.
16
+
17
+ ## API
18
+
19
+ - `parseAllow(list)` reads `ALLOW` (space-separated `issuer:owner/repo:workflow` entries, the string the DTE relay reads) into `Allowed`s; `oidcAuth(options)` is the `Authorize` checking a request's CI token against
20
+ them (its options an `OidcOptions`), answering its `Access` or throwing a `Refusal` (`status` and what
21
+ to fix). `oidcTokenAuth(options)` is the check for a server whose
22
+ requests name their repository plainly, in the body (`@vzn/vx-history-server`):
23
+ `(req, named?)`, `named` a `Repo`, answers the token's `Access`, refusing a token of
24
+ another repository than `named`; with no token where `readAccess` is
25
+ `public`, `named` when ALLOW names it, or `undefined` when none is named.
26
+ - `errorJson(status, message)` is every server's refusal, `{ code, message }`
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.
30
+ - `statusLine(tone, name, text)` is the line a server prints when it starts (`'ok'`) or refuses to
31
+ (`'err'`): `✓ vx-cache-server listening on …`, painted as vx's own status lines (NO_COLOR, then
32
+ FORCE_COLOR, then a terminal whose `TERM` is not `dumb`), with any lines after the first dim.
33
+
34
+ ```ts
35
+ export class Refusal extends Error {
36
+ readonly status: number
37
+ }
38
+ export function errorJson(status: number, message: string): Response
39
+ export function notAllowed(allow: string): Response
40
+ export interface Allowed {
41
+ readonly issuer: string
42
+ readonly repository: string
43
+ readonly workflow: string
44
+ }
45
+ export interface Access {
46
+ readonly issuer: string
47
+ readonly repository: string
48
+ readonly write?: string
49
+ }
50
+ export type Authorize = (req: Request) => Promise<Access>
51
+ export interface OidcOptions {
52
+ readonly allow: readonly Allowed[]
53
+ readonly audience?: string
54
+ readonly fetchJson?: (url: string) => Promise<unknown>
55
+ readonly now?: () => number
56
+ readonly readAccess?: string
57
+ }
58
+ export function parseAllow(list: string): Allowed[]
59
+ export function oidcAuth(opts: OidcOptions): Authorize
60
+ export interface Repo {
61
+ readonly issuer: string
62
+ readonly repository: string
63
+ }
64
+ export function oidcTokenAuth(
65
+ opts: OidcOptions,
66
+ ): (req: Request, named?: Repo) => Promise<Access | undefined>
67
+ ```
68
+
69
+ ## How it decides
70
+
71
+ - jose checks the JWT: `RS256`, the signature against the issuer's keys
72
+ (found through its `/.well-known/openid-configuration`, kept and
73
+ refetched by `createRemoteJWKSet`), `aud`, `exp` and `nbf` within 60 s.
74
+ - With `oidcAuth`, every request names its repository: `vx-issuer` and `vx-repository`,
75
+ the `hashPart`s of the issuer's URL (no trailing slash) and of its
76
+ `org/name` lowercased; one missing or not a hash is `400`.
77
+ - The token's repository is its issuer (a trailing slash, in `ALLOW` or
78
+ the token, is the same issuer) and its name, `org/name` lowercased: `repository` (GitHub) or `project_path` (GitLab), what a
79
+ checkout's origin names (2026-10-25). One `ALLOW` does not name is
80
+ `403`, and so is one other than the request names.
81
+ - The token's workflow (`job_workflow_ref` on GitHub, `ci_config_ref_uri`
82
+ on GitLab, up to the `@` before its ref) must be one an entry names for that
83
+ repository: any other is `403`.
84
+ - With `readAccess: 'public'` (`READ_ACCESS`), a request with no token
85
+ reads the repository it names when `ALLOW` names it (else `403`), and
86
+ writes nothing. Any other value than `public` or unset throws.
87
+ - A token writes its `ref` (GitLab's `ref_path`): a `pull_request` run
88
+ its merge ref, `refs/pull/<n>/merge`, never its `head_ref`, which a fork
89
+ names; a `merge_group` run its base branch for a
90
+ `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>` ref. A
91
+ `pull_request_target`, `issue_comment` or `workflow_run` run writes
92
+ 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.
package/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Root entry shim: Bun's `--compile` binaries resolve an on-disk package
2
+ // by `<pkg>/index.ts` and ignore package.json `exports` / `main` (see
3
+ // packages/vx/index.ts). Same module as the exports map names.
4
+ export * from './src/index.js'
package/package.json CHANGED
@@ -1,8 +1,44 @@
1
1
  {
2
2
  "name": "@vzn/vx-devtools",
3
- "version": "0.0.0",
4
- "description": "Placeholder; real releases are published by CI.",
3
+ "version": "0.0.648",
5
4
  "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/vznjs/vx.git" },
7
- "publishConfig": { "access": "public" }
5
+ "description": "Code vx's servers and plugins share, never core: first, the CI OIDC token check.",
6
+ "keywords": [
7
+ "vx",
8
+ "monorepo",
9
+ "oidc",
10
+ "ci"
11
+ ],
12
+ "type": "module",
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./src/index.ts",
18
+ "import": "./src/index.ts"
19
+ }
20
+ },
21
+ "files": [
22
+ "index.ts",
23
+ "src",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "bun": ">=1.4.4"
29
+ },
30
+ "//": [
31
+ "jose: checks each JWT (signature, algorithm, audience, times) and keeps the issuers' key sets.",
32
+ "@vzn/vx: vx.config.ts's core, for the tasks only; the package never imports it."
33
+ ],
34
+ "dependencies": {
35
+ "jose": "6.2.13"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/vznjs/vx.git",
40
+ "directory": "packages/vx-devtools"
41
+ },
42
+ "homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-devtools#readme",
43
+ "bugs": "https://github.com/vznjs/vx/issues"
8
44
  }
@@ -0,0 +1,23 @@
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.
4
+
5
+ const parts = new Map<string, Promise<string>>()
6
+
7
+ /**
8
+ * SHA-256's first 128 bits of `text` in hex. Web Crypto, so Bun and
9
+ * Workers name alike; a few strings recur per node, so each is hashed once.
10
+ */
11
+ export function hashPart(text: string): Promise<string> {
12
+ let got = parts.get(text)
13
+ if (got === undefined) {
14
+ if (parts.size >= 10_000) parts.clear()
15
+ got = crypto.subtle
16
+ .digest('SHA-256', new TextEncoder().encode(text))
17
+ .then((d) =>
18
+ [...new Uint8Array(d, 0, 16)].map((b) => b.toString(16).padStart(2, '0')).join(''),
19
+ )
20
+ parts.set(text, got)
21
+ }
22
+ return got
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // @vzn/vx-devtools: code vx's servers and plugins share, never core. First,
2
+ // the CI token check, and the hash a server stores in place of a token's values. It imports only
3
+ // jose, so a server on Workers bundles it alone.
4
+
5
+ export {
6
+ type Access,
7
+ type Allowed,
8
+ type Authorize,
9
+ oidcAuth,
10
+ oidcTokenAuth,
11
+ type OidcOptions,
12
+ parseAllow,
13
+ type Repo,
14
+ } from './oidc.js'
15
+ export { hashPart } from './hash-part.js'
16
+ export { statusLine } from './terminal.js'
17
+ export { errorJson, notAllowed, Refusal } from './refusal.js'
package/src/oidc.ts ADDED
@@ -0,0 +1,420 @@
1
+ // CI OIDC tokens, the only tokens vx's servers take (@vzn/vx-cache-server,
2
+ // @vzn/vx-history-server): a job's token names its repository and the one
3
+ // scope it writes, its own branch, never a header or a client option. A
4
+ // cache request names its issuer and repository by their hashes (2026-10-25:
5
+ // the user names them, no client guesses), which a token must match; a
6
+ // history request names them plainly in its body (`oidcTokenAuth`).
7
+ // Who may take part is `ALLOW`, the string @vzn/vx-dte-server reads
8
+ // (2026-10-26: that string and nothing else): `issuer:owner/repo:workflow`
9
+ // entries. `READ_ACCESS=public` lets a request with no token read a
10
+ // repository ALLOW names. Any other repository is refused. The JWT itself is
11
+ // jose's to check: signature, algorithm, audience and times.
12
+
13
+ import { hashPart } from './hash-part.js'
14
+ import { Refusal } from './refusal.js'
15
+ import {
16
+ createRemoteJWKSet,
17
+ customFetch,
18
+ decodeJwt,
19
+ errors,
20
+ jwtVerify,
21
+ type JWTPayload,
22
+ } from 'jose'
23
+
24
+ /** One entry of `ALLOW`: the jobs of one workflow file of one repository at one issuer. */
25
+ export interface Allowed {
26
+ /** The issuer's URL, `https://token.actions.githubusercontent.com`. */
27
+ readonly issuer: string
28
+ /** `owner/repo` (a GitLab project's full path), matched without case. */
29
+ readonly repository: string
30
+ /** The workflow file its jobs are defined in (`ci.yml`, GitLab's `.gitlab-ci.yml`), matched without case. */
31
+ readonly workflow: string
32
+ }
33
+
34
+ /** What a request may do: read its repository's scopes, and write `write` when set. */
35
+ export interface Access {
36
+ /**
37
+ * The issuer, as its tokens name it (`https://token.actions.githubusercontent.com`):
38
+ * it signs the token, so two platforms' repositories never meet.
39
+ */
40
+ readonly issuer: string
41
+ /**
42
+ * The repository at that issuer, lowercased: `org/name` (`repository`,
43
+ * GitLab's `project_path`, 2026-10-25), what a checkout's origin names.
44
+ */
45
+ readonly repository: string
46
+ /** The token's own ref: the one scope it writes. */
47
+ readonly write?: string
48
+ }
49
+
50
+ export type Authorize = (req: Request) => Promise<Access>
51
+
52
+ const GITHUB = 'https://token.actions.githubusercontent.com'
53
+
54
+ /** The headers naming a request's issuer and repository, each its `hashPart`. */
55
+ const ISSUER_HEADER = 'vx-issuer'
56
+ const REPOSITORY_HEADER = 'vx-repository'
57
+ const PART = /^[0-9a-f]{32}$/
58
+
59
+ /** The claim naming a token's repository, `org/name`. */
60
+ const repoClaimOf = (iss: string): string => (iss === GITHUB ? 'repository' : 'project_path')
61
+ /** The claim naming the workflow a token's job is defined in, up to the `@` before its ref. */
62
+ const workflowClaimOf = (iss: string): string =>
63
+ iss === GITHUB ? 'job_workflow_ref' : 'ci_config_ref_uri'
64
+
65
+ /**
66
+ * `ALLOW`'s entries, space separated, each
67
+ * `issuer:owner/repo:workflow`, as @vzn/vx-dte-server reads it: read from
68
+ * the right, since the issuer holds `:`s of its own.
69
+ */
70
+ export function parseAllow(list: string): Allowed[] {
71
+ return list
72
+ .split(/\s+/)
73
+ .filter((e) => e !== '')
74
+ .map((e) => {
75
+ // One grammar on every server: read as a separator nowhere, a comma
76
+ // would end up inside a workflow name no token ever carries.
77
+ if (e.includes(','))
78
+ throw new Error(`${e} is not issuer:owner/repo:workflow: entries are space separated`)
79
+ const w = e.lastIndexOf(':')
80
+ const r = e.lastIndexOf(':', w - 1)
81
+ const [issuer, repository, workflow] = [e.slice(0, r), e.slice(r + 1, w), e.slice(w + 1)]
82
+ // Loopback over plain HTTP too: a server's own tests (its container's)
83
+ // run an issuer beside it, and no one else reaches that address.
84
+ // Its host, not its spelling: `http://127.0.0.1:80@x.example` is x.example.
85
+ const https =
86
+ issuer.startsWith('https://') ||
87
+ (issuer.startsWith('http://') && URL.parse(issuer)?.hostname === '127.0.0.1')
88
+ // An entry missing a part splits elsewhere: its issuer is then no URL.
89
+ const url = /^[a-z][a-z0-9+.-]*:\/\/[^/]/i.test(issuer)
90
+ if (r < 0 || !url || !repository.includes('/') || workflow === '') {
91
+ throw new Error(`${e} is not issuer:owner/repo:workflow`)
92
+ }
93
+ if (!https)
94
+ throw new Error(
95
+ `${e}: the issuer is https://, not ${issuer} (plain http only on 127.0.0.1)`,
96
+ )
97
+ return { issuer, repository, workflow }
98
+ })
99
+ }
100
+
101
+ /** What `workflowClaimOf(iss)` says, up to the `@` before its ref, for a job of `workflow` in `repository`. */
102
+ function workflowPath(iss: string, repository: string, workflow: string): string {
103
+ return iss === GITHUB
104
+ ? `${repository}/.github/workflows/${workflow}`
105
+ : `${iss.replace(/^https?:\/\//, '').replace(/\/$/, '')}/${repository}//${workflow}`
106
+ }
107
+
108
+ const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase()
109
+
110
+ /** GitHub's merge queue ref, `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>`. */
111
+ const QUEUE = /^refs\/heads\/gh-readonly-queue\/(.+)\/pr-\d+-(?:[0-9a-f]{40}|[0-9a-f]{64})$/
112
+
113
+ /**
114
+ * GitHub events that run in the base branch's context on a pull request's
115
+ * behalf (design cache-signing-2026-10.md, "Events"): the token carries the
116
+ * base's `ref` while the job may run the pull request's code, a fork's
117
+ * included, which must never write the base.
118
+ */
119
+ const ON_BEHALF = new Set(['pull_request_target', 'issue_comment', 'workflow_run'])
120
+
121
+ /**
122
+ * The scope a token writes: its `ref`, GitLab's `ref_path`. A GitHub pull
123
+ * request's run writes its merge ref, `refs/pull/<n>/merge`, never its
124
+ * `head_ref`: a fork names that, and no claim tells a fork's from the
125
+ * repository's own, so a fork's branch `release` wrote release. A merge
126
+ * queue's (`merge_group`) run writes its base branch, which its code is
127
+ * about to become (2026-10-25: "just strip it"); only its ref is renamed:
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.
133
+ */
134
+ function branchOf(iss: string, claims: JWTPayload): string | undefined {
135
+ if (iss !== GITHUB && claims['pipeline_source'] === 'merge_request_event') {
136
+ const own = (claim: string) => {
137
+ const p = claims[claim]
138
+ return typeof p === 'string' && same(p, String(claims['project_path']))
139
+ }
140
+ if (
141
+ !own('source_project_path') ||
142
+ (claims['job_project_path'] !== undefined && !own('job_project_path'))
143
+ )
144
+ return undefined
145
+ }
146
+ const event = claims['event_name']
147
+ if (ON_BEHALF.has(event as string)) return undefined
148
+ const ref = iss === GITHUB ? claims['ref'] : claims['ref_path']
149
+ if (typeof ref !== 'string' || !ref.startsWith('refs/') || ref.length > 512) return undefined
150
+ const queued = iss === GITHUB && event === 'merge_group' ? QUEUE.exec(ref) : null
151
+ return queued === null ? ref : `refs/heads/${queued[1]}`
152
+ }
153
+
154
+ export interface OidcOptions {
155
+ /** `ALLOW` (`parseAllow`): who may take part. */
156
+ readonly allow: readonly Allowed[]
157
+ /** The `aud` a token must name; unset, the origin the server is reached at. */
158
+ readonly audience?: string
159
+ /** Reads a URL as JSON; tests serve keys without a network. */
160
+ readonly fetchJson?: (url: string) => Promise<unknown>
161
+ /** Seconds since the epoch; tests move the clock. */
162
+ readonly now?: () => number
163
+ /** `READ_ACCESS`: `public` lets a request with no token read a repository ALLOW names. */
164
+ readonly readAccess?: string | undefined
165
+ }
166
+
167
+ /** Clock skew a token's `exp` / `nbf` may be off by. */
168
+ const LEEWAY_S = 60
169
+
170
+ /** The `Access` a request's verified CI token has; a refused one throws a 401 or 403. */
171
+ export function oidcAuth(opts: OidcOptions): Authorize {
172
+ const open = readAccessOf(opts)
173
+ // The repositories ALLOW names, by their hashes: what a public read may name.
174
+ let named: Promise<Map<string, Access>> | undefined
175
+ const allowed = () =>
176
+ (named ??= Promise.all(
177
+ opts.allow.map(async (a) => {
178
+ const repo = { issuer: issuerOf(a.issuer), repository: a.repository.toLowerCase() }
179
+ return [`${await hashPart(repo.issuer)}/${await hashPart(repo.repository)}`, repo] as [
180
+ string,
181
+ Access,
182
+ ]
183
+ }),
184
+ ).then((pairs) => new Map(pairs)))
185
+ const verify = tokenVerifier(opts)
186
+
187
+ return async (req) => {
188
+ if (opts.allow.length === 0)
189
+ throw new Refusal(401, 'ALLOW is empty: set it to the CI workflows this server serves')
190
+ const asked = `${req.headers.get(ISSUER_HEADER)}/${req.headers.get(REPOSITORY_HEADER)}`
191
+ if (!asked.split('/').every((p) => PART.test(p)))
192
+ throw new Refusal(
193
+ 400,
194
+ `${ISSUER_HEADER} and ${REPOSITORY_HEADER}: the issuer's and the repository's hashes (PROTOCOL.md § Keys)`,
195
+ )
196
+ const bearer = bearerOf(req)
197
+ if (bearer === undefined) {
198
+ if (!open) throw new Refusal(401, 'no bearer token')
199
+ const repo = (await allowed()).get(asked)
200
+ if (repo === undefined)
201
+ throw new Refusal(403, "the repository named is not in this server's ALLOW")
202
+ return repo
203
+ }
204
+ const access = await verify(bearer, req)
205
+ if (`${await hashPart(access.issuer)}/${await hashPart(access.repository)}` !== asked)
206
+ throw new Refusal(
207
+ 403,
208
+ `the token is of ${repoClaimOf(access.issuer)} ${access.repository} at ${access.issuer}, not the repository named`,
209
+ )
210
+ return access
211
+ }
212
+ }
213
+
214
+ /** A repository as a request names it plainly: the issuer's URL and `org/name` there. */
215
+ export interface Repo {
216
+ readonly issuer: string
217
+ readonly repository: string
218
+ }
219
+
220
+ /**
221
+ * A server whose requests name their repository plainly, in the body
222
+ * (@vzn/vx-history-server). A token must be of `named`, when one is named;
223
+ * with no token, `READ_ACCESS=public` reads a repository ALLOW names, and
224
+ * answers `undefined` where none is named. Else a 401 or 403.
225
+ */
226
+ export function oidcTokenAuth(
227
+ opts: OidcOptions,
228
+ ): (req: Request, named?: Repo) => Promise<Access | undefined> {
229
+ const open = readAccessOf(opts)
230
+ const check = tokenVerifier(opts)
231
+ // A request is asked twice, before its body names the repository and
232
+ // after: its token is verified once.
233
+ const verified = new WeakMap<Request, Promise<Access>>()
234
+ const verify = (token: string, req: Request): Promise<Access> => {
235
+ let got = verified.get(req)
236
+ if (got === undefined) verified.set(req, (got = check(token, req)))
237
+ return got
238
+ }
239
+ return async (req, asked) => {
240
+ if (opts.allow.length === 0)
241
+ throw new Refusal(401, 'ALLOW is empty: set it to the CI workflows this server serves')
242
+ const named = asked === undefined ? undefined : repoOf(asked)
243
+ const bearer = bearerOf(req)
244
+ if (bearer === undefined) {
245
+ if (!open) throw new Refusal(401, 'no bearer token')
246
+ if (named === undefined) return undefined
247
+ if (
248
+ !opts.allow.some(
249
+ (a) => issuerOf(a.issuer) === named.issuer && same(a.repository, named.repository),
250
+ )
251
+ )
252
+ throw new Refusal(403, `${named.repository} is not in this server's ALLOW`)
253
+ return named
254
+ }
255
+ const access = await verify(bearer, req)
256
+ if (
257
+ named !== undefined &&
258
+ (access.issuer !== named.issuer || access.repository !== named.repository)
259
+ )
260
+ throw new Refusal(
261
+ 403,
262
+ `the token is of ${access.repository} at ${access.issuer}, not ${named.repository} at ${named.issuer}`,
263
+ )
264
+ return access
265
+ }
266
+ }
267
+
268
+ /** A named repository as a token names it: the issuer's URL with no trailing slash, `org/name` lowercased. */
269
+ const repoOf = (r: Repo): Repo => ({
270
+ issuer: issuerOf(r.issuer),
271
+ repository: r.repository.toLowerCase(),
272
+ })
273
+
274
+ function readAccessOf(opts: OidcOptions): boolean {
275
+ const given: unknown = opts.readAccess
276
+ // A Worker var may be JSON: a list was refused "not public".
277
+ if (given !== undefined && given !== '' && given !== 'public')
278
+ throw new Error(
279
+ `READ_ACCESS: public, or unset, not ${typeof given === 'string' ? given : JSON.stringify(given)}`,
280
+ )
281
+ return opts.readAccess === 'public'
282
+ }
283
+
284
+ const bearerOf = (req: Request): string | undefined =>
285
+ /^Bearer\s+(\S+)$/i.exec(req.headers.get('authorization') ?? '')?.[1]
286
+
287
+ /** A token checked: jose's checks, then ALLOW's repository and workflow, and the branch it may write. */
288
+ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Promise<Access> {
289
+ // A blank one is no origin a token names: every token was "wrong audience".
290
+ if (opts.audience !== undefined && opts.audience.trim() === '')
291
+ throw new Error('AUDIENCE: the aud CI tokens name, or unset, not blank')
292
+ const fetchJson = opts.fetchJson ?? defaultFetchJson
293
+ const now = opts.now ?? (() => Date.now() / 1000)
294
+ // jose keeps each issuer's keys, and refetches them for a key it lacks.
295
+ const keys = new Map<string, Promise<ReturnType<typeof createRemoteJWKSet>>>()
296
+ const keysOf = (iss: string) => {
297
+ let set = keys.get(iss)
298
+ 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
+ })
306
+ keys.set(iss, set)
307
+ // A failed discovery is not kept: the next token tries again.
308
+ // quiet: the awaited set rethrows it; this only forgets it
309
+ set.catch(() => keys.get(iss) === set && keys.delete(iss))
310
+ }
311
+ // The issuer's outage, not the token's fault: a 401 tells a client its
312
+ // token is refused, and vx-cache turns the remote off for the run.
313
+ return set.catch((err: unknown) => {
314
+ throw keysUnread(iss, err)
315
+ })
316
+ }
317
+
318
+ return async (token, req) => {
319
+ let said: unknown
320
+ try {
321
+ said = decodeJwt(token).iss
322
+ } catch {
323
+ throw new Refusal(401, 'not a JWT')
324
+ }
325
+ // A trailing slash is no other issuer, as a key and a tokenless read
326
+ // name it: one in ALLOW or in the token refused every token.
327
+ const iss = typeof said === 'string' ? issuerOf(said) : undefined
328
+ if (iss === undefined || !opts.allow.some((a) => issuerOf(a.issuer) === iss)) {
329
+ throw new Refusal(
330
+ 401,
331
+ typeof said === 'string'
332
+ ? `issuer ${said.slice(0, 200)} is not in this server's ALLOW: add issuer:owner/repo:workflow for it`
333
+ : 'the token names no issuer (iss)',
334
+ )
335
+ }
336
+ const audience = opts.audience ?? new URL(req.url).origin
337
+ let claims: JWTPayload
338
+ try {
339
+ claims = (
340
+ await jwtVerify(token, await keysOf(iss), {
341
+ algorithms: ['RS256'],
342
+ audience,
343
+ clockTolerance: LEEWAY_S,
344
+ currentDate: new Date(now() * 1000),
345
+ requiredClaims: ['exp'],
346
+ })
347
+ ).payload
348
+ } catch (err) {
349
+ throw refusal(err, iss, audience)
350
+ }
351
+ const claim = repoClaimOf(iss)
352
+ const named = claims[claim]
353
+ if (typeof named !== 'string') throw new Refusal(403, `no ${claim} claim`)
354
+ // GitHub and GitLab names ignore case; a checkout's origin may spell it either way.
355
+ const repository = named.toLowerCase()
356
+ const ofRepo = opts.allow.filter(
357
+ (a) => issuerOf(a.issuer) === iss && same(a.repository, repository),
358
+ )
359
+ if (ofRepo.length === 0)
360
+ throw new Refusal(403, `${claim} ${repository} is not in this server's ALLOW`)
361
+ const wclaim = workflowClaimOf(iss)
362
+ const ran = claims[wclaim]
363
+ if (typeof ran !== 'string') throw new Refusal(403, `no ${wclaim} claim`)
364
+ // Cut at the `@` before the ref: GitLab's CI file may itself hold one
365
+ // (`.gitlab-ci.yml@group/project`, a file kept in another project).
366
+ const workflow = ran.replace(/@(?:refs\/.*)?$/, '')
367
+ if (!ofRepo.some((a) => same(workflow, workflowPath(iss, a.repository, a.workflow))))
368
+ throw new Refusal(403, `${wclaim} ${workflow} is not in this server's ALLOW`)
369
+ const repo = { issuer: iss, repository }
370
+ const branch = branchOf(iss, claims)
371
+ return branch === undefined ? repo : { ...repo, write: branch }
372
+ }
373
+ }
374
+
375
+ /** An issuer as a key names it: its URL, no trailing slash. */
376
+ const issuerOf = (iss: string): string => iss.replace(/\/$/, '')
377
+
378
+ /** What jose's refusal of a token says. */
379
+ function refusal(err: unknown, iss: string, audience: string): Refusal {
380
+ if (err instanceof Refusal) return err
381
+ // A refetch of the keys for a kid not held failed, or the issuer served
382
+ // no key set, or one with two keys for a kid: the issuer, not the token.
383
+ // A broken set read "not a JWT".
384
+ if (
385
+ !(err instanceof errors.JOSEError) ||
386
+ err instanceof errors.JWKSTimeout ||
387
+ err instanceof errors.JWKSInvalid ||
388
+ err instanceof errors.JWKSMultipleMatchingKeys
389
+ )
390
+ return keysUnread(iss, err)
391
+ if (err instanceof errors.JOSEAlgNotAllowed) return new Refusal(401, 'alg is not RS256')
392
+ if (err instanceof errors.JWKSNoMatchingKey) return new Refusal(401, 'unknown signing key')
393
+ if (err instanceof errors.JWSSignatureVerificationFailed) return new Refusal(401, 'bad signature')
394
+ if (err instanceof errors.JWTExpired) return new Refusal(401, 'expired')
395
+ if (err instanceof errors.JWTClaimValidationFailed) {
396
+ if (err.claim === 'aud')
397
+ return new Refusal(
398
+ 401,
399
+ `the token's aud is ${audOf(err.payload)}, not this server's ${audience}: ask CI for a token for ${audience}, or set the server's AUDIENCE`,
400
+ )
401
+ if (err.claim === 'nbf') return new Refusal(401, 'not yet valid')
402
+ if (err.reason === 'missing') return new Refusal(401, `no ${err.claim} claim`)
403
+ }
404
+ return new Refusal(401, 'not a JWT')
405
+ }
406
+
407
+ /** A token's `aud`, as a refusal names it: "wrong audience" alone left the fix to a guess. */
408
+ const audOf = (claims: JWTPayload): string => [claims.aud ?? 'unset'].flat().join(', ')
409
+ /** The issuer's keys out of reach, with why: a 503 and a missing jwks_uri read the same. */
410
+ const keysUnread = (iss: string, err: unknown): Refusal =>
411
+ new Refusal(
412
+ 502,
413
+ `could not read ${iss}'s keys: ${err instanceof Error ? err.message : String(err)}`,
414
+ )
415
+
416
+ async function defaultFetchJson(url: string): Promise<unknown> {
417
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
418
+ if (!res.ok) throw new Error(`${url}: ${res.status}`)
419
+ return res.json()
420
+ }
package/src/refusal.ts ADDED
@@ -0,0 +1,36 @@
1
+ // How vx's servers refuse a request: one body, `{ code, message }`, the
2
+ // shape of Turbo's spec's `Error`, so a client reads every server's
3
+ // refusal the same way and warns with its message.
4
+
5
+ /** A request refused: its HTTP status and what to fix. */
6
+ export class Refusal extends Error {
7
+ constructor(
8
+ readonly status: number,
9
+ message: string,
10
+ ) {
11
+ super(message)
12
+ }
13
+ }
14
+
15
+ const CODES: Record<number, string> = {
16
+ 400: 'bad_request',
17
+ 401: 'unauthorized',
18
+ 403: 'forbidden',
19
+ 404: 'not_found',
20
+ 405: 'method_not_allowed',
21
+ 413: 'payload_too_large',
22
+ 500: 'internal_error',
23
+ 502: 'bad_gateway',
24
+ }
25
+
26
+ /** A refusal's answer: `{ code, message }`, the message saying what to fix. */
27
+ export function errorJson(status: number, message: string): Response {
28
+ return Response.json({ code: CODES[status] ?? 'error', message }, { status })
29
+ }
30
+
31
+ /** A route's wrong method: `405`, naming the ones it takes in `Allow` and the message. */
32
+ export function notAllowed(allow: string): Response {
33
+ const res = errorJson(405, `this route takes ${allow}`)
34
+ res.headers.set('allow', allow)
35
+ return res
36
+ }
@@ -0,0 +1,32 @@
1
+ // The one line a server prints at start or when it refuses to start,
2
+ // painted as vx's own status lines are: a server's log is read beside
3
+ // vx's, and plain white read as noise there.
4
+
5
+ const HUE = { ok: '34;197;94', err: '239;68;68' } as const
6
+ const SYMBOL = { ok: '✓', err: '✗' } as const
7
+
8
+ /** Colour as vx decides it: NO_COLOR, then FORCE_COLOR, then a terminal. */
9
+ function paints(stream: { isTTY?: boolean }): boolean {
10
+ const env = process.env
11
+ if (env['NO_COLOR']) return false
12
+ const force = env['FORCE_COLOR']
13
+ if (force !== undefined) return force !== '0' && force !== 'false'
14
+ return stream.isTTY === true && env['TERM'] !== 'dumb'
15
+ }
16
+
17
+ /**
18
+ * `✓ vx-cache-server listening on http://…` or `✗ vx-cache-server <why>`.
19
+ * Lines after the first (a usage text) print dim under it.
20
+ */
21
+ export function statusLine(
22
+ tone: 'ok' | 'err',
23
+ name: string,
24
+ text: string,
25
+ stream: { isTTY?: boolean } = tone === 'ok' ? process.stdout : process.stderr,
26
+ ): string {
27
+ const [head = '', ...rest] = text.split('\n')
28
+ if (!paints(stream)) return `${[`${SYMBOL[tone]} ${name} ${head}`, ...rest].join('\n')}\n`
29
+ const url = head.replace(/(https?:\/\/\S+)/g, '\x1b[38;2;6;182;212m$1\x1b[0m')
30
+ const first = `\x1b[38;2;${HUE[tone]}m${SYMBOL[tone]}\x1b[0m \x1b[1m${name}\x1b[0m ${url}`
31
+ return `${[first, ...rest.map((l) => (l === '' ? l : `\x1b[2m${l}\x1b[0m`))].join('\n')}\n`
32
+ }