@claxedo/workspace-relay 0.7.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 CHANGED
@@ -1,90 +1,12 @@
1
1
  # `@claxedo/workspace-relay`
2
2
 
3
- The Claxedo workspace relay — the single canonical tunnel process between
4
- cloud-hosted browsers and workspace-runtime hosts, whether the host is a cloud
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
- ```sh
24
- bun add @claxedo/workspace-relay jose
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
- { subject: "user_1", orgId: "org_1", workspaceId: "ws_1", hostId: "host_1", role: "editor" },
68
- runtimeAccessKey.privateKey,
69
- "EdDSA",
70
- )
71
-
72
- console.log(`relay listening on ${relay.url}`)
73
- console.log(`curl -H "Authorization: Bearer ${token}" ${relay.url}workspaces/ws_1/hello`)
74
- ```
75
-
76
- ```sh
77
- bun run relay.ts
78
- ```
79
-
80
- Run the printed `curl` command in another terminal — the relay verifies the
81
- Runtime Access Token, calls `resolveTarget`, mints a Relay Host Token, and
82
- forwards the request; you'll see `host saw /hello` come back. This mirrors
83
- what [`src/main.ts`](src/main.ts) assembles for production, minus the
84
- env-driven resolver client, JWKS, and graceful drain — see
85
- [docs/architecture.md](docs/architecture.md) for how those pieces fit
86
- together, and the Configuration table below for the env vars that replace
87
- 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.
88
10
 
89
11
  ## Deployment Security
90
12
 
@@ -96,18 +18,37 @@ accepted traffic to the selected host with a freshly minted relay-host token.
96
18
  | Token | Default TTL | Issuer | Audience | Purpose |
97
19
  | --- | ---: | --- | --- | --- |
98
20
  | Runtime Access Token (RAT) | 30 minutes | `claxedo-control-plane` | `workspace-relay` | User/browser authorization to reach one workspace and host through the relay. |
99
- | Host Tunnel Token (HTT) | 5 minutes | `claxedo-control-plane` | `workspace-relay-host-tunnel` | Workspace runtime authorization to register a user-hosted tunnel. |
21
+ | Host Tunnel Token (HTT) | 5 minutes | `claxedo-control-plane` | `workspace-relay-host-tunnel` | Workspace runtime authorization to register a host tunnel. |
100
22
  | Relay Host Token (RHT) | 60 seconds | `workspace-relay` | `workspace-host-service` | Per-request relay-to-host authorization minted after RAT validation. |
101
23
 
102
24
  Runtime access tokens and host tunnel tokens bind issuer, audience, subject,
103
25
  workspace id, host id, expiry, issue time, and JTI. Runtime access tokens also
104
- bind role. Relay-host tokens additionally bind the deployment pair:
105
- `cloud/cloud-vm` or `user-hosted/local-worktree`.
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.
106
47
 
107
48
  ### Revocation And Active Checks
108
49
 
109
- `isRuntimeAccessTokenActive` is the revocation/target freshness hook. Production
110
- deployments should implement it by calling the control plane or an equivalent
50
+ `isRuntimeAccessTokenActive` is the revocation/target freshness hook. A deployed
51
+ relay implements it by calling the control plane or an equivalent
111
52
  authority on every new HTTP request and WebSocket upgrade. A false result
112
53
  rejects before forwarding to the host.
113
54
 
@@ -116,6 +57,38 @@ SSE, PTY, and WebSocket streams may live until normal close, reconnect, relay
116
57
  drain, host disconnect, or process restart. If immediate stream revocation is a
117
58
  requirement, the control plane must also close the session/runtime channel.
118
59
 
60
+ ### Host Tunnel Serving-Generation Fence
61
+
62
+ Every Host Tunnel Token carries `enrollment_id` and `generation`. A relay
63
+ requires a host-generation resolver and asks
64
+ `GET <CLAXEDO_RELAY_RESOLVER_URL>/host-generation?enrollmentId=` on every
65
+ admission and re-checks established tunnels every 30 s. The
66
+ verdicts, in order:
67
+
68
+ | Control plane answer | Admission | Established tunnel |
69
+ | --- | --- | --- |
70
+ | Same generation, not revoked | admitted | kept |
71
+ | Higher generation | `403 host_generation_superseded` | closed `1008` |
72
+ | Lower generation | `403 host_generation_unknown` | closed `1008` |
73
+ | Enrollment revoked or paused | `403 host_enrollment_revoked` | closed `1008` |
74
+ | `404 relay_resolver_enrollment_not_found` | `403 host_enrollment_unknown` | closed `1008` |
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 |
76
+
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.
91
+
119
92
  ### Forwarding Boundary
