@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.
Files changed (41) hide show
  1. package/MIGRATION.md +22 -0
  2. package/dist/cli/index.js +18 -6
  3. package/dist/cli/usage-text.js +1 -1
  4. package/dist/lib/diagnostics.d.ts +2 -1
  5. package/dist/lib/diagnostics.js +6 -2
  6. package/dist/lib/feedback.js +5 -4
  7. package/dist/lib/lifecycle.js +3 -2
  8. package/dist/lib/relayInfo.d.ts +8 -2
  9. package/dist/lib/relayInfo.js +15 -5
  10. package/dist/lib/terminalText.d.ts +2 -0
  11. package/dist/lib/terminalText.js +7 -0
  12. package/dist/login/pkceLogin.d.ts +7 -1
  13. package/dist/login/pkceLogin.js +25 -4
  14. package/dist/preview/api.d.ts +15 -0
  15. package/dist/preview/api.js +27 -1
  16. package/dist/preview/command.d.ts +1 -1
  17. package/dist/preview/copy.d.ts +14 -1
  18. package/dist/preview/copy.js +37 -3
  19. package/dist/preview/devPort.d.ts +17 -0
  20. package/dist/preview/devPort.js +14 -0
  21. package/dist/preview/proxy.d.ts +16 -9
  22. package/dist/preview/proxy.js +102 -20
  23. package/dist/preview/runner.d.ts +12 -2
  24. package/dist/preview/runner.js +28 -8
  25. package/dist/preview/stream.d.ts +1 -1
  26. package/dist/preview/viewers.d.ts +1 -1
  27. package/dist/relay/guard.d.ts +23 -0
  28. package/dist/relay/guard.js +73 -0
  29. package/dist/relay/server.d.ts +13 -0
  30. package/dist/relay/server.js +53 -5
  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
@@ -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
- * or the binding to any holder of a preview link.
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). A segment
146
- * still carrying an encoded dot after that one decode is judged as the dev
147
- * server would read it after a second decode, so `%252eenv` cannot slip past.
148
- * `.` and `..` segments are always refused, which is what makes the `/@fs/`
149
- * text containment below real containment.
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
- * `projectRoot` is the realpath of the binding directory, taken once at preview
152
- * start; inside-ness is a segment-aligned text prefix, with no disk call.
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 raw = (url.split("#")[0] ?? "").split("?")[0] ?? "";
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
- if (!startsWithDot(seg))
172
- continue;
173
- if (isAllowedDotSegment(segments, i))
174
- continue;
175
- return true;
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
- function startsWithDot(segment) {
180
- if (segment.startsWith("."))
181
- return true;
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).startsWith(".");
201
+ return decodeURIComponent(segment);
184
202
  }
185
203
  catch {
186
- return false;
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) {
@@ -1,9 +1,15 @@
1
- import { type ControlKind, type TunnelState, type UAClass } from "@vincentt-xr/harness/tunnel";
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;
@@ -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 "@vincentt-xr/harness/tunnel";
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
- // Control characters never reach the terminal from a request line.
32
- return raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
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 hiddenRoot = await realpath(projectRoot).catch(() => projectRoot);
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,
@@ -1,5 +1,5 @@
1
1
  import type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "@vincentt-xr/harness/events";
2
- import type { UAClass } from "@vincentt-xr/harness/tunnel";
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;
@@ -1,4 +1,4 @@
1
- import type { UAClass } from "@vincentt-xr/harness/tunnel";
1
+ import type { UAClass } from "../tunnel/index.js";
2
2
  /** The roster block prints once, on the FOURTH TESTER's arrival, and never again. */
3
3
  export declare const ROSTER_AT = 4;
4
4
  /**
@@ -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
+ }
@@ -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 {
@@ -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 { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "@vincentt-xr/harness/tunnel";
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({ server: http, path: wsPath });
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 MCP server / CLI (same laptop) GETs/POSTs /query with a RelayQuery. Localhost
195
- // only; no auth in the local dev loop (the platform phase adds it).
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;