@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 +14 -2
- package/dist/cli/index.js +5 -0
- package/dist/preview/copy.d.ts +7 -0
- package/dist/preview/copy.js +3 -0
- package/dist/preview/runner.d.ts +7 -0
- package/dist/preview/runner.js +4 -0
- package/dist/relay/consoleOrigin.d.ts +12 -0
- package/dist/relay/consoleOrigin.js +68 -0
- package/dist/relay/guard.d.ts +3 -2
- package/dist/relay/guard.js +6 -4
- package/dist/relay/server.d.ts +15 -0
- package/dist/relay/server.js +91 -8
- package/dist/relay/specBounds.d.ts +39 -0
- package/dist/relay/specBounds.js +157 -0
- package/package.json +2 -2
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
|
|
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
|
|
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
|
})) {
|
package/dist/preview/copy.d.ts
CHANGED
|
@@ -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;
|
package/dist/preview/copy.js
CHANGED
|
@@ -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
|
}
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/preview/runner.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/relay/guard.d.ts
CHANGED
|
@@ -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
|
|
20
|
-
* relay
|
|
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;
|
package/dist/relay/guard.js
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
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
|
|
59
|
-
* relay
|
|
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)
|
package/dist/relay/server.d.ts
CHANGED
|
@@ -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>;
|
package/dist/relay/server.js
CHANGED
|
@@ -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 (
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
+
chunks.push(c);
|
|
412
495
|
});
|
|
413
|
-
req.on("end", () => resolve(
|
|
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.
|
|
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.
|
|
31
|
+
"@vincentt-xr/harness": "3.2.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/qrcode": "^1.5.5",
|