120
93
 
121
94
  The relay strips client-supplied `x-forwarded-for`, `x-forwarded-host`,
@@ -123,45 +96,32 @@ The relay strips client-supplied `x-forwarded-for`, `x-forwarded-host`,
123
96
  headers. It replaces `Authorization` with an RHT, sets `x-workspace-id`, and
124
97
  adds `x-forwarded-by: workspace-relay`.
125
98
 
126
- For user-hosted targets, `Cookie` is stripped before forwarding. User-hosted
127
- workspace processes may run near a developer's local browser cookie jar, so
128
- browser cookies must not be passed through to the local host service. Cloud VM
129
- 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.
130
110
 
131
111
  ### CORS
132
112
 
133
113
  The relay owns CORS responses for browser-facing workspace requests. Do not
134
- forward upstream CORS headers as the source of truth. Add allowed product
135
- origins in the relay/server CORS configuration and keep wildcard origins out of
136
- credentialed deployments.
137
-
138
- ### Metrics
139
-
140
- `GET /metrics` is privileged operational data. In production, set
141
- `CLAXEDO_RELAY_METRICS_TOKEN` and scrape with `Authorization: Bearer <token>`.
142
- When no metrics token is configured, the endpoint only allows callers that the
143
- adapter identifies as loopback; if no remote-address resolver exists, it fails
144
- closed.
145
-
146
- ### 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.
147
120
 
148
- Production v1 supports one active relay instance for user-hosted traffic, or a
149
- load balancer with strict stickiness that keeps each `hostId` on the relay
150
- process that owns its tunnel socket. Non-sticky horizontal scaling needs an
151
- external directory and tunnel routing owner before it is safe to advertise as
152
- durable.
121
+ ### Host-Tunnel Topology
153
122
 
154
- ## Why one package, no parallel impl
155
-
156
- In the fa9cabf9 design pass the working assumption was clarified:
157
-
158
- > "I think this creates a parallel implementation problem — why is there
159
- > no devmode in the prod relay?"
160
-
161
- So this package is the relay. Local-dev configuration is selected by
162
- environment variables, not by a sibling `dev-relay.ts` module. If a
163
- future cycle re-introduces a separate `dev-relay.*` file in
164
- `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.
165
125
 
166
126
  ## Public surface
167
127
 
@@ -169,37 +129,34 @@ Re-exported from [`src/index.ts`](src/index.ts):
169
129
 
170
130
  | Concern | Module | Notable exports |
171
131
  | --- | --- | --- |
172
- | Token issuance / verification | [`src/auth.ts`](src/auth.ts) | `mintRuntimeAccessToken`, `verifyRuntimeAccessToken`, `mintRelayHostToken`, `verifyRelayHostToken`, `mintHostTunnelToken`, `verifyHostTunnelToken`, types `RelayRole`, `RelayAccess`, `RelayBacking`, `RelayJwtAlgorithm`, `RuntimeAccessTokenClaims`, `RelayKey`, `RelayKeyResolver`, error class `WorkspaceRelayAuthError` |
173
- | Hono HTTP surface | [`src/server.ts`](src/server.ts) | `createWorkspaceRelay`, `authorizeWorkspaceRelayRequest`, types `WorkspaceRelayOptions`, `WorkspaceRelayTarget`, `RuntimeAccessTokenActiveResult`, `WorkspaceRelayAuditEvent`, `WorkspaceRelayMetricsSources`, `RelayHostPublicKey` |
174
- | Active-host directory | [`src/directory.ts`](src/directory.ts) | `createWorkspaceRelayDirectory`, `disposeWorkspaceRelayDirectory`, types `WorkspaceRelayDirectory`, `HostTunnelPresence` |
175
- | Bun-specific server bootstrap | [`src/bun.ts`](src/bun.ts) | `createWorkspaceRelayBun`, type `WorkspaceRelayBunOptions`, `WorkspaceRelayBunDrainController`, metrics `getFragmentationStats`, `getSlowConsumerStats` |
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` |
176
137
 
