@claxedo/workspace-relay 0.8.0 → 0.10.1

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,98 +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
- {
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 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. |
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 deployment pair:
113
- `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.
114
47
 
115
48
  ### Revocation And Active Checks
116
49
 
117
- `isRuntimeAccessTokenActive` is the revocation/target freshness hook. Production
118
- 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
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
- A Host Tunnel Token minted by a connect-enrolled host carries `enrollment_id`
130
- and `generation`. A relay built by `src/main.ts` or `src/worker.ts` — that is,
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 (both adapters). The
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 without a generation is admitted without asking the control plane. It
146
- never displaces a socket that carries a generation: for one host+workspace
147
- identity (Bun) or one room (Cloudflare), an incumbent with a generation yields
148
- only to an equal or higher generation, and an incumbent without one yields to
149
- any later socket. Every refusal is audited as `host_tunnel.denied` with the
150
- code as `reason` (Bun; the Cloudflare room has no audit hook).
151
-
152
- A `host.registration.update` frame is re-admitted the same way, with the
153
- socket's own claims as the first incumbent: an update whose token carries no
154
- generation, or a lower one than the socket holds, is refused (closed `1008
155
- Host tunnel registration update superseded`); one that passes is then checked
156
- against the control plane exactly as a connect is and closed with the table's
157
- established-tunnel code on refusal (`1012` for an unavailable lookup, without
158
- the outage grace). An accepted update replaces the socket's claims and
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 user-hosted targets, `Cookie` is stripped before forwarding. User-hosted
178
- workspace processes may run near a developer's local browser cookie jar, so
179
- browser cookies must not be passed through to the local host service. Cloud VM
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. Add allowed product
186
- origins in the relay/server CORS configuration and keep wildcard origins out of
187
- credentialed deployments.
188
-
189
- ### Metrics
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
- Production v1 supports one active relay instance for user-hosted traffic, or a
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
- ## Why one package, no parallel impl
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`, `RelayAccess`, `RelayBacking`, `RelayJwtAlgorithm`, `RuntimeAccessTokenClaims`, `RelayKey`, `RelayKeyResolver`, error class `WorkspaceRelayAuthError` |
224
- | Hono HTTP surface | [`src/server.ts`](src/server.ts) | `createWorkspaceRelay`, `authorizeWorkspaceRelayRequest`, types `WorkspaceRelayOptions`, `WorkspaceRelayTarget`, `RuntimeAccessTokenActiveResult`, `WorkspaceRelayAuditEvent`, `WorkspaceRelayMetricsSources`, `RelayHostPublicKey` |
225
- | Active-host directory | [`src/directory.ts`](src/directory.ts) | `createWorkspaceRelayDirectory`, `disposeWorkspaceRelayDirectory`, types `WorkspaceRelayDirectory`, `HostTunnelPresence` |
226
- | 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` |
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 Hono and Jose.
142
+ implement the tunnel protocol without pulling Jose.
233
143
 
234
144
  ## Configuration
235
145
 
236
- All knobs are environment variables. The relay refuses to boot in
237
- production if `CLAXEDO_RELAY_RESOLVER_TOKEN` is missing — the
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
- | `CLAXEDO_RELAY_BIND_HOST`, `CLAXEDO_RELAY_BIND_PORT` | Listening socket. Loopback by default. |
243
- | `CLAXEDO_RELAY_PUBLIC_URL` | Externally-resolvable URL the relay advertises in tokens. |
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
- | `CLAXEDO_RELAY_TARGET_CACHE_TTL_MS`, `CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS` | Cache TTLs for `/target` (default 30000 on Bun, 5000 on the Worker) and `/revocation` (default 10000) answers. |
249
- | `CLAXEDO_RELAY_JWKS_URL` | Optional remote JWKS the relay uses to verify runtime-access tokens. |
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
- | `CLAXEDO_RELAY_HOST_VERIFY_PEM` | Public PEM the relay uses to verify host-tunnel tokens. |
252
- | `CLAXEDO_RELAY_HOST_SIGNING_KEY_PEM` | Private PEM the relay uses to mint relay-host tokens. |
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 in a self-hosted deployment:
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 self-hosted single-tenant setups.
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 process restart.
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
- A Redis, Durable Object, or equivalent implementation should keep the same
338
- semantics:
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
- The relay has **no** `/w/{workspaceId}/*` URL prefix of its own. The
354
- gateway pattern lives in `claxedo-server`:
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/` (own process) |
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 dev # hot-reload main.ts
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