@vincentt-xr/cli 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/MIGRATION.md +24 -0
  2. package/README.md +94 -1
  3. package/dist/cli/index.js +170 -84
  4. package/dist/cli/usage-text.js +32 -10
  5. package/dist/domain/api.d.ts +10 -8
  6. package/dist/domain/api.js +14 -9
  7. package/dist/domain/command.js +25 -16
  8. package/dist/domain/copy.d.ts +6 -3
  9. package/dist/domain/copy.js +10 -6
  10. package/dist/dx/command.d.ts +24 -4
  11. package/dist/dx/command.js +80 -25
  12. package/dist/dx/consent.js +6 -5
  13. package/dist/dx/journal.d.ts +17 -2
  14. package/dist/dx/journal.js +19 -3
  15. package/dist/dx/report.js +4 -3
  16. package/dist/dx/reportIo.js +6 -5
  17. package/dist/dx/send.js +6 -5
  18. package/dist/lib/backend.d.ts +0 -16
  19. package/dist/lib/backend.js +2 -26
  20. package/dist/lib/detect.d.ts +87 -0
  21. package/dist/lib/detect.js +867 -0
  22. package/dist/lib/diagnostics.js +2 -2
  23. package/dist/lib/feedback.js +1 -1
  24. package/dist/lib/feedbackCopy.js +1 -1
  25. package/dist/lib/initFrom.js +4 -4
  26. package/dist/lib/lifecycle.d.ts +2 -0
  27. package/dist/lib/lifecycle.js +9 -3
  28. package/dist/lib/listProjects.js +2 -1
  29. package/dist/lib/projects.d.ts +37 -2
  30. package/dist/lib/projects.js +86 -21
  31. package/dist/lib/settings.d.ts +113 -0
  32. package/dist/lib/settings.js +284 -0
  33. package/dist/lib/settingsCopy.d.ts +6 -0
  34. package/dist/lib/settingsCopy.js +99 -0
  35. package/dist/lib/settle.d.ts +61 -0
  36. package/dist/lib/settle.js +338 -0
  37. package/dist/lib/template.d.ts +19 -0
  38. package/dist/lib/template.js +73 -0
  39. package/dist/lib/terminalText.d.ts +3 -0
  40. package/dist/lib/terminalText.js +10 -0
  41. package/dist/lib/version.js +5 -11
  42. package/dist/lib/whoami.js +10 -3
  43. package/dist/library/assetsCommand.js +1 -1
  44. package/dist/library/client.js +8 -3
  45. package/dist/library/render.js +8 -6
  46. package/dist/login/authBackend.js +3 -2
  47. package/dist/login/ensure.d.ts +8 -2
  48. package/dist/login/ensure.js +21 -10
  49. package/dist/login/guard.js +5 -4
  50. package/dist/login/loginCommand.d.ts +2 -2
  51. package/dist/login/loginCommand.js +29 -5
  52. package/dist/login/pkce.js +1 -1
  53. package/dist/login/pkceLogin.js +1 -1
  54. package/dist/platform/outdated.js +2 -2
  55. package/dist/platform/upgradeReport.js +16 -12
  56. package/dist/preview/api.d.ts +5 -11
  57. package/dist/preview/api.js +13 -15
  58. package/dist/preview/command.d.ts +29 -0
  59. package/dist/preview/command.js +61 -1
  60. package/dist/preview/copy.d.ts +35 -3
  61. package/dist/preview/copy.js +102 -10
  62. package/dist/preview/proxy.d.ts +38 -1
  63. package/dist/preview/proxy.js +151 -2
  64. package/dist/preview/qrPage.js +1 -1
  65. package/dist/preview/runner.d.ts +25 -13
  66. package/dist/preview/runner.js +127 -26
  67. package/dist/preview/viewers.js +1 -1
  68. package/dist/publish/api.d.ts +22 -5
  69. package/dist/publish/api.js +80 -9
  70. package/dist/publish/command.d.ts +25 -0
  71. package/dist/publish/command.js +211 -35
  72. package/dist/publish/copy.d.ts +78 -5
  73. package/dist/publish/copy.js +271 -37
  74. package/dist/publish/fit.d.ts +15 -0
  75. package/dist/publish/fit.js +103 -0
  76. package/dist/publish/manifest.d.ts +44 -3
  77. package/dist/publish/manifest.js +172 -3
  78. package/dist/publish/prompt.d.ts +4 -0
  79. package/dist/publish/prompt.js +15 -1
  80. package/dist/publish/upload.d.ts +6 -0
  81. package/dist/publish/upload.js +11 -1
  82. package/dist/publish/visibility.d.ts +21 -0
  83. package/dist/publish/visibility.js +68 -0
  84. package/dist/relay/annotations.js +9 -4
  85. package/dist/relay/server.d.ts +21 -0
  86. package/dist/relay/server.js +35 -1
  87. package/dist/shared/authError.d.ts +27 -0
  88. package/dist/shared/authError.js +48 -4
  89. package/dist/shared/config.d.ts +21 -1
  90. package/dist/shared/config.js +39 -8
  91. package/dist/shared/countLimit.d.ts +7 -2
  92. package/dist/shared/countLimit.js +12 -5
  93. package/dist/shared/http.d.ts +23 -0
  94. package/dist/shared/http.js +58 -0
  95. package/package.json +7 -3
