@vincentt-xr/cli 0.23.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.
package/MIGRATION.md CHANGED
@@ -3,16 +3,28 @@
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.24.0
7
+
8
+ required: false
9
+ No action required. `vincentt preview` accepts feedback from the console the CLI signs in through, and the relay now refuses marks outside fixed bounds.
10
+ - The relay admits one browser origin besides the preview's own: the origin of the console the CLI is configured with (https, or http on this machine's loopback; anything else admits none). It answers that console's preflight and probe only on the annotation route, with that exact origin, never `*` and never with credentials.
11
+ - The diagnostics socket, the diagnostics query and the feedback drain refuse the console origin with 403, like any other browser origin.
12
+ - An annotation from any sender is refused with 400 and stored nowhere when a coordinate is outside [-1, 2], it has more than 50 marks, a pen path has more than 500 points, a note is over 2,000 characters, the message is over 110,000 characters, or `frameInImage` does not fit inside the screenshot's PNG size.
13
+ - Also refused with 400: a mark whose colour is not red, yellow or blue, or whose number is not 1 to 50; more than 50 legacy strokes, pins or labels; a stroke over 500 points or with a colour that is not a short colour token; a pin label or label over 2,000 characters.
14
+ - The stored annotation's `spec` is rebuilt from the fields the relay checked, so a field it does not know is dropped and never reaches the agent.
15
+ - An annotation sent from the console carries no tester.
16
+ - When the configured console is not the production console (a flag, an environment variable or the machine config can change it), the preview start block prints `Feedback is accepted from the console at <origin>`.
17
+
6
18
  ## 0.23.0
7
19
 
8
20
  required: false
9
21
  No action required. New verb `vincentt editor`, which hands the project to the Vincentt Editor desktop app.
10
- - `vincentt editor` (macOS 14 or later, no account) finds `Vincentt Editor.app` in `/Applications`, then `~/Applications`, checks it is signed by Vincentt, hands it the project folder and exits 0 with `✓ Sent <name> to Vincentt Editor.` It does not wait for the app. The app is a separate download from https://vincentt.studio/editor; the CLI installs no editor binary and has no port option for it.
22
+ - `vincentt editor` (macOS 14 or later, no account) finds the Vincentt Editor app in the Mac's Applications folder, then in the user's own Applications folder, checks it is signed by Vincentt, hands it the project folder and exits 0 with `✓ Sent <name> to Vincentt Editor.` It does not wait for the app. The app is a separate download from https://vincentt.studio/editor; the CLI installs no editor binary and has no port option for it.
11
23
  - It exits 1, last line `Nothing was opened.`, when the app is not installed, the installed copy is not signed by Vincentt, the Mac is not on macOS 14 or later, or another copy of the app is running from a different folder.
12
24
  - `vincentt editor --json` prints one `vincentt.editor-open/1` object: `{"schema","handedOff":true,"project","app","appVersion"}`, or `handedOff: false` with a `reason` (`not_installed`, `bad_signature`, `unsupported_os`, `other_copy_running`, `open_failed`) and, where known, `app` and `detail`. The sentence goes to stderr.
13
25
  - `vincentt editor --selection [--json]` prints what is selected in the editor (`vincentt.editor-selection/1`), and `vincentt editor --status --json` the editor's state for the folder (`vincentt.editor-state/1`, including `app.lastOpen`). Both work on any OS and exit 1, with one JSON object still on stdout, when no editor is running for the folder.
14
26
  - In `--status --json`, `app` is always `{"version","lastOpen"}`; when no editor is running, `lastOpen` is reported only for a folder this machine handed over with `vincentt editor`, from a state file written after that hand-off, and is `null` otherwise.
15
- - The CLI keeps those hand-offs in `~/.vincentt/editor-handoffs.json`, as a hash of each folder's path and a time, never the path itself. A file written in a newer format than this CLI reads is refused with an upgrade line on stderr and nothing on stdout.
27
+ - The CLI keeps those hand-offs in a file in its own settings folder, as a hash of each folder's path and a time, never the path itself. A file written in a newer format than this CLI reads is refused with an upgrade line on stderr and nothing on stdout.
16
28
 
