@vincentt-xr/cli 0.18.2 → 0.18.3

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 CHANGED
@@ -3,6 +3,14 @@
3
3
  Per version, newest first. Read by the platform's own version surface and served to
4
4
  creators' agents. Accurate or absent, never speculative (B-F5-2).
5
5
 
6
+ ## 0.18.3
7
+
8
+ required: false
9
+ No action required when the preview and the commands reading it (`logs`, `feedback`, `preview --stop`) come from the same CLI version. Behaviour that changed:
10
+ - The local preview relay refuses web pages other than the preview itself. It answers only requests whose Host is `127.0.0.1:<port>` or `localhost:<port>`, accepts the diagnostics socket and feedback posts only from the preview's own address (or from a client that sends no Origin), and refuses a POST that is not `application/json` with 415.
11
+ - The relay's diagnostics read and feedback long-poll now require a per-session token, which the preview writes to `.vincentt/relay.json` (now readable by the owner only). The CLI's own commands send it; a direct request to `/query` or `feedback/next` without the `x-vincentt-relay-token` header is refused with 401. A command from CLI 0.18.2 or older cannot read a preview started by this version, and the reverse still works.
12
+ - `vincentt relay` prints the token it requires and accepts `--origin <url>` for a page it should accept; without it, only pages served from localhost or 127.0.0.1 can connect.
13
+
6
14
  ## 0.18.2
7
15
 
8
16
  required: false
package/dist/cli/index.js CHANGED
@@ -727,7 +727,7 @@ async function followPreview(info, ui, filterTester, filterViewer) {
727
727
  console.log("✓ Following this preview");
728
728
  }
729
729
  console.log("");
730
- const relay = httpRelayClient(`http://127.0.0.1:${info.relayPort}`);
730
+ const relay = httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
731
731
  let since = -1;
732
732
  let warnedMissing = false;
733
733
  let stop = false;
