@vincentt-xr/cli 0.18.2 → 0.19.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/MIGRATION.md +22 -0
- package/dist/cli/index.js +18 -6
- package/dist/cli/usage-text.js +1 -1
- package/dist/lib/diagnostics.d.ts +2 -1
- package/dist/lib/diagnostics.js +6 -2
- package/dist/lib/feedback.js +5 -4
- package/dist/lib/lifecycle.js +3 -2
- package/dist/lib/relayInfo.d.ts +8 -2
- package/dist/lib/relayInfo.js +15 -5
- package/dist/lib/terminalText.d.ts +2 -0
- package/dist/lib/terminalText.js +7 -0
- package/dist/login/pkceLogin.d.ts +7 -1
- package/dist/login/pkceLogin.js +25 -4
- package/dist/preview/api.d.ts +15 -0
- package/dist/preview/api.js +27 -1
- package/dist/preview/command.d.ts +1 -1
- package/dist/preview/copy.d.ts +14 -1
- package/dist/preview/copy.js +37 -3
- package/dist/preview/devPort.d.ts +17 -0
- package/dist/preview/devPort.js +14 -0
- package/dist/preview/proxy.d.ts +16 -9
- package/dist/preview/proxy.js +102 -20
- package/dist/preview/runner.d.ts +12 -2
- package/dist/preview/runner.js +28 -8
- package/dist/preview/stream.d.ts +1 -1
- package/dist/preview/viewers.d.ts +1 -1
- package/dist/relay/guard.d.ts +23 -0
- package/dist/relay/guard.js +73 -0
- package/dist/relay/server.d.ts +13 -0
- package/dist/relay/server.js +53 -5
- package/dist/tunnel/cbor.d.ts +71 -0
- package/dist/tunnel/cbor.js +403 -0
- package/dist/tunnel/client.d.ts +225 -0
- package/dist/tunnel/client.js +764 -0
- package/dist/tunnel/forwardTarget.d.ts +69 -0
- package/dist/tunnel/forwardTarget.js +179 -0
- package/dist/tunnel/frame.d.ts +126 -0
- package/dist/tunnel/frame.js +244 -0
- package/dist/tunnel/index.d.ts +9 -0
- package/dist/tunnel/index.js +9 -0
- package/package.json +2 -2
package/dist/preview/proxy.js
CHANGED
|
@@ -138,23 +138,34 @@ export function relayRoute(url, harnessPath, kind) {
|
|
|
138
138
|
}
|
|
139
139
|
/**
|
|
140
140
|
* Whether the front proxy must answer 404 itself for this app-route URL, so a
|
|
141
|
-
* file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config
|
|
142
|
-
*
|
|
141
|
+
* file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config`,
|
|
142
|
+
* the binding or a credential file to any holder of a preview link.
|
|
143
143
|
*
|
|
144
144
|
* The path is decoded once, and an encoded `/` or `\` is refused outright (a
|
|
145
|
-
* separator the dev server would split on and this check would not).
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
145
|
+
* separator the dev server would split on and this check would not). The query
|
|
146
|
+
* and fragment may not carry a traversal or a separator either, because some
|
|
147
|
+
* dev servers resolve a file from a query parameter. A segment still carrying
|
|
148
|
+
* an encoded character after that one decode is judged as the dev server would
|
|
149
|
+
* read it after a second decode, so `%252eenv` cannot slip past; a second decode
|
|
150
|
+
* that throws is done leniently, never treated as "not hidden". `.` and `..`
|
|
151
|
+
* segments are always refused, which is what makes the `/@fs/` text containment
|
|
152
|
+
* below real containment.
|
|
150
153
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
154
|
+
* Three rules read the checked segments: the dot rule (raw name), and the
|
|
155
|
+
* short-name and credential-name rules (normalized name, see `normalizedName`).
|
|
156
|
+
*
|
|
157
|
+
* `projectRoot` is the realpath of the binding directory with forward slashes,
|
|
158
|
+
* taken once at preview start; inside-ness is a segment-aligned text prefix,
|
|
159
|
+
* with no disk call.
|
|
153
160
|
*/
|
|
154
161
|
export function isHiddenAppPath(url, projectRoot) {
|
|
155
|
-
const
|
|
162
|
+
const [beforeHash = "", ...hash] = url.split("#");
|
|
163
|
+
const [raw = "", ...query] = beforeHash.split("?");
|
|
156
164
|
if (/%(2f|5c)/i.test(raw) || raw.includes("\\"))
|
|
157
165
|
return true;
|
|
166
|
+
const tail = [...query, ...hash].join("?");
|
|
167
|
+
if (/%(2f|5c)/i.test(tail) || tail.includes("\\") || tail.includes("/.."))
|
|
168
|
+
return true;
|
|
158
169
|
let decoded;
|
|
159
170
|
try {
|
|
160
171
|
decoded = decodeURIComponent(raw);
|
|
@@ -168,22 +179,93 @@ export function isHiddenAppPath(url, projectRoot) {
|
|
|
168
179
|
const checked = checkedSegmentIndexes(segments, projectRoot);
|
|
169
180
|
for (const i of checked) {
|
|
170
181
|
const seg = segments[i];
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
const spellings = [seg, secondDecode(seg)];
|
|
183
|
+
if (spellings.some((s) => s.startsWith(".")) && !isAllowedDotSegment(segments, i)) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
for (const s of spellings) {
|
|
187
|
+
const name = normalizedName(s);
|
|
188
|
+
if (name === REFUSE)
|
|
189
|
+
return true;
|
|
190
|
+
if (SHORT_NAME.test(name) || isCredentialName(name))
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
176
193
|
}
|
|
177
194
|
return false;
|
|
178
195
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
196
|
+
/** The segment as a second decode would read it; lenient when that decode throws. */
|
|
197
|
+
function secondDecode(segment) {
|
|
198
|
+
if (!/%[0-9a-f]{2}/i.test(segment))
|
|
199
|
+
return segment;
|
|
182
200
|
try {
|
|
183
|
-
return decodeURIComponent(segment)
|
|
201
|
+
return decodeURIComponent(segment);
|
|
184
202
|
}
|
|
185
203
|
catch {
|
|
186
|
-
return
|
|
204
|
+
return segment.replace(/(?:%[0-9a-f]{2})+/gi, (run) => {
|
|
205
|
+
try {
|
|
206
|
+
return decodeURIComponent(run);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return run.replace(/%([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const REFUSE = Symbol("refuse");
|
|
215
|
+
/**
|
|
216
|
+
* The name a Windows or case-insensitive file system would open for this
|
|
217
|
+
* segment: a stream suffix (`:$`) is refused outright, the rest is cut at the
|
|
218
|
+
* first colon (so `/@fs/C:/...` and `virtual:` modules still work), trailing
|
|
219
|
+
* dots and spaces are dropped (Windows resolves `server.pem. .` to `server.pem`),
|
|
220
|
+
* and compatibility and case forms are folded.
|
|
221
|
+
*/
|
|
222
|
+
function normalizedName(segment) {
|
|
223
|
+
if (segment.includes(":$"))
|
|
224
|
+
return REFUSE;
|
|
225
|
+
const beforeColon = segment.split(":")[0] ?? "";
|
|
226
|
+
return beforeColon.replace(/[. ]+$/, "").normalize("NFKC").toUpperCase().toLowerCase();
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* A Windows 8.3 short name (`GIT~1`, `CREDEN~1.JSO`) opens the long-named file
|
|
230
|
+
* it stands for, and its short form carries no leading dot, so `/GIT~1/config`
|
|
231
|
+
* would read `.git/config` past the dot rule. Refused on every platform: the
|
|
232
|
+
* proxy can front a Windows file system from any client.
|
|
233
|
+
*/
|
|
234
|
+
const SHORT_NAME = /^[^~]{1,6}~[0-9]/;
|
|
235
|
+
/** D-Preview-hides-credential-files: hard-coded, no opt-out. `*` is any run of characters. */
|
|
236
|
+
const CREDENTIAL_NAMES = Object.freeze([
|
|
237
|
+
"credentials.json",
|
|
238
|
+
"service-account*.json",
|
|
239
|
+
"*firebase-adminsdk*.json",
|
|
240
|
+
"*.pem",
|
|
241
|
+
"*.key",
|
|
242
|
+
"*.p12",
|
|
243
|
+
"*.pfx",
|
|
244
|
+
"*.p8",
|
|
245
|
+
"*.ppk",
|
|
246
|
+
"id_rsa*",
|
|
247
|
+
"id_dsa*",
|
|
248
|
+
"id_ed25519*",
|
|
249
|
+
"id_ecdsa*",
|
|
250
|
+
"*.jks",
|
|
251
|
+
"*.keystore",
|
|
252
|
+
"*.env",
|
|
253
|
+
"*.tfstate",
|
|
254
|
+
"*.tfvars",
|
|
255
|
+
].map((glob) => new RegExp(`^${glob
|
|
256
|
+
.split("*")
|
|
257
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
258
|
+
.join(".*")}$`, "s")));
|
|
259
|
+
/** A listed name, or one with backup endings (`~`, `.bak`) added, any number of times. */
|
|
260
|
+
function isCredentialName(name) {
|
|
261
|
+
let candidate = name;
|
|
262
|
+
for (;;) {
|
|
263
|
+
if (CREDENTIAL_NAMES.some((re) => re.test(candidate)))
|
|
264
|
+
return true;
|
|
265
|
+
const stripped = candidate.replace(/(?:~|\.bak)$/, "");
|
|
266
|
+
if (stripped === candidate)
|
|
267
|
+
return false;
|
|
268
|
+
candidate = stripped;
|
|
187
269
|
}
|
|
188
270
|
}
|
|
189
271
|
function checkedSegmentIndexes(segments, projectRoot) {
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { type ControlKind, type TunnelState, type UAClass } from "
|
|
1
|
+
import { type ControlKind, type TunnelState, type UAClass } from "../tunnel/index.js";
|
|
2
2
|
import type { DiagEvent } from "@vincentt-xr/harness/events";
|
|
3
3
|
import { type EventTags } from "../relay/server.js";
|
|
4
4
|
import { type ResolvedConfig } from "../shared/config.js";
|
|
5
5
|
/** The path a 404 advisory names: the request's own path, without its query. */
|
|
6
6
|
export declare function requestPathOf(url: string | undefined): string;
|
|
7
|
+
/**
|
|
8
|
+
* The project root as the `/@fs/` check compares it: Vite writes `/@fs/C:/...`
|
|
9
|
+
* with forward slashes, so a Windows root holding backslashes would never match
|
|
10
|
+
* as inside, and a project under a dotted parent folder would get false 404s.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hiddenPathRoot(realRoot: string): string;
|
|
7
13
|
/** Why a preview ended. Each maps to exactly one screen (Flow L20/L21). */
|
|
8
14
|
export type PreviewEndReason = "stopped"
|
|
9
15
|
/**
|
|
@@ -57,8 +63,10 @@ export interface StartPreviewOptions {
|
|
|
57
63
|
/** The API host + PAT. Resolved by the caller so the runner makes no policy. */
|
|
58
64
|
config: ResolvedConfig;
|
|
59
65
|
projectId: string;
|
|
60
|
-
/** App dev-serve port (default 5173). A server already here is REUSED
|
|
66
|
+
/** App dev-serve port (default 5173). A server already here is REUSED unless `appPortFresh`. */
|
|
61
67
|
appPort?: number;
|
|
68
|
+
/** appPort was picked for this preview, so the dev command always starts there. */
|
|
69
|
+
appPortFresh?: boolean;
|
|
62
70
|
relayPort?: number;
|
|
63
71
|
frontPort?: number;
|
|
64
72
|
/** Path routed to the relay instead of the app (default /__harness). */
|
|
@@ -136,6 +144,8 @@ export interface RunningPreview {
|
|
|
136
144
|
/** ISO8601, the hard max. Never extended by a heartbeat. */
|
|
137
145
|
expiresAt: string;
|
|
138
146
|
relayPort: number;
|
|
147
|
+
/** The relay's `/query` and `feedback/next` secret. Goes only into the 0600 marker. */
|
|
148
|
+
relayToken: string;
|
|
139
149
|
appPort: number;
|
|
140
150
|
/** Whether the app was responding when we returned. */
|
|
141
151
|
appReady: boolean;
|
package/dist/preview/runner.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
17
|
import { realpath } from "node:fs/promises";
|
|
18
18
|
import { createServer } from "node:http";
|
|
19
|
-
import { TunnelClient, resolveForwardTarget, } from "
|
|
19
|
+
import { TunnelClient, resolveForwardTarget, } from "../tunnel/index.js";
|
|
20
20
|
import { startRelay } from "../relay/server.js";
|
|
21
|
+
import { mintRelayToken } from "../relay/guard.js";
|
|
21
22
|
import { isHiddenAppPath, proxyWeb, proxyWs, relayRoute } from "./proxy.js";
|
|
22
23
|
import { getFreePort, isPortListening, waitForApp } from "./net.js";
|
|
23
24
|
import { endPreviewSession, heartbeatPreviewSession, mintPreviewSession, workingTreeKey, PreviewEndedError, } from "./api.js";
|
|
@@ -25,19 +26,35 @@ import { withoutCliCredentials } from "../shared/config.js";
|
|
|
25
26
|
import { ProjectUnreachableError } from "../lib/backend.js";
|
|
26
27
|
import { AuthExpiredError } from "../shared/authError.js";
|
|
27
28
|
import { apiFetch } from "../shared/http.js";
|
|
29
|
+
import { terminalSafe } from "../lib/terminalText.js";
|
|
28
30
|
/** The path a 404 advisory names: the request's own path, without its query. */
|
|
29
31
|
export function requestPathOf(url) {
|
|
30
|
-
const raw = (url ?? "/").split("?")[0] ?? "/";
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
const raw = ((url ?? "/").split("#")[0] ?? "/").split("?")[0] ?? "/";
|
|
33
|
+
let decoded = raw;
|
|
34
|
+
try {
|
|
35
|
+
decoded = decodeURIComponent(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* an undecodable path is printed as sent */
|
|
39
|
+
}
|
|
40
|
+
return terminalSafe(decoded);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The project root as the `/@fs/` check compares it: Vite writes `/@fs/C:/...`
|
|
44
|
+
* with forward slashes, so a Windows root holding backslashes would never match
|
|
45
|
+
* as inside, and a project under a dotted parent folder would get false 404s.
|
|
46
|
+
*/
|
|
47
|
+
export function hiddenPathRoot(realRoot) {
|
|
48
|
+
return realRoot.replaceAll("\\", "/");
|
|
33
49
|
}
|
|
34
50
|
export async function startPreview(opts) {
|
|
35
51
|
const { projectCwd, projectRoot = projectCwd, config, projectId, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", appReadyTimeoutMs = 8_000, onLog = () => undefined, fetchImpl = apiFetch, } = opts;
|
|
36
52
|
const appPort = opts.appPort ?? 5173;
|
|
37
53
|
const relayPort = opts.relayPort ?? (await getFreePort());
|
|
38
54
|
const frontPort = opts.frontPort ?? (await getFreePort());
|
|
39
|
-
const reuseApp = await isPortListening(appPort);
|
|
40
|
-
const
|
|
55
|
+
const reuseApp = opts.appPortFresh ? false : await isPortListening(appPort);
|
|
56
|
+
const relayToken = mintRelayToken();
|
|
57
|
+
const hiddenRoot = hiddenPathRoot(await realpath(projectRoot).catch(() => projectRoot));
|
|
41
58
|
let app;
|
|
42
59
|
let killApp;
|
|
43
60
|
let relay;
|
|
@@ -136,9 +153,13 @@ export async function startPreview(opts) {
|
|
|
136
153
|
process.once("exit", killApp);
|
|
137
154
|
}
|
|
138
155
|
}
|
|
156
|
+
// Fixed here, from the server-computed URL, and never from a request.
|
|
157
|
+
const previewOrigin = new URL(minted.url).origin;
|
|
139
158
|
relay = startRelay({
|
|
140
159
|
port: relayPort,
|
|
141
160
|
path: harnessPath,
|
|
161
|
+
allowedOrigins: [previewOrigin],
|
|
162
|
+
token: relayToken,
|
|
142
163
|
// Annotations land beside the binding, so `vincentt feedback` finds them
|
|
143
164
|
// from anywhere in the tree rather than only from the folder that started
|
|
144
165
|
// the preview.
|
|
@@ -149,8 +170,6 @@ export async function startPreview(opts) {
|
|
|
149
170
|
onCapReached: opts.onCapReached,
|
|
150
171
|
onAnnotation: opts.onAnnotation,
|
|
151
172
|
});
|
|
152
|
-
// Fixed here, from the server-computed URL, and never from a request.
|
|
153
|
-
const previewOrigin = new URL(minted.url).origin;
|
|
154
173
|
front = createServer((req, res) => {
|
|
155
174
|
const route = relayRoute(req.url ?? "/", harnessPath, "http");
|
|
156
175
|
if (route === "refuse") {
|
|
@@ -253,6 +272,7 @@ export async function startPreview(opts) {
|
|
|
253
272
|
sessionId: minted.sessionId,
|
|
254
273
|
expiresAt: minted.expiresAt,
|
|
255
274
|
relayPort,
|
|
275
|
+
relayToken,
|
|
256
276
|
appPort,
|
|
257
277
|
appReady,
|
|
258
278
|
stop,
|
package/dist/preview/stream.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "@vincentt-xr/harness/events";
|
|
2
|
-
import type { UAClass } from "
|
|
2
|
+
import type { UAClass } from "../tunnel/index.js";
|
|
3
3
|
/** One line per viewer per 5s on the terminal. The agent's pull is unthrottled. */
|
|
4
4
|
export declare const PERF_THROTTLE_MS = 5000;
|
|
5
5
|
export declare function uaLabel(c: UAClass): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The header carrying the per-session token on the local-only routes. */
|
|
2
|
+
export declare const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
|
|
3
|
+
export declare function mintRelayToken(): string;
|
|
4
|
+
/**
|
|
5
|
+
* Host must name the loopback socket the relay bound. A DNS-rebinding page
|
|
6
|
+
* reaches the same socket under its own hostname, and this is where it stops.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isAllowedHost(raw: string | string[] | undefined, port: number, bindHost?: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* No Origin is a non-browser caller (the CLI, the tunnel's own requests).
|
|
11
|
+
* Otherwise only an exact configured origin passes, or, when none is
|
|
12
|
+
* configured, a page served from this machine's loopback. The literal `null`
|
|
13
|
+
* origin (sandboxed frames, file pages) is a browser and is always refused.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isAllowedOrigin(raw: string | string[] | undefined, allowed: readonly string[] | undefined): boolean;
|
|
16
|
+
export declare function isLoopbackOrigin(raw: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* A cross-site form or `no-cors` fetch can only send `text/plain`, form or
|
|
19
|
+
* multipart bodies without a preflight. Requiring JSON forces a preflight the
|
|
20
|
+
* relay never answers.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isJsonContentType(raw: string | undefined): boolean;
|
|
23
|
+
export declare function hasRelayToken(raw: string | string[] | undefined, token: string): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The relay's request gate. Every caller the relay serves is on this machine:
|
|
2
|
+
// the CLI (no Origin), the tunnel client and the front proxy (both rewrite Host
|
|
3
|
+
// to the loopback target), and the preview page itself (its own minted origin).
|
|
4
|
+
// A web page the creator happens to have open is none of those, and these checks
|
|
5
|
+
// are what tell it apart.
|
|
6
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
|
+
/** The header carrying the per-session token on the local-only routes. */
|
|
8
|
+
export const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
|
|
9
|
+
export function mintRelayToken() {
|
|
10
|
+
return randomBytes(32).toString("base64url");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Host must name the loopback socket the relay bound. A DNS-rebinding page
|
|
14
|
+
* reaches the same socket under its own hostname, and this is where it stops.
|
|
15
|
+
*/
|
|
16
|
+
export function isAllowedHost(raw, port, bindHost = "127.0.0.1") {
|
|
17
|
+
if (typeof raw !== "string")
|
|
18
|
+
return false;
|
|
19
|
+
const host = raw.toLowerCase();
|
|
20
|
+
const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
21
|
+
if (bindHost === "::1")
|
|
22
|
+
allowed.add(`[::1]:${port}`);
|
|
23
|
+
else
|
|
24
|
+
allowed.add(`${bindHost.toLowerCase()}:${port}`);
|
|
25
|
+
return allowed.has(host);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* No Origin is a non-browser caller (the CLI, the tunnel's own requests).
|
|
29
|
+
* Otherwise only an exact configured origin passes, or, when none is
|
|
30
|
+
* configured, a page served from this machine's loopback. The literal `null`
|
|
31
|
+
* origin (sandboxed frames, file pages) is a browser and is always refused.
|
|
32
|
+
*/
|
|
33
|
+
export function isAllowedOrigin(raw, allowed) {
|
|
34
|
+
if (raw === undefined)
|
|
35
|
+
return true;
|
|
36
|
+
if (typeof raw !== "string")
|
|
37
|
+
return false;
|
|
38
|
+
if (allowed !== undefined)
|
|
39
|
+
return allowed.includes(raw);
|
|
40
|
+
return isLoopbackOrigin(raw);
|
|
41
|
+
}
|
|
42
|
+
export function isLoopbackOrigin(raw) {
|
|
43
|
+
let url;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(raw);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (url.origin !== raw)
|
|
51
|
+
return false;
|
|
52
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
53
|
+
return false;
|
|
54
|
+
return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A cross-site form or `no-cors` fetch can only send `text/plain`, form or
|
|
58
|
+
* multipart bodies without a preflight. Requiring JSON forces a preflight the
|
|
59
|
+
* relay never answers.
|
|
60
|
+
*/
|
|
61
|
+
export function isJsonContentType(raw) {
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
return false;
|
|
64
|
+
const type = raw.split(";")[0]?.trim().toLowerCase();
|
|
65
|
+
return type === "application/json";
|
|
66
|
+
}
|
|
67
|
+
export function hasRelayToken(raw, token) {
|
|
68
|
+
if (typeof raw !== "string")
|
|
69
|
+
return false;
|
|
70
|
+
const a = Buffer.from(raw);
|
|
71
|
+
const b = Buffer.from(token);
|
|
72
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
73
|
+
}
|
package/dist/relay/server.d.ts
CHANGED
|
@@ -57,6 +57,19 @@ export interface RelayOptions {
|
|
|
57
57
|
onAnnotation?: (tags: EventTags) => void;
|
|
58
58
|
/** Injected for tests, so cap behavior is drivable without 64 real clients. */
|
|
59
59
|
testerRegistry?: TesterRegistry;
|
|
60
|
+
/**
|
|
61
|
+
* Browser origins allowed on the WebSocket and the annotation POST: the
|
|
62
|
+
* preview's own minted origin. A request with no Origin (the CLI) always
|
|
63
|
+
* passes; any other origin is refused. Omitted, only pages served from
|
|
64
|
+
* loopback pass, which is what a standalone `vincentt relay` serves.
|
|
65
|
+
*/
|
|
66
|
+
allowedOrigins?: readonly string[];
|
|
67
|
+
/**
|
|
68
|
+
* The per-session secret `/query` and `feedback/next` require in
|
|
69
|
+
* `x-vincentt-relay-token`. The preview always sets it; without it those
|
|
70
|
+
* routes are still loopback-Host and no-Origin only.
|
|
71
|
+
*/
|
|
72
|
+
token?: string;
|
|
60
73
|
}
|
|
61
74
|
/** Who produced a batch: the connection's viewer label and its resolved tester. */
|
|
62
75
|
export interface EventTags {
|
package/dist/relay/server.js
CHANGED
|
@@ -9,7 +9,8 @@ import { WebSocketServer } from "ws";
|
|
|
9
9
|
import { EventStore } from "./store.js";
|
|
10
10
|
import { TesterRegistry, isTesterId } from "./testers.js";
|
|
11
11
|
import { AnnotationStore } from "./annotations.js";
|
|
12
|
-
import {
|
|
12
|
+
import { RELAY_TOKEN_HEADER, hasRelayToken, isAllowedHost, isAllowedOrigin, isJsonContentType, } from "./guard.js";
|
|
13
|
+
import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "../tunnel/index.js";
|
|
13
14
|
/** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
|
|
14
15
|
const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
|
|
15
16
|
/** Clamp the long-poll hold so a request can't park forever. */
|
|
@@ -45,7 +46,17 @@ export function startRelay(opts = {}) {
|
|
|
45
46
|
}
|
|
46
47
|
return { connections, testers: open.size };
|
|
47
48
|
};
|
|
49
|
+
const host = opts.host ?? "127.0.0.1";
|
|
50
|
+
const allowedOrigins = opts.allowedOrigins;
|
|
51
|
+
const boundPort = () => {
|
|
52
|
+
const addr = http.address();
|
|
53
|
+
return typeof addr === "object" && addr !== null ? addr.port : port;
|
|
54
|
+
};
|
|
48
55
|
const http = createServer((req, res) => handleHttp(req, res, {
|
|
56
|
+
host,
|
|
57
|
+
port: boundPort(),
|
|
58
|
+
allowedOrigins,
|
|
59
|
+
token: opts.token,
|
|
49
60
|
store,
|
|
50
61
|
annotations,
|
|
51
62
|
testers,
|
|
@@ -56,7 +67,23 @@ export function startRelay(opts = {}) {
|
|
|
56
67
|
onCapReached: opts.onCapReached,
|
|
57
68
|
onAnnotation: opts.onAnnotation,
|
|
58
69
|
}));
|
|
59
|
-
const wss = new WebSocketServer({
|
|
70
|
+
const wss = new WebSocketServer({
|
|
71
|
+
server: http,
|
|
72
|
+
path: wsPath,
|
|
73
|
+
verifyClient: ({ req }, done) => {
|
|
74
|
+
if (!isAllowedHost(req.headers.host, boundPort(), host)) {
|
|
75
|
+
log("refused a WebSocket with a foreign Host");
|
|
76
|
+
done(false, 403);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!isAllowedOrigin(req.headers.origin, allowedOrigins)) {
|
|
80
|
+
log("refused a WebSocket from a foreign Origin");
|
|
81
|
+
done(false, 403);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
done(true);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
60
87
|
wss.on("connection", (ws, req) => {
|
|
61
88
|
// The label comes from the CONNECTION, stamped by the tunnel client on this
|
|
62
89
|
// machine after it re-validated the edge's value. It is re-validated ONCE
|
|
@@ -117,7 +144,6 @@ export function startRelay(opts = {}) {
|
|
|
117
144
|
});
|
|
118
145
|
ws.on("close", () => log("client disconnected"));
|
|
119
146
|
});
|
|
120
|
-
const host = opts.host ?? "127.0.0.1";
|
|
121
147
|
http.listen(port, host, () => log(`relay listening on ${host}:${port} (ws ${wsPath}, http /query)`));
|
|
122
148
|
return {
|
|
123
149
|
store,
|
|
@@ -177,6 +203,28 @@ function handleHttp(req, res, ctx) {
|
|
|
177
203
|
res.writeHead(404).end();
|
|
178
204
|
return;
|
|
179
205
|
}
|
|
206
|
+
if (!isAllowedHost(req.headers.host, ctx.port, ctx.host)) {
|
|
207
|
+
ctx.log("refused a request with a foreign Host");
|
|
208
|
+
res.writeHead(403).end();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// The local-only routes have no browser caller at all, so any Origin is foreign.
|
|
212
|
+
const origins = localOnly ? [] : ctx.allowedOrigins;
|
|
213
|
+
if (!isAllowedOrigin(req.headers.origin, origins)) {
|
|
214
|
+
ctx.log("refused a request from a foreign Origin");
|
|
215
|
+
res.writeHead(403).end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (localOnly && ctx.token !== undefined) {
|
|
219
|
+
if (!hasRelayToken(req.headers[RELAY_TOKEN_HEADER], ctx.token)) {
|
|
220
|
+
res.writeHead(401).end();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
|
|
225
|
+
res.writeHead(415).end();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
180
228
|
if (p === "/query") {
|
|
181
229
|
handleQuery(req, res, url, ctx);
|
|
182
230
|
return;
|
|
@@ -191,8 +239,8 @@ function handleHttp(req, res, ctx) {
|
|
|
191
239
|
}
|
|
192
240
|
res.writeHead(404).end();
|
|
193
241
|
}
|
|
194
|
-
// The
|
|
195
|
-
//
|
|
242
|
+
// The CLI (same laptop) GETs/POSTs /query with a RelayQuery, carrying the
|
|
243
|
+
// session token from the 0600 relay marker.
|
|
196
244
|
function handleQuery(req, res, url, ctx) {
|
|
197
245
|
readQuery(req, url)
|
|
198
246
|
.then((q) => {
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* VIEWER_LABEL_RE is the SAME regex the edge assigns against, and this client
|
|
20
|
+
* RE-VALIDATES rather than trusting the edge. That is closure (c) of the three
|
|
21
|
+
* that keep viewer-supplied bytes out of the creator's agent's context: the
|
|
22
|
+
* label flows viewerLabel -> the relay ring-buffer key -> CLI terminal output
|
|
23
|
+
* and MCP tool output, which is a terminal/log-injection path INTO the agent,
|
|
24
|
+
* and a Go-only check would pass on a client that trusted the edge.
|
|
25
|
+
*/
|
|
26
|
+
export declare const VIEWER_LABEL_RE: RegExp;
|
|
27
|
+
export declare function parseViewerLabel(s: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* UA_CLASSES is the CLOSED ENUM OF SIX, mirroring the Go edge. `unknown` is the
|
|
30
|
+
* TOTAL fallback. This client validates the class it receives against this set
|
|
31
|
+
* before stamping the relay handshake, so an unmatched or forged class becomes
|
|
32
|
+
* `unknown` rather than an echo.
|
|
33
|
+
*/
|
|
34
|
+
export declare const UA_CLASSES: readonly ["ios_safari", "android_chrome", "desktop_chrome", "desktop_safari", "desktop_firefox", "unknown"];
|
|
35
|
+
export type UAClass = (typeof UA_CLASSES)[number];
|
|
36
|
+
export declare function parseUAClass(s: string): UAClass;
|
|
37
|
+
export interface OpenPayload {
|
|
38
|
+
viewerLabel: string;
|
|
39
|
+
method: string;
|
|
40
|
+
path: string;
|
|
41
|
+
headers: [string, string][];
|
|
42
|
+
upgrade?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface HeadPayload {
|
|
45
|
+
status: number;
|
|
46
|
+
headers: [string, string][];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `viewer_gone` is the ONE non-terminal kind: the other three mean the session
|
|
50
|
+
* is ending, this one means a viewer's connection closed while the session
|
|
51
|
+
* continues. It is also the only kind carrying a second key.
|
|
52
|
+
*/
|
|
53
|
+
export type ControlKind = "revoke" | "expiring" | "superseded" | "viewer_gone";
|
|
54
|
+
/** A decoded CONTROL payload. `viewerLabel` is present only on `viewer_gone`. */
|
|
55
|
+
export interface ControlPayload {
|
|
56
|
+
kind: ControlKind;
|
|
57
|
+
viewerLabel?: string;
|
|
58
|
+
}
|
|
59
|
+
export declare function decodeOpen(b: Uint8Array): OpenPayload;
|
|
60
|
+
export declare function decodeHead(b: Uint8Array): HeadPayload;
|
|
61
|
+
/**
|
|
62
|
+
* The map arity is asserted AGAINST THE KIND, so a payload claiming `revoke`
|
|
63
|
+
* with a trailing label is refused as malformed rather than quietly accepted
|
|
64
|
+
* with the extra key ignored.
|
|
65
|
+
*/
|
|
66
|
+
export declare function decodeControl(b: Uint8Array): ControlPayload;
|
|
67
|
+
/** Canonical key order: viewerLabel, method, path, headers, [upgrade]. */
|
|
68
|
+
export declare function encodeOpen(p: OpenPayload): Uint8Array;
|
|
69
|
+
/** Canonical key order: status, headers. */
|
|
70
|
+
export declare function encodeHead(p: HeadPayload): Uint8Array;
|
|
71
|
+
export declare function encodeControl(kind: ControlKind, viewerLabel?: string): Uint8Array;
|