@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.
Files changed (54) hide show
  1. package/dist/client/HarnessProvider.d.ts +43 -1
  2. package/dist/client/HarnessProvider.js +66 -10
  3. package/dist/client/annotate.d.ts +34 -0
  4. package/dist/client/annotate.js +104 -0
  5. package/dist/client/index.d.ts +2 -0
  6. package/dist/client/index.js +1 -0
  7. package/dist/shared/events.d.ts +138 -3
  8. package/dist/shared/events.js +25 -1
  9. package/dist/tunnel/cbor.d.ts +71 -0
  10. package/dist/tunnel/cbor.js +403 -0
  11. package/dist/tunnel/client.d.ts +219 -0
  12. package/dist/tunnel/client.js +620 -0
  13. package/dist/tunnel/forwardTarget.d.ts +69 -0
  14. package/dist/tunnel/forwardTarget.js +174 -0
  15. package/dist/tunnel/frame.d.ts +126 -0
  16. package/dist/tunnel/frame.js +244 -0
  17. package/dist/tunnel/index.d.ts +11 -0
  18. package/dist/tunnel/index.js +11 -0
  19. package/package.json +14 -31
  20. package/README.md +0 -87
  21. package/dist/cli/index.d.ts +0 -2
  22. package/dist/cli/index.js +0 -55
  23. package/dist/login/login.d.ts +0 -34
  24. package/dist/login/login.js +0 -148
  25. package/dist/mcp/backend.d.ts +0 -52
  26. package/dist/mcp/backend.js +0 -146
  27. package/dist/mcp/cli.d.ts +0 -2
  28. package/dist/mcp/cli.js +0 -10
  29. package/dist/mcp/diagnostics.d.ts +0 -13
  30. package/dist/mcp/diagnostics.js +0 -61
  31. package/dist/mcp/server.d.ts +0 -16
  32. package/dist/mcp/server.js +0 -239
  33. package/dist/preview/cloudflared.d.ts +0 -13
  34. package/dist/preview/cloudflared.js +0 -46
  35. package/dist/preview/index.d.ts +0 -3
  36. package/dist/preview/index.js +0 -6
  37. package/dist/preview/net.d.ts +0 -6
  38. package/dist/preview/net.js +0 -56
  39. package/dist/preview/proxy.d.ts +0 -4
  40. package/dist/preview/proxy.js +0 -49
  41. package/dist/preview/runner.d.ts +0 -45
  42. package/dist/preview/runner.js +0 -110
  43. package/dist/preview/tunnel.d.ts +0 -14
  44. package/dist/preview/tunnel.js +0 -28
  45. package/dist/relay/cli.d.ts +0 -2
  46. package/dist/relay/cli.js +0 -7
  47. package/dist/relay/server.d.ts +0 -12
  48. package/dist/relay/server.js +0 -85
  49. package/dist/relay/store.d.ts +0 -13
  50. package/dist/relay/store.js +0 -68
  51. package/dist/scaffold/index.d.ts +0 -26
  52. package/dist/scaffold/index.js +0 -85
  53. package/dist/shared/config.d.ts +0 -39
  54. 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 };