177
138
  Wire types live in the sibling package
178
139
  [`@claxedo/workspace-relay-protocol`](../workspace-relay-protocol/)
179
140
  (`TUNNEL_PROTOCOL_VERSION`, `TunnelMessage`, `isTunnelMessage`,
180
141
  `makeTunnelPong`). Keep that split — it lets non-Node consumers
181
- implement the tunnel protocol without pulling Hono and Jose.
142
+ implement the tunnel protocol without pulling Jose.
182
143
 
183
144
  ## Configuration
184
145
 
185
- All knobs are environment variables. The relay refuses to boot in
186
- production if `CLAXEDO_RELAY_RESOLVER_TOKEN` is missing — the
187
- 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`.
188
148
 
189
149
  | Env var | Purpose |
190
150
  | --- | --- |
191
- | `CLAXEDO_RELAY_BIND_HOST`, `CLAXEDO_RELAY_BIND_PORT` | Listening socket. Loopback by default. |
192
- | `CLAXEDO_RELAY_PUBLIC_URL` | Externally-resolvable URL the relay advertises in tokens. |
193
- | `CLAXEDO_RELAY_RESOLVER_URL` | Where to fetch `RuntimeAccessTokenActiveResult`s (typically `claxedo-server/internal/relay/target`). |
194
- | `CLAXEDO_RELAY_RESOLVER_TOKEN` | Bearer token the relay sends to the resolver. **Required in production.** |
195
- | `CLAXEDO_RELAY_JWKS_URL` | Optional remote JWKS the relay uses to verify runtime-access tokens. |
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. |
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. |
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. |
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. |
196
157
  | `CLAXEDO_RUNTIME_ACCESS_TOKEN_PUBLIC_KEY_PEM` | Inline public key (alternative to JWKS). |
197
- | `CLAXEDO_RELAY_HOST_VERIFY_PEM` | Public PEM the relay uses to verify host-tunnel tokens. |
198
- | `CLAXEDO_RELAY_HOST_SIGNING_KEY_PEM` | Private PEM the relay uses to mint relay-host tokens. |
199
- | `CLAXEDO_RELAY_METRICS_TOKEN` | Optional bearer token for `/metrics`. Without it, `/metrics` requires a trusted loopback remote-address resolver. |
200
- | `CLAXEDO_RELAY_DRAIN_TIMEOUT_MS` | Graceful shutdown wait before force-closing sockets. Defaults to 30000. |
201
- | `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. |
202
- | `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:*`. |
203
160
 
204
161
  Cloudflare Worker tracing knobs (Durable Object deployment):
205
162
 
@@ -218,7 +175,7 @@ crypto/introspection authority; the relay still validates its output into
218
175
  `RuntimeAccessTokenClaims`, binds the URL workspace id to the claims, applies
219
176
  revocation, and allowlists roles. Missing, unknown, or malformed roles deny.
220
177
 
221
- To swap the relay's auth backend in a self-hosted deployment:
178
+ To swap the relay's auth backend:
222
179
 
