@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.
@@ -0,0 +1,226 @@
1
+ import { Hono } from "hono";
2
+ import type { TokenVerifier } from "@claxedo/workspace-relay-protocol";
3
+ import { type RelayAccess, type RelayBacking, type RelayJwtAlgorithm, type RelayKey, type RelayRole, type RuntimeAccessTokenClaims } from "./auth";
4
+ import type { WorkspaceRelayDirectory } from "./directory";
5
+ export type RelayHostPublicKey = {
6
+ publicKey: CryptoKey | Uint8Array;
7
+ kid: string;
8
+ };
9
+ export type WorkspaceRelayTarget = {
10
+ workspaceId: string;
11
+ hostId: string;
12
+ baseUrl: string;
13
+ access: RelayAccess;
14
+ backing: RelayBacking;
15
+ };
16
+ export type RuntimeAccessTokenActiveResult = {
17
+ active: true;
18
+ } | {
19
+ active: false;
20
+ code: string;
21
+ reason: string;
22
+ };
23
+ export type WorkspaceRelayAuditEvent = {
24
+ action: "relay.request.accepted" | "relay.request.denied" | "relay.request.suppressed_summary" | "host_tunnel.connected" | "host_tunnel.disconnected";
25
+ result: "allow" | "deny";
26
+ reason?: string;
27
+ subject?: string;
28
+ role?: RelayRole;
29
+ workspaceId?: string;
30
+ hostId?: string;
31
+ method: string;
32
+ path: string;
33
+ /**
34
+ * T16: only set on `relay.request.suppressed_summary` events. Counts the
35
+ * number of `relay.request.accepted` audit events that were dropped by the
36
+ * sampler in the previous flush interval.
37
+ */
38
+ suppressedCount?: number;
39
+ };
40
+ export type WorkspaceRelayAuthOptions = {
41
+ runtimeAccessKey: RelayKey;
42
+ /**
43
+ * P-shared: optional override for runtime-access-token verification.
44
+ *
45
+ * When set, the relay calls this verifier instead of decoding the JWT
46
+ * with `runtimeAccessKey`. The verifier returns `{ subject, scopes,
47
+ * claims }`; the relay continues to use `claims` to drive
48
+ * `resolveTarget` and the workspace/host audit fields. `runtimeAccessKey`
49
+ * is then only used for legacy token paths and tests that exercise the
50
+ * raw JWT codepath.
51
+ *
52
+ * This is the seam downstream users plug their own auth backend
53
+ * through (Clerk-issued tokens, OIDC introspection, custom keys, etc.).
54
+ * See `@claxedo/workspace-relay-protocol/token-verifier` for the
55
+ * `HttpTokenVerifier` and `StaticTokenVerifier` reference impls.
56
+ */
57
+ tokenVerifier?: TokenVerifier;
58
+ isRuntimeAccessTokenActive?: (claims: RuntimeAccessTokenClaims) => RuntimeAccessTokenActiveResult | Promise<RuntimeAccessTokenActiveResult>;
59
+ };
60
+ export type WorkspaceRelayHostTokenOptions = {
61
+ relayHostSigningKey: CryptoKey | Uint8Array;
62
+ relayHostAlgorithm: RelayJwtAlgorithm;
63
+ /**
64
+ * Public keys to publish at `/.well-known/jwks.json`. The first entry is the
65
+ * "current" mint key; any further entries are "next" keys (for rolling
66
+ * rotation). Verifiers (workspace-runtime → workspace-host-service) consume
67
+ * this set via `jose.createRemoteJWKSet`. Omit to disable the endpoint
68
+ * (during rollout / for tests that do not need JWKS).
69
+ */
70
+ relayHostPublicKeys?: RelayHostPublicKey[];
71
+ /**
72
+ * `kid` to embed in the protected header of freshly minted RHTs. When
73
+ * provided, verifiers can dispatch on `kid` to pick the matching key from
74
+ * the published JWKS. Should match the `kid` of the first entry in
75
+ * `relayHostPublicKeys` so JWKS and mint agree by construction.
76
+ */
77
+ relayHostMintKid?: string;
78
+ };
79
+ export type WorkspaceRelayRoutingOptions = {
80
+ resolveTarget: (claims: RuntimeAccessTokenClaims) => WorkspaceRelayTarget | Promise<WorkspaceRelayTarget | undefined>;
81
+ fetch?: typeof fetch;
82
+ directory?: WorkspaceRelayDirectory;
83
+ };
84
+ export type WorkspaceRelayDrainOptions = {
85
+ /**
86
+ * T9: when this returns true the relay reports unhealthy via `/health` and
87
+ * fast-paths every workspace request to a 503 (`relay_draining`) before
88
+ * doing any auth or upstream work. Wired by `createWorkspaceRelayBun` to
89
+ * the drain controller it exposes.
90
+ */
91
+ isDraining?: () => boolean;
92
+ };
93
+ export type WorkspaceRelayTelemetryOptions = {
94
+ audit?: (event: WorkspaceRelayAuditEvent) => void | Promise<void>;
95
+ /**
96
+ * T16: probability (0.0 – 1.0) at which `relay.request.accepted` audit
97
+ * events are emitted to the user `audit` callback. Denies and tunnel
98
+ * lifecycle events are never sampled. When unset, defaults to 1.0
99
+ * (emit every accept). Suppressed accepts are aggregated into a periodic
100
+ * `relay.request.suppressed_summary` event (see `auditFlushIntervalMs`).
101
+ */
102
+ auditAcceptSampleRate?: number;
103
+ /**
104
+ * T16: how often (ms) the sampler emits a `relay.request.suppressed_summary`
105
+ * audit event with the count of suppressed accepts in the past interval.
106
+ * Defaults to 60_000 (one minute). When sample rate is 1.0 no interval is
107
+ * armed (nothing to flush). Exposed for tests.
108
+ */
109
+ auditFlushIntervalMs?: number;
110
+ /**
111
+ * T16: random source for the sampler. Defaults to `Math.random`. Override
112
+ * for deterministic tests.
113
+ */
114
+ random?: () => number;
115
+ };
116
+ export type WorkspaceRelayMetricsOptions = {
117
+ /**
118
+ * T31: bearer token required to access `GET /metrics` from non-loopback
119
+ * remotes. When unset, the endpoint allows loopback callers only (using
120
+ * `metricsRemoteAddress` to identify the caller). When set, every request
121
+ * must present `Authorization: Bearer <metricsToken>` regardless of origin.
122
+ * Wired from `CLAXEDO_RELAY_METRICS_TOKEN` in main.ts.
123
+ */
124
+ metricsToken?: string;
125
+ /**
126
+ * T31: returns the remote IP of the inbound `/metrics` request (used to
127
+ * gate access to the loopback when `metricsToken` is unset). When this is
128
+ * also unset, the relay falls back to "allow" for dev convenience — the
129
+ * production deploy is expected to either set `metricsToken` or wire this
130
+ * resolver to `Bun.Server.requestIP`.
131
+ */
132
+ metricsRemoteAddress?: (request: Request) => string | undefined;
133
+ /**
134
+ * T31: optional providers used to assemble the `/metrics` response body. The
135
+ * fragmentation/slow-consumer counters live in the bun adapter (module-
136
+ * scoped state in `bun.ts`), and the in-flight pending count lives on the
137
+ * drain controller — `createWorkspaceRelayBun` wires these to make ops
138
+ * counters visible on `/metrics` without giving `server.ts` a hard
139
+ * dependency on the bun-only state.
140
+ */
141
+ metricsSources?: WorkspaceRelayMetricsSources;
142
+ };
143
+ export type WorkspaceRelayOptions = WorkspaceRelayAuthOptions & WorkspaceRelayHostTokenOptions & WorkspaceRelayRoutingOptions & WorkspaceRelayDrainOptions & WorkspaceRelayTelemetryOptions & WorkspaceRelayMetricsOptions;
144
+ export type WorkspaceRelayMetricsSources = {
145
+ fragmentation?: () => {
146
+ fragmentsBuffered: number;
147
+ oversizedClosed: number;
148
+ };
149
+ slowConsumer?: () => {
150
+ overflowEvents: number;
151
+ timerFired: number;
152
+ droppedRequests: number;
153
+ };
154
+ drainPending?: () => number;
155
+ };
156
+ /**
157
+ * T31: shape of the JSON body returned by `GET /metrics`. Exported so the
158
+ * bun adapter and ops dashboards can type-narrow against it.
159
+ */
160
+ export type WorkspaceRelayMetrics = {
161
+ fragmentation: {
162
+ fragmentsBuffered: number;
163
+ oversizedClosed: number;
164
+ };
165
+ slowConsumer: {
166
+ overflowEvents: number;
167
+ timerFired: number;
168
+ droppedRequests: number;
169
+ };
170
+ directory: {
171
+ activeHostCount: number;
172
+ };
173
+ audit: {
174
+ suppressedAccepts: number;
175
+ };
176
+ drain?: {
177
+ pendingCount: number;
178
+ };
179
+ };
180
+ export type AuthorizedWorkspaceRelayRequest = {
181
+ claims: RuntimeAccessTokenClaims;
182
+ target: WorkspaceRelayTarget;
183
+ relayHostToken: string;
184
+ path: string;
185
+ };
186
+ export type WorkspaceRelayForwardHeadersOptions = {
187
+ /**
188
+ * When true, also strip the `Cookie` request header. User-hosted workspaces
189
+ * run on the user's laptop where the host process may share a cookie jar
190
+ * with the browser (e.g. localhost dev tooling); passing through cookies
191
+ * verbatim risks leaking sensitive session data to the host. Cloud-vm
192
+ * workspaces sit behind a dedicated network boundary where session cookies
193
+ * may legitimately be needed (e.g. workspace dashboards), so the default
194
+ * is to NOT strip cookies. T26.
195
+ */
196
+ userHosted?: boolean;
197
+ };
198
+ export declare function workspaceRelayTargetUrl(target: WorkspaceRelayTarget, path: string, search: string): URL;
199
+ export declare function workspaceRelayForwardHeaders(input: Headers, relayHostToken: string, workspaceId: string, options?: WorkspaceRelayForwardHeadersOptions): Headers;
200
+ /**
201
+ * T16: dispose the sampler's periodic flush timer for a given options object.
202
+ * Tests call this in cleanup so the bun process can exit.
203
+ */
204
+ export declare function disposeAuditSampler(options: WorkspaceRelayOptions): void;
205
+ export declare function authorizeWorkspaceRelayRequest(options: WorkspaceRelayOptions, request: Request, workspaceId: string): Promise<{
206
+ ok: true;
207
+ request: AuthorizedWorkspaceRelayRequest;
208
+ } | {
209
+ ok: false;
210
+ response: Response;
211
+ }>;
212
+ export type WorkspaceRelayApp = Hono & {
213
+ /**
214
+ * T16: clear the sampler's periodic flush timer. Tests must call this so
215
+ * `setInterval` doesn't keep the process alive after the test exits.
216
+ */
217
+ disposeAuditSampler(): void;
218
+ };
219
+ /**
220
+ * T31: read the suppressed-accept counter for a given options object. Surfaced
221
+ * for the `/metrics` route handler. Returns 0 if no sampler state has been
222
+ * created yet (no requests have been audit-sampled), which is the same value
223
+ * an external observer would see.
224
+ */
225
+ export declare function getAuditSuppressedCount(options: WorkspaceRelayOptions): number;
226
+ export declare function createWorkspaceRelay(options: WorkspaceRelayOptions): WorkspaceRelayApp;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * T10: Synthetic end-to-end probe.
3
+ *
4
+ * Liveness depends on metrics; metrics can lie. A synthetic probe periodically
5
+ * exercises the relay's HTTP path and records RTT so we have an independent
6
+ * signal that the data path actually works.
7
+ *
8
+ * Implementation notes
9
+ * --------------------
10
+ * - The probe targets `${relayUrl}/health` rather than minting a Runtime
11
+ * Access Token and hitting `/workspaces/<id>/...`. `/health` validates HTTP
12
+ * connectivity end-to-end without pretending the Relay owns Control Plane
13
+ * token issuance.
14
+ * - All clocks, timers and `fetch` are injectable so tests stay deterministic.
15
+ * - "Loud" logs after 3 consecutive failures use `console.error` by default;
16
+ * pageability (PagerDuty / equivalent) is wired separately.
17
+ */
18
+ export type SyntheticProbeStats = {
19
+ successCount: number;
20
+ failureCount: number;
21
+ consecutiveFailures: number;
22
+ /** RTT (ms) of the most recent probe attempt. Undefined before the first probe completes. */
23
+ lastRttMs?: number;
24
+ /** p50 / p99 over the rolling RTT window. Undefined if no successes yet. */
25
+ p50?: number;
26
+ p99?: number;
27
+ /** Number of RTTs currently in the rolling window. */
28
+ windowSize: number;
29
+ };
30
+ export type SyntheticProbe = {
31
+ stop(): void;
32
+ stats(): SyntheticProbeStats;
33
+ };
34
+ export type SyntheticProbeOptions = {
35
+ /** Base URL of the relay, e.g. `http://127.0.0.1:7777`. No trailing slash required. */
36
+ relayUrl: string;
37
+ /** Probe cadence. Default 60_000 (60 s). */
38
+ intervalMs?: number;
39
+ /** Summary log cadence (p50/p99). Default 300_000 (5 min). */
40
+ summaryIntervalMs?: number;
41
+ /** Path to probe. Default `/health`. */
42
+ probePath?: string;
43
+ /** Rolling window size for RTT percentile calculation. Default 100. */
44
+ rttWindowSize?: number;
45
+ /** Number of consecutive failures that triggers a loud log. Default 3. */
46
+ loudFailureThreshold?: number;
47
+ /** Override `fetch`. Defaults to global `fetch`. */
48
+ fetch?: typeof fetch;
49
+ /** Override clock for tests. Defaults to `Date.now`. */
50
+ now?: () => number;
51
+ /** Override `setInterval` for tests. Defaults to global `setInterval`. */
52
+ setInterval?: typeof setInterval;
53
+ /** Override `clearInterval` for tests. Defaults to global `clearInterval`. */
54
+ clearInterval?: typeof clearInterval;
55
+ /** Loud-failure logger. Defaults to `console.error`. */
56
+ logError?: (msg: string, fields?: Record<string, unknown>) => void;
57
+ /** Periodic summary logger (p50/p99). Defaults to `console.log`. */
58
+ logSummary?: (msg: string, fields?: Record<string, unknown>) => void;
59
+ };
60
+ export declare function startSyntheticProbe(options: SyntheticProbeOptions): SyntheticProbe;
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@claxedo/workspace-relay",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.mjs",
6
+ "types": "./dist/index.d.ts",
7
+ "bin": {
8
+ "workspace-relay": "./dist/main.mjs"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "scripts": {
17
+ "start": "bun run src/main.ts",
18
+ "dev": "bun run --hot src/main.ts",
19
+ "build": "tsx scripts/build.ts",
20
+ "prepublishOnly": "npm run build",
21
+ "typecheck": "tsc --noEmit -p tsconfig.json",
22
+ "test": "bun test src"
23
+ },
24
+ "dependencies": {
25
+ "@claxedo/workspace-relay-protocol": "^0.1.0",
26
+ "hono": "4.10.7",
27
+ "jose": "6.0.11"
28
+ },
29
+ "devDependencies": {
30
+ "@tsconfig/node-lts": "^22.0.0",
31
+ "@types/bun": "catalog:",
32
+ "esbuild": "^0.25.0",
33
+ "tsx": "^4.0.0",
34
+ "typescript": "catalog:"
35
+ }
36
+ }