17
29
  ## 0.22.0
18
30
 
package/dist/cli/index.js CHANGED
@@ -660,6 +660,7 @@ async function preview() {
660
660
  appPort: devPort,
661
661
  appPortFresh: devPortFresh,
662
662
  devCommand,
663
+ consoleUrl: (await resolveAuthUrls()).consoleUrl,
663
664
  onHiddenPath: (p) => ui.hiddenPath(p),
664
665
  appStdio: has("--verbose") ? "inherit" : "ignore",
665
666
  onViewer: (label, uaClass) => ui.viewerSeen(label, uaClass),
@@ -727,6 +728,10 @@ async function preview() {
727
728
  targetUrl: `http://localhost:${running.appPort}`,
728
729
  url: running.url,
729
730
  consoleUrl: await projectConsoleUrl(cwd, running.sessionId),
731
+ feedbackOrigin: running.consoleOrigin !== undefined &&
732
+ running.consoleOrigin !== new URL(DEFAULT_CONSOLE_URL).origin
733
+ ? running.consoleOrigin
734
+ : undefined,
730
735
  qr,
731
736
  expiresInHours: 12,
732
737
  })) {
@@ -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>;
@@ -10,6 +10,8 @@ import { EventStore } from "./store.js";
10
10
  import { TesterRegistry, isTesterId } from "./testers.js";
11
11
  import { AnnotationStore } from "./annotations.js";
12
12
  import { RELAY_TOKEN_HEADER, hasRelayToken, isAllowedHost, isAllowedOrigin, isJsonContentType, } from "./guard.js";
13
+ import { isConsolePreflight, preflightHeaders, responseHeaders, } from "./consoleOrigin.js";
14
+ import { boundedSpec } from "./specBounds.js";
13
15
  import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "../tunnel/index.js";
14
16
  /** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
15
17
  const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
@@ -56,6 +58,7 @@ export function startRelay(opts = {}) {
56
58
  host,
57
59
  port: boundPort(),
58
60
  allowedOrigins,
61
+ consoleOrigin: opts.consoleOrigin,
59
62
  token: opts.token,
60
63
  store,
61
64
  annotations,
@@ -208,9 +211,22 @@ function handleHttp(req, res, ctx) {
208
211
  res.writeHead(403).end();
209
212
  return;
210
213
  }
214
+ const annotationRoute = p === `${ctx.wsPath}/annotation`;
215
+ // The console origin is matched HERE, inside the annotation route, and never
216
+ // joins the origin list: that list also gates the WebSocket, and a console
217
+ // page reaching the diagnostics socket is the hole this placement closes.
218
+ // Exact string equality, so `null`, a duplicated header, a trailing slash and
219
+ // a case variant all miss.
220
+ const fromConsole = annotationRoute &&
221
+ ctx.consoleOrigin !== undefined &&
222
+ req.headers.origin === ctx.consoleOrigin;
223
+ if (annotationRoute && req.method === "OPTIONS") {
224
+ handlePreflight(req, res, ctx, fromConsole);
225
+ return;
226
+ }
211
227
  // The local-only routes have no browser caller at all, so any Origin is foreign.
212
228
  const origins = localOnly ? [] : ctx.allowedOrigins;
213
- if (!isAllowedOrigin(req.headers.origin, origins)) {
229
+ if (!fromConsole && !isAllowedOrigin(req.headers.origin, origins)) {
214
230
  ctx.log("refused a request from a foreign Origin");
215
231
  res.writeHead(403).end();
216
232
  return;
@@ -221,6 +237,26 @@ function handleHttp(req, res, ctx) {
221
237
  return;
222
238
  }
223
239
  }
240
+ // On EVERY status from here, so the console can read a refusal as well as a
241
+ // 200 and never retries an annotation the relay already stored.
242
+ if (fromConsole) {
243
+ for (const [k, v] of Object.entries(responseHeaders(ctx.consoleOrigin))) {
244
+ res.setHeader(k, v);
245
+ }
246
+ }
247
+ if (annotationRoute && req.method === "GET") {
248
+ // The console's probe: presence only, and it writes nothing.
249
+ if (!fromConsole) {
250
+ res.writeHead(405).end();
251
+ return;
252
+ }
253
+ res.writeHead(ctx.annotations ? 204 : 503).end();
254
+ return;
255
+ }
256
+ if (annotationRoute && req.method !== "POST") {
257
+ res.writeHead(405).end();
258
+ return;
259
+ }
224
260
  if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
225
261
  res.writeHead(415).end();
226
262
  return;
@@ -229,7 +265,9 @@ function handleHttp(req, res, ctx) {
229
265
  handleQuery(req, res, url, ctx);
230
266
  return;
231
267
  }
232
- if (p === `${ctx.wsPath}/annotation`) {
268
+ if (annotationRoute) {
269
+ if (fromConsole)
270
+ ctx.log("annotation from the console origin");
233
271
  handleAnnotationPost(req, res, ctx);
234
272
  return;
235
273
  }
@@ -239,6 +277,27 @@ function handleHttp(req, res, ctx) {
239
277
  }
240
278
  res.writeHead(404).end();
241
279
  }
280
+ /**
281
+ * The console's CORS preflight on the annotation route. Answered only for the
282
+ * exact console origin asking for POST with at most `content-type`; anything
283
+ * else is 403 with no CORS header at all. Never `*`, never credentials, never a
284
+ * private-network grant.
285
+ */
286
+ function handlePreflight(req, res, ctx, fromConsole) {
287
+ if (!fromConsole) {
288
+ ctx.log("refused a preflight from a foreign Origin");
289
+ res.writeHead(403).end();
290
+ return;
291
+ }
292
+ const ok = isConsolePreflight(req.headers["access-control-request-method"], req.headers["access-control-request-headers"]);
293
+ if (!ok) {
294
+ ctx.log("refused a console preflight for a method or header it does not allow");
295
+ res.writeHead(403).end();
296
+ return;
297
+ }
298
+ ctx.log("answered a console preflight");
299
+ res.writeHead(204, preflightHeaders(ctx.consoleOrigin)).end();
300
+ }
242
301
  // The CLI (same laptop) GETs/POSTs /query with a RelayQuery, carrying the
243
302
  // session token from the 0600 relay marker.
244
303
  function handleQuery(req, res, url, ctx) {
@@ -279,6 +338,16 @@ function handleAnnotationPost(req, res, ctx) {
279
338
  res.writeHead(400).end("annotation requires message + screenshot");
280
339
  return;
281
340
  }
341
+ // Refused, never clamped: the agent acts on these values, and a stored
342
+ // coordinate must be exactly the one that was sent.
343
+ // What is stored is the spec REBUILT from the checked fields, never the
344
+ // body's own object: an unknown key would otherwise ride to the agent.
345
+ const bounded = boundedSpec(input);
346
+ if (!bounded.ok) {
347
+ ctx.log("refused marks out of bounds");
348
+ res.writeHead(400).end("annotation marks out of bounds");
349
+ return;
350
+ }
282
351
  // The annotation's session key is RESOLVED, never assigned — `resolve`,
283
352
  // not `attribute`. This route is unauthenticated and reachable by anyone
284
353
  // holding the capability URL, so a door that minted here would let 64 bare
@@ -288,7 +357,12 @@ function handleAnnotationPost(req, res, ctx) {
288
357
  // The key is still hostile input either way: the record reaches an agent's
289
358
  // context on the consumer's stdout, so the raw string must never survive
290
359
  // onto it.
291
- const annotation = await ctx.annotations.add({ ...input, sessionId: ctx.testers.resolve(input.sessionId) }, { now: Date.now(), id: ctx.makeId() });
360
+ const annotation = await ctx.annotations.add({
361
+ message: input.message,
362
+ screenshot: input.screenshot,
363
+ spec: bounded.spec,
364
+ sessionId: ctx.testers.resolve(input.sessionId),
365
+ }, { now: Date.now(), id: ctx.makeId() });
292
366
  // The live stream is told an annotation ARRIVED, with the resolved tester
293
367
  // and nothing else. It is a fact, not a directive: the consumer is the
294
368
  // long-poll, and a terminal line naming what to run would be the platform
@@ -299,7 +373,11 @@ function handleAnnotationPost(req, res, ctx) {
299
373
  .end(JSON.stringify({ id: annotation.id, seq: annotation.seq }));
300
374
  })
301
375
  .catch((err) => {
302
- ctx.log(`annotation error: ${err}`);
376
+ // A fixed line, never the error's text: a JSON.parse message quotes the
377
+ // body, which is hostile input, and this log reaches the terminal.
378
+ ctx.log(err instanceof SyntaxError
379
+ ? "annotation error: the body is not JSON"
380
+ : "annotation error: not stored (too large, or the write failed)");
303
381
  if (!res.headersSent)
304
382
  res.writeHead(400).end();
305
383
  });
@@ -397,9 +475,14 @@ async function readQuery(req, url) {
397
475
  q.presence = true;
398
476
  return normalizeTester(q);
399
477
  }
400
- function readBody(req, maxBytes = Infinity) {
478
+ /**
479
+ * The body as UTF-8, decoded ONCE from the joined bytes: decoding each chunk on
480
+ * its own turns a multi-byte character split across two chunks into U+FFFD.
481
+ * The size limit is in bytes.
482
+ */
483
+ export function readBody(req, maxBytes = Infinity) {
401
484
  return new Promise((resolve, reject) => {
402
- let data = "";
485
+ const chunks = [];
403
486
  let bytes = 0;
404
487
  req.on("data", (c) => {
405
488
  bytes += c.length;
@@ -408,9 +491,9 @@ function readBody(req, maxBytes = Infinity) {
408
491
  req.destroy();
409
492
  return;
410
493
  }
411
- data += c;
494
+ chunks.push(c);
412
495
  });
413
- req.on("end", () => resolve(data));
496
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
414
497
  req.on("error", reject);
415
498
  });
416
499
  }
@@ -0,0 +1,39 @@
1
+ import type { AnnotationInput, AnnotationSpec } from "@vincentt-xr/harness/events";
2
+ export declare const COORD_MIN = -1;
3
+ export declare const COORD_MAX = 2;
4
+ export declare const MAX_SHAPES = 50;
5
+ export declare const MAX_STROKES = 50;
6
+ export declare const MAX_PINS = 50;
7
+ export declare const MAX_LABELS = 50;
8
+ export declare const MAX_PATH_POINTS = 500;
9
+ export declare const MAX_NOTE_CHARS = 2000;
10
+ export declare const MAX_MESSAGE_CHARS = 110000;
11
+ /** The mark colours every sender draws with: the keys of the harness's MARK_COLORS. */
12
+ export declare const MARK_COLOR_NAMES: ReadonlySet<string>;
13
+ /**
14
+ * The width and height a PNG data URL's IHDR declares, or undefined when it is
15
+ * not a PNG data URL. Reads only the first 24 decoded bytes; never decodes the
16
+ * image.
17
+ */
18
+ export declare function pngSize(dataUrl: string): {
19
+ width: number;
20
+ height: number;
21
+ } | undefined;
22
+ /** Refused, or in bounds with the `spec` to store (undefined when none was sent). */
23
+ export type BoundedSpec = {
24
+ ok: false;
25
+ } | {
26
+ ok: true;
27
+ spec?: AnnotationSpec;
28
+ };
29
+ /**
30
+ * Check an annotation's message and marks, and rebuild `spec` from the fields
31
+ * that passed. Assumes the caller already checked `message` and `screenshot` are
32
+ * strings.
33
+ *
34
+ * `spec` absent, `{}` and `{shapes: []}` are all in bounds. A field the relay
35
+ * does not know is not an error; it is simply not stored.
36
+ */
37
+ export declare function boundedSpec(input: AnnotationInput): BoundedSpec;
38
+ /** Whether an annotation's message and marks are within bounds. */
39
+ export declare function isWithinBounds(input: AnnotationInput): boolean;
@@ -0,0 +1,157 @@
1
+ // The annotation's marks are hostile input the agent then acts on, from any
2
+ // sender: the phone, the console, or anyone holding the preview address. The
3
+ // relay refuses an annotation outside these bounds rather than clamping it, and
4
+ // stores a `spec` REBUILT from the fields it checked: an unknown key is dropped,
5
+ // so what reaches the agent is exactly what was validated, value for value.
6
+ //
7
+ // Coordinates are picture-relative: [0,1] spans the picture and a value outside
8
+ // it is a point past the picture's edge. No sender's drawing margin reaches past
9
+ // [-1, 2].
10
+ export const COORD_MIN = -1;
11
+ export const COORD_MAX = 2;
12
+ export const MAX_SHAPES = 50;
13
+ export const MAX_STROKES = 50;
14
+ export const MAX_PINS = 50;
15
+ export const MAX_LABELS = 50;
16
+ export const MAX_PATH_POINTS = 500;
17
+ export const MAX_NOTE_CHARS = 2_000;
18
+ export const MAX_MESSAGE_CHARS = 110_000;
19
+ /** The mark colours every sender draws with: the keys of the harness's MARK_COLORS. */
20
+ export const MARK_COLOR_NAMES = new Set(["red", "yellow", "blue"]);
21
+ /**
22
+ * A legacy stroke's colour, which an app could set itself: a mark colour name, a
23
+ * hex colour, or one short lowercase word. Never free text.
24
+ */
25
+ const STROKE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|[a-z]{1,16})$/;
26
+ const SHAPE_TYPES = new Set(["rect", "circle", "arrow", "pen"]);
27
+ const PNG_PREFIX = "data:image/png;base64,";
28
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
29
+ /** Thrown inside the rebuild; never escapes `boundedSpec`. */
30
+ class Refused extends Error {
31
+ }
32
+ const refuse = () => {
33
+ throw new Refused();
34
+ };
35
+ const isRec = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
36
+ const coord = (v) => typeof v === "number" && Number.isFinite(v) && v >= COORD_MIN && v <= COORD_MAX
37
+ ? v
38
+ : refuse();
39
+ const size = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : refuse();
40
+ const point = (v) => isRec(v) ? { x: coord(v.x), y: coord(v.y) } : refuse();
41
+ const text = (v) => typeof v === "string" && v.length <= MAX_NOTE_CHARS ? v : refuse();
42
+ const list = (v, max) => Array.isArray(v) && v.length <= max ? v : refuse();
43
+ const path = (v) => list(v, MAX_PATH_POINTS).map(point);
44
+ function shape(v) {
45
+ if (!isRec(v))
46
+ return refuse();
47
+ if (typeof v.type !== "string" || !SHAPE_TYPES.has(v.type))
48
+ return refuse();
49
+ const n = typeof v.n === "number" && Number.isInteger(v.n) && v.n >= 1 && v.n <= MAX_SHAPES
50
+ ? v.n
51
+ : refuse();
52
+ const color = typeof v.color === "string" && MARK_COLOR_NAMES.has(v.color) ? v.color : refuse();
53
+ const base = { n, color, ...(v.note === undefined ? {} : { note: text(v.note) }) };
54
+ if (v.type === "arrow")
55
+ return { ...base, type: "arrow", from: point(v.from), to: point(v.to) };
56
+ if (v.type === "pen")
57
+ return { ...base, type: "pen", points: path(v.points) };
58
+ const [x, y, w, h] = [coord(v.x), coord(v.y), size(v.w), size(v.h)];
59
+ // The far corner is a coordinate too. Its sum carries float error, so it alone
60
+ // gets a hair of tolerance; every coordinate a sender wrote is checked exactly.
61
+ if (x + w > COORD_MAX + 1e-9 || y + h > COORD_MAX + 1e-9)
62
+ return refuse();
63
+ return { ...base, type: v.type, x, y, w, h };
64
+ }
65
+ function stroke(v) {
66
+ if (!isRec(v))
67
+ return refuse();
68
+ const out = { points: path(v.points) };
69
+ if (v.color !== undefined) {
70
+ if (typeof v.color !== "string" || !STROKE_COLOR_RE.test(v.color))
71
+ return refuse();
72
+ out.color = v.color;
73
+ }
74
+ return out;
75
+ }
76
+ function pin(v) {
77
+ if (!isRec(v))
78
+ return refuse();
79
+ const out = { x: coord(v.x), y: coord(v.y) };
80
+ if (v.label !== undefined)
81
+ out.label = text(v.label);
82
+ return out;
83
+ }
84
+ /**
85
+ * The width and height a PNG data URL's IHDR declares, or undefined when it is
86
+ * not a PNG data URL. Reads only the first 24 decoded bytes; never decodes the
87
+ * image.
88
+ */
89
+ export function pngSize(dataUrl) {
90
+ if (!dataUrl.startsWith(PNG_PREFIX))
91
+ return undefined;
92
+ // 32 base64 characters decode to exactly the 24 header bytes.
93
+ const head = Buffer.from(dataUrl.slice(PNG_PREFIX.length, PNG_PREFIX.length + 32), "base64");
94
+ if (head.length < 24)
95
+ return undefined;
96
+ if (!PNG_SIGNATURE.every((b, i) => head[i] === b))
97
+ return undefined;
98
+ if (head.toString("latin1", 12, 16) !== "IHDR")
99
+ return undefined;
100
+ return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) };
101
+ }
102
+ function frameInImage(v, screenshot) {
103
+ if (!isRec(v))
104
+ return refuse();
105
+ const { x, y, w, h } = v;
106
+ if (![x, y, w, h].every((n) => typeof n === "number" && Number.isInteger(n)))
107
+ return refuse();
108
+ const [fx, fy, fw, fh] = [x, y, w, h];
109
+ if (fx < 0 || fy < 0 || fw < 1 || fh < 1)
110
+ return refuse();
111
+ const image = pngSize(screenshot) ?? refuse();
112
+ if (fx + fw > image.width || fy + fh > image.height)
113
+ return refuse();
114
+ return { x: fx, y: fy, w: fw, h: fh };
115
+ }
116
+ /**
117
+ * Check an annotation's message and marks, and rebuild `spec` from the fields
118
+ * that passed. Assumes the caller already checked `message` and `screenshot` are
119
+ * strings.
120
+ *
121
+ * `spec` absent, `{}` and `{shapes: []}` are all in bounds. A field the relay
122
+ * does not know is not an error; it is simply not stored.
123
+ */
124
+ export function boundedSpec(input) {
125
+ if (input.message.length > MAX_MESSAGE_CHARS)
126
+ return { ok: false };
127
+ const raw = input.spec;
128
+ if (raw === undefined)
129
+ return { ok: true };
130
+ try {
131
+ if (!isRec(raw))
132
+ return refuse();
133
+ const spec = {};
134
+ if (raw.shapes !== undefined)
135
+ spec.shapes = list(raw.shapes, MAX_SHAPES).map(shape);
136
+ if (raw.strokes !== undefined)
137
+ spec.strokes = list(raw.strokes, MAX_STROKES).map(stroke);
138
+ if (raw.pins !== undefined)
139
+ spec.pins = list(raw.pins, MAX_PINS).map(pin);
140
+ if (raw.labels !== undefined)
141
+ spec.labels = list(raw.labels, MAX_LABELS).map(text);
142
+ if (raw.note !== undefined)
143
+ spec.note = text(raw.note);
144
+ if (raw.frameInImage !== undefined)
145
+ spec.frameInImage = frameInImage(raw.frameInImage, input.screenshot);
146
+ return { ok: true, spec };
147
+ }
148
+ catch (err) {
149
+ if (err instanceof Refused)
150
+ return { ok: false };
151
+ throw err;
152
+ }
153
+ }
154
+ /** Whether an annotation's message and marks are within bounds. */
155
+ export function isWithinBounds(input) {
156
+ return boundedSpec(input).ok;
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
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",
@@ -28,7 +28,7 @@
28
28
  "qrcode": "^1.5.4",
29
29
  "ws": "^8.18.0",
30
30
  "zod": "^3.23.8",
31
- "@vincentt-xr/harness": "3.1.0"
31
+ "@vincentt-xr/harness": "3.2.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/qrcode": "^1.5.5",