@claxedo/workspace-relay 0.8.0 → 0.10.0
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 +92 -223
- package/dist/auth.d.ts +94 -35
- package/dist/cloudflare.d.ts +15 -45
- package/dist/cors-origins.d.ts +2 -0
- package/dist/directory.d.ts +5 -38
- package/dist/gateway-placement.d.ts +7 -0
- package/dist/host-tunnel-failure.d.ts +7 -0
- package/dist/host-tunnel-forwarding.d.ts +29 -0
- package/dist/http.d.ts +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.mjs +901 -835
- package/dist/relay-audit.d.ts +4 -0
- package/dist/relay-cors.d.ts +12 -0
- package/dist/relay-location.d.ts +93 -0
- package/dist/relay-target.d.ts +25 -0
- package/dist/resolver-clients.d.ts +116 -0
- package/dist/server.d.ts +27 -286
- package/dist/session-host-forwarding.d.ts +22 -0
- package/dist/test-support/unfenced-host-token.d.ts +6 -0
- package/dist/worker.d.ts +3 -3
- package/package.json +6 -25
- package/dist/bun.d.ts +0 -225
- package/dist/bun.mjs +0 -2492
- package/dist/main.d.ts +0 -255
- package/dist/main.mjs +0 -23713
- package/dist/synthetic.d.ts +0 -60
- package/dist/upstream-websocket.d.ts +0 -34
- package/dist/user-hosted-forwarding.d.ts +0 -40
package/README.md
CHANGED
|
@@ -1,98 +1,12 @@
|
|
|
1
1
|
# `@claxedo/workspace-relay`
|
|
2
2
|
|
|
3
|
-
The Claxedo workspace relay
|
|
4
|
-
|
|
5
|
-
VM or a user laptop. One package, one process, one config surface — there is no
|
|
6
|
-
separate `dev-relay` codepath.
|
|
7
|
-
|
|
8
|
-
Runtime requirement: the packaged `workspace-relay` bin and the default server
|
|
9
|
-
bootstrap run on **Bun** (they use `Bun.*` APIs via the Bun adapter). Node is
|
|
10
|
-
not supported for the standalone process today; the Cloudflare Worker adapter
|
|
11
|
-
is the other supported runtime.
|
|
12
|
-
|
|
13
|
-
Production v1 is deliberately single-instance for user-hosted traffic. The
|
|
14
|
-
relay keeps host presence and host-tunnel sockets in process-local maps, and a
|
|
15
|
-
`hostId` has exactly one active tunnel at a time. Deploy one active relay
|
|
16
|
-
process per user-hosted relay fleet; horizontal scaling needs a future routing
|
|
17
|
-
owner for sticky host tunnels, split-brain prevention, and failover. A relay
|
|
18
|
-
process, VM, or region failure drops existing user-hosted HTTP, WebSocket, SSE,
|
|
19
|
-
and PTY sessions until workspace runtimes reconnect.
|
|
20
|
-
|
|
21
|
-
## Quickstart
|
|
3
|
+
The Claxedo workspace relay: the tunnel between browsers and workspace-runtime
|
|
4
|
+
hosts, whether the host is a cloud VM or a user laptop.
|
|
22
5
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
The smallest runnable relay: mint an EdDSA key pair to stand in for the
|
|
28
|
-
control plane's signing key, point `resolveTarget` at a cloud-VM-style
|
|
29
|
-
workspace host, boot the Bun adapter, then mint a Runtime Access Token the
|
|
30
|
-
way `claxedo-control-plane` would and use it to reach the target through the
|
|
31
|
-
relay.
|
|
32
|
-
|
|
33
|
-
```ts
|
|
34
|
-
// relay.ts
|
|
35
|
-
import { generateKeyPair } from "jose"
|
|
36
|
-
import { createWorkspaceRelayBun, mintRuntimeAccessToken } from "@claxedo/workspace-relay"
|
|
37
|
-
|
|
38
|
-
const runtimeAccessKey = await generateKeyPair("EdDSA", { extractable: true })
|
|
39
|
-
const relayHostKey = await generateKeyPair("EdDSA", { extractable: true })
|
|
40
|
-
|
|
41
|
-
// Stand-in workspace host: whatever the relay forwards accepted traffic to.
|
|
42
|
-
const host = Bun.serve({
|
|
43
|
-
port: 0,
|
|
44
|
-
fetch: (request) => new Response(`host saw ${new URL(request.url).pathname}`),
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
const handler = createWorkspaceRelayBun({
|
|
48
|
-
runtimeAccessKey: runtimeAccessKey.publicKey,
|
|
49
|
-
relayHostSigningKey: relayHostKey.privateKey,
|
|
50
|
-
relayHostAlgorithm: "EdDSA",
|
|
51
|
-
resolveTarget: (claims) => ({
|
|
52
|
-
workspaceId: claims.workspace_id,
|
|
53
|
-
hostId: claims.host_id,
|
|
54
|
-
baseUrl: String(host.url).replace(/\/$/, ""),
|
|
55
|
-
access: "cloud",
|
|
56
|
-
backing: "cloud-vm",
|
|
57
|
-
}),
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
const relay = Bun.serve({
|
|
61
|
-
port: 7777,
|
|
62
|
-
fetch: handler.fetch,
|
|
63
|
-
websocket: handler.websocket,
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
const token = await mintRuntimeAccessToken(
|
|
67
|
-
{
|
|
68
|
-
principalKind: "user",
|
|
69
|
-
actorId: "user_1",
|
|
70
|
-
actorKind: "human",
|
|
71
|
-
orgId: "org_1",
|
|
72
|
-
workspaceId: "ws_1",
|
|
73
|
-
hostId: "host_1",
|
|
74
|
-
role: "editor",
|
|
75
|
-
},
|
|
76
|
-
runtimeAccessKey.privateKey,
|
|
77
|
-
"EdDSA",
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
console.log(`relay listening on ${relay.url}`)
|
|
81
|
-
console.log(`curl -H "Authorization: Bearer ${token}" ${relay.url}workspaces/ws_1/hello`)
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
```sh
|
|
85
|
-
bun run relay.ts
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
Run the printed `curl` command in another terminal — the relay verifies the
|
|
89
|
-
Runtime Access Token, calls `resolveTarget`, mints a Relay Host Token, and
|
|
90
|
-
forwards the request; you'll see `host saw /hello` come back. This mirrors
|
|
91
|
-
what [`src/main.ts`](src/main.ts) assembles for production, minus the
|
|
92
|
-
env-driven resolver client, JWKS, and graceful drain — see
|
|
93
|
-
[docs/architecture.md](docs/architecture.md) for how those pieces fit
|
|
94
|
-
together, and the Configuration table below for the env vars that replace
|
|
95
|
-
the hardcoded values above in a real deployment.
|
|
6
|
+
It deploys as a Cloudflare Worker. `src/worker.ts` is a stateless gateway that
|
|
7
|
+
routes each workspace to its own Durable Object room (`src/cloudflare.ts`), so
|
|
8
|
+
the room that owns a workspace's host tunnel also serves every request for that
|
|
9
|
+
workspace. `scripts/deploy-cloudflare.ts` deploys it.
|
|
96
10
|
|
|
97
11
|
## Deployment Security
|
|
98
12
|
|
|
@@ -104,18 +18,37 @@ accepted traffic to the selected host with a freshly minted relay-host token.
|
|
|
104
18
|
| Token | Default TTL | Issuer | Audience | Purpose |
|
|
105
19
|
| --- | ---: | --- | --- | --- |
|
|
106
20
|
| Runtime Access Token (RAT) | 30 minutes | `claxedo-control-plane` | `workspace-relay` | User/browser authorization to reach one workspace and host through the relay. |
|
|
107
|
-
| Host Tunnel Token (HTT) | 5 minutes | `claxedo-control-plane` | `workspace-relay-host-tunnel` | Workspace runtime authorization to register a
|
|
21
|
+
| Host Tunnel Token (HTT) | 5 minutes | `claxedo-control-plane` | `workspace-relay-host-tunnel` | Workspace runtime authorization to register a host tunnel. |
|
|
108
22
|
| Relay Host Token (RHT) | 60 seconds | `workspace-relay` | `workspace-host-service` | Per-request relay-to-host authorization minted after RAT validation. |
|
|
109
23
|
|
|
110
24
|
Runtime access tokens and host tunnel tokens bind issuer, audience, subject,
|
|
111
25
|
workspace id, host id, expiry, issue time, and JTI. Runtime access tokens also
|
|
112
|
-
bind role. Relay-host tokens additionally bind the
|
|
113
|
-
`
|
|
26
|
+
bind role. Relay-host tokens additionally bind the placement: `cloud-vm`,
|
|
27
|
+
`local-worktree` or `durable-object`.
|
|
28
|
+
|
|
29
|
+
A cloud runtime access token carries `routing_id`, a UUID owned by the lease
|
|
30
|
+
store's address transition. It changes when the address or resource changes and
|
|
31
|
+
on a new acquisition, including after lease deletion. The resolver requires the
|
|
32
|
+
signed identity to match the current ready lease; otherwise it returns 401
|
|
33
|
+
`runtime_access_token_invalid`. The app renews through connection POST once.
|
|
34
|
+
|
|
35
|
+
Target lookups are keyed by workspace, host and routing identity. A resolved
|
|
36
|
+
cloud or host-tunnel target is kept for `CLAXEDO_RELAY_TARGET_CACHE_TTL_MS`
|
|
37
|
+
(10 s), the same lag the revocation cache allows; a workspace still starting is
|
|
38
|
+
asked about on every request. A `durable-object` target is kept for 30 minutes,
|
|
39
|
+
the longest life of a Runtime Access Token, because the host id names the
|
|
40
|
+
session's object and the object never moves; revocation is still asked about
|
|
41
|
+
on its own cadence. The revocation check and the target lookup go out together,
|
|
42
|
+
since each needs only the token's claims. A request already resolving or
|
|
43
|
+
forwarding when the lease changes can finish with its previous snapshot;
|
|
44
|
+
established streams are not disconnected by this fence. The runtime receives a
|
|
45
|
+
relay-host token, so it does not independently fence the original routing
|
|
46
|
+
identity. The relay does not retry upstream failures.
|
|
114
47
|
|
|
115
48
|
### Revocation And Active Checks
|
|
116
49
|
|
|
117
|
-
`isRuntimeAccessTokenActive` is the revocation/target freshness hook.
|
|
118
|
-
|
|
50
|
+
`isRuntimeAccessTokenActive` is the revocation/target freshness hook. A deployed
|
|
51
|
+
relay implements it by calling the control plane or an equivalent
|
|
119
52
|
authority on every new HTTP request and WebSocket upgrade. A false result
|
|
120
53
|
rejects before forwarding to the host.
|
|
121
54
|
|
|
@@ -126,11 +59,10 @@ requirement, the control plane must also close the session/runtime channel.
|
|
|
126
59
|
|
|
127
60
|
### Host Tunnel Serving-Generation Fence
|
|
128
61
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
any relay with a resolver — asks
|
|
62
|
+
Every Host Tunnel Token carries `enrollment_id` and `generation`. A relay
|
|
63
|
+
requires a host-generation resolver and asks
|
|
132
64
|
`GET <CLAXEDO_RELAY_RESOLVER_URL>/host-generation?enrollmentId=` on every
|
|
133
|
-
admission and re-checks established tunnels every 30 s
|
|
65
|
+
admission and re-checks established tunnels every 30 s. The
|
|
134
66
|
verdicts, in order:
|
|
135
67
|
|
|
136
68
|
| Control plane answer | Admission | Established tunnel |
|
|
@@ -142,30 +74,20 @@ verdicts, in order:
|
|
|
142
74
|
| `404 relay_resolver_enrollment_not_found` | `403 host_enrollment_unknown` | closed `1008` |
|
|
143
75
|
| Any other status, a bare 404 (route missing), malformed body, or the 5 s deadline | `503 host_generation_lookup_unavailable` (retry) | survives two consecutive failures, closed `1012` on the third |
|
|
144
76
|
|
|
145
|
-
A token
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
identities, and a socket that became fenced starts the 30 s re-check (or arms
|
|
160
|
-
the hibernation alarm).
|
|
161
|
-
|
|
162
|
-
A relay composed directly from `createWorkspaceRelayBun` /
|
|
163
|
-
`createWorkspaceRelayDurableObjectRoom` without `resolveHostGeneration` — the
|
|
164
|
-
desktop and self-hosted composition — admits tokens without a generation
|
|
165
|
-
newest-wins and refuses any token that carries one with
|
|
166
|
-
`403 host_generation_unverifiable` (an update: closed `1008`). A generation is
|
|
167
|
-
a fence the relay cannot verify without the resolver, and the refusal is not
|
|
168
|
-
retryable because the resolver is a property of the composition.
|
|
77
|
+
A token missing either fence claim is refused. An incumbent in a workspace
|
|
78
|
+
room yields only to an equal or higher generation.
|
|
79
|
+
|
|
80
|
+
A `host.registration.update` frame must carry a complete, verified token. A
|
|
81
|
+
missing fence closes the socket with `1008 Host tunnel registration update
|
|
82
|
+
denied`; a lower generation closes it with `1008 Host tunnel registration
|
|
83
|
+
update superseded`. The relay then checks the control plane and closes with
|
|
84
|
+
the table's established-tunnel code on refusal, without outage grace for an
|
|
85
|
+
update. An accepted update replaces the socket's claims and identities and
|
|
86
|
+
keeps the periodic check or hibernation alarm active.
|
|
87
|
+
|
|
88
|
+
A relay composed without `resolveHostGeneration` refuses host tunnels with
|
|
89
|
+
`403 host_generation_unverifiable`. This refusal is not retryable because the
|
|
90
|
+
resolver belongs to the composition.
|
|
169
91
|
|
|
170
92
|
### Forwarding Boundary
|
|
171
93
|
|
|
@@ -174,45 +96,32 @@ The relay strips client-supplied `x-forwarded-for`, `x-forwarded-host`,
|
|
|
174
96
|
headers. It replaces `Authorization` with an RHT, sets `x-workspace-id`, and
|
|
175
97
|
adds `x-forwarded-by: workspace-relay`.
|
|
176
98
|
|
|
177
|
-
For
|
|
178
|
-
|
|
179
|
-
browser cookies must not be passed through to
|
|
180
|
-
targets may receive cookies when the caller intentionally sends them.
|
|
99
|
+
For `local-worktree` targets, `Cookie` is stripped before forwarding. A host
|
|
100
|
+
tunnel ends on a machine someone uses, whose browser cookie jar the host
|
|
101
|
+
service may share, so browser cookies must not be passed through to it. Cloud
|
|
102
|
+
VM targets may receive cookies when the caller intentionally sends them.
|
|
103
|
+
|
|
104
|
+
A `durable-object` target is a session served by its own Durable Object, host
|
|
105
|
+
`session-do:<root>`. The control plane resolves it with an empty `baseUrl`, and
|
|
106
|
+
the room forwards HTTP to the `SESSION_HOST` binding's object named by the root,
|
|
107
|
+
cookies stripped as for a host tunnel and response streams passed through. A
|
|
108
|
+
WebSocket upgrade to it is refused with `400 durable_object_websocket_unsupported`:
|
|
109
|
+
a session host serves its events over HTTP streams.
|
|
181
110
|
|
|
182
111
|
### CORS
|
|
183
112
|
|
|
184
113
|
The relay owns CORS responses for browser-facing workspace requests. Do not
|
|
185
|
-
forward upstream CORS headers as the source of truth.
|
|
186
|
-
|
|
187
|
-
credentialed deployments.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
`GET /metrics` is privileged operational data. In production, set
|
|
192
|
-
`CLAXEDO_RELAY_METRICS_TOKEN` and scrape with `Authorization: Bearer <token>`.
|
|
193
|
-
When no metrics token is configured, the endpoint only allows callers that the
|
|
194
|
-
adapter identifies as loopback; if no remote-address resolver exists, it fails
|
|
195
|
-
closed.
|
|
196
|
-
|
|
197
|
-
### User-Hosted Topology
|
|
114
|
+
forward upstream CORS headers as the source of truth. Configure browser origins
|
|
115
|
+
with `CLAXEDO_RELAY_ALLOWED_ORIGINS` or `CLAXEDO_APP_ORIGINS` (see
|
|
116
|
+
Configuration) and keep wildcard origins out of credentialed deployments. A
|
|
117
|
+
development relay that serves a local app names its loopback origins itself,
|
|
118
|
+
for example `CLAXEDO_APP_ORIGINS=http://localhost:*,http://127.0.0.1:*`; a
|
|
119
|
+
deployed relay admits none by default.
|
|
198
120
|
|
|
199
|
-
|
|
200
|
-
load balancer with strict stickiness that keeps each `hostId` on the relay
|
|
201
|
-
process that owns its tunnel socket. Non-sticky horizontal scaling needs an
|
|
202
|
-
external directory and tunnel routing owner before it is safe to advertise as
|
|
203
|
-
durable.
|
|
121
|
+
### Host-Tunnel Topology
|
|
204
122
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
In the fa9cabf9 design pass the working assumption was clarified:
|
|
208
|
-
|
|
209
|
-
> "I think this creates a parallel implementation problem — why is there
|
|
210
|
-
> no devmode in the prod relay?"
|
|
211
|
-
|
|
212
|
-
So this package is the relay. Local-dev configuration is selected by
|
|
213
|
-
environment variables, not by a sibling `dev-relay.ts` module. If a
|
|
214
|
-
future cycle re-introduces a separate `dev-relay.*` file in
|
|
215
|
-
`packages/claxedo-server/`, that should be flagged as a regression.
|
|
123
|
+
A Durable Object room is scoped to one workspace, so a host tunnel registering
|
|
124
|
+
through the Worker presents exactly one `workspaceId` per connection.
|
|
216
125
|
|
|
217
126
|
## Public surface
|
|
218
127
|
|
|
@@ -220,40 +129,34 @@ Re-exported from [`src/index.ts`](src/index.ts):
|
|
|
220
129
|
|
|
221
130
|
| Concern | Module | Notable exports |
|
|
222
131
|
| --- | --- | --- |
|
|
223
|
-
| Token issuance / verification | [`src/auth.ts`](src/auth.ts) | `mintRuntimeAccessToken`, `verifyRuntimeAccessToken`, `mintRelayHostToken`, `verifyRelayHostToken`, `mintHostTunnelToken`, `verifyHostTunnelToken`, types `RelayRole`, `
|
|
224
|
-
|
|
|
225
|
-
|
|
|
226
|
-
|
|
|
132
|
+
| Token issuance / verification | [`src/auth.ts`](src/auth.ts) | `mintRuntimeAccessToken`, `verifyRuntimeAccessToken`, `mintRelayHostToken`, `verifyRelayHostToken`, `mintHostTunnelToken`, `verifyHostTunnelToken`, types `RelayRole`, `RelayBacking`, `RelayJwtAlgorithm`, `RuntimeAccessTokenClaims`, `RelayKey`, `RelayKeyResolver`, error class `WorkspaceRelayAuthError` |
|
|
133
|
+
| Request authorization and forwarding | [`src/server.ts`](src/server.ts) | `authorizeWorkspaceRelayRequest`, `workspaceRelayForwardRequestInit`, `workspaceRelayTargetUrl`, the cached resolver clients, types `WorkspaceRelayOptions`, `WorkspaceRelayTarget`, `RuntimeAccessTokenActiveResult`, `WorkspaceRelayAuditEvent` |
|
|
134
|
+
| CORS policy | [`src/cors-origins.ts`](src/cors-origins.ts) | `DEFAULT_RELAY_APP_ORIGINS`, `RELAY_ALLOWED_REQUEST_HEADERS`, `createOriginMatcher`, `parseAllowedOrigins` |
|
|
135
|
+
| Active-host directory | [`src/directory.ts`](src/directory.ts) | `createWorkspaceRelayDirectory`, types `WorkspaceRelayDirectory`, `HostTunnelPresence` |
|
|
136
|
+
| Cloudflare Worker gateway and room | [`src/cloudflare.ts`](src/cloudflare.ts) | `createWorkspaceRelayDurableObjectGateway`, `createWorkspaceRelayDurableObjectRoom` |
|
|
227
137
|
|
|
228
138
|
Wire types live in the sibling package
|
|
229
139
|
[`@claxedo/workspace-relay-protocol`](../workspace-relay-protocol/)
|
|
230
140
|
(`TUNNEL_PROTOCOL_VERSION`, `TunnelMessage`, `isTunnelMessage`,
|
|
231
141
|
`makeTunnelPong`). Keep that split — it lets non-Node consumers
|
|
232
|
-
implement the tunnel protocol without pulling
|
|
142
|
+
implement the tunnel protocol without pulling Jose.
|
|
233
143
|
|
|
234
144
|
## Configuration
|
|
235
145
|
|
|
236
|
-
All knobs are environment variables
|
|
237
|
-
|
|
238
|
-
production fail-closed gate at `src/main.ts`.
|
|
146
|
+
All knobs are environment variables: Worker vars and secrets in
|
|
147
|
+
`wrangler.toml` and `scripts/deploy-cloudflare.ts`.
|
|
239
148
|
|
|
240
149
|
| Env var | Purpose |
|
|
241
150
|
| --- | --- |
|
|
242
|
-
| `
|
|
243
|
-
| `
|
|
244
|
-
| `CLAXEDO_RELAY_RESOLVER_URL` | Control-plane resolver base (`https://<control-plane>/internal/relay`). The relay derives `/target`, `/revocation`, and `/host-generation` from it. Required by the Bun process; the Worker also accepts `CLAXEDO_CENTRAL_URL` and appends `/internal/relay`. |
|
|
245
|
-
| `CLAXEDO_RELAY_RESOLVER_TOKEN` | Bearer token the relay sends to the resolver. **Required in production.** |
|
|
151
|
+
| `CLAXEDO_RELAY_RESOLVER_URL` | Control-plane resolver base (`https://<control-plane>/internal/relay`). The relay derives `/target`, `/revocation`, and `/host-generation` from it. The Worker also accepts `CLAXEDO_CENTRAL_URL` and appends `/internal/relay`. |
|
|
152
|
+
| `CLAXEDO_RELAY_RESOLVER_TOKEN` | Bearer token the relay sends to the resolver. The Worker refuses to start without it. |
|
|
246
153
|
| `CLAXEDO_RELAY_HOST_GENERATION_URL` | Optional absolute URL of the host-generation lookup. Unset (the normal case) derives `<CLAXEDO_RELAY_RESOLVER_URL>/host-generation`; set it only when the lookup lives at a different origin than the rest of the resolver. There is no way to turn the fence off on a resolver-backed relay. |
|
|
247
154
|
| `CLAXEDO_RELAY_HOST_GENERATION_CACHE_TTL_MS` | Cache TTL for host-generation answers. Defaults to 10000. A superseded tunnel closes within the re-check interval (30 s) plus this TTL. |
|
|
248
|
-
| `
|
|
249
|
-
| `
|
|
155
|
+
| `CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS` | Cache TTL for `/revocation` answers (default 10000 ms). Target answers are not retained. |
|
|
156
|
+
| `CLAXEDO_CONTROL_PLANE_JWKS_URL` | Remote JWKS the relay uses to verify runtime-access and host-tunnel tokens. |
|
|
250
157
|
| `CLAXEDO_RUNTIME_ACCESS_TOKEN_PUBLIC_KEY_PEM` | Inline public key (alternative to JWKS). |
|
|
251
|
-
| `
|
|
252
|
-
| `
|
|
253
|
-
| `CLAXEDO_RELAY_METRICS_TOKEN` | Optional bearer token for `/metrics`. Without it, `/metrics` requires a trusted loopback remote-address resolver. |
|
|
254
|
-
| `CLAXEDO_RELAY_DRAIN_TIMEOUT_MS` | Graceful shutdown wait before force-closing sockets. Defaults to 30000. |
|
|
255
|
-
| `CLAXEDO_RELAY_ALLOWED_ORIGINS` | Comma-separated browser-origin allowlist for CORS. **Replaces** the built-in default list (Claxedo/OpenCode app origins plus `http://localhost:*` dev hosts) — self-hosted deployments set their own origins here. Grammar: exact origin, `https://*.example.com`, `http://localhost:*`. Works on both the Bun process and the Cloudflare Worker. |
|
|
256
|
-
| `NODE_ENV` | `production` / `development` / `test`. Switches the production fail-closed gate. |
|
|
158
|
+
| `CLAXEDO_RELAY_HOST_SIGNING_KEY_PEM` | Private PEM the relay uses to mint relay-host tokens. The Worker publishes its public half at `/.well-known/jwks.json`. |
|
|
159
|
+
| `CLAXEDO_RELAY_ALLOWED_ORIGINS` | Comma-separated browser-origin allowlist for CORS. **Replaces** the built-in default list (`https://claxedo.com` and `https://*.claxedo.com`; no loopback origin). Grammar: exact origin, `https://*.example.com`, `http://localhost:*`. |
|
|
257
160
|
|
|
258
161
|
Cloudflare Worker tracing knobs (Durable Object deployment):
|
|
259
162
|
|
|
@@ -272,7 +175,7 @@ crypto/introspection authority; the relay still validates its output into
|
|
|
272
175
|
`RuntimeAccessTokenClaims`, binds the URL workspace id to the claims, applies
|
|
273
176
|
revocation, and allowlists roles. Missing, unknown, or malformed roles deny.
|
|
274
177
|
|
|
275
|
-
To swap the relay's auth backend
|
|
178
|
+
To swap the relay's auth backend:
|
|
276
179
|
|
|
277
180
|
```ts
|
|
278
181
|
import { createStaticTokenVerifier } from "@claxedo/workspace-relay-protocol"
|
|
@@ -290,7 +193,7 @@ JWKS fetching, audience binding, and replay caches belong to the
|
|
|
290
193
|
implementation. Two reference implementations ship in the protocol
|
|
291
194
|
package: `createHttpTokenVerifier` for remote verifiers
|
|
292
195
|
(token introspection, custom OIDC), and `createStaticTokenVerifier`
|
|
293
|
-
for tests and
|
|
196
|
+
for tests and single-tenant setups.
|
|
294
197
|
|
|
295
198
|
`createHttpTokenVerifier` is a reference implementation. Its HTTPS endpoint is
|
|
296
199
|
the crypto authority and must enforce issuer, audience, expiry, key selection,
|
|
@@ -300,7 +203,7 @@ operator configuration, not as tenant/user input.
|
|
|
300
203
|
Long-lived relayed sockets are authorized at establishment. Revocation is
|
|
301
204
|
checked for new HTTP requests and WebSocket upgrades; already-established
|
|
302
205
|
WebSocket/SSE/PTY streams may live until their normal close, reconnect, relay
|
|
303
|
-
drain, or
|
|
206
|
+
drain, or room restart.
|
|
304
207
|
|
|
305
208
|
## Tunnel Lifecycle
|
|
306
209
|
|
|
@@ -310,13 +213,6 @@ heartbeat timer, and buffered work before installing the new socket. Stale close
|
|
|
310
213
|
events identity-check the current owner before deleting presence, so an old
|
|
311
214
|
socket cannot mark a replacement offline.
|
|
312
215
|
|
|
313
|
-
Relay drain sets `/health` unhealthy, rejects new workspace requests and tunnel
|
|
314
|
-
registrations with `503 relay_draining`, closes active host tunnels so runtimes
|
|
315
|
-
reconnect promptly, waits for pending work up to the configured timeout, then
|
|
316
|
-
stops the server. Truly uncaught exceptions and unhandled rejections are fatal:
|
|
317
|
-
the relay marks itself draining, stops accepting work, disposes timers, and
|
|
318
|
-
exits for supervisor restart.
|
|
319
|
-
|
|
320
216
|
## External Directory Design
|
|
321
217
|
|
|
322
218
|
The current `WorkspaceRelayDirectory` is an in-memory implementation of the
|
|
@@ -328,44 +224,18 @@ type WorkspaceRelayDirectory = {
|
|
|
328
224
|
recordPong(hostId: string): HostTunnelPresence | undefined
|
|
329
225
|
disconnectHost(hostId: string): void
|
|
330
226
|
activeHost(input: { hostId: string; workspaceId: string }): HostTunnelPresence | undefined
|
|
331
|
-
sweep(): void
|
|
332
|
-
dispose(): void
|
|
333
|
-
size(): number
|
|
334
227
|
}
|
|
335
228
|
```
|
|
336
229
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
- one active owner for a `hostId`;
|
|
341
|
-
- TTL extension on heartbeat pong;
|
|
342
|
-
- immediate removal on disconnect;
|
|
343
|
-
- workspace membership checks before user-hosted forwarding;
|
|
344
|
-
- split-brain prevention when a replacement tunnel connects;
|
|
345
|
-
- observability for active host count and stale owner cleanup.
|
|
346
|
-
|
|
347
|
-
The missing piece for true multi-instance user-hosted relay is not just durable
|
|
348
|
-
presence storage. HTTP/WebSocket/SSE/PTY traffic must also route to the process
|
|
349
|
-
or durable object that owns the live tunnel socket for that `hostId`.
|
|
230
|
+
The Durable Object room keeps presence with its live socket. Hibernation
|
|
231
|
+
restores presence from socket attachments. Cloudflare routes every request for
|
|
232
|
+
one workspace to that room, which owns its tunnel.
|
|
350
233
|
|
|
351
234
|
## Routing
|
|
352
235
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
- The user's browser calls `/api/claxedo/...`.
|
|
357
|
-
- `claxedo-server` mounts `workspaceRuntimeProxy` middleware
|
|
358
|
-
(`packages/claxedo-server/src/workspace/runtime-dispatch/internals.ts:205`) on its top-level Hono
|
|
359
|
-
app at `packages/claxedo-server/src/deployments/local/server.ts:93`.
|
|
360
|
-
- The proxy resolves the active workspace target via
|
|
361
|
-
`internal-relay.ts` (control-plane auth — see "Seam: internal-relay
|
|
362
|
-
vs workspace-relay" below), strips its own prefix, and forwards
|
|
363
|
-
to the relay's tunnel endpoint.
|
|
364
|
-
|
|
365
|
-
If you are adding a new HTTP-level workspace surface, attach it to
|
|
366
|
-
`claxedo-server`'s gateway, not to `workspace-relay`. The relay is
|
|
367
|
-
intentionally narrow — it only handles tunnel traffic and the auth
|
|
368
|
-
checks that gate it.
|
|
236
|
+
Browsers call `/workspaces/:workspaceId/*` on the relay with a Runtime Access
|
|
237
|
+
Token. `src/worker.ts` selects the workspace room; `src/cloudflare.ts` authorizes
|
|
238
|
+
the request and forwards it to the resolved cloud target or machine tunnel.
|
|
369
239
|
|
|
370
240
|
## Seam: `internal-relay` vs `workspace-relay`
|
|
371
241
|
|
|
@@ -375,7 +245,7 @@ checks that gate it.
|
|
|
375
245
|
| Trust | Authenticates the relay process itself (loopback or bearer) | Authenticates the user's runtime-access token |
|
|
376
246
|
| Routes | `GET /internal/relay/target`, `GET /internal/relay/revocation` | WS upgrade + tunnel framing |
|
|
377
247
|
| Owners | `claxedo-server` (depends on the workspace authority, the identity provider, audit log) | `workspace-relay` (no authority or identity-provider dependency) |
|
|
378
|
-
| Lives in | `packages/claxedo-server/` (server-side only) | `packages/workspace-relay/` (
|
|
248
|
+
| Lives in | `packages/claxedo-server/` (server-side only) | `packages/workspace-relay/` (Worker and Durable Object) |
|
|
379
249
|
|
|
380
250
|
This split is deliberate: the relay never reads the workspace authority or the
|
|
381
251
|
identity provider directly. It calls back to `claxedo-server` over HTTP for resolver
|
|
@@ -385,8 +255,7 @@ decisions. The decision data crosses the seam as
|
|
|
385
255
|
## Development
|
|
386
256
|
|
|
387
257
|
```sh
|
|
388
|
-
bun --cwd packages/workspace-relay
|
|
389
|
-
bun --cwd packages/workspace-relay test # run all *.test.ts
|
|
258
|
+
bun --cwd packages/workspace-relay test # unit suites, including the workerd ones
|
|
390
259
|
bun --cwd packages/workspace-relay typecheck
|
|
391
260
|
```
|
|
392
261
|
|