223
180
  ```ts
224
181
  import { createStaticTokenVerifier } from "@claxedo/workspace-relay-protocol"
@@ -235,8 +192,8 @@ The `TokenVerifier` interface intentionally contains only `verify(token)`.
235
192
  JWKS fetching, audience binding, and replay caches belong to the
236
193
  implementation. Two reference implementations ship in the protocol
237
194
  package: `createHttpTokenVerifier` for remote verifiers
238
- (Clerk-style introspection, custom OIDC), and `createStaticTokenVerifier`
239
- for tests and self-hosted single-tenant setups.
195
+ (token introspection, custom OIDC), and `createStaticTokenVerifier`
196
+ for tests and single-tenant setups.
240
197
 
241
198
  `createHttpTokenVerifier` is a reference implementation. Its HTTPS endpoint is
242
199
  the crypto authority and must enforce issuer, audience, expiry, key selection,
@@ -246,7 +203,7 @@ operator configuration, not as tenant/user input.
246
203
  Long-lived relayed sockets are authorized at establishment. Revocation is
247
204
  checked for new HTTP requests and WebSocket upgrades; already-established
248
205
  WebSocket/SSE/PTY streams may live until their normal close, reconnect, relay
249
- drain, or process restart.
206
+ drain, or room restart.
250
207
 
251
208
  ## Tunnel Lifecycle
252
209
 
@@ -256,13 +213,6 @@ heartbeat timer, and buffered work before installing the new socket. Stale close
256
213
  events identity-check the current owner before deleting presence, so an old
257
214
  socket cannot mark a replacement offline.
258
215
 
259
- Relay drain sets `/health` unhealthy, rejects new workspace requests and tunnel
260
- registrations with `503 relay_draining`, closes active host tunnels so runtimes
261
- reconnect promptly, waits for pending work up to the configured timeout, then
262
- stops the server. Truly uncaught exceptions and unhandled rejections are fatal:
263
- the relay marks itself draining, stops accepting work, disposes timers, and
264
- exits for supervisor restart.
265
-
266
216
  ## External Directory Design
267
217
 
268
218
  The current `WorkspaceRelayDirectory` is an in-memory implementation of the
@@ -274,65 +224,38 @@ type WorkspaceRelayDirectory = {
274
224
  recordPong(hostId: string): HostTunnelPresence | undefined
275
225
  disconnectHost(hostId: string): void
276
226
  activeHost(input: { hostId: string; workspaceId: string }): HostTunnelPresence | undefined
277
- sweep(): void
278
- dispose(): void
279
- size(): number
280
227
  }
281
228
  ```
282
229
 
283
- A Redis, Durable Object, or equivalent implementation should keep the same
284
- semantics:
285
-
286
- - one active owner for a `hostId`;
287
- - TTL extension on heartbeat pong;
288
- - immediate removal on disconnect;
289
- - workspace membership checks before user-hosted forwarding;
290
- - split-brain prevention when a replacement tunnel connects;
291
- - observability for active host count and stale owner cleanup.
292
-
293
- The missing piece for true multi-instance user-hosted relay is not just durable
294
- presence storage. HTTP/WebSocket/SSE/PTY traffic must also route to the process
295
- 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.
296
233
 
297
234
  ## Routing
298
235
 
