@vincentt-xr/harness 0.4.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/HarnessProvider.d.ts +43 -1
- package/dist/client/HarnessProvider.js +66 -10
- package/dist/client/annotate.d.ts +34 -0
- package/dist/client/annotate.js +104 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/shared/events.d.ts +138 -3
- package/dist/shared/events.js +25 -1
- package/dist/tunnel/cbor.d.ts +71 -0
- package/dist/tunnel/cbor.js +403 -0
- package/dist/tunnel/client.d.ts +219 -0
- package/dist/tunnel/client.js +620 -0
- package/dist/tunnel/forwardTarget.d.ts +69 -0
- package/dist/tunnel/forwardTarget.js +174 -0
- package/dist/tunnel/frame.d.ts +126 -0
- package/dist/tunnel/frame.js +244 -0
- package/dist/tunnel/index.d.ts +11 -0
- package/dist/tunnel/index.js +11 -0
- package/package.json +14 -31
- package/README.md +0 -87
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -55
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- package/dist/mcp/backend.d.ts +0 -52
- package/dist/mcp/backend.js +0 -146
- package/dist/mcp/cli.d.ts +0 -2
- package/dist/mcp/cli.js +0 -10
- package/dist/mcp/diagnostics.d.ts +0 -13
- package/dist/mcp/diagnostics.js +0 -61
- package/dist/mcp/server.d.ts +0 -16
- package/dist/mcp/server.js +0 -239
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- package/dist/preview/index.d.ts +0 -3
- package/dist/preview/index.js +0 -6
- package/dist/preview/net.d.ts +0 -6
- package/dist/preview/net.js +0 -56
- package/dist/preview/proxy.d.ts +0 -4
- package/dist/preview/proxy.js +0 -49
- package/dist/preview/runner.d.ts +0 -45
- package/dist/preview/runner.js +0 -110
- package/dist/preview/tunnel.d.ts +0 -14
- package/dist/preview/tunnel.js +0 -28
- package/dist/relay/cli.d.ts +0 -2
- package/dist/relay/cli.js +0 -7
- package/dist/relay/server.d.ts +0 -12
- package/dist/relay/server.js +0 -85
- package/dist/relay/store.d.ts +0 -13
- package/dist/relay/store.js +0 -68
- package/dist/scaffold/index.d.ts +0 -26
- package/dist/scaffold/index.js +0 -85
- package/dist/shared/config.d.ts +0 -39
- package/dist/shared/config.js +0 -90
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CBOR SUBSET, Node half. It mirrors `api/internal/edge/cbor.go` decision
|
|
3
|
+
* for decision, because two implementations of one wire format that were
|
|
4
|
+
* written independently are two implementations that drift.
|
|
5
|
+
*
|
|
6
|
+
* It decodes CONTROL payloads only — OPEN and HEAD. It NEVER touches a DATA
|
|
7
|
+
* payload.
|
|
8
|
+
*
|
|
9
|
+
* It is hand-written rather than a library for the same reason the Go one is: a
|
|
10
|
+
* general CBOR library's allocation behavior on hostile input would be someone
|
|
11
|
+
* else's decision, and the limits below are the whole point.
|
|
12
|
+
*
|
|
13
|
+
* Everything indefinite-length, every tag, every float, every byte string and
|
|
14
|
+
* every negative integer is REFUSED — not because they are dangerous in
|
|
15
|
+
* themselves, but because the encoder we own never emits them, so accepting
|
|
16
|
+
* them would only widen the input this parser has to be correct about.
|
|
17
|
+
*/
|
|
18
|
+
import { MAX_CBOR_DEPTH, MAX_HEADERS, MAX_HEADER_BYTES, MAX_OPEN_PAYLOAD, MAX_PATH_LEN, ProtocolError, } from "./frame.js";
|
|
19
|
+
const MAX_METHOD_LEN = 16;
|
|
20
|
+
const MAX_VIEWER_LABEL_LEN = 11;
|
|
21
|
+
/** Every CBOR error is STREAM-level: the stream dies, the tunnel SURVIVES. */
|
|
22
|
+
function fail(code) {
|
|
23
|
+
throw new ProtocolError(code, false);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* VIEWER_LABEL_RE is the SAME regex the edge assigns against, and this client
|
|
27
|
+
* RE-VALIDATES rather than trusting the edge. That is closure (c) of the three
|
|
28
|
+
* that keep viewer-supplied bytes out of the creator's agent's context: the
|
|
29
|
+
* label flows viewerLabel -> the relay ring-buffer key -> CLI terminal output
|
|
30
|
+
* and MCP tool output, which is a terminal/log-injection path INTO the agent,
|
|
31
|
+
* and a Go-only check would pass on a client that trusted the edge.
|
|
32
|
+
*/
|
|
33
|
+
export const VIEWER_LABEL_RE = /^viewer-[0-9]{1,4}$/;
|
|
34
|
+
export function parseViewerLabel(s) {
|
|
35
|
+
return VIEWER_LABEL_RE.test(s) ? s : null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* UA_CLASSES is the CLOSED ENUM OF SIX, mirroring the Go edge. `unknown` is the
|
|
39
|
+
* TOTAL fallback. This client validates the class it receives against this set
|
|
40
|
+
* before stamping the relay handshake, so an unmatched or forged class becomes
|
|
41
|
+
* `unknown` rather than an echo.
|
|
42
|
+
*/
|
|
43
|
+
export const UA_CLASSES = [
|
|
44
|
+
"ios_safari",
|
|
45
|
+
"android_chrome",
|
|
46
|
+
"desktop_chrome",
|
|
47
|
+
"desktop_safari",
|
|
48
|
+
"desktop_firefox",
|
|
49
|
+
"unknown",
|
|
50
|
+
];
|
|
51
|
+
export function parseUAClass(s) {
|
|
52
|
+
return UA_CLASSES.includes(s) ? s : "unknown";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* CONTROL_ARITY is the EXACT map length per kind, mirroring controlArity in the
|
|
56
|
+
* Go edge. The grammar stays closed per-kind rather than becoming "one or two
|
|
57
|
+
* keys, whatever arrives": a decoder that accepts a viewerLabel on `revoke`, or
|
|
58
|
+
* omits it on `viewer_gone`, is a decoder with a shape the encoder never emits.
|
|
59
|
+
*/
|
|
60
|
+
const CONTROL_ARITY = new Map([
|
|
61
|
+
["revoke", 1],
|
|
62
|
+
["expiring", 1],
|
|
63
|
+
["superseded", 1],
|
|
64
|
+
["viewer_gone", 2],
|
|
65
|
+
]);
|
|
66
|
+
// ---- decoding ---------------------------------------------------------------
|
|
67
|
+
class Reader {
|
|
68
|
+
b;
|
|
69
|
+
i = 0;
|
|
70
|
+
depth = 1;
|
|
71
|
+
constructor(b) {
|
|
72
|
+
this.b = b;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* head reads a CBOR initial byte and its argument. Indefinite-length
|
|
76
|
+
* (ai === 31) is REFUSED outright: it is the one CBOR shape whose length is
|
|
77
|
+
* not knowable before reading, which is the property every limit depends on.
|
|
78
|
+
*/
|
|
79
|
+
head() {
|
|
80
|
+
if (this.i >= this.b.length)
|
|
81
|
+
fail("cbor_malformed");
|
|
82
|
+
const c = this.b[this.i++];
|
|
83
|
+
const major = c >> 5;
|
|
84
|
+
const ai = c & 0x1f;
|
|
85
|
+
if (ai < 24)
|
|
86
|
+
return { major, arg: ai };
|
|
87
|
+
if (ai === 24) {
|
|
88
|
+
if (this.i >= this.b.length)
|
|
89
|
+
fail("cbor_malformed");
|
|
90
|
+
return { major, arg: this.b[this.i++] };
|
|
91
|
+
}
|
|
92
|
+
if (ai === 25) {
|
|
93
|
+
if (this.i + 2 > this.b.length)
|
|
94
|
+
fail("cbor_malformed");
|
|
95
|
+
const v = (this.b[this.i] << 8) + this.b[this.i + 1];
|
|
96
|
+
this.i += 2;
|
|
97
|
+
return { major, arg: v };
|
|
98
|
+
}
|
|
99
|
+
if (ai === 26) {
|
|
100
|
+
if (this.i + 4 > this.b.length)
|
|
101
|
+
fail("cbor_malformed");
|
|
102
|
+
const v = this.b[this.i] * 0x1000000 +
|
|
103
|
+
(this.b[this.i + 1] << 16) +
|
|
104
|
+
(this.b[this.i + 2] << 8) +
|
|
105
|
+
this.b[this.i + 3];
|
|
106
|
+
this.i += 4;
|
|
107
|
+
return { major, arg: v };
|
|
108
|
+
}
|
|
109
|
+
if (ai === 27) {
|
|
110
|
+
if (this.i + 8 > this.b.length)
|
|
111
|
+
fail("cbor_malformed");
|
|
112
|
+
let v = 0;
|
|
113
|
+
for (let k = 0; k < 8; k++)
|
|
114
|
+
v = v * 256 + this.b[this.i + k];
|
|
115
|
+
this.i += 8;
|
|
116
|
+
return { major, arg: v };
|
|
117
|
+
}
|
|
118
|
+
// 28..30 reserved; 31 indefinite-length. Both refused.
|
|
119
|
+
return fail("cbor_malformed");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* text reads a definite-length UTF-8 string, bounded by `max` BEFORE the
|
|
123
|
+
* slice is taken — so a declared 4 GiB string costs a comparison, not a copy.
|
|
124
|
+
*/
|
|
125
|
+
text(max) {
|
|
126
|
+
const { major, arg } = this.head();
|
|
127
|
+
if (major !== 3)
|
|
128
|
+
fail("cbor_malformed");
|
|
129
|
+
if (arg > max)
|
|
130
|
+
fail("cbor_malformed");
|
|
131
|
+
if (this.i + arg > this.b.length)
|
|
132
|
+
fail("cbor_malformed");
|
|
133
|
+
const s = new TextDecoder("utf-8", { fatal: false }).decode(this.b.subarray(this.i, this.i + arg));
|
|
134
|
+
this.i += arg;
|
|
135
|
+
return s;
|
|
136
|
+
}
|
|
137
|
+
uint(max) {
|
|
138
|
+
const { major, arg } = this.head();
|
|
139
|
+
if (major !== 0 || arg > max)
|
|
140
|
+
fail("cbor_malformed");
|
|
141
|
+
return arg;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* headerPairs reads a map of text->text, enforcing MAX_HEADERS and
|
|
145
|
+
* MAX_HEADER_BYTES as it goes. The COUNT bound is checked against the
|
|
146
|
+
* DECLARED map size before a single pair is read, so a payload claiming 2^32
|
|
147
|
+
* headers allocates nothing.
|
|
148
|
+
*/
|
|
149
|
+
headerPairs() {
|
|
150
|
+
this.depth++;
|
|
151
|
+
if (this.depth > MAX_CBOR_DEPTH)
|
|
152
|
+
fail("cbor_depth");
|
|
153
|
+
try {
|
|
154
|
+
const { major, arg } = this.head();
|
|
155
|
+
if (major !== 5)
|
|
156
|
+
fail("cbor_malformed");
|
|
157
|
+
if (arg > MAX_HEADERS)
|
|
158
|
+
fail("cbor_headers");
|
|
159
|
+
const out = [];
|
|
160
|
+
let total = 0;
|
|
161
|
+
for (let k = 0; k < arg; k++) {
|
|
162
|
+
const name = this.text(MAX_HEADER_BYTES);
|
|
163
|
+
const val = this.text(MAX_HEADER_BYTES);
|
|
164
|
+
total += name.length + val.length;
|
|
165
|
+
if (total > MAX_HEADER_BYTES)
|
|
166
|
+
fail("cbor_header_bytes");
|
|
167
|
+
out.push([name, val]);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
this.depth--;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function decodeOpen(b) {
|
|
177
|
+
if (b.length > MAX_OPEN_PAYLOAD)
|
|
178
|
+
fail("open_payload_too_large");
|
|
179
|
+
const r = new Reader(b);
|
|
180
|
+
const { major, arg } = r.head();
|
|
181
|
+
if (major !== 5 || arg > 8)
|
|
182
|
+
fail("cbor_malformed");
|
|
183
|
+
let viewerLabel;
|
|
184
|
+
let method;
|
|
185
|
+
let path;
|
|
186
|
+
let headers = [];
|
|
187
|
+
let upgrade;
|
|
188
|
+
for (let k = 0; k < arg; k++) {
|
|
189
|
+
const key = r.text(32);
|
|
190
|
+
switch (key) {
|
|
191
|
+
case "viewerLabel": {
|
|
192
|
+
const raw = r.text(MAX_VIEWER_LABEL_LEN);
|
|
193
|
+
// RE-VALIDATED here, not merely trusted because the edge assigned it.
|
|
194
|
+
const parsed = parseViewerLabel(raw);
|
|
195
|
+
if (parsed === null)
|
|
196
|
+
fail("cbor_viewer");
|
|
197
|
+
viewerLabel = parsed;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "method":
|
|
201
|
+
method = r.text(MAX_METHOD_LEN);
|
|
202
|
+
break;
|
|
203
|
+
case "path": {
|
|
204
|
+
const p = r.text(MAX_PATH_LEN + 1);
|
|
205
|
+
if (p.length > MAX_PATH_LEN)
|
|
206
|
+
fail("cbor_path_len");
|
|
207
|
+
path = p;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "headers":
|
|
211
|
+
headers = r.headerPairs();
|
|
212
|
+
break;
|
|
213
|
+
case "upgrade":
|
|
214
|
+
upgrade = r.text(32);
|
|
215
|
+
break;
|
|
216
|
+
default:
|
|
217
|
+
// An unknown key is a MALFORMED payload, not a skipped field. Skipping
|
|
218
|
+
// would mean accepting bytes we do not understand on the one payload
|
|
219
|
+
// this client parses.
|
|
220
|
+
fail("cbor_malformed");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (viewerLabel === undefined || method === undefined || path === undefined) {
|
|
224
|
+
fail("cbor_malformed");
|
|
225
|
+
}
|
|
226
|
+
// Trailing bytes after a complete payload are a second message hiding in one
|
|
227
|
+
// frame.
|
|
228
|
+
if (r.i !== b.length)
|
|
229
|
+
fail("cbor_malformed");
|
|
230
|
+
const out = { viewerLabel, method, path, headers };
|
|
231
|
+
if (upgrade !== undefined)
|
|
232
|
+
out.upgrade = upgrade;
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
export function decodeHead(b) {
|
|
236
|
+
if (b.length > MAX_OPEN_PAYLOAD)
|
|
237
|
+
fail("open_payload_too_large");
|
|
238
|
+
const r = new Reader(b);
|
|
239
|
+
const { major, arg } = r.head();
|
|
240
|
+
if (major !== 5 || arg > 4)
|
|
241
|
+
fail("cbor_malformed");
|
|
242
|
+
let status;
|
|
243
|
+
let headers = [];
|
|
244
|
+
for (let k = 0; k < arg; k++) {
|
|
245
|
+
const key = r.text(32);
|
|
246
|
+
switch (key) {
|
|
247
|
+
case "status": {
|
|
248
|
+
const v = r.uint(0xffff);
|
|
249
|
+
// A status outside 100..599 is a protocol violation and never a
|
|
250
|
+
// relayed status.
|
|
251
|
+
if (v < 100 || v > 599)
|
|
252
|
+
fail("cbor_status");
|
|
253
|
+
status = v;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "headers":
|
|
257
|
+
headers = r.headerPairs();
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
fail("cbor_malformed");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (status === undefined)
|
|
264
|
+
fail("cbor_malformed");
|
|
265
|
+
if (r.i !== b.length)
|
|
266
|
+
fail("cbor_malformed");
|
|
267
|
+
return { status, headers };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The map arity is asserted AGAINST THE KIND, so a payload claiming `revoke`
|
|
271
|
+
* with a trailing label is refused as malformed rather than quietly accepted
|
|
272
|
+
* with the extra key ignored.
|
|
273
|
+
*/
|
|
274
|
+
export function decodeControl(b) {
|
|
275
|
+
if (b.length > MAX_OPEN_PAYLOAD)
|
|
276
|
+
fail("open_payload_too_large");
|
|
277
|
+
const r = new Reader(b);
|
|
278
|
+
const { major, arg } = r.head();
|
|
279
|
+
if (major !== 5)
|
|
280
|
+
fail("cbor_malformed");
|
|
281
|
+
if (r.text(32) !== "kind")
|
|
282
|
+
fail("cbor_malformed");
|
|
283
|
+
const v = r.text(32);
|
|
284
|
+
const want = CONTROL_ARITY.get(v);
|
|
285
|
+
if (want === undefined || arg !== want)
|
|
286
|
+
fail("cbor_malformed");
|
|
287
|
+
const kind = v;
|
|
288
|
+
let viewerLabel;
|
|
289
|
+
if (kind === "viewer_gone") {
|
|
290
|
+
if (r.text(32) !== "viewerLabel")
|
|
291
|
+
fail("cbor_malformed");
|
|
292
|
+
// Parsed, not copied: the label reaches the creator's terminal.
|
|
293
|
+
const raw = r.text(MAX_VIEWER_LABEL_LEN);
|
|
294
|
+
if (parseViewerLabel(raw) === null)
|
|
295
|
+
fail("cbor_viewer");
|
|
296
|
+
viewerLabel = raw;
|
|
297
|
+
}
|
|
298
|
+
if (r.i !== b.length)
|
|
299
|
+
fail("cbor_malformed");
|
|
300
|
+
return viewerLabel === undefined ? { kind } : { kind, viewerLabel };
|
|
301
|
+
}
|
|
302
|
+
// ---- encoding ---------------------------------------------------------------
|
|
303
|
+
/**
|
|
304
|
+
* The encoder emits CANONICAL CBOR for the subset: definite lengths, shortest
|
|
305
|
+
* integer encoding, and map keys in a fixed order, with header pairs SORTED.
|
|
306
|
+
*
|
|
307
|
+
* Canonicality is what makes the checked-in golden fixture comparable
|
|
308
|
+
* byte-for-byte against BOTH implementations' output. A non-deterministic
|
|
309
|
+
* encoder would make the fixture assertable in one direction only.
|
|
310
|
+
*/
|
|
311
|
+
function head(major, arg) {
|
|
312
|
+
const m = major << 5;
|
|
313
|
+
if (arg < 24)
|
|
314
|
+
return [m | arg];
|
|
315
|
+
if (arg <= 0xff)
|
|
316
|
+
return [m | 24, arg];
|
|
317
|
+
if (arg <= 0xffff)
|
|
318
|
+
return [m | 25, (arg >>> 8) & 0xff, arg & 0xff];
|
|
319
|
+
if (arg <= 0xffffffff) {
|
|
320
|
+
return [
|
|
321
|
+
m | 26,
|
|
322
|
+
(arg >>> 24) & 0xff,
|
|
323
|
+
(arg >>> 16) & 0xff,
|
|
324
|
+
(arg >>> 8) & 0xff,
|
|
325
|
+
arg & 0xff,
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
const out = [m | 27];
|
|
329
|
+
let v = arg;
|
|
330
|
+
const bytes = [];
|
|
331
|
+
for (let k = 0; k < 8; k++) {
|
|
332
|
+
bytes.unshift(v % 256);
|
|
333
|
+
v = Math.floor(v / 256);
|
|
334
|
+
}
|
|
335
|
+
return out.concat(bytes);
|
|
336
|
+
}
|
|
337
|
+
function text(s) {
|
|
338
|
+
const b = Array.from(new TextEncoder().encode(s));
|
|
339
|
+
return head(3, b.length).concat(b);
|
|
340
|
+
}
|
|
341
|
+
function headerMap(h) {
|
|
342
|
+
// Sorted by name then value, so two encoders handed the same headers in a
|
|
343
|
+
// different order still produce the same bytes.
|
|
344
|
+
const sorted = [...h].sort((a, b) => a[0] === b[0] ? (a[1] < b[1] ? -1 : 1) : a[0] < b[0] ? -1 : 1);
|
|
345
|
+
let out = head(5, sorted.length);
|
|
346
|
+
for (const [k, v] of sorted)
|
|
347
|
+
out = out.concat(text(k), text(v));
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
/** Canonical key order: viewerLabel, method, path, headers, [upgrade]. */
|
|
351
|
+
export function encodeOpen(p) {
|
|
352
|
+
if (parseViewerLabel(p.viewerLabel) === null)
|
|
353
|
+
fail("cbor_viewer");
|
|
354
|
+
if (p.path.length > MAX_PATH_LEN)
|
|
355
|
+
fail("cbor_path_len");
|
|
356
|
+
if (p.headers.length > MAX_HEADERS)
|
|
357
|
+
fail("cbor_headers");
|
|
358
|
+
const n = p.upgrade ? 5 : 4;
|
|
359
|
+
let out = head(5, n);
|
|
360
|
+
out = out.concat(text("viewerLabel"), text(p.viewerLabel));
|
|
361
|
+
out = out.concat(text("method"), text(p.method));
|
|
362
|
+
out = out.concat(text("path"), text(p.path));
|
|
363
|
+
out = out.concat(text("headers"), headerMap(p.headers));
|
|
364
|
+
if (p.upgrade)
|
|
365
|
+
out = out.concat(text("upgrade"), text(p.upgrade));
|
|
366
|
+
const b = new Uint8Array(out);
|
|
367
|
+
if (b.length > MAX_OPEN_PAYLOAD)
|
|
368
|
+
fail("open_payload_too_large");
|
|
369
|
+
return b;
|
|
370
|
+
}
|
|
371
|
+
/** Canonical key order: status, headers. */
|
|
372
|
+
export function encodeHead(p) {
|
|
373
|
+
if (p.status < 100 || p.status > 599)
|
|
374
|
+
fail("cbor_status");
|
|
375
|
+
if (p.headers.length > MAX_HEADERS)
|
|
376
|
+
fail("cbor_headers");
|
|
377
|
+
let out = head(5, 2);
|
|
378
|
+
out = out.concat(text("status"), head(0, p.status));
|
|
379
|
+
out = out.concat(text("headers"), headerMap(p.headers));
|
|
380
|
+
const b = new Uint8Array(out);
|
|
381
|
+
if (b.length > MAX_OPEN_PAYLOAD)
|
|
382
|
+
fail("open_payload_too_large");
|
|
383
|
+
return b;
|
|
384
|
+
}
|
|
385
|
+
export function encodeControl(kind, viewerLabel) {
|
|
386
|
+
const arity = CONTROL_ARITY.get(kind);
|
|
387
|
+
if (arity === undefined)
|
|
388
|
+
fail("cbor_malformed");
|
|
389
|
+
if (kind === "viewer_gone") {
|
|
390
|
+
if (viewerLabel === undefined || parseViewerLabel(viewerLabel) === null) {
|
|
391
|
+
fail("cbor_viewer");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if (viewerLabel !== undefined) {
|
|
395
|
+
// A label on a terminal kind is a caller bug, refused rather than dropped.
|
|
396
|
+
fail("cbor_malformed");
|
|
397
|
+
}
|
|
398
|
+
const out = head(5, arity).concat(text("kind"), text(kind));
|
|
399
|
+
if (kind === "viewer_gone") {
|
|
400
|
+
out.push(...text("viewerLabel"), ...text(viewerLabel));
|
|
401
|
+
}
|
|
402
|
+
return new Uint8Array(out);
|
|
403
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tunnel client: the creator's side of the wire.
|
|
3
|
+
*
|
|
4
|
+
* It opens ONE outbound WSS connection to the edge and multiplexes every
|
|
5
|
+
* viewer's request onto it. Nothing dials INTO the creator's machine; the
|
|
6
|
+
* client never opens a stream, and the edge rejects an even streamId from it as
|
|
7
|
+
* a protocol violation.
|
|
8
|
+
*
|
|
9
|
+
* What this file does NOT do, deliberately:
|
|
10
|
+
* - it does not parse a DATA payload (they are opaque here too);
|
|
11
|
+
* - it does not follow a redirect on the creator's behalf;
|
|
12
|
+
* - it does not trust the edge's viewerLabel or ua-class without
|
|
13
|
+
* re-validating both against the same regex and the same closed enum.
|
|
14
|
+
*/
|
|
15
|
+
import { WebSocket } from "ws";
|
|
16
|
+
import { encodeCredit } from "./frame.js";
|
|
17
|
+
import { type ControlKind, type UAClass } from "./cbor.js";
|
|
18
|
+
import { type ForwardTarget } from "./forwardTarget.js";
|
|
19
|
+
/**
|
|
20
|
+
* VIEWER_STAMP_HEADER carries the VALIDATED label to the creator's own local
|
|
21
|
+
* server, so the relay can key its ring buffer by viewer.
|
|
22
|
+
*
|
|
23
|
+
* The stamp happens HERE, on the creator's side of the wire, which is the whole
|
|
24
|
+
* point of 2.8: the device never chooses its own label, and the value written
|
|
25
|
+
* has already passed parseViewerLabel. It is added AFTER sanitizeHeaders, so a
|
|
26
|
+
* viewer-supplied header of the same name cannot survive to shadow it.
|
|
27
|
+
*/
|
|
28
|
+
export declare const VIEWER_STAMP_HEADER = "x-vincentt-viewer";
|
|
29
|
+
export declare const UA_CLASS_STAMP_HEADER = "x-vincentt-ua-class";
|
|
30
|
+
/**
|
|
31
|
+
* RESUME_HEADER is ONE spelling for both halves of the wire: the edge writes it
|
|
32
|
+
* on the 101 (`ResumeHeader` in `internal/edge/websocket.go`), and this client
|
|
33
|
+
* sends it back on the next dial.
|
|
34
|
+
*
|
|
35
|
+
* It is lower-case here because Node lower-cases every response header name,
|
|
36
|
+
* and `res.headers["X-Vincentt-Resume"]` is silently `undefined`. That mistake
|
|
37
|
+
* would reproduce the exact defect this constant exists to fix — a client that
|
|
38
|
+
* never learns its next credential — with a fully green suite, so the casing is
|
|
39
|
+
* asserted by a test rather than trusted.
|
|
40
|
+
*/
|
|
41
|
+
export declare const RESUME_HEADER = "x-vincentt-resume";
|
|
42
|
+
/**
|
|
43
|
+
* SESSION_HEADER carries the sessionId hex on the RECONNECT dial only.
|
|
44
|
+
*
|
|
45
|
+
* It exists because the reconnect path carries NO GRANT
|
|
46
|
+
* (`D-The-reconnect-path-carries-no-grant`), and the grant is what used to carry
|
|
47
|
+
* the session id in its signed `sid` claim. With the grant gone the edge has no
|
|
48
|
+
* other way to name the row, so the id travels as its own header.
|
|
49
|
+
*
|
|
50
|
+
* It is NOT a secret — it is an ObjectID the edge immediately parses with
|
|
51
|
+
* `primitive.ObjectIDFromHex` before it touches anything (`ingress.go:288`) —
|
|
52
|
+
* but exactly one must be sent: the edge refuses duplicates on all three
|
|
53
|
+
* credential-bearing headers with `Header.Values()` rather than `Get`, because
|
|
54
|
+
* `Get` silently returns the first value and that is the bypass being closed
|
|
55
|
+
* (`ingress.go:120-126`).
|
|
56
|
+
*
|
|
57
|
+
* Lower-case for the same reason as RESUME_HEADER: Node normalizes header names.
|
|
58
|
+
*/
|
|
59
|
+
export declare const SESSION_HEADER = "x-vincentt-session";
|
|
60
|
+
/** The Authorization header, named once so the never-send-both rule cites it. */
|
|
61
|
+
export declare const AUTHORIZATION_HEADER = "authorization";
|
|
62
|
+
/**
|
|
63
|
+
* RESUME_TOKEN_LEN is base64url(32 random bytes), unpadded. It mirrors
|
|
64
|
+
* `resumeTokenLen` in the Go edge so the two ends of this wire state the same
|
|
65
|
+
* fact rather than each carrying a magic number.
|
|
66
|
+
*/
|
|
67
|
+
export declare const RESUME_TOKEN_LEN = 43;
|
|
68
|
+
/**
|
|
69
|
+
* parseResumeToken accepts only the grammar the API's issuer produces.
|
|
70
|
+
*
|
|
71
|
+
* ABSENCE IS LEGITIMATE, not an error: the edge OMITS the header rather than
|
|
72
|
+
* emitting it empty when it has no token to hand over, and the handshake still
|
|
73
|
+
* succeeds. The cost is one reconnect, which is recoverable — treating it as
|
|
74
|
+
* fatal would turn a token problem into a dead preview.
|
|
75
|
+
*
|
|
76
|
+
* A malformed value is discarded for the same reason it is validated at the
|
|
77
|
+
* edge before being written: this value is concatenated into an outbound header
|
|
78
|
+
* on the next dial, and a CR or LF in it would not be a bad credential, it
|
|
79
|
+
* would be a second request.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseResumeToken(raw: unknown): string | null;
|
|
82
|
+
export interface TunnelClientOptions {
|
|
83
|
+
/** The edge's tunnel ingress: wss://edge-<env>.vincentt.studio/tunnel. */
|
|
84
|
+
readonly edgeUrl: string;
|
|
85
|
+
/**
|
|
86
|
+
* The opaque JWS the API minted. THE CLIENT NEVER PARSES IT.
|
|
87
|
+
*
|
|
88
|
+
* READONLY AND SINGLE-ASSIGNMENT, and that is a design property rather than
|
|
89
|
+
* style (`D-Client-holds-one-credential-per-state`). It is a FIRST-ATTACH
|
|
90
|
+
* credential and nothing else: 10-minute TTL, spent by the attach CAS, and
|
|
91
|
+
* never presented again. Two withdrawn designs needed a mutable grant field so
|
|
92
|
+
* the client could hold a second, longer-lived grant for the reconnect path;
|
|
93
|
+
* both were falsified, and the field staying readonly is what makes the
|
|
94
|
+
* "which credential for which door" question unaskable here.
|
|
95
|
+
*/
|
|
96
|
+
readonly grant: string;
|
|
97
|
+
/**
|
|
98
|
+
* The session id hex from the MINT response, sent as X-Vincentt-Session on
|
|
99
|
+
* every redial. Not a credential — the reconnect path's two conditions are the
|
|
100
|
+
* resume token's single-use consume and the tunnel-slot CAS, both enforced by
|
|
101
|
+
* state at the API.
|
|
102
|
+
*/
|
|
103
|
+
readonly sessionId: string;
|
|
104
|
+
readonly target: ForwardTarget;
|
|
105
|
+
/** onViewer is the CLI's connect line. It receives only VALIDATED values. */
|
|
106
|
+
readonly onViewer?: (label: string, uaClass: UAClass) => void;
|
|
107
|
+
/**
|
|
108
|
+
* onViewerGone is the CLI's disconnect line — the other half of onViewer.
|
|
109
|
+
*
|
|
110
|
+
* onViewer fires per OPEN frame (so the roster dedupes by label); this fires
|
|
111
|
+
* ONCE, when the viewer's connection closes. Without it the roster only ever
|
|
112
|
+
* grows: for a long time the edge logged the detach server-side and told the
|
|
113
|
+
* client nothing, so a phone refreshing read as an arriving crowd.
|
|
114
|
+
*/
|
|
115
|
+
readonly onViewerGone?: (label: string) => void;
|
|
116
|
+
/** onControl carries revoke/expiring/superseded so the CLI prints the right ending. */
|
|
117
|
+
readonly onControl?: (kind: ControlKind) => void;
|
|
118
|
+
/** onOriginError is the creator-facing detail the 502 page must never carry. */
|
|
119
|
+
readonly onOriginError?: (label: string, code: string) => void;
|
|
120
|
+
readonly onStateChange?: (state: TunnelState) => void;
|
|
121
|
+
/**
|
|
122
|
+
* onTunnelError carries the error CODE of a failed dial (`ECONNREFUSED`,
|
|
123
|
+
* `CERT_HAS_EXPIRED`, …). Never the message: an error string from a TLS or
|
|
124
|
+
* HTTP layer can carry the URL, and the URL is the capability.
|
|
125
|
+
*/
|
|
126
|
+
readonly onTunnelError?: (code: string) => void;
|
|
127
|
+
/**
|
|
128
|
+
* onReconnectRefused fires when the EDGE ANSWERED and refused a reconnect —
|
|
129
|
+
* a real HTTP status on the upgrade, not a transport failure.
|
|
130
|
+
*
|
|
131
|
+
* It exists because a refused reconnect stopped being routine noise: the edge
|
|
132
|
+
* emits `preview.resume_token_rejected` on it, which QA and security made an
|
|
133
|
+
* alertable theft signal, and the honest client is not supposed to trigger it.
|
|
134
|
+
* Without this callback the client retried silently forever and the creator
|
|
135
|
+
* watched "Reconnecting…" for a session that was already unrecoverable.
|
|
136
|
+
*
|
|
137
|
+
* The three statuses are deliberately distinguishable rather than collapsed
|
|
138
|
+
* (`ingress.go:299-310`): 401 the token was refused, 409 another tunnel holds
|
|
139
|
+
* this preview, 410 the session is gone. They map to different creator actions.
|
|
140
|
+
*/
|
|
141
|
+
readonly onReconnectRefused?: (status: number) => void;
|
|
142
|
+
/** Injectable for tests; never a real socket in a unit test. */
|
|
143
|
+
readonly connect?: (url: string, headers: Record<string, string>) => WebSocket;
|
|
144
|
+
}
|
|
145
|
+
export type TunnelState = "connecting" | "live" | "reconnecting" | "closed";
|
|
146
|
+
export declare class TunnelClient {
|
|
147
|
+
private readonly opts;
|
|
148
|
+
private ws;
|
|
149
|
+
private decoder;
|
|
150
|
+
private readonly streams;
|
|
151
|
+
/**
|
|
152
|
+
* resumeToken is 32 random bytes, single-use, rotated on every attach, bound
|
|
153
|
+
* to the session, 5-minute TTL. It is held in memory only and NEVER logged:
|
|
154
|
+
* "single-use" is enforced by the API's atomic consume, and a token in a log
|
|
155
|
+
* is a credential in a log.
|
|
156
|
+
*/
|
|
157
|
+
private resumeToken;
|
|
158
|
+
private attempt;
|
|
159
|
+
private closed;
|
|
160
|
+
private state;
|
|
161
|
+
private pingTimer;
|
|
162
|
+
private lastPong;
|
|
163
|
+
constructor(opts: TunnelClientOptions);
|
|
164
|
+
start(): void;
|
|
165
|
+
close(): void;
|
|
166
|
+
private setState;
|
|
167
|
+
/**
|
|
168
|
+
* THE DIAL RULE. One boolean, two disjoint states, one credential each.
|
|
169
|
+
*
|
|
170
|
+
* first dial (resumeToken === null): Authorization: Bearer <mint grant>
|
|
171
|
+
* every redial (resumeToken !== null): X-Vincentt-Resume + X-Vincentt-Session
|
|
172
|
+
*
|
|
173
|
+
* This function is the WIRING of `D-Client-holds-one-credential-per-state`:
|
|
174
|
+
* every dial's header set is built here and nowhere else, so the never-send-both
|
|
175
|
+
* rule is a property of one `if/else` rather than of a convention.
|
|
176
|
+
*
|
|
177
|
+
* NEVER BOTH, and this is the rule most likely to be "helpfully" broken later.
|
|
178
|
+
* The edge REFUSES a request carrying a grant alongside a valid resume token
|
|
179
|
+
* (`ingress.go:144-148`) — it does not ignore the grant — so adding
|
|
180
|
+
* `Authorization` here "for robustness" would break every reconnect while
|
|
181
|
+
* passing every functional test in the suite except the negative that exists
|
|
182
|
+
* for exactly this. The reason it is a refusal rather than a preference: a
|
|
183
|
+
* header that is sometimes meaningful and sometimes ignored is a branch waiting
|
|
184
|
+
* to be written.
|
|
185
|
+
*
|
|
186
|
+
* There is no per-door judgement to get wrong because the states are disjoint:
|
|
187
|
+
* the grant is spent by the attach CAS the moment the first dial succeeds, and
|
|
188
|
+
* a resume token only ever exists after a 101 has delivered one.
|
|
189
|
+
*/
|
|
190
|
+
private dialHeaders;
|
|
191
|
+
private open;
|
|
192
|
+
private onClosed;
|
|
193
|
+
private startPing;
|
|
194
|
+
private stopPing;
|
|
195
|
+
private send;
|
|
196
|
+
private onBytes;
|
|
197
|
+
private dispatch;
|
|
198
|
+
/**
|
|
199
|
+
* serveOpen forwards ONE viewer request to the single configured target.
|
|
200
|
+
*
|
|
201
|
+
* Every 2.15.4 rule executes here, in order: the target is the configured one
|
|
202
|
+
* (never from the request), the request target is normalized-then-checked,
|
|
203
|
+
* the viewer's routing headers are dropped, loopback is RE-CHECKED at connect,
|
|
204
|
+
* and no redirect is followed.
|
|
205
|
+
*/
|
|
206
|
+
private serveOpen;
|
|
207
|
+
/**
|
|
208
|
+
* serveUpgrade splices ONE viewer's WebSocket to the creator's local server.
|
|
209
|
+
*
|
|
210
|
+
* It re-issues the handshake over a raw socket rather than using an HTTP
|
|
211
|
+
* client, because Node's `http` module owns the connection after a 101 and
|
|
212
|
+
* will not hand back a usable duplex without the same hijack this does
|
|
213
|
+
* explicitly. The response's status line and headers are parsed only far
|
|
214
|
+
* enough to build the HEAD frame; every byte after the blank line is OPAQUE
|
|
215
|
+
* and is forwarded without being read.
|
|
216
|
+
*/
|
|
217
|
+
private serveUpgrade;
|
|
218
|
+
}
|
|
219
|
+
export { encodeCredit };
|