@claxedo/workspace-relay-protocol 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,7 @@ const claims = await verifier.verify("test-token")
59
59
  | `TunnelMessageValidation` | Stable | Result type returned by `validateTunnelMessage` — `{ ok: true, message }` or a typed failure (`protocol_mismatch` / `invalid`). |
60
60
  | `makeTunnelPing`, `makeTunnelPong` | Stable | Helpers for heartbeat requests and replies. |
61
61
  | `TokenVerifier` and claim types | Stable | Narrow verifier interface shared by relay/runtime boundaries. |
62
- | `RelayHostVerifierClaims` (incl. `role`) | Stable | Claims shape for host-side verification: org, `role` (`viewer` \| `editor` \| `admin` \| `owner`), and cloud/user-hosted backing discriminant. |
62
+ | `RelayHostVerifierClaims` (incl. `role`) | Stable | Claims shape for host-side verification: org, `role` (`viewer` \| `editor` \| `admin` \| `owner`), and the `cloud-vm` / `local-worktree` backing that names the placement. |
63
63
  | `createOidcTokenVerifier` | Public beta | Verifies session JWTs with issuer, audience, JWKS, and algorithm constraints. |
64
64
  | `createHttpTokenVerifier` | Public beta | Calls an operator-controlled verifier endpoint over HTTP. |
65
65
  | `createStaticTokenVerifier` | Test/single-tenant only | Fixed token table for tests and isolated self-hosted deployments. |
@@ -100,8 +100,11 @@ endpoint and expects `{ subject, scopes?, claims }` back. The endpoint is a
100
100
  trusted operator configuration value. Do not derive it from a request, tenant
101
101
  record, query string, workspace config, or other user-controlled input.
102
102
 
103
- The endpoint should normally be an HTTPS URL on infrastructure you control. It
104
- must enforce issuer, audience, expiry, key selection, and replay/revocation
103
+ The endpoint requires HTTPS and redirects are refused. Explicit
104
+ `allowInsecureLoopback: true` permits HTTP only to `localhost`, `127.0.0.1`, or
105
+ `::1` for development. URL credentials and fragments are rejected. Returned
106
+ claims must match the subject and be within their issued/expiry/not-before
107
+ window. The endpoint must enforce issuer, audience, key selection, and replay/revocation
105
108
  policy before returning claims. Treat a compromised verifier endpoint as
106
109
  equivalent to a compromised token issuer.
107
110
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,28 @@
1
1
  export declare const TUNNEL_PROTOCOL_VERSION = 1;
2
2
  export declare const SESSION_STREAM_LEASE_TTL_MS = 15000;
3
3
  export declare const SESSION_TURN_LEASE_TTL_MS = 60000;
4
+ /**
5
+ * Which generation of the sender-identity contract authorizes. 0 is every
6
+ * binding row and every token written before the transports were proven to
7
+ * carry the platform's stable account id, whose key may be a handle that now
8
+ * belongs to somebody else. Authority stores write this number, the relay
9
+ * admits only this number, and a lower one is never promoted.
10
+ */
11
+ export declare const CURRENT_CHANNEL_IDENTITY_VERSION = 1;
12
+ /**
13
+ * What a Runtime Access Token's scope reaches, asked of the runtime path a
14
+ * request names. A token with no session is the workspace owner's and reaches
15
+ * every path; a token scoped to one session, a share holder's, reaches that
16
+ * session's routes, its own event stream and the answers to its agent's
17
+ * questions, and the session authority decides which writes the share
18
+ * carries. The relay asks it before forwarding and the runtime again on
19
+ * arrival.
20
+ */
21
+ export declare function sessionScopeReaches(sessionScope: string | undefined, path: string, search: string): boolean;
22
+ /** The relay host id of a session hosted in its own Durable Object, named by the session at the root of its tree. */
23
+ export declare const SESSION_HOST_PREFIX = "session-do:";
24
+ export declare function sessionHostId(rootSessionId: string): string;
25
+ export declare function sessionHostRootOf(hostId: string): string | undefined;
4
26
  export { type TokenClaims, type TokenVerifierBaseClaims, type RuntimeAccessVerifierClaims, type RelayHostVerifierClaims, type TokenVerifier, type OidcTokenVerifierClaims, TokenVerifierError, type OidcTokenVerifierOptions, createOidcTokenVerifier, type HttpTokenVerifierOptions, createHttpTokenVerifier, type StaticTokenVerifierOptions, createStaticTokenVerifier, } from "./token-verifier";