@@ -1357,13 +1357,19 @@ async function domain() {
1357
1357
  // ---- servers ----------------------------------------------------------------
1358
1358
  async function relay() {
1359
1359
  const { startRelay } = await import("../relay/server.js");
1360
+ const { mintRelayToken, RELAY_TOKEN_HEADER } = await import("../relay/guard.js");
1360
1361
  const port = numFlag("--port") ?? 7331;
1361
1362
  const dir = flag("--dir");
1363
+ const origin = flag("--origin");
1364
+ const token = mintRelayToken();
1362
1365
  startRelay({
1363
1366
  port,
1364
1367
  projectDir: dir ? path.resolve(dir) : undefined,
1368
+ allowedOrigins: origin ? [origin] : undefined,
1369
+ token,
1365
1370
  onLog: (m) => console.log(`[relay] ${m}`),
1366
1371
  });
1372
+ console.log(`[relay] /query and feedback/next require ${RELAY_TOKEN_HEADER}: ${token}`);
1367
1373
  }
1368
1374
  // ---- dispatch ---------------------------------------------------------------
1369
1375
  export function usage() {
@@ -1,9 +1,10 @@
1
1
  import type { DiagEvent, LogEvent, NetworkEvent, RelayQuery, RelayResult, TraceEvent } from "@vincentt-xr/harness/events";
2
+ export declare function relayAuthHeaders(token: string | undefined): Record<string, string>;
2
3
  export interface RelayClient {
3
4
  query(q: RelayQuery): Promise<RelayResult>;
4
5
  }
5
6
  /** A RelayClient backed by the relay's HTTP /query endpoint. */
6
- export declare function httpRelayClient(baseUrl: string): RelayClient;
7
+ export declare function httpRelayClient(baseUrl: string, token?: string): RelayClient;
7
8
  export declare function formatLog(e: LogEvent, now: number): string;
8
9
  export declare function formatNetwork(e: NetworkEvent, now: number): string;
9
10
  export declare function formatTrace(e: TraceEvent, now: number): string;
@@ -2,13 +2,17 @@
2
2
  // the relay and format them into the compact text an agent reads. Kept separate
3
3
  // from the SDK wiring (server.ts) so the formatting is unit-testable and the SDK
4
4
  // version can churn without touching this.
5
+ import { RELAY_TOKEN_HEADER } from "../relay/guard.js";
6
+ export function relayAuthHeaders(token) {
7
+ return token === undefined ? {} : { [RELAY_TOKEN_HEADER]: token };
8
+ }
5
9
  /** A RelayClient backed by the relay's HTTP /query endpoint. */
6
- export function httpRelayClient(baseUrl) {
10
+ export function httpRelayClient(baseUrl, token) {
7
11
  return {
8
12
  async query(q) {
9
13
  const res = await fetch(`${baseUrl}/query`, {
10
14
  method: "POST",
11
- headers: { "content-type": "application/json" },
15
+ headers: { "content-type": "application/json", ...relayAuthHeaders(token) },
12
16
  body: JSON.stringify(q),
13
17
  });
14
18
  if (!res.ok)
@@ -11,6 +11,7 @@
11
11
  import { promises as fs } from "node:fs";
12
12
  import path from "node:path";
13
13
  import { isProcessAlive, isRelayResponding, readRelayInfo, relayInfoDir, } from "./relayInfo.js";
14
+ import { relayAuthHeaders } from "./diagnostics.js";
14
15
  const CURSOR_FILE = ".vincentt/annotations/offset";
15
16
  /**
16
17
  * A cursor that reads too LOW re-delivers, which is the accepted cost of
@@ -96,13 +97,13 @@ export async function fetchNext(cwd, opts) {
96
97
  u.searchParams.set("timeout", String(opts.timeoutMs));
97
98
  let res;
98
99
  try {
99
- res = await fetch(u);
100
+ res = await fetch(u, { headers: relayAuthHeaders(info.token) });
100
101
  }
101
102
  catch {
102
103
  // The socket died under us. The pid gate + relay-identity probe is what
103
104
  // separates "the preview ended" from "the request failed but a relay is
104
105
  // still there" — only the latter is a fault worth an exit 1.
105
- if (await relayGone(info.pid, info.relayPort)) {
106
+ if (await relayGone(info.pid, info.relayPort, info.token)) {
106
107
  return { kind: "closed", reason: "unreachable" };
107
108
  }
108
109
  throw new Error(`Could not reach the preview relay on :${info.relayPort}. The relay is answering, so this is not a session end.`);
@@ -117,8 +118,8 @@ export async function fetchNext(cwd, opts) {
117
118
  throw new Error(`relay feedback/next returned ${res.status}`);
118
119
  return { kind: "annotation", annotation: (await res.json()) };
119
120
  }
120
- async function relayGone(pid, port) {
121
+ async function relayGone(pid, port, token) {
121
122
  if (pid !== undefined && !isProcessAlive(pid))
122
123
  return true;
123
- return !(await isRelayResponding(port));
124
+ return !(await isRelayResponding(port, undefined, undefined, token));
124
125
  }
@@ -175,6 +175,7 @@ export async function previewStart(projectCwd, onLog, extra = {}) {
175
175
  harnessPath: "/__harness",
176
176
  expiresAt: preview.expiresAt,
177
177
  pidStartTime: await readProcessStartTime(process.pid),
178
+ token: preview.relayToken,
178
179
  });
179
180
  return { preview, started: true };
180
181
  }
@@ -286,7 +287,7 @@ export async function previewStopSignal(projectCwd, deps = {}) {
286
287
  // relay's own teardown and print a number that was true of a preview which
287
288
  // no longer exists. A dead pid is never queried at all — the marker is stale
288
289
  // and there is nothing on that port to ask.
289
- const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}`);
290
+ const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
290
291
  const connected = await readLiveConnections(relay);
291
292
  try {
292
293
  (deps.kill ?? ((pid, sig) => process.kill(pid, sig)))(info.pid, "SIGTERM");
@@ -333,7 +334,7 @@ async function relayForProject(projectCwd, override) {
333
334
  throw new Error("No running preview for this project. Run `vincentt preview` first.");
334
335
  }
335
336
  // The relay listens on 127.0.0.1 only; `localhost` may resolve to ::1 first.
336
- return httpRelayClient(`http://127.0.0.1:${info.relayPort}`);
337
+ return httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
337
338
  }
338
339
  /** Raw diagnostics query against the running relay (used by CLI --json). */
339
340
  export async function diagQuery(projectCwd, kind, opts = {}, relay) {
@@ -24,6 +24,12 @@ export interface RelayInfo {
24
24
  * written by an older CLI or on a platform with no reader.
25
25
  */
26
26
  pidStartTime?: string;
27
+ /**
28
+ * The relay's per-session secret for `/query` and `feedback/next`. The marker
29
+ * is written 0600 because of it. Absent from markers written by an older CLI,
30
+ * whose relay asks for none.
31
+ */
32
+ token?: string;
27
33
  }
28
34
  export declare function relayInfoPath(cwd: string): string;
29
35
  /**
@@ -64,7 +70,7 @@ export declare function isProcessAlive(pid: number): boolean;
64
70
  * runs fifty times a day, and the fallback (mint a preview) is what they asked
65
71
  * for anyway.
66
72
  */
67
- export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch): Promise<boolean>;
73
+ export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch, token?: string): Promise<boolean>;
68
74
  /**
69
75
  * readLivePreview returns the marker ONLY when it still describes a preview that
70
76
  * is genuinely running, and CLEARS it otherwise.
@@ -81,5 +87,5 @@ export declare function isRelayResponding(port: number, timeoutMs?: number, fetc
81
87
  */
82
88
  export declare function readLivePreview(cwd: string, deps?: {
83
89
  processAlive?: (pid: number) => boolean;
84
- relayResponding?: (port: number) => Promise<boolean>;
90
+ relayResponding?: (port: number, token?: string) => Promise<boolean>;
85
91
  }): Promise<RelayInfo | undefined>;
@@ -5,6 +5,7 @@
5
5
  import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { locateProjectBinding } from "../shared/config.js";
8
+ import { relayAuthHeaders } from "./diagnostics.js";
8
9
  const RELAY_INFO_FILE = "relay.json";
9
10
  const VINCENTT_DIR = ".vincentt";
10
11
  export function relayInfoPath(cwd) {
@@ -28,7 +29,15 @@ export async function relayInfoDir(cwd) {
28
29
  export async function writeRelayInfo(cwd, info) {
29
30
  const p = relayInfoPath(cwd);
30
31
  await fs.mkdir(path.dirname(p), { recursive: true });
31
- await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", "utf8");
32
+ // Removed first so an older 0644 marker is not rewritten in place: `mode`
33
+ // only applies when the file is created, and the token must never be
34
+ // readable by another user.
35
+ await fs.rm(p, { force: true });
36
+ await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", {
37
+ encoding: "utf8",
38
+ mode: 0o600,
39
+ flag: "wx",
40
+ });
32
41
  return p;
33
42
  }
34
43
  /** Reads the marker for the TREE containing cwd, not only the folder itself. */
@@ -83,7 +92,7 @@ export function isProcessAlive(pid) {
83
92
  * runs fifty times a day, and the fallback (mint a preview) is what they asked
84
93
  * for anyway.
85
94
  */
86
- export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch) {
95
+ export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch, token) {
87
96
  if (!Number.isInteger(port) || port <= 0 || port > 65535)
88
97
  return false;
89
98
  const ac = new AbortController();
@@ -91,7 +100,7 @@ export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch
91
100
  try {
92
101
  const res = await fetchImpl(`http://127.0.0.1:${port}/query`, {
93
102
  method: "POST",
94
- headers: { "content-type": "application/json" },
103
+ headers: { "content-type": "application/json", ...relayAuthHeaders(token) },
95
104
  body: JSON.stringify({ kind: "log", limit: 0 }),
96
105
  signal: ac.signal,
97
106
  });
@@ -132,14 +141,15 @@ export async function readLivePreview(cwd, deps = {}) {
132
141
  if (!info)
133
142
  return undefined;
134
143
  const processAlive = deps.processAlive ?? isProcessAlive;
135
- const relayResponding = deps.relayResponding ?? ((p) => isRelayResponding(p));
144
+ const relayResponding = deps.relayResponding ??
145
+ ((p, t) => isRelayResponding(p, undefined, undefined, t));
136
146
  // The cheap gate first: a dead pid needs no network round trip.
137
147
  if (info.pid !== undefined && !processAlive(info.pid)) {
138
148
  await clearRelayInfo(cwd);
139
149
  return undefined;
140
150
  }
141
151
  // The identity gate: alive-but-not-ours (a recycled pid) fails here.
142
- if (!(await relayResponding(info.relayPort))) {
152
+ if (!(await relayResponding(info.relayPort, info.token))) {
143
153
  await clearRelayInfo(cwd);
144
154
  return undefined;
145
155
  }
@@ -136,6 +136,8 @@ export interface RunningPreview {
136
136
  /** ISO8601, the hard max. Never extended by a heartbeat. */
137
137
  expiresAt: string;
138
138
  relayPort: number;
139
+ /** The relay's `/query` and `feedback/next` secret. Goes only into the 0600 marker. */
140
+ relayToken: string;
139
141
  appPort: number;
140
142
  /** Whether the app was responding when we returned. */
141
143
  appReady: boolean;
@@ -18,6 +18,7 @@ import { realpath } from "node:fs/promises";
18
18
  import { createServer } from "node:http";
19
19
  import { TunnelClient, resolveForwardTarget, } from "@vincentt-xr/harness/tunnel";
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";
@@ -37,6 +38,7 @@ export async function startPreview(opts) {
37
38
  const relayPort = opts.relayPort ?? (await getFreePort());
38
39
  const frontPort = opts.frontPort ?? (await getFreePort());
39
40
  const reuseApp = await isPortListening(appPort);
41
+ const relayToken = mintRelayToken();
40
42
  const hiddenRoot = await realpath(projectRoot).catch(() => projectRoot);
41
43
  let app;
42
44
  let killApp;
@@ -136,9 +138,13 @@ export async function startPreview(opts) {
136
138
  process.once("exit", killApp);
137
139
  }
138
140
  }
141
+ // Fixed here, from the server-computed URL, and never from a request.
142
+ const previewOrigin = new URL(minted.url).origin;
139
143
  relay = startRelay({
140
144
  port: relayPort,
141
145
  path: harnessPath,
146
+ allowedOrigins: [previewOrigin],
147
+ token: relayToken,
142
148
  // Annotations land beside the binding, so `vincentt feedback` finds them
143
149
  // from anywhere in the tree rather than only from the folder that started
144
150
  // the preview.
@@ -149,8 +155,6 @@ export async function startPreview(opts) {
149
155
  onCapReached: opts.onCapReached,
150
156
  onAnnotation: opts.onAnnotation,
151
157
  });
152
- // Fixed here, from the server-computed URL, and never from a request.
153
- const previewOrigin = new URL(minted.url).origin;
154
158
  front = createServer((req, res) => {
155
159
  const route = relayRoute(req.url ?? "/", harnessPath, "http");
156
160
  if (route === "refuse") {
@@ -253,6 +257,7 @@ export async function startPreview(opts) {
253
257
  sessionId: minted.sessionId,
254
258
  expiresAt: minted.expiresAt,
255
259
  relayPort,
260
+ relayToken,
256
261
  appPort,
257
262
  appReady,
258
263
  stop,
@@ -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,6 +9,7 @@ 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 { RELAY_TOKEN_HEADER, hasRelayToken, isAllowedHost, isAllowedOrigin, isJsonContentType, } from "./guard.js";
12
13
  import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "@vincentt-xr/harness/tunnel";
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;
@@ -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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",