@@ -11,13 +11,178 @@ import { normalizePath, validatePathSet } from "./paths.js";
11
11
  export class ManifestError extends Error {
12
12
  reason;
13
13
  offendingPath;
14
- constructor(reason, offendingPath, message) {
14
+ paths;
15
+ stats;
16
+ constructor(reason, offendingPath, message, detail = {}) {
15
17
  super(message);
16
18
  this.reason = reason;
17
19
  this.offendingPath = offendingPath;
18
20
  this.name = "ManifestError";
21
+ this.paths = detail.paths ?? [];
22
+ this.stats = detail.stats;
19
23
  }
20
24
  }
25
+ /** The only dotted names allowed, and only at the output root. Case-insensitive. */
26
+ const ALLOWED_ROOT_DIR = ".well-known";
27
+ const ALLOWED_ROOT_FILE = ".nojekyll";
28
+ const LFS_POINTER_MAX_BYTES = 1024;
29
+ const LFS_FIRST_LINE = "version https://git-lfs.github.com/spec/v1";
30
+ function isInside(child, parent) {
31
+ const rel = path.relative(parent, child);
32
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
33
+ }
34
+ function hasDottedSegment(rel) {
35
+ return rel.split(/[\\/]/).some((seg) => seg.startsWith("."));
36
+ }
37
+ /**
38
+ * The leak walk. Unlike `walkDist` it records what it does not upload: a dotted
39
+ * name is an offender (a folder once, with a trailing slash, never descended),
40
+ * and every symlink is resolved and judged against the real repo root.
41
+ */
42
+ async function walkOutput(outReal, rootReal) {
43
+ const files = [];
44
+ const offenders = [];
45
+ const visit = async (dir) => {
46
+ const entries = await fs.readdir(dir, { withFileTypes: true });
47
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
48
+ for (const entry of entries) {
49
+ const full = path.join(dir, entry.name);
50
+ const rel = path.relative(outReal, full).split(path.sep).join("/");
51
+ const atRoot = dir === outReal;
52
+ const lower = entry.name.toLowerCase();
53
+ if (entry.name.startsWith(".")) {
54
+ const allowed = atRoot &&
55
+ ((lower === ALLOWED_ROOT_DIR && entry.isDirectory()) ||
56
+ (lower === ALLOWED_ROOT_FILE && entry.isFile()));
57
+ if (!allowed) {
58
+ offenders.push({ path: entry.isDirectory() ? `${rel}/` : rel });
59
+ continue;
60
+ }
61
+ }
62
+ if (entry.isSymbolicLink()) {
63
+ const target = await symlinkTarget(full);
64
+ if (!isInside(target, rootReal)) {
65
+ offenders.push({ path: rel, target });
66
+ }
67
+ else if (hasDottedSegment(path.relative(rootReal, target))) {
68
+ offenders.push({
69
+ path: rel,
70
+ target: path.relative(rootReal, target).split(path.sep).join("/"),
71
+ });
72
+ }
73
+ // A symlink that resolves cleanly inside is not uploaded, as before.
74
+ continue;
75
+ }
76
+ if (entry.isDirectory()) {
77
+ await visit(full);
78
+ }
79
+ else if (entry.isFile()) {
80
+ const stat = await fs.stat(full);
81
+ files.push({ rel, size: stat.size });
82
+ }
83
+ }
84
+ };
85
+ await visit(outReal);
86
+ return { files, offenders };
87
+ }
88
+ /** Where a symlink lands: its realpath, or its lexical target when it dangles. */
89
+ async function symlinkTarget(link) {
90
+ try {
91
+ return await fs.realpath(link);
92
+ }
93
+ catch {
94
+ const raw = await fs.readlink(link).catch(() => link);
95
+ return path.resolve(path.dirname(link), raw);
96
+ }
97
+ }
98
+ /** A Git LFS pointer file: at most 1 KB, the spec's exact first line, and an oid line. */
99
+ export async function isLfsPointer(absPath, size) {
100
+ if (size > LFS_POINTER_MAX_BYTES)
101
+ return false;
102
+ const head = await fs.readFile(absPath, "utf8");
103
+ const lines = head.split("\n");
104
+ if (lines[0] !== LFS_FIRST_LINE)
105
+ return false;
106
+ return lines.some((l) => l.startsWith("oid sha256:"));
107
+ }
108
+ /**
109
+ * The leak and LFS checks, in that order, before anything is hashed or sent.
110
+ * Throws ManifestError; returns nothing on a clean output.
111
+ */
112
+ /**
113
+ * Folders an output must never be, or sit inside: the repository's history and
114
+ * the Vincentt binding. Compared case-insensitively, as a default macOS volume
115
+ * resolves `.GIT` to `.git`. Other dotted parents stay allowed (`.output/public`).
116
+ */
117
+ const FORBIDDEN_OUTPUT_SEGMENTS = new Set([".git", ".vincentt"]);
118
+ function forbiddenSegmentIn(rel) {
119
+ return rel
120
+ .split(/[\\/]/)
121
+ .find((seg) => FORBIDDEN_OUTPUT_SEGMENTS.has(seg.toLowerCase()));
122
+ }
123
+ /**
124
+ * The leak and LFS checks, in that order, before anything is hashed or sent.
125
+ * Returns the output-relative file list the checks passed, which is exactly the
126
+ * list the manifest is built from: what is checked is what is uploaded.
127
+ */
128
+ export async function checkOutput(distDir, repoRoot) {
129
+ const rootReal = await fs.realpath(repoRoot).catch(() => path.resolve(repoRoot));
130
+ const lexical = path.resolve(distDir);
131
+ const lexicalRoot = path.resolve(repoRoot);
132
+ if (lexical === lexicalRoot) {
133
+ throw new ManifestError("output_is_repo_root", distDir, `${distDir} is the repo root`);
134
+ }
135
+ if (!isInside(lexical, lexicalRoot) && !isInside(lexical, rootReal)) {
136
+ throw new ManifestError("output_outside_repo", distDir, `${distDir} is outside the repo`);
137
+ }
138
+ let outReal;
139
+ try {
140
+ outReal = await fs.realpath(distDir);
141
+ }
142
+ catch (err) {
143
+ if (err.code === "ENOENT") {
144
+ throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
145
+ }
146
+ throw err;
147
+ }
148
+ if (outReal === rootReal) {
149
+ throw new ManifestError("output_is_repo_root", distDir, `${distDir} resolves to the repo root`);
150
+ }
151
+ if (!isInside(outReal, rootReal)) {
152
+ throw new ManifestError("output_outside_repo", distDir, `${distDir} resolves outside the repo`);
153
+ }
154
+ const hiddenParent = forbiddenSegmentIn(path.relative(lexicalRoot, lexical)) ??
155
+ forbiddenSegmentIn(path.relative(rootReal, outReal));
156
+ if (hiddenParent !== undefined) {
157
+ throw new ManifestError("output_in_hidden", hiddenParent, `${distDir} is inside ${hiddenParent}`);
158
+ }
159
+ if (!(await fs.stat(outReal)).isDirectory()) {
160
+ throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
161
+ }
162
+ const walk = await walkOutput(outReal, rootReal);
163
+ const stats = {
164
+ files: walk.files.length,
165
+ bytes: walk.files.reduce((n, f) => n + f.size, 0),
166
+ };
167
+ if (walk.offenders.length > 0) {
168
+ throw new ManifestError("secret_path", walk.offenders[0].path, "hidden paths in the output", {
169
+ paths: walk.offenders,
170
+ stats,
171
+ });
172
+ }
173
+ const pointers = [];
174
+ for (const f of walk.files) {
175
+ if (await isLfsPointer(path.join(outReal, f.rel), f.size))
176
+ pointers.push({ path: f.rel });
177
+ }
178
+ if (pointers.length > 0) {
179
+ throw new ManifestError("lfs_pointer", pointers[0].path, "Git LFS pointers in the output", {
180
+ paths: pointers,
181
+ stats,
182
+ });
183
+ }
184
+ return walk.files;
185
+ }
21
186
  /** Recursively list files under `dir` as dir-relative POSIX paths. Symlinks are not followed. */
22
187
  export async function walkDist(dir, base = dir) {
23
188
  let entries;
@@ -54,8 +219,12 @@ export async function hashFile(absPath) {
54
219
  * hashed, so the string this returns is the string the API validates, signs, and
55
220
  * later lists — there is no second form anywhere.
56
221
  */
57
- export async function buildManifest(distDir) {
58
- const raw = await walkDist(distDir);
222
+ export async function buildManifest(distDir, opts = {}) {
223
+ // With a repo root, the list the leak and LFS checks passed IS the upload
224
+ // list. A second walk could see a file created after the checks ran.
225
+ const raw = opts.repoRoot !== undefined
226
+ ? (await checkOutput(distDir, opts.repoRoot)).map((f) => f.rel)
227
+ : await walkDist(distDir);
59
228
  const normalized = [];
60
229
  for (const rel of raw) {
61
230
  const result = normalizePath(rel);
@@ -3,5 +3,9 @@ export type Confirm = (prompt: string, defaultYes: boolean) => Promise<boolean>;
3
3
  export declare function stdinConfirm(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Confirm;
4
4
  /** Reads one free-text line. An empty answer takes the offered default. */
5
5
  export type Ask = (prompt: string, fallback: string) => Promise<string>;
6
+ /** Input ended (Ctrl-D, a closed pipe) before a free-text question was answered. */
7
+ export declare class PromptClosedError extends Error {
8
+ constructor();
9
+ }
6
10
  export declare function stdinAsk(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Ask;
7
11
  export declare function interpretAnswer(answer: string, defaultYes: boolean): boolean;
@@ -32,11 +32,25 @@ export function stdinConfirm(input = process.stdin, output = process.stdout) {
32
32
  }
33
33
  };
34
34
  }
35
+ /** Input ended (Ctrl-D, a closed pipe) before a free-text question was answered. */
36
+ export class PromptClosedError extends Error {
37
+ constructor() {
38
+ super("Input ended before the question was answered. Nothing more was done.");
39
+ this.name = "PromptClosedError";
40
+ }
41
+ }
35
42
  export function stdinAsk(input = process.stdin, output = process.stdout) {
36
43
  return async (prompt, fallback) => {
37
44
  const rl = readline.createInterface({ input, output });
38
45
  try {
39
- const answer = await new Promise((resolve) => rl.question(prompt, resolve));
46
+ // EOF is a refusal, not an empty answer: taking the fallback here would
47
+ // register a name or address the creator never agreed to.
48
+ const answer = await new Promise((resolve) => {
49
+ rl.question(prompt, resolve);
50
+ rl.once("close", () => resolve(undefined));
51
+ });
52
+ if (answer === undefined)
53
+ throw new PromptClosedError();
40
54
  return answer.trim() === "" ? fallback : answer.trim();
41
55
  }
42
56
  finally {
@@ -22,6 +22,12 @@ export interface UploadOptions {
22
22
  /** Per-file retries for a transient network/5xx failure. A BadDigest is retried once. */
23
23
  maxAttempts?: number;
24
24
  }
25
+ /**
26
+ * One PUT's deadline: a minute, plus the time the file takes at 64 KB/s. A fixed
27
+ * deadline would fail every large file on a slow uplink; none at all lets a
28
+ * stalled connection hang the publish forever.
29
+ */
30
+ export declare function putDeadlineMs(bytes: number): number;
25
31
  /**
26
32
  * PUT every file. A re-run that finds files already landed simply uploads
27
33
  * fewer — resumption is silent, because the creator did not ask for a resume,
@@ -10,6 +10,7 @@
10
10
  // The CLI never synthesizes a MIME type and never parses the presigned URL.
11
11
  import { promises as fs } from "node:fs";
12
12
  import path from "node:path";
13
+ import { withDeadline } from "../shared/http.js";
13
14
  export class UploadFailed extends Error {
14
15
  path;
15
16
  status;
@@ -23,6 +24,14 @@ export class UploadFailed extends Error {
23
24
  }
24
25
  }
25
26
  const DEFAULT_CONCURRENCY = 6;
27
+ /**
28
+ * One PUT's deadline: a minute, plus the time the file takes at 64 KB/s. A fixed
29
+ * deadline would fail every large file on a slow uplink; none at all lets a
30
+ * stalled connection hang the publish forever.
31
+ */
32
+ export function putDeadlineMs(bytes) {
33
+ return 60_000 + Math.ceil(bytes / 64_000) * 1000;
34
+ }
26
35
  /**
27
36
  * PUT every file. A re-run that finds files already landed simply uploads
28
37
  * fewer — resumption is silent, because the creator did not ask for a resume,
@@ -60,12 +69,13 @@ export async function uploadAll(opts) {
60
69
  async function putOne(target, distDir, fetchImpl, maxAttempts) {
61
70
  const abs = path.join(distDir, ...target.path.split("/"));
62
71
  const body = await fs.readFile(abs);
72
+ const put = withDeadline(fetchImpl, putDeadlineMs(body.byteLength));
63
73
  let lastStatus = 0;
64
74
  let sawBadDigest = false;
65
75
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
66
76
  let res;
67
77
  try {
68
- res = await fetchImpl(target.url, {
78
+ res = await put(target.url, {
69
79
  method: "PUT",
70
80
  // VERBATIM. No key dropped, no key added, no key renamed.
71
81
  headers: { ...target.headers },
@@ -0,0 +1,21 @@
1
+ /** The longest the CLI waits for the address to serve the new version. */
2
+ export declare const VISIBILITY_WAIT_MS = 90000;
3
+ export interface VisibilityDeps {
4
+ fetchImpl: typeof fetch;
5
+ now: () => number;
6
+ sleep: (ms: number) => Promise<void>;
7
+ /** Called once, before the first wait, and only if the first probe missed. */
8
+ onWaiting?: () => void;
9
+ }
10
+ export interface VisibilityResult {
11
+ visible: boolean;
12
+ waitedMs: number;
13
+ }
14
+ /**
15
+ * Probe `liveUrl` until its body hashes to `indexSha256` (std base64, as in the
16
+ * manifest) or `VISIBILITY_WAIT_MS` passes. Never throws: a failed probe is a miss.
17
+ *
18
+ * Elapsed time is the larger of the clock and the summed sleeps, so the bound
19
+ * holds under a frozen test clock as well as against slow probes.
20
+ */
21
+ export declare function waitUntilServing(liveUrl: string, indexSha256: string, deps: VisibilityDeps): Promise<VisibilityResult>;
@@ -0,0 +1,68 @@
1
+ // After commit, wait until the public address serves the version just committed.
2
+ //
3
+ // The api flips the slug pointer in CloudFront KeyValueStore, which is eventually
4
+ // consistent: staging measured 13 to 36 s between "committed" and the edge serving
5
+ // the new bytes. The marker is index.html itself. The CLI already holds its sha256
6
+ // from the manifest, so a probe hashes the served body and compares; nothing new
7
+ // has to be added to the bundle, the router or the api.
8
+ //
9
+ // Used by publish/command.ts. IO (fetch, clock, sleep) is injected for tests.
10
+ import { createHash } from "node:crypto";
11
+ import { withDeadline } from "../shared/http.js";
12
+ /** The longest the CLI waits for the address to serve the new version. */
13
+ export const VISIBILITY_WAIT_MS = 90_000;
14
+ const FIRST_DELAY_MS = 1_000;
15
+ const MAX_DELAY_MS = 5_000;
16
+ const PROBE_DEADLINE_MS = 10_000;
17
+ /**
18
+ * Probe `liveUrl` until its body hashes to `indexSha256` (std base64, as in the
19
+ * manifest) or `VISIBILITY_WAIT_MS` passes. Never throws: a failed probe is a miss.
20
+ *
21
+ * Elapsed time is the larger of the clock and the summed sleeps, so the bound
22
+ * holds under a frozen test clock as well as against slow probes.
23
+ */
24
+ export async function waitUntilServing(liveUrl, indexSha256, deps) {
25
+ const probe = withDeadline(deps.fetchImpl, PROBE_DEADLINE_MS);
26
+ const startedAt = deps.now();
27
+ let slept = 0;
28
+ let delay = FIRST_DELAY_MS;
29
+ const elapsed = () => Math.max(deps.now() - startedAt, slept);
30
+ for (let attempt = 1;; attempt++) {
31
+ if (await serves(probe, liveUrl, indexSha256, attempt)) {
32
+ return { visible: true, waitedMs: elapsed() };
33
+ }
34
+ const left = VISIBILITY_WAIT_MS - elapsed();
35
+ if (left <= 0)
36
+ return { visible: false, waitedMs: elapsed() };
37
+ if (attempt === 1)
38
+ deps.onWaiting?.();
39
+ const step = Math.min(delay, left);
40
+ await deps.sleep(step);
41
+ slept += step;
42
+ delay = Math.min(delay * 2, MAX_DELAY_MS);
43
+ }
44
+ }
45
+ async function serves(probe, liveUrl, indexSha256, attempt) {
46
+ try {
47
+ const res = await probe(bustedUrl(liveUrl, attempt), {
48
+ headers: { "cache-control": "no-cache", pragma: "no-cache" },
49
+ });
50
+ if (!res.ok)
51
+ return false;
52
+ const body = Buffer.from(await res.arrayBuffer());
53
+ return createHash("sha256").update(body).digest("base64") === indexSha256;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ function bustedUrl(liveUrl, attempt) {
60
+ try {
61
+ const u = new URL(liveUrl);
62
+ u.searchParams.set("vincentt-check", `${Date.now().toString(36)}-${attempt}`);
63
+ return u.toString();
64
+ }
65
+ catch {
66
+ return liveUrl;
67
+ }
68
+ }
@@ -74,7 +74,7 @@ export class AnnotationStore {
74
74
  };
75
75
  this.items.push(annotation);
76
76
  await fs.appendFile(path.join(this.dir, STORE_FILE), JSON.stringify(annotation) + "\n");
77
- this.wake(annotation);
77
+ this.wake();
78
78
  return annotation;
79
79
  }
80
80
  /** The first annotation with seq greater than `after`, if one is already held. */
@@ -113,12 +113,17 @@ export class AnnotationStore {
113
113
  this.waiters.push(waiter);
114
114
  });
115
115
  }
116
- wake(a) {
116
+ // A waiter gets the LOWEST held seq past its cursor, not the annotation whose
117
+ // write just finished: concurrent adds can finish persisting out of seq order,
118
+ // and handing over the later one first moves the consumer's cursor past the
119
+ // earlier one for good.
120
+ wake() {
117
121
  const remaining = [];
118
122
  for (const w of this.waiters) {
119
- if (a.seq > w.after) {
123
+ const next = this.peekAfter(w.after);
124
+ if (next) {
120
125
  clearTimeout(w.timer);
121
- w.resolve({ kind: "annotation", annotation: a });
126
+ w.resolve({ kind: "annotation", annotation: next });
122
127
  }
123
128
  else {
124
129
  remaining.push(w);
@@ -1,9 +1,16 @@
1
+ import { type IncomingMessage } from "node:http";
1
2
  import { EventStore } from "./store.js";
2
3
  import { TesterRegistry } from "./testers.js";
3
4
  import { AnnotationStore } from "./annotations.js";
4
5
  import type { DiagEvent } from "@vincentt-xr/harness/events";
5
6
  export interface RelayOptions {
6
7
  port?: number;
8
+ /**
9
+ * The interface to listen on. Loopback by default and in every caller: the
10
+ * phone reaches the relay only through the tunnel client on this machine,
11
+ * which dials loopback, and `/query` and `feedback/next` are local-only.
12
+ */
13
+ host?: string;
7
14
  /** WS path the client connects to (default matches HarnessProvider). */
8
15
  path?: string;
9
16
  capacity?: number;
@@ -92,6 +99,20 @@ export declare function startRelay(opts?: RelayOptions): RelayHandle;
92
99
  * does not match is not a label, and the events simply carry no viewer.
93
100
  */
94
101
  export declare function readViewerLabel(raw: string | string[] | undefined): string | undefined;
102
+ /**
103
+ * Whether a request arrived through the preview tunnel. The tunnel client stamps
104
+ * `x-vincentt-viewer` on EVERY request it forwards, after it has sanitised the
105
+ * viewer's headers, so a viewer cannot remove it; a local CLI never sends it.
106
+ * Any value counts, including a malformed or duplicated one: presence is the
107
+ * signal, and a viewer adding a second copy only makes it more present.
108
+ */
109
+ export declare function cameThroughTunnel(req: IncomingMessage): boolean;
110
+ /**
111
+ * A raw request path the relay refuses before `URL` normalises it: a `.` or `..`
112
+ * segment, or an encoded dot or separator. Without this, `/__harness/../query`
113
+ * normalises onto a local-only route.
114
+ */
115
+ export declare function isUnsafeRelayPath(rawUrl: string): boolean;
95
116
  /**
96
117
  * Clamp the hold to `[0, MAX]`, explicitly, with `0` meaning ANSWER NOW.
97
118
  *
@@ -117,7 +117,8 @@ export function startRelay(opts = {}) {
117
117
  });
118
118
  ws.on("close", () => log("client disconnected"));
119
119
  });
120
- http.listen(port, () => log(`relay listening on :${port} (ws ${wsPath}, http /query)`));
120
+ const host = opts.host ?? "127.0.0.1";
121
+ http.listen(port, host, () => log(`relay listening on ${host}:${port} (ws ${wsPath}, http /query)`));
121
122
  return {
122
123
  store,
123
124
  annotations,
@@ -140,9 +141,42 @@ export function readViewerLabel(raw) {
140
141
  return undefined;
141
142
  return VIEWER_LABEL_RE.test(raw) ? raw : undefined;
142
143
  }
144
+ /**
145
+ * Whether a request arrived through the preview tunnel. The tunnel client stamps
146
+ * `x-vincentt-viewer` on EVERY request it forwards, after it has sanitised the
147
+ * viewer's headers, so a viewer cannot remove it; a local CLI never sends it.
148
+ * Any value counts, including a malformed or duplicated one: presence is the
149
+ * signal, and a viewer adding a second copy only makes it more present.
150
+ */
151
+ export function cameThroughTunnel(req) {
152
+ return req.headers[VIEWER_STAMP_HEADER] !== undefined;
153
+ }
154
+ /**
155
+ * A raw request path the relay refuses before `URL` normalises it: a `.` or `..`
156
+ * segment, or an encoded dot or separator. Without this, `/__harness/../query`
157
+ * normalises onto a local-only route.
158
+ */
159
+ export function isUnsafeRelayPath(rawUrl) {
160
+ const raw = rawUrl.split("?")[0] ?? "";
161
+ if (/%(2e|2f|5c)/i.test(raw) || raw.includes("\\"))
162
+ return true;
163
+ return raw.split("/").some((s) => s === "." || s === "..");
164
+ }
143
165
  function handleHttp(req, res, ctx) {
166
+ if (isUnsafeRelayPath(req.url ?? "/")) {
167
+ res.writeHead(404).end();
168
+ return;
169
+ }
144
170
  const url = new URL(req.url ?? "/", "http://localhost");
145
171
  const p = url.pathname;
172
+ // Local-only: the diagnostics read and the reverse-channel drain belong to
173
+ // the creator's own CLI. A link holder reaches the relay only through the
174
+ // tunnel, and is refused here even if a route to it were ever opened.
175
+ const localOnly = p === "/query" || p === `${ctx.wsPath}/feedback/next`;
176
+ if (localOnly && cameThroughTunnel(req)) {
177
+ res.writeHead(404).end();
178
+ return;
179
+ }
146
180
  if (p === "/query") {
147
181
  handleQuery(req, res, url, ctx);
148
182
  return;
@@ -1 +1,28 @@
1
+ export declare const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
1
2
  export declare function authFailureMessage(label: string, status: number, detail: string): string;
3
+ /**
4
+ * The api refused the token. Distinct from every other refusal because the
5
+ * remedy is the same wherever it happens and it is not a retry: a command that
6
+ * reads it prints authExpiredLines() and exits EXIT.NOT_AUTHENTICATED.
7
+ */
8
+ export declare class AuthExpiredError extends Error {
9
+ readonly status = 401;
10
+ constructor();
11
+ }
12
+ /**
13
+ * Every authenticated api client calls this on a non-OK response before it
14
+ * reads the error body. authPaths.test.ts drives each client with a 401 and
15
+ * fails if one reaches its own error arm instead.
16
+ */
17
+ export declare function rejectAuthExpired(res: Response): void;
18
+ export declare function authExpiredLines(): string[];
19
+ export declare const NOT_SIGNED_IN_LINES: readonly string[];
20
+ /** No token on this machine. Thrown where a verb needs one and cannot ask for it. */
21
+ export declare class NotSignedInError extends Error {
22
+ constructor();
23
+ }
24
+ /**
25
+ * The api refused the token, in either shape a client reports it: the shared
26
+ * AuthExpiredError, or a client error carrying the 401 status.
27
+ */
28
+ export declare function isAuthRefused(err: unknown): boolean;
@@ -2,10 +2,54 @@
2
2
  // universal first-run state: every existing `vct_` token was invalidated, so the raw
3
3
  // "(401): <body>" is unactionable noise for every user at once. Name the cause and give
4
4
  // the one instruction that fixes it. Any other status keeps its existing "(<n>): <body>"
5
- // shape — a 500/timeout is a different problem and must not be mislabeled a stale token.
5
+ // shape: a 500/timeout is a different problem and must not be mislabeled a stale token.
6
+ export const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
6
7
  export function authFailureMessage(label, status, detail) {
7
- if (status === 401) {
8
- return "Your Vincentt access token is no longer valid — run `vincentt login` to sign in again.";
9
- }
8
+ if (status === 401)
9
+ return AUTH_EXPIRED_MESSAGE;
10
10
  return `${label} (${status})${detail ? `: ${detail}` : ""}`;
11
11
  }
12
+ /**
13
+ * The api refused the token. Distinct from every other refusal because the
14
+ * remedy is the same wherever it happens and it is not a retry: a command that
15
+ * reads it prints authExpiredLines() and exits EXIT.NOT_AUTHENTICATED.
16
+ */
17
+ export class AuthExpiredError extends Error {
18
+ status = 401;
19
+ constructor() {
20
+ super(AUTH_EXPIRED_MESSAGE);
21
+ this.name = "AuthExpiredError";
22
+ }
23
+ }
24
+ /**
25
+ * Every authenticated api client calls this on a non-OK response before it
26
+ * reads the error body. authPaths.test.ts drives each client with a 401 and
27
+ * fails if one reaches its own error arm instead.
28
+ */
29
+ export function rejectAuthExpired(res) {
30
+ if (res.status === 401)
31
+ throw new AuthExpiredError();
32
+ }
33
+ export function authExpiredLines() {
34
+ return ["", `✗ ${AUTH_EXPIRED_MESSAGE}`, ""];
35
+ }
36
+ export const NOT_SIGNED_IN_LINES = [
37
+ "✗ You're not signed in.",
38
+ " Run `vincentt login` to connect this machine to your Vincentt account.",
39
+ ];
40
+ /** No token on this machine. Thrown where a verb needs one and cannot ask for it. */
41
+ export class NotSignedInError extends Error {
42
+ constructor() {
43
+ super(NOT_SIGNED_IN_LINES.join("\n"));
44
+ this.name = "NotSignedInError";
45
+ }
46
+ }
47
+ /**
48
+ * The api refused the token, in either shape a client reports it: the shared
49
+ * AuthExpiredError, or a client error carrying the 401 status.
50
+ */
51
+ export function isAuthRefused(err) {
52
+ if (err instanceof AuthExpiredError)
53
+ return true;
54
+ return err instanceof Error && err.status === 401;
55
+ }
@@ -117,7 +117,27 @@ export interface ResolvedConfig {
117
117
  apiUrl: string;
118
118
  pat: string;
119
119
  }
120
- export declare const DEFAULT_API_URL = "http://localhost:5051";
120
+ /**
121
+ * Every environment variable the CLI reads a credential from. They are removed
122
+ * from the environment of the creator's dev command, which is repo-controlled.
123
+ * A credential read added to this file without a name here fails a unit test.
124
+ */
125
+ export declare const CLI_CREDENTIAL_ENV: readonly string[];
126
+ export declare function withoutCliCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
127
+ export declare const DEFAULT_API_URL = "https://api.vincentt.studio";
128
+ /**
129
+ * The API host the person running the command configured: an explicit flag, then
130
+ * VINCENTT_API_URL, then the machine config, then the default. Every verb reads
131
+ * its host through here, so a token is never sent to one host by one verb and to
132
+ * another by the next. A binding's override is layered on top by decideApiUrl.
133
+ */
134
+ export declare function configuredApiUrl(input: {
135
+ flagApiUrl?: string;
136
+ envApiUrl?: string;
137
+ machineApiUrl?: string;
138
+ }): string;
139
+ /** configuredApiUrl from this process's environment and a machine config already read. */
140
+ export declare function machineApiUrl(machine: Partial<MachineConfig>, flagApiUrl?: string): string;
121
141
  export interface ResolveConfigOptions {
122
142
  /** Honor a binding's apiUrl override (the `--allow-custom-api` flag). */
123
143
  allowCustomApi?: boolean;