@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 +7 -4
- package/dist/index.d.ts +25 -1
- package/dist/index.mjs +179 -98
- package/dist/token-verifier.d.ts +36 -16
- package/package.json +10 -11
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/
|
|
63
|
-
| `
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
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 !==
|
|
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 ??
|
|
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(
|
|
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 :
|
|
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
|
|
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 !==
|
|
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
|
|
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)
|
|
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
|
|
78
|
-
function
|
|
138
|
+
var oidcJwks = new Map;
|
|
139
|
+
function createOidcTokenVerifier(options) {
|
|
79
140
|
const algorithms = options.algorithms ?? ["ES256", "EdDSA", "RS256"];
|
|
80
|
-
const remote =
|
|
81
|
-
|
|
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 =
|
|
151
|
+
const claims = oidcClaims(result.payload);
|
|
91
152
|
if (!claims) {
|
|
92
153
|
throw new TokenVerifierError({
|
|
93
|
-
code: "
|
|
94
|
-
message: "
|
|
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)
|
|
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: "
|
|
106
|
-
message: "
|
|
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 =
|
|
178
|
+
const tokens = new Map(Object.entries(options.tokens));
|
|
115
179
|
return {
|
|
116
180
|
async verify(token) {
|
|
117
|
-
const claims = tokens
|
|
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
|
|
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)
|
|
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
|
|
160
|
-
if (!input.iss || !input.sub || !input.exp)
|
|
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
|
-
...
|
|
169
|
-
...
|
|
170
|
-
...
|
|
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))
|
|
175
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
"
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
if (
|
|
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:
|
|
204
|
-
...typeof
|
|
276
|
+
actual_protocol: input.protocol,
|
|
277
|
+
...typeof input.type === "string" ? { type: input.type } : {}
|
|
205
278
|
};
|
|
206
279
|
}
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
|
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
|
|
309
|
+
return isNonEmptyString(row.id) && isFiniteNumber(row.sent_at);
|
|
236
310
|
case "pong":
|
|
237
|
-
return
|
|
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) &&
|
|
313
|
+
return isStringArray(row.workspace_ids) && isNonEmptyString(row.token);
|
|
240
314
|
case "http.request":
|
|
241
|
-
return
|
|
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
|
|
317
|
+
return isNonEmptyString(row.request_id) && isHttpStatus(row.status) && isTunnelHeaderMap(row.headers);
|
|
244
318
|
case "http.response.chunk":
|
|
245
|
-
return
|
|
319
|
+
return isNonEmptyString(row.request_id) && isBase64(row.body_base64);
|
|
246
320
|
case "http.response.end":
|
|
247
|
-
return
|
|
321
|
+
return isNonEmptyString(row.request_id);
|
|
248
322
|
case "http.response.flow":
|
|
249
|
-
return
|
|
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
|
|
325
|
+
return isNonEmptyString(row.channel_id) && isNonEmptyString(row.workspace_id) && isNonEmptyString(row.path) && isTunnelHeaderMap(row.headers);
|
|
252
326
|
case "ws.frame":
|
|
253
|
-
return
|
|
327
|
+
return isNonEmptyString(row.channel_id) && isBase64(row.data_base64) && typeof row.binary === "boolean";
|
|
254
328
|
case "ws.close":
|
|
255
|
-
return
|
|
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 ===
|
|
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
|
|
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(
|
|
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 >=
|
|
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))
|
|
279
|
-
|
|
280
|
-
|
|
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 ===
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
createStaticTokenVerifier,
|
|
296
|
-
isTunnelMessage,
|
|
297
|
-
makeTunnelPing,
|
|
365
|
+
validateTunnelMessage,
|
|
366
|
+
sessionScopeReaches,
|
|
367
|
+
sessionHostRootOf,
|
|
368
|
+
sessionHostId,
|
|
298
369
|
makeTunnelPong,
|
|
299
|
-
|
|
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
|
};
|
package/dist/token-verifier.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
subject
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
38
|
-
|
|
39
|
-
},
|
|
35
|
+
"name": "@claxedo/workspace-relay-protocol",
|
|
36
|
+
"version": "0.10.0",
|
|
40
37
|
"scripts": {
|
|
41
|
-
"build": "
|
|
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
|
}
|