299
- The relay has **no** `/w/{workspaceId}/*` URL prefix of its own. The
300
- gateway pattern lives in `claxedo-server`:
301
-
302
- - The user's browser calls `/api/claxedo/...`.
303
- - `claxedo-server` mounts `workspaceRuntimeProxy` middleware
304
- (`packages/claxedo-server/src/proxy.ts:205`) on its top-level Hono
305
- app at `packages/claxedo-server/src/server.ts:93`.
306
- - The proxy resolves the active workspace target via
307
- `internal-relay.ts` (control-plane auth — see "Seam: internal-relay
308
- vs workspace-relay" below), strips its own prefix, and forwards
309
- to the relay's tunnel endpoint.
310
-
311
- If you are adding a new HTTP-level workspace surface, attach it to
312
- `claxedo-server`'s gateway, not to `workspace-relay`. The relay is
313
- intentionally narrow — it only handles tunnel traffic and the auth
314
- 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.
315
239
 
316
240
  ## Seam: `internal-relay` vs `workspace-relay`
317
241
 
318
- | | `claxedo-server/src/routes/internal-relay.ts` | `@claxedo/workspace-relay` |
242
+ | | `claxedo-server/src/deployments/shared-routes/internal-relay.ts` | `@claxedo/workspace-relay` |
319
243
  | --- | --- | --- |
320
244
  | Layer | Control-plane auth | Tunnel transport |
321
245
  | Trust | Authenticates the relay process itself (loopback or bearer) | Authenticates the user's runtime-access token |
322
246
  | Routes | `GET /internal/relay/target`, `GET /internal/relay/revocation` | WS upgrade + tunnel framing |
323
- | Owners | `claxedo-server` (depends on Convex authority, Clerk, audit log) | `workspace-relay` (no Convex/Clerk dependency) |
324
- | Lives in | `packages/claxedo-server/` (server-side only) | `packages/workspace-relay/` (own process) |
247
+ | Owners | `claxedo-server` (depends on the workspace authority, the identity provider, audit log) | `workspace-relay` (no authority or identity-provider dependency) |
248
+ | Lives in | `packages/claxedo-server/` (server-side only) | `packages/workspace-relay/` (Worker and Durable Object) |
325
249
 
326
- This split is deliberate: the relay never reads Convex or Clerk
327
- directly. It calls back to `claxedo-server` over HTTP for resolver
250
+ This split is deliberate: the relay never reads the workspace authority or the
251
+ identity provider directly. It calls back to `claxedo-server` over HTTP for resolver
328
252
  decisions. The decision data crosses the seam as
329
253
  `RuntimeAccessTokenActiveResult` (defined in `server.ts`).
330
254
 
331
255
  ## Development
332
256
 
333
257
  ```sh
334
- bun --cwd packages/workspace-relay dev # hot-reload main.ts
335
- bun --cwd packages/workspace-relay test # run all *.test.ts
258
+ bun --cwd packages/workspace-relay test # unit suites, including the workerd ones
336
259
  bun --cwd packages/workspace-relay typecheck
337
260
  ```
338
261
 
package/dist/auth.d.ts CHANGED
@@ -5,25 +5,66 @@ export declare const runtimeAccessTokenAudience = "workspace-relay";
5
5
  export declare const hostTunnelTokenAudience = "workspace-relay-host-tunnel";
6
6
  export declare const relayHostTokenIssuer = "workspace-relay";
7
7
  export declare const relayHostTokenAudience = "workspace-host-service";
8
- export type RelayClaimPair = {
9
- access: "cloud";
10
- backing: "cloud-vm";
11
- } | {
12
- access: "user-hosted";
13
- backing: "local-worktree";
14
- };
15
- export type RelayAccess = RelayClaimPair["access"];
16
- export type RelayBacking = RelayClaimPair["backing"];
8
+ /**
9
+ * Where the host behind a relayed request runs: the provisioner's machine
10
+ * (`cloud-vm`), an enrolled one (`local-worktree`), or one session's Durable
11
+ * Object (`durable-object`). The only placement word on the relay wire; the
12
+ * control plane stores the first two as a workspace's backing.
13
+ */
14
+ export type RelayBacking = "cloud-vm" | "local-worktree" | "durable-object";
17
15
  export type RelayJwtAlgorithm = (typeof algorithms)[number];
18
16
  export type RelayRole = "viewer" | "editor" | "admin" | "owner";
17
+ export type ActorKind = "human" | "agent";
18
+ /**
19
+ * Earliest `iat` a Runtime Access Token may carry (2026-09-21T00:00:00Z).
20
+ *
21
+ * Tokens minted before channel provenance existed carry no `channel_identity`,
22
+ * so a channel-issued one among them is indistinguishable from an app token
23
+ * and there is no binding generation to check it against. The whole vintage is
24
+ * refused rather than trusted until it expires.
25
+ */
26
+ export declare const RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS = 1789948800;
27
+ /** The channel binding that authorized a token, as the token carries it. */
28
+ export type ChannelIdentityClaim = {
29
+ channel: string;
30
+ external_user_id: string;
31
+ identity_version: number;
32
+ };
33
+ /** The same binding as a mint argument. */
34
+ export type ChannelIdentityInput = {
35
+ channel: string;
36
+ externalUserId: string;
37
+ identityVersion: number;
38
+ };
39
+ export declare function toChannelIdentityClaim(input: ChannelIdentityInput): ChannelIdentityClaim;
40
+ export type TokenScope = "workspace" | "session";
41
+ /** A token minted for one job; only the routes serving that job accept it. */
42
+ export type TokenPurpose = "turn-execution";
19
43
  export type RuntimeAccessTokenClaims = {
20
44
  iss: typeof runtimeAccessTokenIssuer;
21
45
  aud: typeof runtimeAccessTokenAudience;
22
- sub: string;
46
+ principal_kind: "user" | "service";
47
+ actor_id: string;
48
+ user_id?: string;
49
+ actor_kind: "human" | "agent";
50
+ actor_public_id?: string;
51
+ actor_name?: string;
52
+ actor_avatar_url?: string;
23
53
  org_id: string;
24
54
  workspace_id: string;
25
55
  host_id: string;
26
56
  role: RelayRole;
57
+ /**
58
+ * What the token reaches: the whole workspace (its owner's token) or the one
59
+ * session `session_id` names (a share holder's). A token naming neither is
60
+ * refused, so a token minted before reach was named reaches nothing.
61
+ */
62
+ scope: TokenScope;
63
+ session_id?: string;
64
+ purpose?: TokenPurpose;
65
+ channel_identity?: ChannelIdentityClaim;
66
+ /** Present for cloud workspaces; assigned atomically with the sandbox address. */
67
+ routing_id?: string;
27
68
  exp: number;
28
69
  iat: number;
29
70
  jti: string;
@@ -31,40 +72,68 @@ export type RuntimeAccessTokenClaims = {
31
72
  export type RelayHostTokenClaims = {
32
73
  iss: typeof relayHostTokenIssuer;
33
74
  aud: typeof relayHostTokenAudience;
34
- sub: string;
75
+ principal_kind: "user" | "service";
76
+ actor_id: string;
77
+ user_id?: string;
78
+ actor_kind: "human" | "agent";
79
+ actor_public_id?: string;
80
+ actor_name?: string;
81
+ actor_avatar_url?: string;
35
82
  org_id: string;
36
83
  workspace_id: string;
37
84
  host_id: string;
38
85
  role: RelayRole;
86
+ scope: TokenScope;
87
+ session_id?: string;
88
+ purpose?: TokenPurpose;
89
+ channel_identity?: ChannelIdentityClaim;
39
90
  exp: number;
40
91
  iat: number;
41
92
  jti: string;
42
- } & RelayClaimPair;
93
+ /** Durable parent Runtime Access Token id used for revocation checks. */
94
+ parent_jti: string;
95
+ backing: RelayBacking;
96
+ };
43
97
  export type HostTunnelTokenClaims = {
44
98
  iss: typeof runtimeAccessTokenIssuer;
45
99
  aud: typeof hostTunnelTokenAudience;
46
100
  sub: string;
47
101
  host_id: string;
48
102
  workspace_ids: string[];
103
+ enrollment_id: string;
104
+ generation: number;
49
105
  exp: number;
50
106
  iat: number;
51
107
  jti: string;
52
108
  };
53
109
  export declare class WorkspaceRelayAuthError extends Error {
54
- readonly code: "invalid_relay_token" | "relay_token_workspace_mismatch" | "relay_token_host_mismatch" | "relay_token_claims_invalid";
55
- constructor(code: "invalid_relay_token" | "relay_token_workspace_mismatch" | "relay_token_host_mismatch" | "relay_token_claims_invalid", message: string);
110
+ readonly code: "runtime_access_token_invalid" | "invalid_relay_token" | "relay_token_workspace_mismatch" | "relay_token_host_mismatch" | "relay_token_claims_invalid";
111
+ constructor(code: "runtime_access_token_invalid" | "invalid_relay_token" | "relay_token_workspace_mismatch" | "relay_token_host_mismatch" | "relay_token_claims_invalid", message: string);
56
112
  }
57
113
  type RuntimeInput = {
58
- subject: string;
114
+ principalKind: "user" | "service";
115
+ actorId: string;
116
+ userId?: string;
117
+ actorKind: ActorKind;
118
+ actorPublicId?: string;
119
+ actorName?: string;
120
+ actorAvatarUrl?: string;
59
121
  orgId: string;
60
122
  workspaceId: string;
61
123
  hostId: string;
62
124
  role: RelayRole;
125
+ sessionId?: string;
126
+ purpose?: TokenPurpose;
127
+ channelIdentity?: ChannelIdentityInput;
128
+ routingId?: string;
63
129
  ttlSeconds?: number;
64
130
  jti?: string;
65
131
  now?: number;
66
132
  };
67
- type RelayHostInput = RuntimeInput & RelayClaimPair & {
133
+ type RelayHostInput = RuntimeInput & {
134
+ backing: RelayBacking;
135
+ /** The Runtime Access Token jti from which this one-request RHT is derived. */
136
+ parentJti: string;
68
137
  /**
69
138
  * Optional `kid` to embed in the JWT protected header. Verifiers using a
70
139
  * JWKS resolver dispatch on this to pick the matching key, so a freshly
@@ -78,6 +147,8 @@ type HostTunnelInput = {
78
147
  subject: string;
79
148
  hostId: string;
80
149
  workspaceIds: string[];
150
+ enrollmentId: string;
151
+ generation: number;
81
152
  ttlSeconds?: number;
82
153
  jti?: string;
83
154
  now?: number;
@@ -90,10 +161,8 @@ type ExpectedHostTunnel = {
90
161
  hostId: string;
91
162
  workspaceIds: string[];
92
163
  };
93
- export declare function isRelayClaimPair(input: {
94
- access?: unknown;
95
- backing?: unknown;
96
- }): input is RelayClaimPair;
164
+ export declare function isHostGeneration(value: unknown): value is number;
165
+ export declare function isRelayBacking(input: unknown): input is RelayBacking;
97
166
  /**
98
167
  * Resolver function returned by `createRemoteJWKSet` / `createLocalJWKSet`.
99
168
  * Accepted by `jwtVerify` directly — jose dispatches based on whether the
@@ -101,6 +170,22 @@ export declare function isRelayClaimPair(input: {
101
170
  */
102
171
  export type RelayKeyResolver = JWTVerifyGetKey;
103
172
  export type RelayKey = CryptoKey | Uint8Array | RelayKeyResolver;
173
+ /**
174
+ * Re-imports the PUBLIC half of an EdDSA relay-host signing key.
175
+ *
176
+ * `importJWK` is declared `Promise<CryptoKey | Uint8Array>` because a symmetric
177
+ * (`oct`) JWK imports as raw bytes. An EdDSA public JWK never does, so the byte
178
+ * branch is a configuration error worth failing loudly on.
179
+ */
180
+ export declare function deriveRelayHostPublicKey(privateKey: CryptoKey): Promise<CryptoKey>;
181
+ /**
182
+ * The relay-host key id, derived from the key's public component.
183
+ *
184
+ * Signing with the same key must always publish the same `kid`, or a token
185
+ * minted under one build fails key lookup at the next. Written against
186
+ * WebCrypto rather than `node:crypto` so the workerd bundle can use it.
187
+ */
188
+ export declare function deriveRelayHostKid(publicKey: CryptoKey): Promise<string>;
104
189
  /**
105
190
  * Mint variants accept only a signing key — never a resolver. Resolvers are
106
191
  * verification-only. This narrowed alias keeps the mint signatures unchanged.
@@ -111,6 +196,31 @@ export declare function verifyRuntimeAccessToken(token: string, key: RelayKey, e
111
196
  export declare function validateRuntimeAccessTokenClaims(input: Record<string, unknown>, expected: ExpectedTarget): RuntimeAccessTokenClaims;
112
197
  export declare function mintHostTunnelToken(input: HostTunnelInput, key: RelaySigningKey, alg: RelayJwtAlgorithm): Promise<string>;
113
198
  export declare function verifyHostTunnelToken(token: string, key: RelayKey, expected: ExpectedHostTunnel): Promise<HostTunnelTokenClaims>;
199
+ /**
200
+ * Validates a claims result a custom `authorizeHostTunnel` policy returns.
201
+ * The policy decides WHO the tunnel is; the binding is the relay's: the
202
+ * claims' `host_id` must equal the requested host and its `workspace_ids`
203
+ * must cover every requested workspace, so a permissive policy cannot widen
204
+ * the identity its own claims assert.
205
+ */
206
+ /** What a Relay Host Token minted from a verified Runtime Access Token carries over from it. */
207
+ export declare function relayHostInputFromRuntimeClaims(claims: RuntimeAccessTokenClaims): Omit<RelayHostInput, "backing" | "hostId" | "workspaceId">;
114
208
  export declare function mintRelayHostToken(input: RelayHostInput, key: RelaySigningKey, alg: RelayJwtAlgorithm): Promise<string>;
115
209
  export declare function verifyRelayHostToken(token: string, key: RelayKey, expected: ExpectedTarget): Promise<RelayHostTokenClaims>;
210
+ export declare function tokenScopePayload(sessionId: string | undefined): {
211
+ scope: "workspace";
212
+ session_id?: undefined;
213
+ } | {
214
+ scope: "session";
215
+ session_id: string;
216
+ };
217
+ /** The token's reach, or nothing when it names neither the workspace nor exactly one session. */
218
+ /** The purpose claim, or undefined when it names a purpose this build does not know. */
219
+ export declare function tokenPurposeClaims(payload: Record<string, unknown>): {
220
+ purpose?: TokenPurpose;
221
+ } | undefined;
222
+ export declare function tokenScopeClaims(payload: Record<string, unknown>): {
223
+ scope: TokenScope;
224
+ session_id?: string;
225
+ } | undefined;
116
226
  export {};