@vzn/vx-dte-server 0.0.0 → 0.0.650

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/PROTOCOL.md ADDED
@@ -0,0 +1,212 @@
1
+ # Switchboard protocol, v1
2
+
3
+ Switchboard connects one **host** to any number of **peers** through a
4
+ relay, when none of them takes an inbound connection (CI runners, laptops
5
+ behind NAT). Each participant dials out: one websocket, which joins as it
6
+ opens and carries the rest. The shape is a star: the host talks to every
7
+ peer, a peer talks only to the host, and the relay passes each message on
8
+ without reading it. What host and peers say to each other is their own
9
+ protocol, versioned on its own.
10
+
11
+ `@vzn/vx-dte-server` is one relay that speaks it (on Bun and on Cloudflare
12
+ Workers); any server that does can stand in for it.
13
+
14
+ ## Versions
15
+
16
+ A client names the versions it speaks in the websocket's subprotocol
17
+ header, on the request that joins, so negotiation costs no round trip:
18
+
19
+ ```
20
+ Sec-WebSocket-Protocol: switchboard.v2, switchboard.v1
21
+ ```
22
+
23
+ The relay answers with the highest version both speak. If none is, it
24
+ answers with the first one offered (a client offered nothing it can read
25
+ otherwise) and closes the socket `4406`, reason `supported:
26
+ switchboard.v<lowest>-v<highest>`. A request offering none is refused the
27
+ same way. This document is `switchboard.v1`.
28
+
29
+ The path names the API version apart from the protocol's: every route is
30
+ under `/v1/`. A change to the routes ships under `/v2/`, beside `/v1/` for
31
+ as long as the relay keeps it, while the messages keep their subprotocol.
32
+
33
+ ## Sessions
34
+
35
+ One request is HTTP, a websocket upgrade of `GET
36
+ <relay>/v1/sessions/<session>/socket`. `<session>` is the session id, a random
37
+ (v4) UUID, so no one can guess it; another answers `400`, another path
38
+ `404`, another method `405` (`Allow: GET`), a request that is no upgrade `426`. It carries
39
+ `Authorization: Bearer <token>` (§ Tokens).
40
+
41
+ | Query | Does |
42
+ | ----------- | ---------------------------------------------------------------------------- |
43
+ | `?join=<j>` | joins: `<j>` is `{ "role": "host" \| "peer", "info"?: any, "key"?: string }` |
44
+ | `?id=<id>` | a participant's socket dialed again (§ Sockets) |
45
+
46
+ - A join's socket says first `{ "joined": { "id", "heartbeatMs" } }`. The
47
+ `id` is a secret the relay picked: whoever holds it, with a token of this
48
+ session, acts as that participant.
49
+ - The host's join opens the session; a session has one host, and a second
50
+ host's join is refused `409`. A peer's join of a session with no host is
51
+ refused `410`.
52
+ - A session lives while its host does. The host's `leave` is its final
53
+ call: it closes the session at once, and the relay keeps nothing of it.
54
+ A host whose socket dropped without it has the grace (§ Liveness) to
55
+ dial again; the session waits for it. When the grace passes, the relay
56
+ forgets the session and closes each peer's socket with no code: the
57
+ host may still join it again, and a peer's redial, closed `4404`, joins
58
+ again too.
59
+
60
+ ## The host's key
61
+
62
+ The host's join names `key`, a P-256 public key (raw, base64url), which
63
+ the relay keeps for the session; one without it is refused `400`. Each of
64
+ the host's socket requests, its join too, carries `relay-proof: <ms>.<nonce>.<signature>`, an
65
+ ECDSA SHA-256 signature by that key of
66
+
67
+ ```
68
+ switchboard-proof\0GET\0<path and query>\0<ms>\0<nonce>\0
69
+ ```
70
+
71
+ `<ms>` is when it was made, within two minutes of the relay's clock, and
72
+ `<nonce>` 16 to 64 base64url characters the relay takes once; else `403`.
73
+ So only the side holding the private key acts as the host, and a leaked
74
+ host `id` acts as it nowhere. A frame carries no proof: its socket's
75
+ upgrade did.
76
+
77
+ ## Frames
78
+
79
+ Frames are JSON text, each at most 1 MiB of UTF-8 (bytes, not characters).
80
+
81
+ The relay pushes:
82
+
83
+ - `{ "messages": [{ "from", "body" }] }` as messages arrive. `from` is
84
+ `"host"`, a peer's `id`, or `"relay"` (§ Notices);
85
+ - `{ "close": <code>, "error"? }` just before it closes the socket, `0`
86
+ for no code. A client acts on this frame: a host may never end TCP after
87
+ its close.
88
+
89
+ A participant sends:
90
+
91
+ | Frame | Does |
92
+ | -------------------------------------- | ---------------------------------------------------------- |
93
+ | `{ "n", "sends": [{ "to", "body" }] }` | passes each `body` to `to` |
94
+ | `{ "n", "leave": true }` | removes the participant; its answer is the socket's close |
95
+ | `{ "n", "remove": "<peer id>" }` | the host only (`403` else): takes the peer off the session |
96
+
97
+ - The host sends to any peer `id`; a peer sends only to `"host"`. An
98
+ unknown or gone `to` is `404`.
99
+ - A frame with a number `n` is answered `{ "n" }` when all went, else
100
+ `{ "n", "results" }` with `null` or `{ "status", "error" }` for each
101
+ send. A frame of sends with no `n` is answered only if a send in it
102
+ fails, `n` then `null`.
103
+ - A frame that fails is answered `{ "n", "status", "error" }`; one that is
104
+ no JSON object, or none of the above, `400`; one over 1 MiB `413`,
105
+ answered by its `n` when the frame starts `{"n":`.
106
+ - A removed peer's socket is closed `4404` after what waited for it; the
107
+ host is not told it left.
108
+
109
+ ## Delivery
110
+
111
+ The relay holds no participant's message. One it cannot push now (no
112
+ socket, or a push that failed) is dropped. Delivery is the clients': a
113
+ sender numbers each send, the receiver acknowledges it end to end, and
114
+ the sender sends again what is not acknowledged, at once on a new socket.
115
+ How they number and acknowledge is inside `body`, their own protocol;
116
+ `@vzn/vx-dte` puts `{ "n" }` and `{ "acked" }` beside its payload.
117
+
118
+ Only the relay's notices wait, in memory, for the participant's next
119
+ socket.
120
+
121
+ ## Notices
122
+
123
+ The relay itself sends, `"from": "relay"`:
124
+
125
+ - to the host, `{ "type": "joined", "id", "info" }` for each peer, its
126
+ `info` as its join said, then forgotten;
127
+ - to the host, `{ "type": "left", "id" }` when a peer leaves, or its
128
+ grace passes with no socket;
129
+ - to every peer, `{ "type": "closed" }` when the host leaves: the
130
+ session is over. A peer on a socket is told and let go; one with no
131
+ socket gets `4404` when it opens one.
132
+
133
+ ## Sockets
134
+
135
+ The socket a frame comes down is its participant's from then on. A
136
+ client that gave up on a socket whose close never reached the relay dials
137
+ again by its `id`: the relay closes the earlier one and pushes what
138
+ waited. The relay closes `4404` the socket of a participant that left, was
139
+ removed or lost, or that it never knew (a failed upgrade tells a client
140
+ nothing it can read, so the request is let in and closed). It closes a
141
+ socket a push failed on with no code.
142
+
143
+ ## Liveness
144
+
145
+ Only `leave` ends a participant at once. One whose socket closed
146
+ without it, the host too, may dial again by its `id` for a grace, 10 s
147
+ on `@vzn/vx-dte-server`, and is gone when the grace passes with no socket:
148
+ a peer as if it left, the host closing its session. The relay keeps no
149
+ timer and counts no heartbeat: it finds a grace passed as the session is
150
+ next used. A join whose socket never opened (its client dropped during
151
+ the upgrade) has the same grace from the join. A client whose socket drops dials again, and says its `leave`
152
+ again on a new socket if the first dropped under it.
153
+ `heartbeatMs` is how often participants should hear from each other: a
154
+ peer quiet on a socket that never closed is its peers' to lose. The
155
+ clients probe a quiet peer with an empty numbered send, which it
156
+ acknowledges at once, and lose one quiet for two `heartbeatMs`. A
157
+ numbered frame the relay leaves unanswered a heartbeat is a dead socket.
158
+
159
+ ## Close codes
160
+
161
+ A refused request is let in and its socket closed with `4000 + status`,
162
+ said first in a `{ "close", "error" }` frame:
163
+
164
+ | Code | Why |
165
+ | ------ | -------------------------------------------------------------------------- |
166
+ | `4400` | a malformed request or join |
167
+ | `4401` | no token, or an invalid one |
168
+ | `4403` | a token the relay does not accept, or a host request not signed by its key |
169
+ | `4404` | an unknown, left or removed participant (and every socket it let go) |
170
+ | `4406` | no version both speak; the reason names the relay's range |
171
+ | `4409` | a second host |
172
+ | `4410` | a closed session |
173
+ | `4413` | a join over 1 MiB |
174
+ | `4502` | the relay could not read the token issuer's keys; retry |
175
+ | `4503` | the relay's host refused the session (a plan's limit) |
176
+
177
+ A request that is no upgrade is answered its status and `{ "error" }`.
178
+
179
+ ## Tokens
180
+
181
+ The relay checks the bearer token. `@vzn/vx-dte-server` takes CI OIDC
182
+ tokens: their signature by the issuer's published keys
183
+ (`<iss>/.well-known/openid-configuration`, then `jwks_uri`; RS256), `exp`
184
+ and `nbf`, an issuer it trusts, an `aud` naming its audience, and a
185
+ repository and workflow it allows (`403` else). Keys it cannot read
186
+ are the issuer's outage, not the token's fault: `502`, which a client
187
+ retries. Keys are read again once ten minutes old, so a key the issuer
188
+ withdraws stops admitting tokens, and for a key the relay does not hold
189
+ at most once a minute. A session is its id
190
+ within its token's issuer and repository claims (`repository_id` on
191
+ GitHub Actions, `project_id` on GitLab), so a tenant that learns another's
192
+ session id cannot open or join it.
193
+
194
+ ## What the relay knows
195
+
196
+ A relay stores nothing. It writes no message, participant or session to
197
+ any file, database, key-value store or alarm: what it knows lives in
198
+ memory and in its open sockets, and a restart forgets it. Its host and
199
+ peers carry a session across one by joining it again. The one thing a
200
+ relay may cache is an issuer's published public keys (§ Tokens).
201
+
202
+ It holds only what routing needs, and no longer:
203
+
204
+ - A session is a SHA-256 of its id and its tokens' issuer and claims: no
205
+ name says whose it is. A token is checked, never kept.
206
+ - A peer's `info` is held until the host is told it joined.
207
+ - A message is pushed or dropped, never held; a notice waits in memory.
208
+ - A closed session is gone at once; while open, each participant's role
209
+ is kept, and when a socket dropped. A relay restart keeps a session whose
210
+ host's socket, or a peer's naming that host, it kept; one whose host's
211
+ socket it did not keep waits the grace from the restart.
212
+ - It logs nothing. It still sees each message's size, time and recipient.
package/README.md CHANGED
@@ -1,3 +1,166 @@
1
1
  # @vzn/vx-dte-server
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
+ The reference relay for `@vzn/vx-dte`, which spreads one `vx run` over CI
6
+ workers. CI runners take no inbound connection, so the run's orchestrator
7
+ and its workers all dial out to a relay, which passes their messages on.
8
+ It admits only CI OIDC tokens of the repositories it names, and binds
9
+ each run to the repository that started it.
10
+
11
+ The relay is one package whose one runtime dependency is `jose`, which checks
12
+ the CI tokens; not even vx itself. It speaks the
13
+ [Switchboard protocol](./PROTOCOL.md) (`switchboard.v1`): one host, the
14
+ run's orchestrator, and its peers, the workers, behind one relay. Any
15
+ server that speaks it can take its place, and any tool can use it.
16
+
17
+ ## Run it
18
+
19
+ ### On Cloudflare Workers
20
+
21
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/vznjs/vx/tree/main/packages/vx-dte-server)
22
+
23
+ The button copies this directory into a new repository of yours and
24
+ deploys it. It asks for one var, `ALLOW`: who may take part, space
25
+ separated, each `issuer:owner/repo:workflow`. It is not a secret: keep it
26
+ in your copy's `wrangler.jsonc`, since each deploy sets vars from that file.
27
+ `https://token.actions.githubusercontent.com:vznjs/vx:ci.yml` admits the
28
+ jobs of `.github/workflows/ci.yml` in `vznjs/vx`;
29
+ `https://gitlab.com:group/project:.gitlab-ci.yml` a GitLab project's.
30
+ Repeat an entry for each repository and workflow, the orchestrator's and
31
+ the workers'. Every other token is refused. The relay's URL is the Cloudflare Worker's URL, such
32
+ as `https://vx-dte-server.<you>.workers.dev`. Name it as `relay` in
33
+ `dte()`.
34
+
35
+ Or deploy from this directory with Cloudflare's
36
+ [`wrangler`](https://developers.cloudflare.com/workers/wrangler/):
37
+
38
+ ```sh
39
+ bun install
40
+ bunx wrangler login
41
+ bunx wrangler deploy
42
+ ```
43
+
44
+ Set `ALLOW` in `wrangler.jsonc`'s `vars` first.
45
+
46
+ `src/worker.ts` is the same relay as the one on Bun. Each run is one
47
+ Durable Object, so its sockets meet in one place. Participants hold
48
+ websockets, which the object hibernates between messages: the run is kept
49
+ in their attachments, and the object writes no storage. It sets no alarm and counts no heartbeat: a
50
+ socket's close loses its participant, and a peer quiet on a socket that
51
+ never closed is its peers' to lose. Issuers' keys
52
+ are read once per isolate and held ten minutes in the data center's
53
+ cache, so a run's object does not fetch them again; a token naming a key
54
+ they lack is checked against a fresh read.
55
+ It fits Workers' free plan: each participant joins on the socket it
56
+ opens, one request, and its websocket messages count 20 to one Durable
57
+ Object request, against 100 000 a day; it writes no row.
58
+ `wrangler.jsonc` sets the rest; change a value there, in the repository
59
+ the button made, or in the Worker's settings in the Cloudflare dashboard:
60
+
61
+ - `AUDIENCE`, unset, is the URL the relay is reached at. Set it only
62
+ when tokens name another one. Blanks around it are dropped, and a
63
+ blank one is refused, as on Bun.
64
+
65
+ ### On Bun
66
+
67
+ ```sh
68
+ ALLOW=https://gitlab.com:group/app:.gitlab-ci.yml AUDIENCE=https://relay.example.com \
69
+ bunx @vzn/vx-dte-server --port 8080
70
+ ```
71
+
72
+ Or as a container:
73
+
74
+ ```sh
75
+ docker run -p 8080:8080 -e ALLOW=https://gitlab.com:group/app:.gitlab-ci.yml \
76
+ -e AUDIENCE=https://relay.example.com ghcr.io/vznjs/vx-dte-server
77
+ ```
78
+
79
+ All vx servers at once, this relay with its state on a volume included:
80
+ [`private/vx-servers/compose.yaml`](../../private/vx-servers/compose.yaml)
81
+ (`docker compose up`).
82
+
83
+ - `ALLOW` is the same list as on Workers (above), read from the
84
+ environment as every vx server reads it. GitLab names a project by
85
+ its full path, `group/subgroup/project`.
86
+ - `AUDIENCE` is the `aud` every token must name: the URL the CI job
87
+ asks its ID token for.
88
+ - `--port` (default 8080) and `--host` (default `0.0.0.0`) set where it
89
+ listens.
90
+ - `--no-auth` admits every request. Use it only to try `@vzn/vx-dte`
91
+ locally: it listens on loopback only.
92
+
93
+ CI builds this image and carries one run through it (`test.live`).
94
+
95
+ Runs live in memory. A restarted relay forgets the runs it was carrying,
96
+ and vx-dte joins each again.
97
+ Participants hold websockets here too, so a message reaches its receiver
98
+ the moment it is sent.
99
+
100
+ Each participant is one socket and one file descriptor. Bun raises the
101
+ open-file limit to the container's hard limit (Docker's is 524288 or
102
+ more), so it never needs a `--ulimit`. Measured on 4 cores (2026-10-24):
103
+ 10,000 sockets over 100 runs take 41 MB over an idle relay (about 4 KB
104
+ each) and 6% of a core idle; with each worker sending one message a
105
+ second (10,000 a second) 43% of a core, every message delivered.
106
+
107
+ ## Environment
108
+
109
+ - `RELAY`: on Workers, the `RelayRun` Durable Object binding
110
+ (`wrangler.jsonc`); the relay refuses every request without it.
111
+ - `NO_COLOR`, then `FORCE_COLOR`, then a terminal whose `TERM` is not
112
+ dumb: whether the Bun relay's status lines print in colour.
113
+
114
+ ## API
115
+
116
+ The same relay as a `fetch` handler, to mount in a server of your own
117
+ (`src/index.ts`):
118
+
119
+ - `createRelay(options?: RelayOptions): Relay`. `RelayOptions` is
120
+ `{ verify?, heartbeatMs?, graceMs? }`: `verify` checks a request's bearer token
121
+ (none admits every request) and gets the request too, `heartbeatMs` (default 2000) is how often
122
+ participants hear from each other, said in each join's answer, and
123
+ `graceMs` (default 10 000) how long a participant whose socket dropped
124
+ without `leave` may dial again before it is gone (PROTOCOL.md § Liveness). A `Relay` is `{ fetch(req) }`,
125
+ plus the hooks a host of websockets drives (`src/worker.ts` is their
126
+ user): the options `sockets`, the `[Kept, RelaySocket]` pairs a host
127
+ kept across a restart, from which the sessions are rebuilt (PROTOCOL.md
128
+ § What the relay knows), and `upgrade`, which answers a `socket`
129
+ request; and the methods `attach` and `detach` (its socket closed: the
130
+ participant is gone unless it dials again within `graceMs`), which hand the relay a participant's
131
+ `RelaySocket` (`{ send, close }`), and `frame`, which returns the
132
+ answer to send down the socket, or `undefined` for none. `attach`
133
+ returns the `Kept` its host keeps with the socket (a Durable Object's
134
+ attachment: session, id, role, the host's key, and a peer's host's id), or
135
+ `false`. A host closes a socket `attach` refuses
136
+ with `closeSocket(socket, GONE)` (`GONE` is 4404), which says the close in a frame first
137
+ (PROTOCOL.md § Sockets).
138
+ - `serveRelay(options: ServeOptions): RelayServer` serves the relay on
139
+ `Bun.serve`, websockets included. `ServeOptions` is
140
+ `{ hostname, port, verify?, heartbeatMs? }`, and a `RelayServer` is
141
+ `{ url, stop() }`.
142
+ - `createProver(): Promise<Prover>` makes the host's key for one
143
+ session (PROTOCOL.md § The host's key). A `Prover` is
144
+ `{ key, proof(method, path, body) }`: `key` is what its `join` names,
145
+ and `proof` is the value of the `PROOF_HEADER` (`relay-proof`) header of
146
+ each request as the host.
147
+ - `createVerifier(options: OidcOptions)` returns that `verify`.
148
+ `OidcOptions` is
149
+ `{ allow, audience, runClaims?, fetchJson?, now? }`: who may take part,
150
+ `Allowed`s `{ issuer, repository?, workflow? }` (an unset part admits
151
+ any; a token's `repositoryClaimOf(iss)` and `workflowClaimOf(iss)` up to
152
+ its `@` are matched without case), the `aud` a token must name, run claims per issuer (default
153
+ `runClaimsOf(iss)`), and a key reader and clock for tests. It
154
+ resolves a `VerifiedToken`, `{ iss, run }`, or rejects with a
155
+ `TokenError` carrying the `status` (401 or 403) the relay answers.
156
+ - `parseAllow(list)` reads `ALLOW`'s entries into `Allowed`s, each
157
+ `issuer:owner/repo:workflow` read from the right (an issuer holds `:`s),
158
+ or throws naming the entry it cannot read. An issuer is `https://`, or
159
+ plain `http://` only to `127.0.0.1`, as the cache and history servers read it.
160
+ - `runClaimsOf(iss)` names the claims that bind a run: `repository_id`
161
+ for GitHub Actions, `project_id` otherwise.
162
+ - `repositoryClaimOf(iss)` names the claim that names a token's
163
+ repository: `repository` for GitHub Actions, `project_path` otherwise.
164
+ - `workflowClaimOf(iss)` names the claim that names the workflow a
165
+ token's job is defined in: `job_workflow_ref` for GitHub Actions,
166
+ `ci_config_ref_uri` otherwise.
package/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Root entry shim: Bun 1.4.0's `--compile` binaries resolve an on-disk
2
+ // package by `<pkg>/index.ts` and ignore package.json `exports` / `main`
3
+ // (see packages/vx/index.ts). Same module as the exports map names.
4
+ export * from './src/index.js'
package/package.json CHANGED
@@ -1,8 +1,63 @@
1
1
  {
2
2
  "name": "@vzn/vx-dte-server",
3
- "version": "0.0.0",
4
- "description": "Placeholder; real releases are published by CI.",
3
+ "version": "0.0.650",
5
4
  "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/vznjs/vx.git" },
7
- "publishConfig": { "access": "public" }
5
+ "description": "The reference relay for @vzn/vx-dte: carries messages between a vx run's orchestrator and its CI agents, admitting only CI OIDC tokens of the same repository.",
6
+ "keywords": [
7
+ "vx",
8
+ "monorepo",
9
+ "distributed",
10
+ "ci",
11
+ "relay"
12
+ ],
13
+ "type": "module",
14
+ "main": "./src/index.ts",
15
+ "types": "./src/index.ts",
16
+ "bin": {
17
+ "vx-dte-server": "./src/bun.ts"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/index.ts",
22
+ "import": "./src/index.ts"
23
+ },
24
+ "./worker": {
25
+ "types": "./src/worker.ts",
26
+ "import": "./src/worker.ts"
27
+ }
28
+ },
29
+ "files": [
30
+ "index.ts",
31
+ "src",
32
+ "PROTOCOL.md",
33
+ "wrangler.jsonc",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "bun": ">=1.4.4"
39
+ },
40
+ "//": [
41
+ "wrangler: deploys src/worker.ts by wrangler.jsonc, which the Deploy to Cloudflare button reads (it parses no other config).",
42
+ "@vzn/vx: vx.config.ts's core, the workspace's own here; `*`, not workspace:, which npm refuses when the button installs this directory alone.",
43
+ "miniflare: tests/worker-sockets.test.ts runs src/worker.ts in workerd, the runtime Workers deploy to.",
44
+ "jose: checks the CI OIDC tokens (signature, algorithm, audience, times) with a widely used, dependency-free JWT library instead of hand-rolled WebCrypto; it runs in Bun and workerd alike."
45
+ ],
46
+ "cloudflare": {
47
+ "bindings": {
48
+ "ALLOW": {
49
+ "description": "Who may take part, space separated, each `issuer:owner/repo:workflow`: `https://token.actions.githubusercontent.com:vznjs/vx:ci.yml` admits jobs of `.github/workflows/ci.yml` in vznjs/vx (GitLab: `https://gitlab.com:group/project:.gitlab-ci.yml`). List the orchestrator's workflow and the agents'."
50
+ }
51
+ }
52
+ },
53
+ "dependencies": {
54
+ "jose": "6.2.13"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/vznjs/vx.git",
59
+ "directory": "packages/vx-dte-server"
60
+ },
61
+ "homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-dte-server#readme",
62
+ "bugs": "https://github.com/vznjs/vx/issues"
8
63
  }
