@vincentt-xr/cli 0.22.0 → 0.24.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.
@@ -0,0 +1,269 @@
1
+ // `vincentt editor --selection [--json]` and `--status --json`: read the editor's local files
2
+ // and print them. A read flag never talks to the running app; when none runs for the folder it
3
+ // says so on stderr, still prints one JSON object on stdout, and exits 1. A file in a schema
4
+ // major this CLI does not know is refused whole ("too new"), never printed in part.
5
+ import { pidAlive } from "./app.js";
6
+ import { NOT_RUNNING, tooNew } from "./copy.js";
7
+ import { realpath } from "node:fs/promises";
8
+ import { lastHandoff } from "./handoffs.js";
9
+ import { parsePidFile, readLocalFile, readLocalFileStat, } from "./localFiles.js";
10
+ import { agentSafe, agentSafeValue } from "./text.js";
11
+ export const SELECTION_SCHEMA = "vincentt.editor-selection/1";
12
+ export const STATE_SCHEMA = "vincentt.editor-state/1";
13
+ /**
14
+ * True when the code `pid` is running meets the requirement, from any path. Checked on the
15
+ * running process itself, so no path the OS reports for it is trusted.
16
+ */
17
+ export async function isVerifiedEditor(pid, p) {
18
+ const verdict = await p.system.verifyPid(pid, p.requirement).catch(() => undefined);
19
+ return verdict?.ok === true;
20
+ }
21
+ /** `ps` reports whole seconds and the app writes whole seconds. */
22
+ const START_SLACK_MS = 1_000;
23
+ /**
24
+ * True when the process now holding `pid` was already running when the record was written. A
25
+ * process that started after `startedAt` reused the pid of the editor that wrote it.
26
+ */
27
+ export function startedBeforeRecord(processStart, rec) {
28
+ return processStart !== undefined && processStart <= rec.startedAt + START_SLACK_MS;
29
+ }
30
+ /** The editor recorded in `pid`, when that process is live, is that record's process, and is a verified editor. */
31
+ export async function runningEditor(root, p) {
32
+ const text = await readLocalFile(root, "pid").catch(() => undefined);
33
+ const rec = text === undefined ? undefined : parsePidFile(text);
34
+ if (rec === undefined || !(p.isAlive ?? pidAlive)(rec.pid))
35
+ return undefined;
36
+ const start = await p.system.startTime(rec.pid).catch(() => undefined);
37
+ if (!startedBeforeRecord(start, rec))
38
+ return undefined;
39
+ return (await isVerifiedEditor(rec.pid, p)) ? rec : undefined;
40
+ }
41
+ /** Whether `schema` is `<family>/1`, a later major of the family, or something else. */
42
+ export function schemaMajor(schema, family) {
43
+ if (typeof schema !== "string")
44
+ return "foreign";
45
+ const m = /^(.+)\/(\d+)$/.exec(schema);
46
+ if (m === null || m[1] !== family)
47
+ return "foreign";
48
+ const major = Number(m[2]);
49
+ if (major === 1)
50
+ return "known";
51
+ return major > 1 ? { newer: schema } : "foreign";
52
+ }
53
+ const SELECTION_FAMILY = "vincentt.editor-selection";
54
+ const STATE_FAMILY = "vincentt.editor-state";
55
+ async function readJson(root, name) {
56
+ return (await readJsonStat(root, name))?.value;
57
+ }
58
+ async function readJsonStat(root, name) {
59
+ const file = await readLocalFileStat(root, name).catch(() => undefined);
60
+ if (file === undefined)
61
+ return undefined;
62
+ try {
63
+ const v = JSON.parse(file.text);
64
+ return typeof v === "object" && v !== null && !Array.isArray(v)
65
+ ? { value: v, mtimeMs: file.mtimeMs }
66
+ : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ /**
73
+ * A snapshot belongs to the running editor when it carries the pid record's session and, when it
74
+ * names a pid, that pid.
75
+ */
76
+ function sameEditor(file, rec) {
77
+ if (typeof file.session !== "string" || file.session !== rec.session)
78
+ return false;
79
+ return typeof file.pid !== "number" || file.pid === rec.pid;
80
+ }
81
+ /** Fields the CLI states itself; file content never supplies them. */
82
+ const RESERVED_STATE_KEYS = ["schema", "running", "pid", "session"];
83
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
84
+ /** A string field, sanitised; anything that is not a string reads as empty. */
85
+ const str = (v, max) => typeof v === "string" ? agentSafe(v, max) : "";
86
+ /** The selection as the agent may read it: an allowlist of fields, every string sanitised. */
87
+ export function shapeSelection(sel) {
88
+ if (typeof sel !== "object" || sel === null)
89
+ return null;
90
+ const s = sel;
91
+ const src = (typeof s.source === "object" && s.source !== null ? s.source : {});
92
+ const values = Array.isArray(s.values) ? s.values : [];
93
+ return {
94
+ objectKey: str(s.objectKey, 400),
95
+ label: str(s.label, 200),
96
+ type: str(s.type, 80),
97
+ kind: str(s.kind, 20),
98
+ source: {
99
+ file: str(src.file, 400),
100
+ line: num(src.line) ?? null,
101
+ column: num(src.column) ?? null,
102
+ },
103
+ values: values.slice(0, 256).map((raw) => {
104
+ const v = (typeof raw === "object" && raw !== null ? raw : {});
105
+ const out = {
106
+ path: str(v.path, 200),
107
+ type: str(v.type, 20),
108
+ value: agentSafeValue(v.value),
109
+ };
110
+ if (typeof v.unit === "string")
111
+ out.unit = agentSafe(v.unit, 10);
112
+ out.editable =
113
+ v.editable === true
114
+ ? true
115
+ : agentSafeValue(v.editable ?? { readonly: "unsupported" }, 40);
116
+ if (typeof v.decl === "string")
117
+ out.decl = agentSafe(v.decl, 200);
118
+ if (num(v.sharedBy) !== undefined)
119
+ out.sharedBy = num(v.sharedBy);
120
+ return out;
121
+ }),
122
+ };
123
+ }
124
+ function tooNewResult(schema) {
125
+ return { stdout: [], stderr: tooNew(schema), exitCode: 1, reason: "too_new" };
126
+ }
127
+ export async function selectionRead(root, json, p) {
128
+ const file = await readJson(root, "selection.json");
129
+ const major = schemaMajor(file?.schema, SELECTION_FAMILY);
130
+ if (typeof major === "object")
131
+ return tooNewResult(major.newer);
132
+ const rec = await runningEditor(root, p);
133
+ if (rec === undefined) {
134
+ return {
135
+ stdout: json
136
+ ? [JSON.stringify({ schema: SELECTION_SCHEMA, running: false, selection: null })]
137
+ : [],
138
+ stderr: [NOT_RUNNING],
139
+ exitCode: 1,
140
+ reason: "not_running",
141
+ };
142
+ }
143
+ const selection = file && major === "known" && sameEditor(file, rec)
144
+ ? shapeSelection(file.selection)
145
+ : null;
146
+ if (json) {
147
+ return {
148
+ stdout: [JSON.stringify({ schema: SELECTION_SCHEMA, running: true, selection })],
149
+ stderr: [],
150
+ exitCode: 0,
151
+ };
152
+ }
153
+ return { stdout: humanSelection(selection), stderr: [], exitCode: 0 };
154
+ }
155
+ function display(v) {
156
+ if (Array.isArray(v))
157
+ return v.map(display).join(", ");
158
+ if (v !== null && typeof v === "object") {
159
+ return Object.entries(v)
160
+ .map(([k, x]) => `${k}: ${display(x)}`)
161
+ .join(", ");
162
+ }
163
+ return String(v);
164
+ }
165
+ export function humanSelection(sel) {
166
+ if (sel === null)
167
+ return ["Nothing is selected in the editor."];
168
+ const src = sel.source;
169
+ const kind = sel.kind === "sdk" ? " (Vincentt SDK)" : "";
170
+ const out = [
171
+ `${sel.label} ${sel.type}${kind} ${src.file}${src.line !== null ? `:${src.line}` : ""}`,
172
+ ];
173
+ for (const v of sel.values) {
174
+ const unit = typeof v.unit === "string" ? ` ${v.unit}` : "";
175
+ const ro = v.editable === true
176
+ ? ""
177
+ : ` (read-only: ${display(v.editable?.readonly ?? "unsupported")})`;
178
+ out.push(` ${String(v.path).padEnd(26)} ${display(v.value)}${unit}${ro}`);
179
+ }
180
+ return out;
181
+ }
182
+ const APP_VERSION = /^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]{1,32})?$/;
183
+ const LAST_OPEN = /^(opened|awaiting_trust|cancelled|refused:[A-Za-z0-9_-]{1,16})$/;
184
+ /**
185
+ * `app` as the agent may read it: exactly `{version, lastOpen}`, each null unless it is one of
186
+ * the shapes the app writes. `trustLastOpen` false nulls `lastOpen` whatever the file says.
187
+ */
188
+ export function shapeApp(app, trustLastOpen) {
189
+ const a = (typeof app === "object" && app !== null ? app : {});
190
+ const version = typeof a.version === "string" && APP_VERSION.test(a.version) ? a.version : null;
191
+ const lastOpen = trustLastOpen && typeof a.lastOpen === "string" && LAST_OPEN.test(a.lastOpen)
192
+ ? a.lastOpen
193
+ : null;
194
+ return { version, lastOpen };
195
+ }
196
+ /**
197
+ * A state file no running editor vouches for is believed about the last open only when it
198
+ * changed after this machine's last hand-off of this folder: a committed or copied state.json
199
+ * predates it, and a folder never handed over has no open to report. A time in the future (an
200
+ * unpacked archive keeps whatever mtime it was given) is not after anything.
201
+ */
202
+ async function lastOpenTrusted(root, mtimeMs) {
203
+ const project = await realpath(root).catch(() => undefined);
204
+ if (project === undefined)
205
+ return false;
206
+ const at = await lastHandoff(project).catch(() => undefined);
207
+ return at !== undefined && mtimeMs > at && mtimeMs <= Date.now() + 5_000;
208
+ }
209
+ function appField(file, trustLastOpen) {
210
+ return file !== undefined && file.app !== undefined
211
+ ? { app: shapeApp(file.app, trustLastOpen) }
212
+ : {};
213
+ }
214
+ export async function statusRead(root, p) {
215
+ const read = await readJsonStat(root, "state.json");
216
+ const file = read?.value;
217
+ const major = schemaMajor(file?.schema, STATE_FAMILY);
218
+ if (typeof major === "object")
219
+ return tooNewResult(major.newer);
220
+ const known = major === "known" ? file : undefined;
221
+ const rec = await runningEditor(root, p);
222
+ if (known && rec && known.running === true && sameEditor(known, rec)) {
223
+ // The running, verified editor wrote this file. Sanitised first, so a key that only
224
+ // becomes `pid` once its control characters are stripped is removed too.
225
+ const shaped = agentSafeValue(known, 400);
226
+ for (const k of RESERVED_STATE_KEYS)
227
+ delete shaped[k];
228
+ if (known.app !== undefined)
229
+ shaped.app = shapeApp(known.app, true);
230
+ return {
231
+ stdout: [
232
+ JSON.stringify({ schema: STATE_SCHEMA, running: true, pid: rec.pid, ...shaped }),
233
+ ],
234
+ stderr: [],
235
+ exitCode: 0,
236
+ };
237
+ }
238
+ const trusted = known !== undefined &&
239
+ read !== undefined &&
240
+ (await lastOpenTrusted(root, read.mtimeMs));
241
+ if (rec === undefined) {
242
+ // A refused or cancelled open leaves no running editor behind, so the outcome of the last
243
+ // hand-off is reported here too, when the file is believed.
244
+ return {
245
+ stdout: [
246
+ JSON.stringify({
247
+ schema: STATE_SCHEMA,
248
+ running: false,
249
+ ...appField(known, trusted),
250
+ }),
251
+ ],
252
+ stderr: [NOT_RUNNING],
253
+ exitCode: 1,
254
+ reason: "not_running",
255
+ };
256
+ }
257
+ return {
258
+ stdout: [
259
+ JSON.stringify({
260
+ schema: STATE_SCHEMA,
261
+ running: true,
262
+ pid: rec.pid,
263
+ ...appField(known, trusted),
264
+ }),
265
+ ],
266
+ stderr: [],
267
+ exitCode: 0,
268
+ };
269
+ }
@@ -0,0 +1,7 @@
1
+ export declare function isUnsafeChar(ch: string): boolean;
2
+ /** Unsafe characters removed and the result capped at `max` code points. */
3
+ export declare function agentSafe(s: string, max: number): string;
4
+ /** Nesting kept in a value an agent reads; anything deeper is dropped (null). */
5
+ export declare const MAX_DEPTH = 8;
6
+ /** A JSON value with every string (keys included) made agent-safe, depth- and size-capped. */
7
+ export declare function agentSafeValue(v: unknown, max?: number, depth?: number): unknown;
@@ -0,0 +1,58 @@
1
+ // Text that reaches the creator's terminal or their agent from the editor's files. The app
2
+ // already strips these characters (B-F21-8); the CLI strips them again because it is the last
3
+ // step before a shell, and a stale or planted file never passed the app.
4
+ // C0 and C1 controls, DEL, the Arabic letter mark, zero-width characters and direction marks,
5
+ // line and paragraph separators, bidi embeddings, overrides and isolates, the word joiner,
6
+ // invisible operators, the byte order mark, the soft hyphen, the combining grapheme joiner,
7
+ // the Mongolian vowel separator, variation selectors and tag characters. Code points rather
8
+ // than a character class, so no bidi character sits in source.
9
+ const UNSAFE = [
10
+ [0x0000, 0x001f],
11
+ [0x007f, 0x009f],
12
+ [0x00ad, 0x00ad],
13
+ [0x034f, 0x034f],
14
+ [0x061c, 0x061c],
15
+ [0x180e, 0x180e],
16
+ [0x200b, 0x200f],
17
+ [0x2028, 0x2029],
18
+ [0x2060, 0x2064],
19
+ [0xfe00, 0xfe0f],
20
+ [0xfeff, 0xfeff],
21
+ [0x202a, 0x202e],
22
+ [0x2066, 0x2069],
23
+ [0xe0000, 0xe007f],
24
+ [0xe0100, 0xe01ef],
25
+ ];
26
+ export function isUnsafeChar(ch) {
27
+ const cp = ch.codePointAt(0) ?? 0;
28
+ return UNSAFE.some(([lo, hi]) => cp >= lo && cp <= hi);
29
+ }
30
+ // CSI and OSC sequences, removed whole so no `[31m` residue is left behind.
31
+ // eslint-disable-next-line no-control-regex
32
+ const ANSI_G = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u009b[0-?]*[ -/]*[@-~]/g;
33
+ /** Unsafe characters removed and the result capped at `max` code points. */
34
+ export function agentSafe(s, max) {
35
+ return Array.from(s.replace(ANSI_G, ""))
36
+ .filter((ch) => !isUnsafeChar(ch))
37
+ .slice(0, max)
38
+ .join("");
39
+ }
40
+ /** Nesting kept in a value an agent reads; anything deeper is dropped (null). */
41
+ export const MAX_DEPTH = 8;
42
+ /** A JSON value with every string (keys included) made agent-safe, depth- and size-capped. */
43
+ export function agentSafeValue(v, max = 200, depth = 0) {
44
+ if (typeof v === "string")
45
+ return agentSafe(v, max);
46
+ if (depth >= MAX_DEPTH && v !== null && typeof v === "object")
47
+ return null;
48
+ if (Array.isArray(v))
49
+ return v.slice(0, 64).map((x) => agentSafeValue(x, max, depth + 1));
50
+ if (v !== null && typeof v === "object") {
51
+ const out = {};
52
+ for (const [k, x] of Object.entries(v).slice(0, 256)) {
53
+ out[agentSafe(k, 80)] = agentSafeValue(x, max, depth + 1);
54
+ }
55
+ return out;
56
+ }
57
+ return v;
58
+ }
@@ -0,0 +1,5 @@
1
+ export type Tool = "codesign" | "open" | "sw_vers" | "plutil" | "lsappinfo" | "ps";
2
+ /** Where each tool lives, most likely first. Never a relative path; none off macOS. */
3
+ export declare function toolCandidates(tool: Tool, platform?: NodeJS.Platform): string[];
4
+ /** The tool's absolute path, or undefined when it is not where the OS keeps it. */
5
+ export declare function toolPath(tool: Tool, platform?: NodeJS.Platform, exists?: (p: string) => boolean): string | undefined;
@@ -0,0 +1,20 @@
1
+ // The macOS tools the launcher runs, by absolute path only. A bare name would be resolved from
2
+ // PATH, so a project (or anything earlier on PATH) could plant a `codesign` that answers "this
3
+ // app is signed by Vincentt".
4
+ import { existsSync } from "node:fs";
5
+ const DARWIN = {
6
+ ps: ["/bin/ps"],
7
+ codesign: ["/usr/bin/codesign"],
8
+ open: ["/usr/bin/open"],
9
+ sw_vers: ["/usr/bin/sw_vers"],
10
+ plutil: ["/usr/bin/plutil"],
11
+ lsappinfo: ["/usr/bin/lsappinfo"],
12
+ };
13
+ /** Where each tool lives, most likely first. Never a relative path; none off macOS. */
14
+ export function toolCandidates(tool, platform = process.platform) {
15
+ return platform === "darwin" ? DARWIN[tool] : [];
16
+ }
17
+ /** The tool's absolute path, or undefined when it is not where the OS keeps it. */
18
+ export function toolPath(tool, platform = process.platform, exists = existsSync) {
19
+ return toolCandidates(tool, platform).find((p) => exists(p));
20
+ }
@@ -11,6 +11,13 @@ export interface StartBlockInput {
11
11
  * project is worse than no link.
12
12
  */
13
13
  consoleUrl?: string;
14
+ /**
15
+ * The console origin the relay accepts feedback from, passed ONLY when it is
16
+ * not the production console. A flag or environment variable can point the
17
+ * CLI at another console, and the creator sees which one before any feedback
18
+ * arrives from it.
19
+ */
20
+ feedbackOrigin?: string;
14
21
  /** Pre-rendered QR rows, or undefined for non-TTY / piped / --no-qr. */
15
22
  qr?: string[];
16
23
  expiresInHours: number;
@@ -48,6 +48,9 @@ export function startBlock(i) {
48
48
  if (i.consoleUrl) {
49
49
  out.push(" In the console:", "", ` ${i.consoleUrl}`, "");
50
50
  }
51
+ if (i.feedbackOrigin) {
52
+ out.push(` Feedback is accepted from the console at ${terminalSafe(i.feedbackOrigin)}`, "");
53
+ }
51
54
  out.push(" Waiting for a device…", "");
52
55
  return out;
53
56
  }
@@ -101,6 +101,11 @@ export interface StartPreviewOptions {
101
101
  appReadyTimeoutMs?: number;
102
102
  /** Progress sink. Never write app stdout to an MCP server's stdout. */
103
103
  onLog?: (message: string) => void;
104
+ /**
105
+ * The console this CLI signs in through, from the creator's own configuration.
106
+ * Its origin, if valid, may send feedback to the relay's annotation route.
107
+ */
108
+ consoleUrl?: string;
104
109
  /**
105
110
  * A viewer label appearing on a stream. Fires per OPEN, so the roster's
106
111
  * per-label idempotence is what keeps one device from announcing itself once
@@ -171,6 +176,8 @@ export interface RunningPreview {
171
176
  appPort: number;
172
177
  /** Whether the app was responding when we returned. */
173
178
  appReady: boolean;
179
+ /** The console origin the relay admits on its annotation route, if any. */
180
+ consoleOrigin?: string;
174
181
  /** Tear down the tunnel, the app dev serve, the front proxy and the relay. */
175
182
  stop: () => Promise<void>;
176
183
  }
@@ -17,6 +17,7 @@ import { spawn, spawnSync } from "node:child_process";
17
17
  import { realpath } from "node:fs/promises";
18
18
  import { createServer } from "node:http";
19
19
  import { TunnelClient, resolveForwardTarget, } from "../tunnel/index.js";
20
+ import { consoleOriginOf } from "../relay/consoleOrigin.js";
20
21
  import { startRelay } from "../relay/server.js";
21
22
  import { mintRelayToken } from "../relay/guard.js";
22
23
  import { isHiddenAppPath, proxyWeb, proxyWs, relayRoute } from "./proxy.js";
@@ -168,10 +169,12 @@ export async function startPreview(opts) {
168
169
  }
169
170
  // Fixed here, from the server-computed URL, and never from a request.
170
171
  const previewOrigin = new URL(minted.url).origin;
172
+ const consoleOrigin = consoleOriginOf(opts.consoleUrl);
171
173
  relay = startRelay({
172
174
  port: relayPort,
173
175
  path: harnessPath,
174
176
  allowedOrigins: [previewOrigin],
177
+ consoleOrigin,
175
178
  token: relayToken,
176
179
  // Annotations land beside the binding, so `vincentt feedback` finds them
177
180
  // from anywhere in the tree rather than only from the folder that started
@@ -293,6 +296,7 @@ export async function startPreview(opts) {
293
296
  relayToken,
294
297
  appPort,
295
298
  appReady,
299
+ consoleOrigin,
296
300
  stop,
297
301
  };
298
302
  }
@@ -0,0 +1,12 @@
1
+ /** The console origin to admit for `consoleUrl`, or undefined to admit none. */
2
+ export declare function consoleOriginOf(consoleUrl: string | undefined): string | undefined;
3
+ /**
4
+ * Is this preflight one the relay answers? The Origin was already matched
5
+ * exactly. The method must be exactly POST, and the requested headers, parsed
6
+ * as a comma list, trimmed and lowercased, a subset of `content-type`.
7
+ */
8
+ export declare function isConsolePreflight(method: string | string[] | undefined, requestHeaders: string | string[] | undefined): boolean;
9
+ /** The CORS headers on a preflight the relay answers. Nothing else, ever. */
10
+ export declare function preflightHeaders(consoleOrigin: string): Record<string, string>;
11
+ /** The CORS headers on the probe and on every POST response to the console. */
12
+ export declare function responseHeaders(consoleOrigin: string): Record<string, string>;
@@ -0,0 +1,68 @@
1
+ // The one browser origin the relay admits besides the preview's own: the console
2
+ // this CLI signs in through, and only on the annotation route.
3
+ //
4
+ // It comes from the creator's own configuration (flag, env, machine config),
5
+ // never from a request and never from the folder's binding. A value that is not
6
+ // exactly an https origin, or an http origin on this machine's loopback, grants
7
+ // nothing, and the relay then behaves as one with no console origin at all.
8
+ import { isLoopbackOrigin } from "./guard.js";
9
+ /** The console origin to admit for `consoleUrl`, or undefined to admit none. */
10
+ export function consoleOriginOf(consoleUrl) {
11
+ if (consoleUrl === undefined)
12
+ return undefined;
13
+ let url;
14
+ try {
15
+ url = new URL(consoleUrl.trim());
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ // `origin` is the literal "null" for every scheme without a host, so this one
21
+ // check refuses `javascript:`, `data:`, `file:` and a blob URL alike.
22
+ const origin = url.origin;
23
+ if (origin === "null" || origin === "*")
24
+ return undefined;
25
+ if (url.username !== "" || url.password !== "")
26
+ return undefined;
27
+ if (url.protocol === "https:")
28
+ return origin;
29
+ if (url.protocol === "http:" && isLoopbackOrigin(origin))
30
+ return origin;
31
+ return undefined;
32
+ }
33
+ /**
34
+ * Is this preflight one the relay answers? The Origin was already matched
35
+ * exactly. The method must be exactly POST, and the requested headers, parsed
36
+ * as a comma list, trimmed and lowercased, a subset of `content-type`.
37
+ */
38
+ export function isConsolePreflight(method, requestHeaders) {
39
+ if (method !== "POST")
40
+ return false;
41
+ if (requestHeaders === undefined)
42
+ return true;
43
+ if (typeof requestHeaders !== "string")
44
+ return false;
45
+ if (requestHeaders.trim() === "")
46
+ return true;
47
+ return requestHeaders
48
+ .split(",")
49
+ .map((h) => h.trim().toLowerCase())
50
+ .every((h) => h === "content-type");
51
+ }
52
+ /** The CORS headers on a preflight the relay answers. Nothing else, ever. */
53
+ export function preflightHeaders(consoleOrigin) {
54
+ return {
55
+ "access-control-allow-origin": consoleOrigin,
56
+ "access-control-allow-methods": "POST",
57
+ "access-control-allow-headers": "content-type",
58
+ "access-control-max-age": "600",
59
+ vary: "Origin",
60
+ };
61
+ }
62
+ /** The CORS headers on the probe and on every POST response to the console. */
63
+ export function responseHeaders(consoleOrigin) {
64
+ return {
65
+ "access-control-allow-origin": consoleOrigin,
66
+ vary: "Origin",
67
+ };
68
+ }
@@ -16,8 +16,9 @@ export declare function isAllowedOrigin(raw: string | string[] | undefined, allo
16
16
  export declare function isLoopbackOrigin(raw: string): boolean;
17
17
  /**
18
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.
19
+ * multipart bodies without a preflight. Requiring JSON forces a preflight, and
20
+ * the relay answers one only for the configured console origin, only on the
21
+ * annotation route.
21
22
  */
22
23
  export declare function isJsonContentType(raw: string | undefined): boolean;
23
24
  export declare function hasRelayToken(raw: string | string[] | undefined, token: string): boolean;
@@ -1,8 +1,9 @@
1
1
  // The relay's request gate. Every caller the relay serves is on this machine:
2
2
  // the CLI (no Origin), the tunnel client and the front proxy (both rewrite Host
3
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.
4
+ // The one exception is the creator's console, admitted on the annotation route
5
+ // only (see consoleOrigin.ts). A web page the creator happens to have open is
6
+ // none of those, and these checks are what tell it apart.
6
7
  import { randomBytes, timingSafeEqual } from "node:crypto";
7
8
  /** The header carrying the per-session token on the local-only routes. */
8
9
  export const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
@@ -55,8 +56,9 @@ export function isLoopbackOrigin(raw) {
55
56
  }
56
57
  /**
57
58
  * 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.
59
+ * multipart bodies without a preflight. Requiring JSON forces a preflight, and
60
+ * the relay answers one only for the configured console origin, only on the
61
+ * annotation route.
60
62
  */
61
63
  export function isJsonContentType(raw) {
62
64
  if (raw === undefined)
@@ -64,6 +64,15 @@ export interface RelayOptions {
64
64
  * loopback pass, which is what a standalone `vincentt relay` serves.
65
65
  */
66
66
  allowedOrigins?: readonly string[];
67
+ /**
68
+ * The console this CLI signs in through, already validated (`consoleOriginOf`).
69
+ * Admitted on the annotation route ONLY: its preflight and probe are answered
70
+ * there and its POST carries CORS headers. It is never added to
71
+ * `allowedOrigins`, which also gates the WebSocket, so the diagnostics socket,
72
+ * `/query` and `feedback/next` refuse it like any foreign origin. Omitted, the
73
+ * relay admits no console.
74
+ */
75
+ consoleOrigin?: string;
67
76
  /**
68
77
  * The per-session secret `/query` and `feedback/next` require in
69
78
  * `x-vincentt-relay-token`. The preview always sets it; without it those
@@ -136,3 +145,9 @@ export declare function isUnsafeRelayPath(rawUrl: string): boolean;
136
145
  * number before it reaches a timer.
137
146
  */
138
147
  export declare function feedbackTimeoutMs(raw: string | null): number;
148
+ /**
149
+ * The body as UTF-8, decoded ONCE from the joined bytes: decoding each chunk on
150
+ * its own turns a multi-byte character split across two chunks into U+FFFD.
151
+ * The size limit is in bytes.
152
+ */
153
+ export declare function readBody(req: IncomingMessage, maxBytes?: number): Promise<string>;