@vincentt-xr/cli 0.18.3 → 0.20.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 (41) hide show
  1. package/MIGRATION.md +26 -2
  2. package/README.md +3 -2
  3. package/dist/cli/index.js +43 -12
  4. package/dist/cli/usage-text.js +4 -3
  5. package/dist/lib/terminalText.d.ts +2 -0
  6. package/dist/lib/terminalText.js +7 -0
  7. package/dist/login/authBackend.d.ts +27 -2
  8. package/dist/login/authBackend.js +79 -13
  9. package/dist/login/loginCommand.d.ts +35 -3
  10. package/dist/login/loginCommand.js +52 -3
  11. package/dist/login/machineLabel.d.ts +46 -0
  12. package/dist/login/machineLabel.js +119 -0
  13. package/dist/login/pkceLogin.d.ts +14 -2
  14. package/dist/login/pkceLogin.js +31 -8
  15. package/dist/preview/api.d.ts +15 -0
  16. package/dist/preview/api.js +27 -1
  17. package/dist/preview/command.d.ts +1 -1
  18. package/dist/preview/copy.d.ts +14 -1
  19. package/dist/preview/copy.js +38 -4
  20. package/dist/preview/devPort.d.ts +17 -0
  21. package/dist/preview/devPort.js +14 -0
  22. package/dist/preview/proxy.d.ts +16 -9
  23. package/dist/preview/proxy.js +102 -20
  24. package/dist/preview/runner.d.ts +10 -2
  25. package/dist/preview/runner.js +21 -6
  26. package/dist/preview/stream.d.ts +1 -1
  27. package/dist/preview/viewers.d.ts +1 -1
  28. package/dist/relay/server.js +1 -1
  29. package/dist/shared/authError.d.ts +1 -1
  30. package/dist/shared/authError.js +3 -1
  31. package/dist/tunnel/cbor.d.ts +71 -0
  32. package/dist/tunnel/cbor.js +403 -0
  33. package/dist/tunnel/client.d.ts +225 -0
  34. package/dist/tunnel/client.js +764 -0
  35. package/dist/tunnel/forwardTarget.d.ts +69 -0
  36. package/dist/tunnel/forwardTarget.js +179 -0
  37. package/dist/tunnel/frame.d.ts +126 -0
  38. package/dist/tunnel/frame.js +244 -0
  39. package/dist/tunnel/index.d.ts +9 -0
  40. package/dist/tunnel/index.js +9 -0
  41. package/package.json +2 -2
@@ -0,0 +1,225 @@
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
+ * End a stream on purpose: return any credit owed, RESET it, and tear the
200
+ * local request down WITHOUT the origin-error advisory, which is reserved for
201
+ * the creator's dev server actually failing.
202
+ */
203
+ private quietReset;
204
+ /**
205
+ * serveOpen forwards ONE viewer request to the single configured target.
206
+ *
207
+ * Every 2.15.4 rule executes here, in order: the target is the configured one
208
+ * (never from the request), the request target is normalized-then-checked,
209
+ * the viewer's routing headers are dropped, loopback is RE-CHECKED at connect,
210
+ * and no redirect is followed.
211
+ */
212
+ private serveOpen;
213
+ /**
214
+ * serveUpgrade splices ONE viewer's WebSocket to the creator's local server.
215
+ *
216
+ * It re-issues the handshake over a raw socket rather than using an HTTP
217
+ * client, because Node's `http` module owns the connection after a 101 and
218
+ * will not hand back a usable duplex without the same hijack this does
219
+ * explicitly. The response's status line and headers are parsed only far
220
+ * enough to build the HEAD frame; every byte after the blank line is OPAQUE
221
+ * and is forwarded without being read.
222
+ */
223
+ private serveUpgrade;
224
+ }
225
+ export { encodeCredit };