@claxedo/workspace-relay-protocol 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 CHANGED
@@ -59,8 +59,8 @@ 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. |
63
- | `createClerkTokenVerifier` | Public beta | Verifies Clerk session tokens with issuer, audience, JWKS, and algorithm constraints. |
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
+ | `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. |
66
66
 
@@ -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,5 +1,29 @@
1
1
  export declare const TUNNEL_PROTOCOL_VERSION = 1;
2
- export { type TokenClaims, type TokenVerifierBaseClaims, type RuntimeAccessVerifierClaims, type RelayHostVerifierClaims, type TokenVerifier, type ClerkTokenVerifierClaims, TokenVerifierError, type ClerkTokenVerifierOptions, createClerkTokenVerifier, type HttpTokenVerifierOptions, createHttpTokenVerifier, type StaticTokenVerifierOptions, createStaticTokenVerifier, } from "./token-verifier";
2
+ export declare const SESSION_STREAM_LEASE_TTL_MS = 15000;
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;
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";
3
27
  export type TunnelHeaderMap = Record<string, string>;
4
28
  export type TunnelPing = {
5
29
  type: "ping";
package/dist/index.mjs CHANGED
@@ -1,6 +1,42 @@
1
+ // ../claxedo-helpers/dist/guards.mjs
2
+ function isRecord(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4
+ }
5
+ function isString(value) {
6
+ return typeof value === "string";
7
+ }
8
+ function isFiniteNumber(value) {
9
+ return typeof value === "number" && Number.isFinite(value);
10
+ }
11
+ function asFiniteNumber(value) {
12
+ return isFiniteNumber(value) ? value : undefined;
13
+ }
14
+ function stringClaim(payload, key) {
15
+ const value = payload[key];
16
+ return isString(value) && value.trim().length > 0 ? value : undefined;
17
+ }
18
+ function numberClaim(payload, key) {
19
+ return asFiniteNumber(payload[key]);
20
+ }
21
+
1
22
  // src/token-verifier.ts
2
- import { createRemoteJWKSet, jwtVerify } from "jose";
3
- var TokenVerifierError = class extends Error {
23
+ import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose";
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 {
4
40
  code;
5
41
  status;
6
42
  constructor(input) {
@@ -8,10 +44,20 @@ var TokenVerifierError = class extends Error {
8
44
  this.name = "TokenVerifierError";
9
45
  this.code = input.code;
10
46
  this.status = input.status ?? 401;
11
- if (input.cause !== void 0) this.cause = input.cause;
47
+ if (input.cause !== undefined)
48
+ this.cause = input.cause;
12
49
  }
13
- };
50
+ }
14
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
+ }
15
61
  const fetchImpl = options.fetch ?? globalThis.fetch;
16
62
  if (!fetchImpl) {
17
63
  throw new TokenVerifierError({
@@ -20,52 +66,67 @@ function createHttpTokenVerifier(options) {
20
66
  status: 500
21
67
  });
22
68
  }
23
- const timeoutMs = options.timeoutMs ?? 5e3;
69
+ const timeoutMs = options.timeoutMs ?? 5000;
24
70
  return {
25
71
  async verify(token) {
26
- const controller = new AbortController();
72
+ const controller = new AbortController;
27
73
  const timer = setTimeout(() => controller.abort(), timeoutMs);
28
74
  try {
29
- const res = await fetchImpl(options.endpoint, {
75
+ const res = await fetchImpl(endpoint.href, {
30
76
  method: "POST",
77
+ redirect: "error",
31
78
  headers: {
32
79
  "content-type": "application/json",
33
- ...options.headers ?? {}
80
+ ...options.headers
34
81
  },
35
82
  body: JSON.stringify({ token }),
36
83
  signal: controller.signal
37
84
  });
38
85
  if (!res.ok) {
39
86
  throw new TokenVerifierError({
40
- code: "verifier_rejected",
87
+ code: res.status === 401 || res.status === 403 ? "verifier_rejected" : "verifier_unavailable",
41
88
  message: `Verifier endpoint returned ${res.status}`,
42
- status: res.status === 401 || res.status === 403 ? res.status : 401
89
+ status: res.status === 401 || res.status === 403 ? res.status : 503
43
90
  });
44
91
  }
45
92
  const body = await res.json().catch(() => null);
46
- if (!body || typeof body.subject !== "string" || !body.subject) {
93
+ if (!isRecord(body)) {
94
+ throw new TokenVerifierError({
95
+ code: "verifier_response_invalid",
96
+ message: "Verifier response is missing a subject"
97
+ });
98
+ }
99
+ const subject = stringClaim(body, "subject");
100
+ if (!subject) {
47
101
  throw new TokenVerifierError({
48
102
  code: "verifier_response_invalid",
49
103
  message: "Verifier response is missing a subject"
50
104
  });
51
105
  }
52
106
  const claims = tokenVerifierBaseClaims(body.claims);
53
- if (!claims || claims.sub !== body.subject) {
107
+ if (!claims || claims.sub !== subject) {
54
108
  throw new TokenVerifierError({
55
109
  code: "verifier_response_invalid",
56
110
  message: "Verifier response is missing required token claims"
57
111
  });
58
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
+ }
59
118
  return {
60
- subject: body.subject,
119
+ subject,
61
120
  scopes: Array.isArray(body.scopes) ? body.scopes.filter((s) => typeof s === "string") : [],
62
121
  claims
63
122
  };
64
123
  } catch (err) {
65
- if (err instanceof TokenVerifierError) throw err;
124
+ if (err instanceof TokenVerifierError)
125
+ throw err;
66
126
  throw new TokenVerifierError({
67
127
  code: "verifier_unreachable",
68
128
  message: err instanceof Error ? err.message : String(err),
129
+ status: 503,
69
130
  cause: err
70
131
  });
71
132
  } finally {
@@ -74,11 +135,11 @@ function createHttpTokenVerifier(options) {
74
135
  }
75
136
  };
76
137
  }
77
- var clerkJwks = /* @__PURE__ */ new Map();
78
- function createClerkTokenVerifier(options) {
138
+ var oidcJwks = new Map;
139
+ function createOidcTokenVerifier(options) {
79
140
  const algorithms = options.algorithms ?? ["ES256", "EdDSA", "RS256"];
80
- const remote = clerkJwks.get(options.jwksUrl) ?? createRemoteJWKSet(new URL(options.jwksUrl));
81
- clerkJwks.set(options.jwksUrl, remote);
141
+ const remote = oidcJwks.get(options.jwksUrl) ?? createRemoteJWKSet(new URL(options.jwksUrl));
142
+ oidcJwks.set(options.jwksUrl, remote);
82
143
  return {
83
144
  async verify(token) {
84
145
  try {
@@ -87,11 +148,11 @@ function createClerkTokenVerifier(options) {
87
148
  audience: options.audience,
88
149
  algorithms
89
150
  });
90
- const claims = clerkClaims(result.payload);
151
+ const claims = oidcClaims(result.payload);
91
152
  if (!claims) {
92
153
  throw new TokenVerifierError({
93
- code: "clerk_claims_invalid",
94
- message: "Clerk token is missing required session claims"
154
+ code: "oidc_claims_invalid",
155
+ message: "Session token is missing required claims"
95
156
  });
96
157
  }
97
158
  return {
@@ -100,10 +161,13 @@ function createClerkTokenVerifier(options) {
100
161
  claims
101
162
  };
102
163
  } catch (err) {
103
- if (err instanceof TokenVerifierError) throw err;
164
+ if (err instanceof TokenVerifierError)
165
+ throw err;
166
+ const unavailable = err instanceof joseErrors.JWKSTimeout || !(err instanceof joseErrors.JOSEError);
104
167
  throw new TokenVerifierError({
105
- code: "clerk_token_invalid",
106
- message: "Clerk session token is invalid",
168
+ code: unavailable ? "oidc_verifier_unavailable" : "oidc_token_invalid",
169
+ message: unavailable ? "Session verifier is unavailable" : "Session token is invalid",
170
+ status: unavailable ? 503 : 401,
107
171
  cause: err
108
172
  });
109
173
  }
@@ -111,10 +175,10 @@ function createClerkTokenVerifier(options) {
111
175
  };
112
176
  }
113
177
  function createStaticTokenVerifier(options) {
114
- const tokens = Object.assign(/* @__PURE__ */ Object.create(null), options.tokens);
178
+ const tokens = new Map(Object.entries(options.tokens));
115
179
  return {
116
180
  async verify(token) {
117
- const claims = tokens[token];
181
+ const claims = tokens.get(token);
118
182
  if (!claims) {
119
183
  throw new TokenVerifierError({
120
184
  code: "token_not_in_static_table",
@@ -125,16 +189,9 @@ function createStaticTokenVerifier(options) {
125
189
  }
126
190
  };
127
191
  }
128
- function stringClaim(input, key) {
129
- const value = input[key];
130
- return typeof value === "string" && value.trim() ? value : void 0;
131
- }
132
- function numberClaim(input, key) {
133
- const value = input[key];
134
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
135
- }
136
192
  function tokenVerifierBaseClaims(input) {
137
- if (!input || typeof input !== "object") return;
193
+ if (!isRecord(input))
194
+ return;
138
195
  const iss = stringClaim(input, "iss");
139
196
  const aud = stringClaim(input, "aud");
140
197
  const sub = stringClaim(input, "sub");
@@ -143,7 +200,8 @@ function tokenVerifierBaseClaims(input) {
143
200
  const exp = numberClaim(input, "exp");
144
201
  const iat = numberClaim(input, "iat");
145
202
  const jti = stringClaim(input, "jti");
146
- if (!iss || !aud || !sub || !workspace_id || !host_id || !exp || !iat || !jti) return;
203
+ if (!iss || !aud || !sub || !workspace_id || !host_id || !exp || !iat || !jti)
204
+ return;
147
205
  return {
148
206
  ...input,
149
207
  iss,
@@ -156,8 +214,12 @@ function tokenVerifierBaseClaims(input) {
156
214
  jti
157
215
  };
158
216
  }
159
- function clerkClaims(input) {
160
- if (!input.iss || !input.sub || !input.exp) return;
217
+ function oidcClaims(input) {
218
+ if (!input.iss || !input.sub || !input.exp)
219
+ return;
220
+ const sid = stringClaim(input, "sid");
221
+ const org_id = stringClaim(input, "org_id");
222
+ const orgId = stringClaim(input, "orgId");
161
223
  return {
162
224
  ...input,
163
225
  iss: input.iss,
@@ -165,52 +227,62 @@ function clerkClaims(input) {
165
227
  exp: input.exp,
166
228
  ...input.iat ? { iat: input.iat } : {},
167
229
  ...typeof input.jti === "string" ? { jti: input.jti } : {},
168
- ...stringClaim(input, "sid") ? { sid: stringClaim(input, "sid") } : {},
169
- ...stringClaim(input, "org_id") ? { org_id: stringClaim(input, "org_id") } : {},
170
- ...stringClaim(input, "orgId") ? { orgId: stringClaim(input, "orgId") } : {}
230
+ ...sid ? { sid } : {},
231
+ ...org_id ? { org_id } : {},
232
+ ...orgId ? { orgId } : {}
171
233
  };
172
234
  }
173
235
  function scopeClaims(input) {
174
- if (Array.isArray(input.scp)) return input.scp.filter((item) => typeof item === "string");
175
- 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);
176
240
  return [];
177
241
  }
178
242
 
179
243
  // src/index.ts
180
244
  var TUNNEL_PROTOCOL_VERSION = 1;
181
- var tunnelMessageTypes = /* @__PURE__ */ new Set([
182
- "ping",
183
- "pong",
184
- "host.registration.update",
185
- "http.request",
186
- "http.response.start",
187
- "http.response.chunk",
188
- "http.response.end",
189
- "http.response.flow",
190
- "ws.open",
191
- "ws.frame",
192
- "ws.close",
193
- "error"
194
- ]);
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
+ }
195
268
  function validateTunnelMessage(input) {
196
- if (!input || typeof input !== "object") return { ok: false, reason: "invalid" };
197
- const row = input;
198
- if (row.protocol !== TUNNEL_PROTOCOL_VERSION) {
269
+ if (!isRecord(input))
270
+ return { ok: false, reason: "invalid" };
271
+ if (input.protocol !== TUNNEL_PROTOCOL_VERSION) {
199
272
  return {
200
273
  ok: false,
201
274
  reason: "protocol_mismatch",
202
275
  expected_protocol: TUNNEL_PROTOCOL_VERSION,
203
- actual_protocol: row.protocol,
204
- ...typeof row.type === "string" ? { type: row.type } : {}
276
+ actual_protocol: input.protocol,
277
+ ...typeof input.type === "string" ? { type: input.type } : {}
205
278
  };
206
279
  }
207
- if (typeof row.type !== "string") return { ok: false, reason: "invalid" };
208
- if (!tunnelMessageTypes.has(row.type)) return { ok: false, reason: "invalid" };
209
- if (!validateTunnelMessageShape(row)) return { ok: false, reason: "invalid" };
210
- return { ok: true, message: row };
280
+ if (!isTunnelMessageRecord(input))
281
+ return { ok: false, reason: "invalid" };
282
+ return { ok: true, message: input };
211
283
  }
212
284
  function isTunnelMessage(input) {
213
- return validateTunnelMessage(input).ok;
285
+ return isRecord(input) && isTunnelMessageRecord(input);
214
286
  }
215
287
  function makeTunnelPong(input, receivedAt = Date.now()) {
216
288
  return {
@@ -229,72 +301,81 @@ function makeTunnelPing(sentAt = Date.now(), id = crypto.randomUUID()) {
229
301
  sent_at: sentAt
230
302
  };
231
303
  }
232
- function validateTunnelMessageShape(row) {
304
+ function isTunnelMessageRecord(row) {
305
+ if (row.protocol !== TUNNEL_PROTOCOL_VERSION)
306
+ return false;
233
307
  switch (row.type) {
234
308
  case "ping":
235
- return isString(row.id) && isFiniteNumber(row.sent_at);
309
+ return isNonEmptyString(row.id) && isFiniteNumber(row.sent_at);
236
310
  case "pong":
237
- return isString(row.id) && isFiniteNumber(row.sent_at) && isFiniteNumber(row.received_at);
311
+ return isNonEmptyString(row.id) && isFiniteNumber(row.sent_at) && isFiniteNumber(row.received_at);
238
312
  case "host.registration.update":
239
- return isStringArray(row.workspace_ids) && isString(row.token);
313
+ return isStringArray(row.workspace_ids) && isNonEmptyString(row.token);
240
314
  case "http.request":
241
- return isString(row.request_id) && isString(row.workspace_id) && isString(row.method) && isString(row.path) && isTunnelHeaderMap(row.headers) && isOptionalBase64(row.body_base64) && typeof row.end === "boolean";
315
+ return isNonEmptyString(row.request_id) && isNonEmptyString(row.workspace_id) && isNonEmptyString(row.method) && isNonEmptyString(row.path) && isTunnelHeaderMap(row.headers) && isOptionalBase64(row.body_base64) && typeof row.end === "boolean";
242
316
  case "http.response.start":
243
- return isString(row.request_id) && isHttpStatus(row.status) && isTunnelHeaderMap(row.headers);
317
+ return isNonEmptyString(row.request_id) && isHttpStatus(row.status) && isTunnelHeaderMap(row.headers);
244
318
  case "http.response.chunk":
245
- return isString(row.request_id) && isBase64(row.body_base64);
319
+ return isNonEmptyString(row.request_id) && isBase64(row.body_base64);
246
320
  case "http.response.end":
247
- return isString(row.request_id);
321
+ return isNonEmptyString(row.request_id);
248
322
  case "http.response.flow":
249
- return isString(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");
250
324
  case "ws.open":
251
- return isString(row.channel_id) && isString(row.workspace_id) && isString(row.path) && isTunnelHeaderMap(row.headers);
325
+ return isNonEmptyString(row.channel_id) && isNonEmptyString(row.workspace_id) && isNonEmptyString(row.path) && isTunnelHeaderMap(row.headers);
252
326
  case "ws.frame":
253
- return isString(row.channel_id) && isBase64(row.data_base64) && typeof row.binary === "boolean";
327
+ return isNonEmptyString(row.channel_id) && isBase64(row.data_base64) && typeof row.binary === "boolean";
254
328
  case "ws.close":
255
- return isString(row.channel_id) && (row.code === void 0 || isCloseCode(row.code)) && (row.reason === void 0 || isString(row.reason));
329
+ return isNonEmptyString(row.channel_id) && (row.code === undefined || isCloseCode(row.code)) && (row.reason === undefined || isNonEmptyString(row.reason));
256
330
  case "error":
257
- return (row.request_id === void 0 || isString(row.request_id)) && (row.channel_id === void 0 || isString(row.channel_id)) && isString(row.code) && isString(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);
258
332
  default:
259
333
  return false;
260
334
  }
261
335
  }
262
- function isString(input) {
336
+ function isNonEmptyString(input) {
263
337
  return typeof input === "string" && input.length > 0;
264
338
  }
265
339
  function isStringArray(input) {
266
- return Array.isArray(input) && input.length > 0 && input.every(isString);
267
- }
268
- function isFiniteNumber(input) {
269
- return typeof input === "number" && Number.isFinite(input);
340
+ return Array.isArray(input) && input.length > 0 && input.every(isNonEmptyString);
270
341
  }
271
342
  function isHttpStatus(input) {
272
343
  return typeof input === "number" && Number.isInteger(input) && input >= 100 && input <= 599;
273
344
  }
274
345
  function isCloseCode(input) {
275
- 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;
276
347
  }
348
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
277
349
  function isTunnelHeaderMap(input) {
278
- if (!input || typeof input !== "object" || Array.isArray(input)) return false;
279
- for (const value of Object.values(input)) {
280
- 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;
281
355
  }
282
356
  return true;
283
357
  }
284
358
  function isOptionalBase64(input) {
285
- return input === void 0 || isBase64(input);
359
+ return input === undefined || isBase64(input);
286
360
  }
287
361
  function isBase64(input) {
288
362
  return typeof input === "string" && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input);
289
363
  }
290
364
  export {
291
- TUNNEL_PROTOCOL_VERSION,
292
- TokenVerifierError,
293
- createClerkTokenVerifier,
294
- createHttpTokenVerifier,
295
- createStaticTokenVerifier,
296
- isTunnelMessage,
297
- makeTunnelPing,
365
+ validateTunnelMessage,
366
+ sessionScopeReaches,
367
+ sessionHostRootOf,
368
+ sessionHostId,
298
369
  makeTunnelPong,
299
- 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
300
381
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Unified token-verifier interface (P-shared).
3
3
  *
4
- * Each of the four Claxedo boundaries (relay/tunnel, agent extension,
4
+ * Each of the four Claxedo boundaries (relay/tunnel, optional modules,
5
5
  * SDK runner, workspace host) historically defined its own ad-hoc
6
6
  * verifier shape. This module is the single seam every downstream user
7
7
  * can swap a custom implementation through.
@@ -23,24 +23,42 @@ export type TokenVerifierBaseClaims = {
23
23
  iat: number;
24
24
  jti: string;
25
25
  } & Record<string, unknown>;
26
- export type RuntimeAccessVerifierClaims = TokenVerifierBaseClaims & {
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";
33
+ principal_kind: "user" | "service";
34
+ actor_id: string;
35
+ user_id?: string;
36
+ actor_kind: "human" | "agent";
37
+ actor_public_id?: string;
38
+ actor_name?: string;
39
+ actor_avatar_url?: string;
29
40
  };
30
- export type RelayHostVerifierClaims = TokenVerifierBaseClaims & {
41
+ export type RelayHostVerifierClaims = Omit<TokenVerifierBaseClaims, "sub"> & {
31
42
  org_id: string;
32
43
  role: "viewer" | "editor" | "admin" | "owner";
33
- } & ({
34
- access: "cloud";
35
- backing: "cloud-vm";
36
- } | {
37
- access: "user-hosted";
38
- backing: "local-worktree";
39
- });
44
+ scope: "workspace" | "session";
45
+ session_id?: string;
46
+ purpose?: "turn-execution";
47
+ principal_kind: "user" | "service";
48
+ actor_id: string;
49
+ user_id?: string;
50
+ actor_kind: "human" | "agent";
51
+ actor_public_id?: string;
52
+ actor_name?: string;
53
+ actor_avatar_url?: string;
54
+ /** Durable Runtime Access Token id; distinct from this short-lived RHT jti. */
55
+ parent_jti: string;
56
+ backing: "cloud-vm" | "local-worktree" | "durable-object";
57
+ };
40
58
  export type TokenClaims<TClaims extends Record<string, unknown> = Record<string, unknown>> = {
41
- /** Stable subject id. The boundary is responsible for mapping this
42
- * to a domain principal (user, workspace, runtime, etc.). */
43
- subject: string;
59
+ /** Provider subject for provider-backed identity verifiers. Runtime and
60
+ * relay-host tokens instead carry canonical actor/principal claims. */
61
+ subject?: string;
44
62
  /** Scopes / capabilities asserted by the issuer. May be empty. */
45
63
  scopes: string[];
46
64
  /** Full claim payload. Carries the common verifier token contract plus
@@ -50,7 +68,7 @@ export type TokenClaims<TClaims extends Record<string, unknown> = Record<string,
50
68
  export type TokenVerifier<TClaims extends Record<string, unknown> = Record<string, unknown>> = {
51
69
  verify(token: string): Promise<TokenClaims<TClaims>>;
52
70
  };
53
- export type ClerkTokenVerifierClaims = JWTPayload & {
71
+ export type OidcTokenVerifierClaims = JWTPayload & {
54
72
  iss: string;
55
73
  sub: string;
56
74
  exp: number;
@@ -75,6 +93,8 @@ export type HttpTokenVerifierOptions = {
75
93
  * and return `{ subject: string; scopes?: string[]; claims: TokenVerifierBaseClaims }`
76
94
  * on success, or HTTP non-2xx on failure. */
77
95
  endpoint: string;
96
+ /** Development only: permit HTTP to the exact loopback hosts. HTTPS is otherwise required. */
97
+ allowInsecureLoopback?: boolean;
78
98
  /** Optional fixed headers (e.g., bearer auth on the verifier itself). */
79
99
  headers?: Record<string, string>;
80
100
  /** fetch override for tests. */
@@ -83,14 +103,14 @@ export type HttpTokenVerifierOptions = {
83
103
  timeoutMs?: number;
84
104
  };
85
105
  export declare function createHttpTokenVerifier(options: HttpTokenVerifierOptions): TokenVerifier<TokenVerifierBaseClaims>;
86
- export type ClerkTokenVerifierOptions = {
106
+ export type OidcTokenVerifierOptions = {
87
107
  issuer: string;
88
108
  jwksUrl: string;
89
109
  audience?: string;
90
110
  scopes?: string[];
91
111
  algorithms?: Array<"ES256" | "EdDSA" | "RS256">;
92
112
  };
93
- export declare function createClerkTokenVerifier(options: ClerkTokenVerifierOptions): TokenVerifier<ClerkTokenVerifierClaims>;
113
+ export declare function createOidcTokenVerifier(options: OidcTokenVerifierOptions): TokenVerifier<OidcTokenVerifierClaims>;
94
114
  export type StaticTokenVerifierOptions<TClaims extends Record<string, unknown> = Record<string, unknown>> = {
95
115
  /** Map of token string → claims to return. */
96
116
  tokens: Record<string, TokenClaims<TClaims>>;
package/package.json CHANGED
@@ -1,6 +1,4 @@
1
1
  {
2
- "name": "@claxedo/workspace-relay-protocol",
3
- "version": "0.7.0",
4
2
  "type": "module",
5
3
  "description": "Wire types, message validation, and token verifier interfaces for the Claxedo workspace relay tunnel protocol",
6
4
  "license": "MIT",
@@ -19,9 +17,9 @@
19
17
  "types": "./dist/index.d.ts",
20
18
  "exports": {
21
19
  ".": {
20
+ "development": "./src/index.ts",
21
+ "bun": "./src/index.ts",
22
22
  "types": "./dist/index.d.ts",
23
- "bun": "./dist/index.mjs",
24
- "development": "./dist/index.mjs",
25
23
  "import": "./dist/index.mjs",
26
24
  "default": "./dist/index.mjs"
27
25
  }
@@ -34,20 +32,21 @@
34
32
  "publishConfig": {
35
33
  "access": "public"
36
34
  },
37
- "dependencies": {
38
- "jose": "6.0.11"
39
- },
35
+ "name": "@claxedo/workspace-relay-protocol",
36
+ "version": "0.10.0",
40
37
  "scripts": {
41
- "build": "tsx scripts/build.ts",
38
+ "build": "bun scripts/build.ts",
42
39
  "prepublishOnly": "npm run build",
43
40
  "typecheck": "tsc --noEmit -p tsconfig.json",
44
- "test": "mkdir -p .artifacts/unit && 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
+ },
43
+ "dependencies": {
44
+ "@claxedo/helpers": "0.2.0",
45
+ "jose": "6.0.11"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@tsconfig/node-lts": "22.0.4",
48
49
  "@types/bun": "1.3.13",
49
- "esbuild": "0.25.12",
50
- "tsx": "4.22.3",
51
50
  "typescript": "catalog:"
52
51
  }
53
52
  }