@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 +114 -191
- package/dist/auth.d.ts +130 -20
- package/dist/cloudflare.d.ts +26 -43
- package/dist/cors-origins.d.ts +2 -0
- package/dist/directory.d.ts +10 -32
- 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 +6 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.mjs +1285 -2049
- 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 +51 -188
- 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 +32 -4
- package/dist/workerd-fixture/boot.d.ts +18 -0
- package/package.json +9 -20
- package/dist/bun.d.ts +0 -170
- package/dist/main.d.ts +0 -246
- package/dist/main.mjs +0 -23219
- package/dist/synthetic.d.ts +0 -60
package/README.md
CHANGED
|
@@ -1,90 +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
|
-
{ 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
|
|
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
|
|
105
|
-
`
|
|
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.
|
|
110
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
browser cookies must not be passed through to
|
|
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.
|
|
135
|
-
|
|
136
|
-
credentialed deployments.
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`, `
|
|
173
|
-
|
|
|
174
|
-
|
|
|
175
|
-
|
|
|
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
|
|
142
|
+
implement the tunnel protocol without pulling Jose.
|
|
182
143
|
|
|
183
144
|
## Configuration
|
|
184
145
|
|
|
185
|
-
All knobs are environment variables
|
|
186
|
-
|
|
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
|
-
| `
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
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
|
-
| `
|
|
198
|
-
| `
|
|
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
|
|
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
|
-
(
|
|
239
|
-
for tests and
|
|
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
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
|
324
|
-
| Lives in | `packages/claxedo-server/` (server-side only) | `packages/workspace-relay/` (
|
|
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
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &
|
|
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
|
|
94
|
-
|
|
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 {};
|