package/src/bun.ts ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env -S bun --no-env-file --no-install
2
+ // `bunx @vzn/vx-dte-server`: the relay on Bun, runs in memory (PROTOCOL.md).
3
+
4
+ import { parseArgs } from 'node:util'
5
+ import { createVerifier, parseAllow } from './oidc.js'
6
+ import { serveRelay } from './serve.js'
7
+
8
+ const USAGE = `usage: vx-dte-server [--port 8080] [--host 0.0.0.0]
9
+ vx-dte-server --no-auth [--port 8080] (loopback only)
10
+ ALLOW who may take part, space separated, each issuer:owner/repo:workflow
11
+ AUDIENCE the aud every CI token names: the URL CI reaches the relay at`
12
+
13
+ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost'])
14
+
15
+ function main(argv: string[]): void {
16
+ const { values } = parseArgs({
17
+ args: argv,
18
+ options: {
19
+ port: { type: 'string', default: '8080' },
20
+ host: { type: 'string' },
21
+ 'no-auth': { type: 'boolean', default: false },
22
+ help: { type: 'boolean', short: 'h', default: false },
23
+ },
24
+ })
25
+ if (values.help) {
26
+ console.log(USAGE)
27
+ return
28
+ }
29
+ const noAuth = values['no-auth']
30
+ const host = values.host ?? (noAuth ? '127.0.0.1' : '0.0.0.0')
31
+ // Bun.serve reads '' as localhost: unreachable from another host or container.
32
+ if (host.trim() === '') fail(`--host: needs an address, got '${host}'`)
33
+ if (noAuth && !LOOPBACK.has(host)) fail('--no-auth listens on loopback only')
34
+ // Read from the environment, as the cache and history servers and the
35
+ // relay's Worker read them: one way to configure every vx server.
36
+ const env = Bun.env
37
+ const audience = env.AUDIENCE?.trim() ?? ''
38
+ // The bare usage said nothing of which was missing.
39
+ if (!noAuth) {
40
+ const missing = [
41
+ // An empty one is missing too: it started a relay that refused every token.
42
+ ...((env.ALLOW ?? '').trim() === '' ? ['ALLOW'] : []),
43
+ ...(audience === '' ? ['AUDIENCE'] : []),
44
+ ]
45
+ if (missing.length > 0) fail(`missing ${missing.join(' and ')} (or --no-auth)\n${USAGE}`)
46
+ }
47
+ let allow: ReturnType<typeof parseAllow> = []
48
+ try {
49
+ allow = parseAllow(env.ALLOW ?? '')
50
+ } catch (err) {
51
+ fail(`ALLOW: ${(err as Error).message}`)
52
+ }
53
+ // Digits only, as the cache and history servers read it: Number('') is
54
+ // 0, a random port for `--port "$PORT"` with PORT unset, and Number read
55
+ // 0x50 as 80 and 1e1 as 10.
56
+ const port = /^\d+$/.test(values.port) ? Number(values.port) : Number.NaN
57
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
58
+ fail(`--port: not a port: ${values.port}`)
59
+ const server = serveRelay({
60
+ hostname: host,
61
+ port,
62
+ verify: noAuth ? undefined : createVerifier({ allow, audience }),
63
+ })
64
+ process.stdout.write(statusLine('ok', `listening on ${server.url}`))
65
+ const stop = (): void => {
66
+ void server.stop().then(() => process.exit(0))
67
+ }
68
+ process.on('SIGINT', stop)
69
+ process.on('SIGTERM', stop)
70
+ }
71
+
72
+ function fail(message: string): never {
73
+ process.stderr.write(statusLine('err', message))
74
+ process.exit(2)
75
+ }
76
+
77
+ try {
78
+ main(process.argv.slice(2))
79
+ } catch (err) {
80
+ // parseArgs names the flag it refused, not the ones there are.
81
+ const usage = (err as { code?: string }).code?.startsWith('ERR_PARSE_ARGS') ? `\n${USAGE}` : ''
82
+ fail(`${err instanceof Error ? err.message : String(err)}${usage}`)
83
+ }
84
+
85
+ /**
86
+ * `✓ vx-dte-server listening on …` / `✗ vx-dte-server <why>`, painted as
87
+ * vx's status lines are (NO_COLOR, then FORCE_COLOR, then a terminal). The
88
+ * relay imports only jose, so this is @vzn/vx-devtools' `statusLine` kept
89
+ * here; usage lines under a refusal print dim.
90
+ */
91
+ function statusLine(tone: 'ok' | 'err', text: string): string {
92
+ const stream = tone === 'ok' ? process.stdout : process.stderr
93
+ const env = process.env
94
+ const force = env['FORCE_COLOR']
95
+ const on = env['NO_COLOR']
96
+ ? false
97
+ : force !== undefined
98
+ ? force !== '0' && force !== 'false'
99
+ : stream.isTTY === true && env['TERM'] !== 'dumb'
100
+ const [head = '', ...rest] = text.split('\n')
101
+ const symbol = tone === 'ok' ? '✓' : '✗'
102
+ if (!on) return `${[`${symbol} vx-dte-server ${head}`, ...rest].join('\n')}\n`
103
+ const hue = tone === 'ok' ? '34;197;94' : '239;68;68'
104
+ const url = head.replace(/(https?:\/\/\S+)/g, '\x1b[38;2;6;182;212m$1\x1b[0m')
105
+ const first = `\x1b[38;2;${hue}m${symbol}\x1b[0m \x1b[1mvx-dte-server\x1b[0m ${url}`
106
+ return `${[first, ...rest.map((l) => (l === '' ? l : `\x1b[2m${l}\x1b[0m`))].join('\n')}\n`
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export {
2
+ closeSocket,
3
+ createRelay,
4
+ GONE,
5
+ type Kept,
6
+ type Relay,
7
+ type RelayOptions,
8
+ } from './relay.js'
9
+ export { serveRelay, type RelayServer, type ServeOptions } from './serve.js'
10
+ export {
11
+ createVerifier,
12
+ parseAllow,
13
+ repositoryClaimOf,
14
+ runClaimsOf,
15
+ workflowClaimOf,
16
+ TokenError,
17
+ type Allowed,
18
+ type OidcOptions,
19
+ type VerifiedToken,
20
+ } from './oidc.js'
21
+ export { createProver, PROOF_HEADER, type Prover } from './proof.js'