5
27
  export type TunnelHeaderMap = Record<string, string>;
6
28
  export type TunnelPing = {
package/dist/index.mjs CHANGED
@@ -9,11 +9,11 @@ function isFiniteNumber(value) {
9
9
  return typeof value === "number" && Number.isFinite(value);
10
10
  }
11
11
  function asFiniteNumber(value) {
12
- return isFiniteNumber(value) ? value : void 0;
12
+ return isFiniteNumber(value) ? value : undefined;
13
13
  }
14
14
  function stringClaim(payload, key) {
15
15
  const value = payload[key];
16
- return isString(value) && value.trim().length > 0 ? value : void 0;
16
+ return isString(value) && value.trim().length > 0 ? value : undefined;
17
17
  }
18
18
  function numberClaim(payload, key) {
19
19
  return asFiniteNumber(payload[key]);
@@ -21,7 +21,22 @@ function numberClaim(payload, key) {
21
21
 
22
22
  // src/token-verifier.ts
23
23
  import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose";
24
- var TokenVerifierError = class extends Error {
24
+
25
+ // ../claxedo-helpers/dist/index.mjs
26
+ var requestPeerAddresses = new WeakMap;
27
+ function normalizeHostname(hostname) {
28
+ const value = (hostname ?? "").trim().toLowerCase();
29
+ if (value.startsWith("[") && value.endsWith("]") && value.includes(":"))
30
+ return value.slice(1, -1);
31
+ return value;
32
+ }
33
+ function isLoopbackHostname(hostname) {
34
+ const normalized = normalizeHostname(hostname);
35
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
36
+ }
37
+
38
+ // src/token-verifier.ts
39
+ class TokenVerifierError extends Error {
25
40
  code;
26
41
  status;
27
42
  constructor(input) {
@@ -29,10 +44,20 @@ var TokenVerifierError = class extends Error {
29
44
  this.name = "TokenVerifierError";
30
45
  this.code = input.code;
31
46
  this.status = input.status ?? 401;
32
- if (input.cause !== void 0) this.cause = input.cause;
47
+ if (input.cause !== undefined)
48
+ this.cause = input.cause;
33
49
  }
34
- };
50
+ }
35
51
  function createHttpTokenVerifier(options) {
52
+ let endpoint;
53
+ try {
54
+ endpoint = new URL(options.endpoint);
55
+ if (endpoint.username || endpoint.password || endpoint.hash || endpoint.protocol !== "https:" && !(options.allowInsecureLoopback && endpoint.protocol === "http:" && isLoopbackHostname(endpoint.hostname))) {
56
+ throw new Error("untrusted verifier endpoint");
57
+ }
58
+ } catch {
59
+ throw new TokenVerifierError({ code: "verifier_endpoint_invalid", status: 500, message: "Verifier endpoint requires HTTPS without URL credentials or a fragment; explicit loopback development is the only HTTP exception" });
60
+ }
36
61
  const fetchImpl = options.fetch ?? globalThis.fetch;
37
62
  if (!fetchImpl) {
38
63
  throw new TokenVerifierError({
@@ -41,14 +66,15 @@ function createHttpTokenVerifier(options) {
41
66
  status: 500
42
67
  });
43
68
  }
44
- const timeoutMs = options.timeoutMs ?? 5e3;
69
+ const timeoutMs = options.timeoutMs ?? 5000;
45
70
  return {
46
71
  async verify(token) {
47
- const controller = new AbortController();
72
+ const controller = new AbortController;
48
73
  const timer = setTimeout(() => controller.abort(), timeoutMs);
49
74
  try {
50
- const res = await fetchImpl(options.endpoint, {
75
+ const res = await fetchImpl(endpoint.href, {
51
76
  method: "POST",
77
+ redirect: "error",
52
78
  headers: {
53
79
  "content-type": "application/json",
54
80
  ...options.headers
@@ -84,13 +110,19 @@ function createHttpTokenVerifier(options) {
84
110
  message: "Verifier response is missing required token claims"
85
111
  });
86
112
  }
113
+ const now = Date.now() / 1000;
114
+ const nbf = claims.nbf;
115
+ if (claims.exp <= now || claims.iat > now || claims.exp <= claims.iat || nbf !== undefined && (typeof nbf !== "number" || !Number.isFinite(nbf) || nbf > now)) {
116
+ throw new TokenVerifierError({ code: "verifier_claims_invalid", message: "Verifier claims are expired or not yet valid" });
117
+ }
87
118
  return {
88
119
  subject,
89
120
  scopes: Array.isArray(body.scopes) ? body.scopes.filter((s) => typeof s === "string") : [],
90
121
  claims
91
122
  };
92
123
  } catch (err) {
93
- if (err instanceof TokenVerifierError) throw err;
124
+ if (err instanceof TokenVerifierError)
125
+ throw err;
94
126
  throw new TokenVerifierError({
95
127
  code: "verifier_unreachable",
96
128
  message: err instanceof Error ? err.message : String(err),
@@ -103,7 +135,7 @@ function createHttpTokenVerifier(options) {
103
135
  }
104
136
  };
105
137
  }
106
- var oidcJwks = /* @__PURE__ */ new Map();
138
+ var oidcJwks = new Map;
107
139
  function createOidcTokenVerifier(options) {
108
140
  const algorithms = options.algorithms ?? ["ES256", "EdDSA", "RS256"];
109
141
  const remote = oidcJwks.get(options.jwksUrl) ?? createRemoteJWKSet(new URL(options.jwksUrl));
@@ -129,7 +161,8 @@ function createOidcTokenVerifier(options) {
129
161
  claims
130
162
  };
131
163
  } catch (err) {
132
- if (err instanceof TokenVerifierError) throw err;
164
+ if (err instanceof TokenVerifierError)
165
+ throw err;
133
166
  const unavailable = err instanceof joseErrors.JWKSTimeout || !(err instanceof joseErrors.JOSEError);
134
167
  throw new TokenVerifierError({
135
168
  code: unavailable ? "oidc_verifier_unavailable" : "oidc_token_invalid",
@@ -157,7 +190,8 @@ function createStaticTokenVerifier(options) {
157
190
  };
158
191
  }
159
192
  function tokenVerifierBaseClaims(input) {
160
- if (!isRecord(input)) return void 0;
193
+ if (!isRecord(input))
194
+ return;
161
195
  const iss = stringClaim(input, "iss");
162
196
  const aud = stringClaim(input, "aud");
163
197
  const sub = stringClaim(input, "sub");
@@ -166,7 +200,8 @@ function tokenVerifierBaseClaims(input) {
166
200
  const exp = numberClaim(input, "exp");
167
201
  const iat = numberClaim(input, "iat");
168
202
  const jti = stringClaim(input, "jti");
169
- if (!iss || !aud || !sub || !workspace_id || !host_id || !exp || !iat || !jti) return void 0;
203
+ if (!iss || !aud || !sub || !workspace_id || !host_id || !exp || !iat || !jti)
204
+ return;
170
205
  return {
171
206
  ...input,
172
207
  iss,
@@ -180,7 +215,8 @@ function tokenVerifierBaseClaims(input) {
180
215
  };
181
216
  }
182
217
  function oidcClaims(input) {
183
- if (!input.iss || !input.sub || !input.exp) return void 0;
218
+ if (!input.iss || !input.sub || !input.exp)
219
+ return;
184
220
  const sid = stringClaim(input, "sid");
185
221
  const org_id = stringClaim(input, "org_id");
186
222
  const orgId = stringClaim(input, "orgId");
@@ -197,17 +233,41 @@ function oidcClaims(input) {
197
233
  };
198
234
  }
199
235
  function scopeClaims(input) {
200
- if (Array.isArray(input.scp)) return input.scp.filter((item) => typeof item === "string");
201
- if (typeof input.scope === "string") return input.scope.split(" ").filter(Boolean);
236
+ if (Array.isArray(input.scp))
237
+ return input.scp.filter((item) => typeof item === "string");
238
+ if (typeof input.scope === "string")
239
+ return input.scope.split(" ").filter(Boolean);
202
240
  return [];
203
241
  }
204
242
 
205
243
  // src/index.ts
206
244
  var TUNNEL_PROTOCOL_VERSION = 1;
207
- var SESSION_STREAM_LEASE_TTL_MS = 15e3;
208
- var SESSION_TURN_LEASE_TTL_MS = 6e4;
245
+ var SESSION_STREAM_LEASE_TTL_MS = 15000;
246
+ var SESSION_TURN_LEASE_TTL_MS = 60000;
247
+ var CURRENT_CHANNEL_IDENTITY_VERSION = 1;
248
+ function sessionScopeReaches(sessionScope, path, search) {
249
+ if (sessionScope === undefined)
250
+ return true;
251
+ if (path === "/api/wr/events")
252
+ return new URLSearchParams(search).get("sessionID") === sessionScope;
253
+ if (path === `/session/${sessionScope}` || path.startsWith(`/session/${sessionScope}/`))
254
+ return true;
255
+ if (/^\/api\/wr\/execution-env\/(fs|exec|mcp\/[^/]+)$/.test(path))
256
+ return true;
257
+ return /^\/question\/[^/]+\/(reply|reject)$/.test(path);
258
+ }
259
+ var SESSION_HOST_PREFIX = "session-do:";
260
+ function sessionHostId(rootSessionId) {
261
+ return `${SESSION_HOST_PREFIX}${rootSessionId}`;
262
+ }
263
+ function sessionHostRootOf(hostId) {
264
+ if (!hostId.startsWith(SESSION_HOST_PREFIX))
265
+ return;
266
+ return hostId.slice(SESSION_HOST_PREFIX.length) || undefined;
267
+ }
209
268
  function validateTunnelMessage(input) {
210
- if (!isRecord(input)) return { ok: false, reason: "invalid" };
269
+ if (!isRecord(input))
270
+ return { ok: false, reason: "invalid" };
211
271
  if (input.protocol !== TUNNEL_PROTOCOL_VERSION) {
212
272
  return {
213
273
  ok: false,
@@ -217,7 +277,8 @@ function validateTunnelMessage(input) {
217
277
  ...typeof input.type === "string" ? { type: input.type } : {}
218
278
  };
219
279
  }
220
- if (!isTunnelMessageRecord(input)) return { ok: false, reason: "invalid" };
280
+ if (!isTunnelMessageRecord(input))
281
+ return { ok: false, reason: "invalid" };
221
282
  return { ok: true, message: input };
222
283
  }
223
284
  function isTunnelMessage(input) {
@@ -241,7 +302,8 @@ function makeTunnelPing(sentAt = Date.now(), id = crypto.randomUUID()) {
241
302
  };
242
303
  }
243
304
  function isTunnelMessageRecord(row) {
244
- if (row.protocol !== TUNNEL_PROTOCOL_VERSION) return false;
305
+ if (row.protocol !== TUNNEL_PROTOCOL_VERSION)
306
+ return false;
245
307
  switch (row.type) {
246
308
  case "ping":
247
309
  return isNonEmptyString(row.id) && isFiniteNumber(row.sent_at);
@@ -258,15 +320,15 @@ function isTunnelMessageRecord(row) {
258
320
  case "http.response.end":
259
321
  return isNonEmptyString(row.request_id);
260
322
  case "http.response.flow":
261
- return isNonEmptyString(row.request_id) && typeof row.paused === "boolean" && (row.reason === void 0 || row.reason === "slow_consumer" || row.reason === "drained" || row.reason === "closed");
323
+ return isNonEmptyString(row.request_id) && typeof row.paused === "boolean" && (row.reason === undefined || row.reason === "slow_consumer" || row.reason === "drained" || row.reason === "closed");
262
324
  case "ws.open":
263
325
  return isNonEmptyString(row.channel_id) && isNonEmptyString(row.workspace_id) && isNonEmptyString(row.path) && isTunnelHeaderMap(row.headers);
264
326
  case "ws.frame":
265
327
  return isNonEmptyString(row.channel_id) && isBase64(row.data_base64) && typeof row.binary === "boolean";
266
328
  case "ws.close":
267
- return isNonEmptyString(row.channel_id) && (row.code === void 0 || isCloseCode(row.code)) && (row.reason === void 0 || isNonEmptyString(row.reason));
329
+ return isNonEmptyString(row.channel_id) && (row.code === undefined || isCloseCode(row.code)) && (row.reason === undefined || isNonEmptyString(row.reason));
268
330
  case "error":
269
- return (row.request_id === void 0 || isNonEmptyString(row.request_id)) && (row.channel_id === void 0 || isNonEmptyString(row.channel_id)) && isNonEmptyString(row.code) && isNonEmptyString(row.message);
331
+ return (row.request_id === undefined || isNonEmptyString(row.request_id)) && (row.channel_id === undefined || isNonEmptyString(row.channel_id)) && isNonEmptyString(row.code) && isNonEmptyString(row.message);
270
332
  default:
271
333
  return false;
272
334
  }
@@ -281,31 +343,39 @@ function isHttpStatus(input) {
281
343
  return typeof input === "number" && Number.isInteger(input) && input >= 100 && input <= 599;
282
344
  }
283
345
  function isCloseCode(input) {
284
- return typeof input === "number" && Number.isInteger(input) && input >= 0 && input <= 65535;
346
+ return typeof input === "number" && Number.isInteger(input) && input >= 1000 && input <= 4999 && input !== 1004 && input !== 1005 && input !== 1006 && input !== 1015;
285
347
  }
348
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
286
349
  function isTunnelHeaderMap(input) {
287
- if (!input || typeof input !== "object" || Array.isArray(input)) return false;
288
- for (const value of Object.values(input)) {
289
- if (typeof value !== "string") return false;
350
+ if (!input || typeof input !== "object" || Array.isArray(input))
351
+ return false;
352
+ for (const [name, value] of Object.entries(input)) {
353
+ if (!HEADER_NAME.test(name) || typeof value !== "string")
354
+ return false;
290
355
  }
291
356
  return true;
292
357
  }
293
358
  function isOptionalBase64(input) {
294
- return input === void 0 || isBase64(input);
359
+ return input === undefined || isBase64(input);
295
360
  }
296
361
  function isBase64(input) {
297
362
  return typeof input === "string" && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input);
298
363
  }
299
364
  export {
300
- SESSION_STREAM_LEASE_TTL_MS,
301
- SESSION_TURN_LEASE_TTL_MS,
302
- TUNNEL_PROTOCOL_VERSION,
303
- TokenVerifierError,
304
- createHttpTokenVerifier,
305
- createOidcTokenVerifier,
306
- createStaticTokenVerifier,
307
- isTunnelMessage,
308
- makeTunnelPing,
365
+ validateTunnelMessage,
366
+ sessionScopeReaches,
367
+ sessionHostRootOf,
368
+ sessionHostId,
309
369
  makeTunnelPong,
310
- validateTunnelMessage
370
+ makeTunnelPing,
371
+ isTunnelMessage,
372
+ createStaticTokenVerifier,
373
+ createOidcTokenVerifier,
374
+ createHttpTokenVerifier,
375
+ TokenVerifierError,
376
+ TUNNEL_PROTOCOL_VERSION,
377
+ SESSION_TURN_LEASE_TTL_MS,
378
+ SESSION_STREAM_LEASE_TTL_MS,
379
+ SESSION_HOST_PREFIX,
380
+ CURRENT_CHANNEL_IDENTITY_VERSION
311
381
  };
@@ -26,8 +26,13 @@ export type TokenVerifierBaseClaims = {
26
26
  export type RuntimeAccessVerifierClaims = Omit<TokenVerifierBaseClaims, "sub"> & {
27
27
  org_id: string;
28
28
  role: "viewer" | "editor" | "admin" | "owner";
29
+ /** The whole workspace, or the one session `session_id` names. */
30
+ scope: "workspace" | "session";
31
+ session_id?: string;
32
+ purpose?: "turn-execution";
29
33
  principal_kind: "user" | "service";
30
34
  actor_id: string;
35
+ user_id?: string;
31
36
  actor_kind: "human" | "agent";
32
37
  actor_public_id?: string;
33
38
  actor_name?: string;
@@ -36,21 +41,20 @@ export type RuntimeAccessVerifierClaims = Omit<TokenVerifierBaseClaims, "sub"> &
36
41
  export type RelayHostVerifierClaims = Omit<TokenVerifierBaseClaims, "sub"> & {
37
42
  org_id: string;
38
43
  role: "viewer" | "editor" | "admin" | "owner";
44
+ scope: "workspace" | "session";
45
+ session_id?: string;
46
+ purpose?: "turn-execution";
39
47
  principal_kind: "user" | "service";
40
48
  actor_id: string;
49
+ user_id?: string;
41
50
  actor_kind: "human" | "agent";
42
51
  actor_public_id?: string;
43
52
  actor_name?: string;
44
53
  actor_avatar_url?: string;
45
54
  /** Durable Runtime Access Token id; distinct from this short-lived RHT jti. */
46
55
  parent_jti: string;
47
- } & ({
48
- access: "cloud";
49
- backing: "cloud-vm";
50
- } | {
51
- access: "user-hosted";
52
- backing: "local-worktree";
53
- });
56
+ backing: "cloud-vm" | "local-worktree" | "durable-object";
57
+ };
54
58
  export type TokenClaims<TClaims extends Record<string, unknown> = Record<string, unknown>> = {
55
59
  /** Provider subject for provider-backed identity verifiers. Runtime and
56
60
  * relay-host tokens instead carry canonical actor/principal claims. */
@@ -89,6 +93,8 @@ export type HttpTokenVerifierOptions = {
89
93
  * and return `{ subject: string; scopes?: string[]; claims: TokenVerifierBaseClaims }`
90
94
  * on success, or HTTP non-2xx on failure. */
91
95
  endpoint: string;
96
+ /** Development only: permit HTTP to the exact loopback hosts. HTTPS is otherwise required. */
97
+ allowInsecureLoopback?: boolean;
92
98
  /** Optional fixed headers (e.g., bearer auth on the verifier itself). */
93
99
  headers?: Record<string, string>;
94
100
  /** fetch override for tests. */
package/package.json CHANGED
@@ -33,22 +33,20 @@
33
33
  "access": "public"
34
34
  },
35
35
  "name": "@claxedo/workspace-relay-protocol",
36
- "version": "0.8.0",
36
+ "version": "0.10.0",
37
37
  "scripts": {
38
- "build": "tsx scripts/build.ts",
38
+ "build": "bun scripts/build.ts",
39
39
  "prepublishOnly": "npm run build",
40
40
  "typecheck": "tsc --noEmit -p tsconfig.json",
41
- "test": "mkdir -p .artifacts/unit && node ../../scripts/test-deadline.mjs bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
41
+ "test": "mkdir -p .artifacts/unit && node ../../script/test-deadline.mjs bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
42
42
  },
43
43
  "dependencies": {
44
- "@claxedo/helpers": "0.1.0",
44
+ "@claxedo/helpers": "0.2.0",
45
45
  "jose": "6.0.11"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@tsconfig/node-lts": "22.0.4",
49
49
  "@types/bun": "1.3.13",
50
- "esbuild": "0.25.12",
51
- "tsx": "4.22.3",
52
50
  "typescript": "catalog:"
53
51
  }
54
52
  }