@claxedo/workspace-relay 0.1.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 ADDED
@@ -0,0 +1,135 @@
1
+ # `@claxedo/workspace-relay`
2
+
3
+ The Claxedo workspace relay — a single canonical bidirectional tunnel
4
+ between cloud-hosted browsers and workspace-runtime hosts (whether the
5
+ host is a cloud VM or a user laptop). One package, one process, one
6
+ config surface — there is no separate `dev-relay` codepath.
7
+
8
+ ## Why one package, no parallel impl
9
+
10
+ In the fa9cabf9 design pass the working assumption was clarified:
11
+
12
+ > "I think this creates a parallel implementation problem — why is there
13
+ > no devmode in the prod relay?"
14
+
15
+ So this package is the relay. Local-dev configuration is selected by
16
+ environment variables, not by a sibling `dev-relay.ts` module. If a
17
+ future cycle re-introduces a separate `dev-relay.*` file in
18
+ `packages/claxedo-server/`, that should be flagged as a regression.
19
+
20
+ ## Public surface
21
+
22
+ Re-exported from [`src/index.ts`](src/index.ts):
23
+
24
+ | Concern | Module | Notable exports |
25
+ | --- | --- | --- |
26
+ | 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` |
27
+ | Hono HTTP surface | [`src/server.ts`](src/server.ts) | `createWorkspaceRelayServer`, types `WorkspaceRelayOptions`, `WorkspaceRelayTarget`, `RuntimeAccessTokenActiveResult`, `WorkspaceRelayAuditEvent`, `WorkspaceRelayMetricsSources`, `RelayHostPublicKey` |
28
+ | Active-host directory | [`src/directory.ts`](src/directory.ts) | `createWorkspaceRelayDirectory`, `disposeWorkspaceRelayDirectory`, types `WorkspaceRelayDirectory`, `HostTunnelPresence` |
29
+ | Bun-specific server bootstrap | [`src/bun.ts`](src/bun.ts) | `createWorkspaceRelayBun`, type `WorkspaceRelayBunOptions`, `WorkspaceRelayBunDrainController`, metrics `getFragmentationStats`, `getSlowConsumerStats` |
30
+
31
+ Wire types live in the sibling package
32
+ [`@claxedo/workspace-relay-protocol`](../workspace-relay-protocol/)
33
+ (`TUNNEL_PROTOCOL_VERSION`, `TunnelMessage`, `isTunnelMessage`,
34
+ `makeTunnelPong`). Keep that split — it lets non-Node consumers
35
+ implement the tunnel protocol without pulling Hono and Jose.
36
+
37
+ ## Configuration
38
+
39
+ All knobs are environment variables. The relay refuses to boot in
40
+ production if `CLAXEDO_RELAY_RESOLVER_TOKEN` is missing — the
41
+ production fail-closed gate at `src/main.ts`.
42
+
43
+ | Env var | Purpose |
44
+ | --- | --- |
45
+ | `CLAXEDO_RELAY_BIND_HOST`, `CLAXEDO_RELAY_BIND_PORT` | Listening socket. Loopback by default. |
46
+ | `CLAXEDO_RELAY_PUBLIC_URL` | Externally-resolvable URL the relay advertises in tokens. |
47
+ | `CLAXEDO_RELAY_RESOLVER_URL` | Where to fetch `RuntimeAccessTokenActiveResult`s (typically `claxedo-server/internal/relay/target`). |
48
+ | `CLAXEDO_RELAY_RESOLVER_TOKEN` | Bearer token the relay sends to the resolver. **Required in production.** |
49
+ | `CLAXEDO_RELAY_JWKS_URL` | Optional remote JWKS the relay uses to verify runtime-access tokens. |
50
+ | `CLAXEDO_RUNTIME_ACCESS_TOKEN_PUBLIC_KEY_PEM` | Inline public key (alternative to JWKS). |
51
+ | `CLAXEDO_RELAY_HOST_VERIFY_PEM` | Public PEM the relay uses to verify host-tunnel tokens. |
52
+ | `CLAXEDO_RELAY_HOST_SIGNING_KEY_PEM` | Private PEM the relay uses to mint relay-host tokens. |
53
+ | `NODE_ENV` | `production` / `development` / `test`. Switches the production fail-closed gate. |
54
+
55
+ Token verification is pluggable today via the
56
+ `RelayKey | RelayKeyResolver` pair on `verifyRuntimeAccessToken` and
57
+ friends, **and** via the unified
58
+ [`TokenVerifier`](../workspace-relay-protocol/src/token-verifier.ts)
59
+ interface in `@claxedo/workspace-relay-protocol`. To swap the relay's
60
+ auth backend in a self-hosted deployment:
61
+
62
+ ```ts
63
+ import { createStaticTokenVerifier } from "@claxedo/workspace-relay-protocol"
64
+
65
+ const verifier = createStaticTokenVerifier({
66
+ tokens: {
67
+ "tok-tenant-1": { subject: "u1", scopes: ["workspace:write"], claims: {} },
68
+ },
69
+ })
70
+ // Pass `verifier` into a future `WorkspaceRelayOptions.tokenVerifier`
71
+ // option when the relay accepts custom verification backends directly.
72
+ ```
73
+
74
+ The `TokenVerifier` interface intentionally contains only `verify(token)`.
75
+ JWKS fetching, audience binding, and replay caches belong to the
76
+ implementation. Two reference implementations ship in the protocol
77
+ package: `createHttpTokenVerifier` for remote verifiers
78
+ (Clerk-style introspection, custom OIDC), and `createStaticTokenVerifier`
79
+ for tests and self-hosted single-tenant setups.
80
+
81
+ ## Routing
82
+
83
+ The relay has **no** `/w/{workspaceId}/*` URL prefix of its own. The
84
+ gateway pattern lives in `claxedo-server`:
85
+
86
+ - The user's browser calls `/api/claxedo/...`.
87
+ - `claxedo-server` mounts `workspaceRuntimeProxy` middleware
88
+ (`packages/claxedo-server/src/proxy.ts:205`) on its top-level Hono
89
+ app at `packages/claxedo-server/src/server.ts:93`.
90
+ - The proxy resolves the active workspace target via
91
+ `internal-relay.ts` (control-plane auth — see "Seam: internal-relay
92
+ vs workspace-relay" below), strips its own prefix, and forwards
93
+ to the relay's tunnel endpoint.
94
+
95
+ If you are adding a new HTTP-level workspace surface, attach it to
96
+ `claxedo-server`'s gateway, not to `workspace-relay`. The relay is
97
+ intentionally narrow — it only handles tunnel traffic and the auth
98
+ checks that gate it.
99
+
100
+ ## Seam: `internal-relay` vs `workspace-relay`
101
+
102
+ | | `claxedo-server/src/routes/internal-relay.ts` | `@claxedo/workspace-relay` |
103
+ | --- | --- | --- |
104
+ | Layer | Control-plane auth | Tunnel transport |
105
+ | Trust | Authenticates the relay process itself (loopback or bearer) | Authenticates the user's runtime-access token |
106
+ | Routes | `GET /internal/relay/target`, `GET /internal/relay/revocation` | WS upgrade + tunnel framing |
107
+ | Owners | `claxedo-server` (depends on Convex authority, Clerk, audit log) | `workspace-relay` (no Convex/Clerk dependency) |
108
+ | Lives in | `packages/claxedo-server/` (server-side only) | `packages/workspace-relay/` (own process) |
109
+
110
+ This split is deliberate: the relay never reads Convex or Clerk
111
+ directly. It calls back to `claxedo-server` over HTTP for resolver
112
+ decisions. The decision data crosses the seam as
113
+ `RuntimeAccessTokenActiveResult` (defined in `server.ts`).
114
+
115
+ ## Development
116
+
117
+ ```sh
118
+ bun --cwd packages/workspace-relay dev # hot-reload main.ts
119
+ bun --cwd packages/workspace-relay test # run all *.test.ts
120
+ bun --cwd packages/workspace-relay typecheck
121
+ ```
122
+
123
+ The TS error baseline for this package is **0** — keep it that way.
124
+
125
+ ## Boundary rule
126
+
127
+ Consumers outside this package import `@claxedo/workspace-relay` only —
128
+ not `@claxedo/workspace-relay/src/...`. Direct deep-source imports
129
+ break the package boundary. The check is
130
+
131
+ ```sh
132
+ grep -rn "workspace-relay/src/" packages/claxedo-{server,app}/src
133
+ ```
134
+
135
+ — required to return zero hits.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { type JWTVerifyGetKey } from "jose";
2
+ declare const algorithms: readonly ["EdDSA", "ES256", "RS256"];
3
+ export declare const runtimeAccessTokenIssuer = "claxedo-control-plane";
4
+ export declare const runtimeAccessTokenAudience = "workspace-relay";
5
+ export declare const hostTunnelTokenAudience = "workspace-relay-host-tunnel";
6
+ export declare const relayHostTokenIssuer = "workspace-relay";
7
+ export declare const relayHostTokenAudience = "workspace-host-service";
8
+ export type RelayAccess = "cloud" | "user-hosted";
9
+ export type RelayBacking = "local-worktree" | "cloud-vm";
10
+ export type RelayJwtAlgorithm = (typeof algorithms)[number];
11
+ export type RelayRole = "viewer" | "editor" | "admin" | "owner";
12
+ export type RuntimeAccessTokenClaims = {
13
+ iss: typeof runtimeAccessTokenIssuer;
14
+ aud: typeof runtimeAccessTokenAudience;
15
+ sub: string;
16
+ org_id: string;
17
+ workspace_id: string;
18
+ host_id: string;
19
+ role: RelayRole;
20
+ exp: number;
21
+ iat: number;
22
+ jti: string;
23
+ };
24
+ export type RelayHostTokenClaims = {
25
+ iss: typeof relayHostTokenIssuer;
26
+ aud: typeof relayHostTokenAudience;
27
+ sub: string;
28
+ org_id: string;
29
+ workspace_id: string;
30
+ host_id: string;
31
+ access: RelayAccess;
32
+ backing: RelayBacking;
33
+ exp: number;
34
+ iat: number;
35
+ jti: string;
36
+ };
37
+ export type HostTunnelTokenClaims = {
38
+ iss: typeof runtimeAccessTokenIssuer;
39
+ aud: typeof hostTunnelTokenAudience;
40
+ sub: string;
41
+ host_id: string;
42
+ workspace_ids: string[];
43
+ exp: number;
44
+ iat: number;
45
+ jti: string;
46
+ };
47
+ export declare class WorkspaceRelayAuthError extends Error {
48
+ readonly code: "invalid_relay_token" | "relay_token_workspace_mismatch" | "relay_token_host_mismatch" | "relay_token_claims_invalid";
49
+ constructor(code: "invalid_relay_token" | "relay_token_workspace_mismatch" | "relay_token_host_mismatch" | "relay_token_claims_invalid", message: string);
50
+ }
51
+ type RuntimeInput = {
52
+ subject: string;
53
+ orgId: string;
54
+ workspaceId: string;
55
+ hostId: string;
56
+ role: RelayRole;
57
+ ttlSeconds?: number;
58
+ jti?: string;
59
+ now?: number;
60
+ };
61
+ type RelayHostInput = RuntimeInput & {
62
+ access: RelayAccess;
63
+ backing: RelayBacking;
64
+ /**
65
+ * Optional `kid` to embed in the JWT protected header. Verifiers using a
66
+ * JWKS resolver dispatch on this to pick the matching key, so a freshly
67
+ * minted RHT must carry the same `kid` that the relay publishes at
68
+ * `/.well-known/jwks.json`. When omitted, the header has no `kid` and
69
+ * verifiers fall back to single-key matching (PEM env-var path).
70
+ */
71
+ kid?: string;
72
+ };
73
+ type HostTunnelInput = {
74
+ subject: string;
75
+ hostId: string;
76
+ workspaceIds: string[];
77
+ ttlSeconds?: number;
78
+ jti?: string;
79
+ now?: number;
80
+ };
81
+ type ExpectedTarget = {
82
+ workspaceId: string;
83
+ hostId?: string;
84
+ };
85
+ type ExpectedHostTunnel = {
86
+ hostId: string;
87
+ workspaceIds: string[];
88
+ };
89
+ /**
90
+ * Resolver function returned by `createRemoteJWKSet` / `createLocalJWKSet`.
91
+ * Accepted by `jwtVerify` directly — jose dispatches based on whether the
92
+ * argument is callable.
93
+ */
94
+ export type RelayKeyResolver = JWTVerifyGetKey;
95
+ export type RelayKey = CryptoKey | Uint8Array | RelayKeyResolver;
96
+ /**
97
+ * Mint variants accept only a signing key — never a resolver. Resolvers are
98
+ * verification-only. This narrowed alias keeps the mint signatures unchanged.
99
+ */
100
+ type RelaySigningKey = CryptoKey | Uint8Array;
101
+ export declare function mintRuntimeAccessToken(input: RuntimeInput, key: RelaySigningKey, alg: RelayJwtAlgorithm): Promise<string>;
102
+ export declare function verifyRuntimeAccessToken(token: string, key: RelayKey, expected: ExpectedTarget): Promise<RuntimeAccessTokenClaims>;
103
+ export declare function mintHostTunnelToken(input: HostTunnelInput, key: RelaySigningKey, alg: RelayJwtAlgorithm): Promise<string>;
104
+ export declare function verifyHostTunnelToken(token: string, key: RelayKey, expected: ExpectedHostTunnel): Promise<HostTunnelTokenClaims>;
105
+ export declare function mintRelayHostToken(input: RelayHostInput, key: RelaySigningKey, alg: RelayJwtAlgorithm): Promise<string>;
106
+ export declare function verifyRelayHostToken(token: string, key: RelayKey, expected: ExpectedTarget): Promise<RelayHostTokenClaims>;
107
+ export {};
package/dist/bun.d.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { type WorkspaceRelayOptions } from "./server";
2
+ type RelayClientWebSocketData = {
3
+ kind: "client";
4
+ upstreamUrl: string;
5
+ headers: Record<string, string>;
6
+ queue: Array<string | Buffer<ArrayBuffer>>;
7
+ upstream?: WebSocket;
8
+ };
9
+ type RelayHostTunnelWebSocketData = {
10
+ kind: "host-tunnel";
11
+ hostId: string;
12
+ workspaceIds: string[];
13
+ pending: Map<string, PendingTunnelHttpResponse>;
14
+ channels: Map<string, Bun.ServerWebSocket<RelayUserHostedClientWebSocketData>>;
15
+ heartbeat?: ReturnType<typeof setInterval>;
16
+ messageBuffer: string;
17
+ };
18
+ type RelayUserHostedClientWebSocketData = {
19
+ kind: "user-hosted-client";
20
+ hostId: string;
21
+ workspaceId: string;
22
+ channelId: string;
23
+ path: string;
24
+ relayHostToken: string;
25
+ };
26
+ type RelayWebSocketData = RelayClientWebSocketData | RelayHostTunnelWebSocketData | RelayUserHostedClientWebSocketData;
27
+ type PendingTunnelHttpResponse = {
28
+ controller: ReadableStreamDefaultController<Uint8Array>;
29
+ stream: ReadableStream<Uint8Array>;
30
+ resolve: (response: Response) => void;
31
+ reject: (error: Error) => void;
32
+ timeout: ReturnType<typeof setTimeout>;
33
+ pendingChunks: Uint8Array[];
34
+ bytesQueued: number;
35
+ slowConsumerTimeout?: ReturnType<typeof setTimeout>;
36
+ responseStarted: boolean;
37
+ };
38
+ export type WorkspaceRelayHostTunnelOptions = {
39
+ authorizeHostTunnel?: (request: Request, input: {
40
+ hostId: string;
41
+ workspaceIds: string[];
42
+ }) => boolean | Promise<boolean>;
43
+ hostTunnelPingIntervalMs?: number;
44
+ hostTunnelStateDebounceMs?: number;
45
+ };
46
+ export type WorkspaceRelayBackpressureOptions = {
47
+ slowConsumerHighWaterMarkBytes?: number;
48
+ slowConsumerTimeoutMs?: number;
49
+ };
50
+ export type WorkspaceRelayBunOptions = WorkspaceRelayHostTunnelOptions & WorkspaceRelayBackpressureOptions;
51
+ /**
52
+ * T9: drain controller exposed by `createWorkspaceRelayBun`.
53
+ *
54
+ * On SIGTERM the operator (see `installShutdownDrainHandler` in main.ts)
55
+ * flips `setDraining(true)`; from that point onward the relay
56
+ * - returns 503 from `/health` so Fly removes the instance from routing
57
+ * - rejects new HTTP requests with 503 (`relay_draining`) before doing auth
58
+ * - rejects new host-tunnel WebSocket upgrades with 503
59
+ * - leaves all existing in-flight HTTP requests, host tunnels, and
60
+ * multiplexed WS channels alone so they can complete naturally
61
+ *
62
+ * `pendingCount()` reports the total number of in-flight tunnel HTTP
63
+ * responses across every connected host tunnel — this is what the operator
64
+ * polls via `waitForDrain(timeoutMs)` before hard-closing remaining sockets.
65
+ */
66
+ export type WorkspaceRelayBunDrainController = {
67
+ isDraining(): boolean;
68
+ setDraining(value: boolean): void;
69
+ pendingCount(): number;
70
+ waitForDrain(timeoutMs: number): Promise<{
71
+ drained: boolean;
72
+ remaining: number;
73
+ }>;
74
+ };
75
+ export declare function getFragmentationStats(): {
76
+ fragmentsBuffered: number;
77
+ oversizedClosed: number;
78
+ };
79
+ export declare function resetFragmentationStats(): void;
80
+ export declare function getSlowConsumerStats(): {
81
+ overflowEvents: number;
82
+ timerFired: number;
83
+ droppedRequests: number;
84
+ };
85
+ export declare function resetSlowConsumerStats(): void;
86
+ export declare function __resetHostTunnelStateDebounce(): void;
87
+ declare function enqueueChunkWithBackpressure(input: {
88
+ ws: Bun.ServerWebSocket<RelayHostTunnelWebSocketData>;
89
+ requestId: string;
90
+ entry: PendingTunnelHttpResponse;
91
+ chunk: Uint8Array;
92
+ slowConsumerTimeoutMs: number;
93
+ }): void;
94
+ export declare const __slowConsumerInternalsForTest: {
95
+ enqueueChunkWithBackpressure: typeof enqueueChunkWithBackpressure;
96
+ };
97
+ export declare function createWorkspaceRelayBun(options: WorkspaceRelayOptions, bunOptions?: WorkspaceRelayBunOptions): {
98
+ fetch(request: Request, server: Bun.Server<RelayWebSocketData>): Promise<Response | undefined>;
99
+ websocket: {
100
+ maxPayloadLength: number;
101
+ message(ws: Bun.ServerWebSocket<RelayWebSocketData>, message: string | Buffer<ArrayBuffer>): void;
102
+ open(ws: Bun.ServerWebSocket<RelayWebSocketData>): void;
103
+ close(ws: Bun.ServerWebSocket<RelayWebSocketData>, code: number, reason: string): void;
104
+ };
105
+ drain: WorkspaceRelayBunDrainController;
106
+ };
107
+ export {};
@@ -0,0 +1,55 @@
1
+ export type HostTunnelPresence = {
2
+ hostId: string;
3
+ workspaceIds: string[];
4
+ connectedAt: number;
5
+ lastPongAt: number;
6
+ expiresAt: number;
7
+ };
8
+ export type WorkspaceRelayDirectory = {
9
+ registerHostTunnel(input: {
10
+ hostId: string;
11
+ workspaceIds: string[];
12
+ }): HostTunnelPresence;
13
+ recordPong(hostId: string): HostTunnelPresence | undefined;
14
+ disconnectHost(hostId: string): void;
15
+ activeHost(input: {
16
+ hostId: string;
17
+ workspaceId: string;
18
+ }): HostTunnelPresence | undefined;
19
+ /**
20
+ * Manually run an expiry sweep over the presence map. Removes any entry
21
+ * whose `expiresAt <= now()`. Safe to call on a fresh/empty directory.
22
+ * Useful in tests; the production path runs this on a timer (see
23
+ * `sweepIntervalMs`).
24
+ */
25
+ sweep(): void;
26
+ /**
27
+ * Stop the active sweep timer (if one was started). Safe to call multiple
28
+ * times and on directories that were created with `sweepIntervalMs: 0`.
29
+ */
30
+ dispose(): void;
31
+ /**
32
+ * T31: count of currently-active (non-expired) host presence entries.
33
+ * Equivalent to running `sweep()` then returning the residual map size, but
34
+ * implemented as a single pass without mutating eviction state when the
35
+ * caller only wants a snapshot. Surfaced via the `/metrics` endpoint as
36
+ * `directory.activeHostCount`.
37
+ */
38
+ size(): number;
39
+ };
40
+ export declare function createWorkspaceRelayDirectory(options?: {
41
+ ttlMs?: number;
42
+ now?: () => number;
43
+ /**
44
+ * Interval in milliseconds at which a background sweep runs to evict
45
+ * expired host presence entries. Defaults to 30_000. Set to `0` to disable
46
+ * the timer entirely (useful for tests; callers can still drive cleanup
47
+ * manually via `sweep()`).
48
+ */
49
+ sweepIntervalMs?: number;
50
+ }): WorkspaceRelayDirectory;
51
+ /**
52
+ * Convenience companion to `createWorkspaceRelayDirectory` for callers that
53
+ * prefer a free function over a method. Equivalent to `directory.dispose()`.
54
+ */
55
+ export declare function disposeWorkspaceRelayDirectory(directory: WorkspaceRelayDirectory): void;
package/dist/http.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export declare function bearerToken(header: string | null | undefined): string | undefined;
2
+ export declare function errorBody(code: string, message: string): {
3
+ error: {
4
+ code: string;
5
+ message: string;
6
+ };
7
+ };
@@ -0,0 +1,4 @@
1
+ export * from "./auth";
2
+ export * from "./bun";
3
+ export * from "./directory";
4
+ export * from "./server";