@vincentt-xr/cli 0.19.0 → 0.21.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 (67) hide show
  1. package/MIGRATION.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/cli/index.js +95 -13
  4. package/dist/cli/usage-text.js +3 -2
  5. package/dist/domain/api.js +16 -12
  6. package/dist/domain/command.js +9 -0
  7. package/dist/dx/command.js +3 -0
  8. package/dist/dx/consent.js +10 -14
  9. package/dist/dx/reportIo.js +6 -4
  10. package/dist/dx/send.js +6 -4
  11. package/dist/lib/backend.js +5 -10
  12. package/dist/lib/exitCodes.d.ts +6 -0
  13. package/dist/lib/exitCodes.js +6 -0
  14. package/dist/lib/terminalText.d.ts +6 -0
  15. package/dist/lib/terminalText.js +10 -0
  16. package/dist/lib/whoami.js +3 -0
  17. package/dist/library/assetsCommand.js +4 -0
  18. package/dist/library/client.js +6 -8
  19. package/dist/login/authBackend.d.ts +27 -2
  20. package/dist/login/authBackend.js +86 -17
  21. package/dist/login/loginCommand.d.ts +35 -3
  22. package/dist/login/loginCommand.js +81 -5
  23. package/dist/login/machineLabel.d.ts +46 -0
  24. package/dist/login/machineLabel.js +119 -0
  25. package/dist/login/pkceLogin.d.ts +17 -1
  26. package/dist/login/pkceLogin.js +30 -5
  27. package/dist/platform/releaseFetch.js +36 -69
  28. package/dist/preview/api.js +9 -12
  29. package/dist/preview/command.d.ts +3 -2
  30. package/dist/preview/command.js +8 -5
  31. package/dist/preview/copy.d.ts +8 -1
  32. package/dist/preview/copy.js +17 -4
  33. package/dist/preview/proxy.js +5 -39
  34. package/dist/preview/runner.d.ts +25 -9
  35. package/dist/preview/runner.js +42 -15
  36. package/dist/publish/api.js +14 -14
  37. package/dist/publish/command.d.ts +3 -0
  38. package/dist/publish/command.js +71 -7
  39. package/dist/publish/copy.d.ts +9 -0
  40. package/dist/publish/copy.js +83 -14
  41. package/dist/publish/keyScan.d.ts +25 -0
  42. package/dist/publish/keyScan.js +88 -0
  43. package/dist/publish/manifest.d.ts +51 -19
  44. package/dist/publish/manifest.js +246 -63
  45. package/dist/publish/upload.d.ts +8 -0
  46. package/dist/publish/upload.js +15 -0
  47. package/dist/publish/weight.d.ts +23 -0
  48. package/dist/publish/weight.js +113 -0
  49. package/dist/shared/authError.d.ts +1 -1
  50. package/dist/shared/authError.js +3 -1
  51. package/dist/shared/credentialNames.d.ts +17 -0
  52. package/dist/shared/credentialNames.js +72 -0
  53. package/dist/shared/http.d.ts +34 -0
  54. package/dist/shared/http.js +98 -2
  55. package/dist/shared/upgradeError.d.ts +62 -0
  56. package/dist/shared/upgradeError.js +215 -0
  57. package/dist/tunnel/caps.d.ts +19 -0
  58. package/dist/tunnel/caps.js +52 -0
  59. package/dist/tunnel/cbor.d.ts +39 -12
  60. package/dist/tunnel/cbor.js +84 -10
  61. package/dist/tunnel/client.d.ts +48 -2
  62. package/dist/tunnel/client.js +157 -44
  63. package/dist/tunnel/frame.d.ts +1 -1
  64. package/dist/tunnel/frame.js +8 -4
  65. package/dist/tunnel/index.d.ts +3 -2
  66. package/dist/tunnel/index.js +3 -2
  67. package/package.json +4 -3
@@ -5,7 +5,8 @@
5
5
  // 2. Mint state + PKCE code_verifier/code_challenge (S256).
6
6
  // 3. Open the browser to /cli-auth?redirect_uri=<loopback>&state=…&code_challenge=….
7
7
  // 4. On the loopback callback: verify state constant-time (reject mismatch → no exchange).
8
- // 5. POST /access-tokens/exchange {code, code_verifier} → raw PAT (in body, once).
8
+ // 5. POST /access-tokens/exchange {code, code_verifier, label} → raw PAT (in body, once),
9
+ // the stored label, and any machines the 10-machine cap signed out.
9
10
  // 6. Write ~/.vincentt/config.json, then GET /me Bearer <PAT> to confirm identity + orgs.
10
11
  //
11
12
  // Every external boundary is injected (getFreePort, listen, openBrowser, backend, clock/
@@ -14,8 +15,9 @@
14
15
  import { getFreePort } from "../preview/net.js";
15
16
  import { writeMachineConfig } from "../shared/config.js";
16
17
  import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
17
- import { httpAuthBackend } from "./authBackend.js";
18
+ import { httpAuthBackend, } from "./authBackend.js";
18
19
  import { waitForLoopbackCallback } from "./loopback.js";
20
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
19
21
  /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
20
22
  export class LoginTimeoutError extends Error {
21
23
  constructor() {
@@ -23,6 +25,19 @@ export class LoginTimeoutError extends Error {
23
25
  this.name = "LoginTimeoutError";
24
26
  }
25
27
  }
28
+ /**
29
+ * The exchange was admitted and the token SAVED, then the identity check was
30
+ * refused for this CLI's version. Distinct from a refused exchange: a sign-in
31
+ * now exists on this machine, and machines may have been signed out for it.
32
+ */
33
+ export class SignedInThenRefusedError extends UpgradeRequiredError {
34
+ evicted;
35
+ constructor(refused, evicted = []) {
36
+ super(refused.minimum, refused.declared);
37
+ this.evicted = evicted;
38
+ this.name = "SignedInThenRefusedError";
39
+ }
40
+ }
26
41
  /** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
27
42
  export class LoginCallbackError extends Error {
28
43
  constructor(message) {
@@ -166,8 +181,18 @@ export async function runPkceLogin(opts) {
166
181
  },
167
182
  });
168
183
  // (5) redeem the code for the raw PAT; (6) persist then confirm identity.
169
- const pat = await backend.exchange(code, material.codeVerifier);
184
+ const exchanged = await backend.exchange(code, material.codeVerifier, opts.label);
185
+ const pat = exchanged.token;
170
186
  const configPath = await saveToken(opts.apiUrl.replace(/\/$/, ""), pat);
171
- const identity = await backend.me(pat);
172
- return { identity, configPath };
187
+ let identity;
188
+ try {
189
+ identity = await backend.me(pat);
190
+ }
191
+ catch (err) {
192
+ if (err instanceof UpgradeRequiredError) {
193
+ throw new SignedInThenRefusedError(err, exchanged.evicted);
194
+ }
195
+ throw err;
196
+ }
197
+ return { identity, configPath, label: exchanged.label, evicted: exchanged.evicted };
173
198
  }
@@ -11,9 +11,7 @@
11
11
  * The 2s budget is f7's `FETCH_TIMEOUT_MS`, reused rather than re-chosen. A
12
12
  * second timeout constant is a second thing to tune and the one nobody finds.
13
13
  */
14
- import http from "node:http";
15
- import https from "node:https";
16
- import { CLIENT_HEADER, clientHeaderValue } from "../shared/clientHeader.js";
14
+ import { apiRequest, apiRequestDetached } from "../shared/http.js";
17
15
  import { FETCH_TIMEOUT_MS } from "../dx/updateCheck.js";
18
16
  import { readPlatformRelease } from "./releaseCache.js";
19
17
  import { platformCheckEnabled } from "./offSwitch.js";
@@ -35,18 +33,14 @@ export async function fetchRelease(opts) {
35
33
  const onAbort = () => controller.abort();
36
34
  opts.signal?.addEventListener("abort", onAbort, { once: true });
37
35
  try {
38
- const res = await fetchImpl(`${opts.apiUrl.replace(/\/$/, "")}${VERSIONS_PATH}`, {
36
+ // The client tag rides along (apiRequest stamps it) because the middleware
37
+ // LOGS it, and logging is not branching: the handler reads no header but
38
+ // `If-None-Match`, so this cannot change the answer. No credential: the
39
+ // route is public and never judged by the CLI floor.
40
+ const res = await apiRequest({ apiUrl: opts.apiUrl }, VERSIONS_PATH, {
39
41
  signal: controller.signal,
40
- headers: {
41
- // The client tag rides along because the existing middleware LOGS it,
42
- // and logging is not branching: the handler reads no header but
43
- // `If-None-Match`, so this cannot change the answer. Omitting it would
44
- // make this one route's requests distinguishable from every other
45
- // request the CLI makes, which is the opposite of what is wanted.
46
- [CLIENT_HEADER]: clientHeaderValue(),
47
- ...(opts.etag ? { "if-none-match": opts.etag } : {}),
48
- },
49
- });
42
+ headers: opts.etag ? { "if-none-match": opts.etag } : {},
43
+ }, { authenticated: false }, fetchImpl);
50
44
  if (!res.ok)
51
45
  return undefined;
52
46
  const release = readPlatformRelease(await res.json());
@@ -90,61 +84,34 @@ export async function fetchRelease(opts) {
90
84
  export function fetchReleaseDetached(opts, onResult) {
91
85
  if (!platformCheckEnabled())
92
86
  return;
93
- let url;
94
- try {
95
- url = new URL(`${opts.apiUrl.replace(/\/$/, "")}${VERSIONS_PATH}`);
96
- }
97
- catch {
98
- return;
99
- }
100
- const transport = url.protocol === "https:" ? https : http;
101
- try {
102
- const req = transport.request({
103
- protocol: url.protocol,
104
- hostname: url.hostname,
105
- port: url.port,
106
- path: `${url.pathname}${url.search}`,
107
- method: "GET",
108
- headers: {
109
- [CLIENT_HEADER]: clientHeaderValue(),
110
- ...(opts.etag ? { "if-none-match": opts.etag } : {}),
111
- },
112
- }, (res) => {
113
- if (res.statusCode !== 200) {
114
- res.resume();
115
- return;
87
+ // The socket is unref'd inside apiRequestDetached: without that a pending
88
+ // request is an active handle and the process waits on it.
89
+ apiRequestDetached({ apiUrl: opts.apiUrl }, VERSIONS_PATH, opts.etag ? { "if-none-match": opts.etag } : {}, FETCH_TIMEOUT_MS, (res, abort) => {
90
+ if (res.statusCode !== 200) {
91
+ res.resume();
92
+ return;
93
+ }
94
+ let body = "";
95
+ res.setEncoding("utf8");
96
+ res.on("data", (chunk) => {
97
+ body += chunk;
98
+ // Bounded: this is parsed on a creator's machine off a public route,
99
+ // and an endless body must not grow without limit.
100
+ if (body.length > 256_000)
101
+ abort();
102
+ });
103
+ res.on("end", () => {
104
+ try {
105
+ const release = readPlatformRelease(JSON.parse(body));
106
+ if (release === undefined)
107
+ return;
108
+ const etag = res.headers.etag;
109
+ onResult(typeof etag === "string" ? { release, etag } : { release });
110
+ }
111
+ catch {
112
+ // A malformed body is nothing to say.
116
113
  }
117
- let body = "";
118
- res.setEncoding("utf8");
119
- res.on("data", (chunk) => {
120
- body += chunk;
121
- // Bounded: this is parsed on a creator's machine off a public route,
122
- // and an endless body must not grow without limit.
123
- if (body.length > 256_000)
124
- req.destroy();
125
- });
126
- res.on("end", () => {
127
- try {
128
- const release = readPlatformRelease(JSON.parse(body));
129
- if (release === undefined)
130
- return;
131
- const etag = res.headers.etag;
132
- onResult(typeof etag === "string" ? { release, etag } : { release });
133
- }
134
- catch {
135
- // A malformed body is nothing to say.
136
- }
137
- });
138
- res.on("error", () => { });
139
114
  });
140
- // THE LOAD-BEARING CALL. Without it a pending request is an active handle
141
- // and the process waits on it.
142
- req.on("socket", (socket) => socket.unref());
143
- req.on("error", () => { });
144
- req.setTimeout(FETCH_TIMEOUT_MS, () => req.destroy());
145
- req.end();
146
- }
147
- catch {
148
- // A refresh that cannot even be started is nothing to say.
149
- }
115
+ res.on("error", () => { });
116
+ });
150
117
  }
@@ -8,7 +8,8 @@ import { createHash } from "node:crypto";
8
8
  import { realpath } from "node:fs/promises";
9
9
  import { BackendError, ProjectUnreachableError } from "../lib/backend.js";
10
10
  import { rejectAuthExpired } from "../shared/authError.js";
11
- import { apiFetch } from "../shared/http.js";
11
+ import { apiFetch, apiRequest } from "../shared/http.js";
12
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
12
13
  /** The mint rate limit (§2.16). `retryAfterSec` comes from the header, never the body. */
13
14
  export class PreviewRateLimitedError extends BackendError {
14
15
  retryAfterSec;
@@ -106,9 +107,6 @@ async function errorBody(res) {
106
107
  return {};
107
108
  }
108
109
  }
109
- function authHeaders(cfg) {
110
- return { authorization: `Bearer ${cfg.pat}`, "content-type": "application/json" };
111
- }
112
110
  /**
113
111
  * parseRetryAfter reads the 429's header. The BODY carries no discriminator for
114
112
  * which of the two mint limits fired — the header answers "how long", never
@@ -121,11 +119,12 @@ export function parseRetryAfter(raw) {
121
119
  }
122
120
  /** POST /projects/{id}/preview-sessions — mint. */
123
121
  export async function mintPreviewSession(cfg, projectId, key, fetchImpl = apiFetch) {
124
- const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/preview-sessions`, {
122
+ const res = await apiRequest(cfg, `/projects/${encodeURIComponent(projectId)}/preview-sessions`, {
125
123
  method: "POST",
126
- headers: authHeaders(cfg),
124
+ headers: { "content-type": "application/json" },
127
125
  body: JSON.stringify({ workingTreeKey: key }),
128
- });
126
+ }, { authenticated: true }, fetchImpl);
127
+ await rejectUpgradeRequired(res);
129
128
  if (res.status === 429) {
130
129
  throw new PreviewRateLimitedError(parseRetryAfter(res.headers.get("retry-after")));
131
130
  }
@@ -189,8 +188,9 @@ export function parseMint(raw) {
189
188
  }
190
189
  /** POST /preview-sessions/{id}/heartbeat — keepalive AND re-authorization. */
191
190
  export async function heartbeatPreviewSession(cfg, sessionId, fetchImpl = apiFetch) {
192
- const res = await fetchImpl(`${cfg.apiUrl}/preview-sessions/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", headers: authHeaders(cfg) });
191
+ const res = await apiRequest(cfg, `/preview-sessions/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", headers: { "content-type": "application/json" } }, { authenticated: true }, fetchImpl);
193
192
  if (!res.ok) {
193
+ await rejectUpgradeRequired(res);
194
194
  rejectAuthExpired(res);
195
195
  const body = await errorBody(res);
196
196
  if (res.status === 410) {
@@ -216,10 +216,7 @@ export async function heartbeatPreviewSession(cfg, sessionId, fetchImpl = apiFet
216
216
  */
217
217
  export async function endPreviewSession(cfg, sessionId, fetchImpl = apiFetch) {
218
218
  try {
219
- await fetchImpl(`${cfg.apiUrl}/preview-sessions/${encodeURIComponent(sessionId)}`, {
220
- method: "DELETE",
221
- headers: { authorization: `Bearer ${cfg.pat}` },
222
- });
219
+ await apiRequest(cfg, `/preview-sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, { authenticated: true }, fetchImpl);
223
220
  }
224
221
  catch {
225
222
  // See above.
@@ -2,6 +2,7 @@ import type { DiagEvent } from "@vincentt-xr/harness/events";
2
2
  import type { TunnelState, UAClass } from "../tunnel/index.js";
3
3
  import type { EventTags } from "../relay/server.js";
4
4
  import type { PreviewEndReason } from "./runner.js";
5
+ import { type UpgradeFacts } from "../shared/upgradeError.js";
5
6
  export interface PreviewUiDeps {
6
7
  print: (line: string) => void;
7
8
  now: () => number;
@@ -124,13 +125,13 @@ export declare class PreviewUi {
124
125
  stopping(): void;
125
126
  stopped(): void;
126
127
  /** Every ending the creator did NOT cause. One first line per cause. */
127
- endedBy(reason: PreviewEndReason, orgName: string): void;
128
+ endedBy(reason: PreviewEndReason, orgName: string, refusal?: UpgradeFacts): void;
128
129
  following(label: string): void;
129
130
  notFollowing(label: string): void;
130
131
  notFollowingTester(tester: string): void;
131
132
  /** Live testers — what `stopping`/`stopped` should be counting. */
132
133
  get testerCount(): number;
133
134
  }
134
- export declare function endingLines(reason: PreviewEndReason, orgName: string): string[];
135
+ export declare function endingLines(reason: PreviewEndReason, orgName: string, refusal?: UpgradeFacts): string[];
135
136
  /** `21:04` — what a creator plans a demo around, in their own local time. */
136
137
  export declare function clockTime(d: Date): string;
@@ -15,6 +15,7 @@ import * as copy from "./copy.js";
15
15
  import { PerfThrottle, palette, renderAnnotation, renderEvent, uaLabel, } from "./stream.js";
16
16
  import { ViewerRoster } from "./viewers.js";
17
17
  import { MAX_TESTERS } from "../relay/testers.js";
18
+ import { previewTooOldLines } from "../shared/upgradeError.js";
18
19
  /** How long a viewer may be present with no harness hello before the runtime line. */
19
20
  export const NO_DIAGNOSTICS_WINDOW_MS = 10_000;
20
21
  function defaultSchedule(fn, ms) {
@@ -295,11 +296,11 @@ export class PreviewUi {
295
296
  this.line("");
296
297
  }
297
298
  /** Every ending the creator did NOT cause. One first line per cause. */
298
- endedBy(reason, orgName) {
299
+ endedBy(reason, orgName, refusal) {
299
300
  if (this.ended)
300
301
  return;
301
302
  this.ended = true;
302
- this.lines(endingLines(reason, orgName));
303
+ this.lines(endingLines(reason, orgName, refusal));
303
304
  }
304
305
  // ---- --viewer -------------------------------------------------------------
305
306
  following(label) {
@@ -317,7 +318,7 @@ export class PreviewUi {
317
318
  return this.roster.testerCount;
318
319
  }
319
320
  }
320
- export function endingLines(reason, orgName) {
321
+ export function endingLines(reason, orgName, refusal = {}) {
321
322
  switch (reason) {
322
323
  case "hard_max":
323
324
  return copy.hardMaxReached();
@@ -331,8 +332,10 @@ export function endingLines(reason, orgName) {
331
332
  return copy.endedAbandoned();
332
333
  case "authz_revoked":
333
334
  return copy.endedUnreachable(orgName);
334
- case "authz_unconfirmed":
335
- return copy.endedUnconfirmed();
335
+ case "authz_lapsed":
336
+ return copy.endedAuthzLapsed();
337
+ case "upgrade_required":
338
+ return previewTooOldLines(refusal);
336
339
  case "auth_expired":
337
340
  return copy.endedAuthExpired();
338
341
  case "egress_cap":
@@ -211,7 +211,14 @@ export declare function endedUnreachable(orgName: string): string[];
211
211
  * project and the access are fine, and the one thing to do is sign in again.
212
212
  */
213
213
  export declare function endedAuthExpired(): string[];
214
- export declare function endedUnconfirmed(): string[];
214
+ /**
215
+ * The `authzUntil` drain: the edge ended the session because the platform
216
+ * stopped confirming it, and told this CLI so. It names no access, permission or
217
+ * outage, because the wire carries none of those and the cause may be on either
218
+ * side. "so one isn't started for you" is load-bearing: a new preview is a new
219
+ * link, and a silent re-mint would break every link already shared.
220
+ */
221
+ export declare function endedAuthzLapsed(): string[];
215
222
  /**
216
223
  * e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
217
224
  * silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
@@ -448,19 +448,32 @@ export function endedUnreachable(orgName) {
448
448
  export function endedAuthExpired() {
449
449
  return [
450
450
  "",
451
- "✗ Preview ended. Your Vincentt access token is no longer valid.",
451
+ "✗ Preview ended. This machine is no longer signed in to Vincentt.",
452
452
  "",
453
453
  " Sign in again: vincentt login",
454
454
  " Then start a new preview: vincentt preview",
455
455
  "",
456
456
  ];
457
457
  }
458
- export function endedUnconfirmed() {
458
+ /**
459
+ * The `authzUntil` drain: the edge ended the session because the platform
460
+ * stopped confirming it, and told this CLI so. It names no access, permission or
461
+ * outage, because the wire carries none of those and the cause may be on either
462
+ * side. "so one isn't started for you" is load-bearing: a new preview is a new
463
+ * link, and a silent re-mint would break every link already shared.
464
+ */
465
+ export function endedAuthzLapsed() {
459
466
  return [
460
467
  "",
461
- "✗ Preview ended. Couldn't confirm your access to this project.",
468
+ "✗ Preview ended. Vincentt stopped confirming it, and the link no longer works.",
469
+ "",
470
+ " This window can't tell whether this machine lost touch with Vincentt or",
471
+ " the cause was on our side. Nothing is wrong with your project.",
462
472
  "",
463
- " Try again: vincentt preview",
473
+ " A new preview gets a new link, so one isn't started for you. Any phone on",
474
+ " the old link will need the new one.",
475
+ "",
476
+ " Start a new one: vincentt preview",
464
477
  "",
465
478
  ];
466
479
  }
@@ -4,6 +4,7 @@
4
4
  import { connect } from "node:net";
5
5
  import { request, } from "node:http";
6
6
  import { createGzip } from "node:zlib";
7
+ import { CREDENTIAL_NAMES, PREVIEW_STRIP, foldName, isCredentialName, } from "../shared/credentialNames.js";
7
8
  // Rewrite Host to the loopback target. cloudflared forwards the public Host, and
8
9
  // esbuild's `serve` 403s any Host it doesn't recognize — so the phone would get a
9
10
  // 403. The origin only needs a Host it accepts; the browser never sees this value.
@@ -187,8 +188,10 @@ export function isHiddenAppPath(url, projectRoot) {
187
188
  const name = normalizedName(s);
188
189
  if (name === REFUSE)
189
190
  return true;
190
- if (SHORT_NAME.test(name) || isCredentialName(name))
191
+ if (SHORT_NAME.test(name) ||
192
+ isCredentialName(name, CREDENTIAL_NAMES, PREVIEW_STRIP)) {
191
193
  return true;
194
+ }
192
195
  }
193
196
  }
194
197
  return false;
@@ -222,8 +225,7 @@ const REFUSE = Symbol("refuse");
222
225
  function normalizedName(segment) {
223
226
  if (segment.includes(":$"))
224
227
  return REFUSE;
225
- const beforeColon = segment.split(":")[0] ?? "";
226
- return beforeColon.replace(/[. ]+$/, "").normalize("NFKC").toUpperCase().toLowerCase();
228
+ return foldName(segment.split(":")[0] ?? "");
227
229
  }
228
230
  /**
229
231
  * A Windows 8.3 short name (`GIT~1`, `CREDEN~1.JSO`) opens the long-named file
@@ -232,42 +234,6 @@ function normalizedName(segment) {
232
234
  * proxy can front a Windows file system from any client.
233
235
  */
234
236
  const SHORT_NAME = /^[^~]{1,6}~[0-9]/;
235
- /** D-Preview-hides-credential-files: hard-coded, no opt-out. `*` is any run of characters. */
236
- const CREDENTIAL_NAMES = Object.freeze([
237
- "credentials.json",
238
- "service-account*.json",
239
- "*firebase-adminsdk*.json",
240
- "*.pem",
241
- "*.key",
242
- "*.p12",
243
- "*.pfx",
244
- "*.p8",
245
- "*.ppk",
246
- "id_rsa*",
247
- "id_dsa*",
248
- "id_ed25519*",
249
- "id_ecdsa*",
250
- "*.jks",
251
- "*.keystore",
252
- "*.env",
253
- "*.tfstate",
254
- "*.tfvars",
255
- ].map((glob) => new RegExp(`^${glob
256
- .split("*")
257
- .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
258
- .join(".*")}$`, "s")));
259
- /** A listed name, or one with backup endings (`~`, `.bak`) added, any number of times. */
260
- function isCredentialName(name) {
261
- let candidate = name;
262
- for (;;) {
263
- if (CREDENTIAL_NAMES.some((re) => re.test(candidate)))
264
- return true;
265
- const stripped = candidate.replace(/(?:~|\.bak)$/, "");
266
- if (stripped === candidate)
267
- return false;
268
- candidate = stripped;
269
- }
270
- }
271
237
  function checkedSegmentIndexes(segments, projectRoot) {
272
238
  const all = segments.map((_, i) => i);
273
239
  if (segments[0] !== "@fs")
@@ -2,6 +2,7 @@ import { type ControlKind, type TunnelState, type UAClass } from "../tunnel/inde
2
2
  import type { DiagEvent } from "@vincentt-xr/harness/events";
3
3
  import { type EventTags } from "../relay/server.js";
4
4
  import { type ResolvedConfig } from "../shared/config.js";
5
+ import { type UpgradeFacts } from "../shared/upgradeError.js";
5
6
  /** The path a 404 advisory names: the request's own path, without its query. */
6
7
  export declare function requestPathOf(url: string | undefined): string;
7
8
  /**
@@ -31,7 +32,19 @@ export type PreviewEndReason = "stopped"
31
32
  * elapsed without the client getting back): same remedy, different diagnosis,
32
33
  * and the copy explains the bound only at the moment it bites.
33
34
  */
34
- | "grace_cap" | "authz_revoked" | "authz_unconfirmed"
35
+ | "grace_cap" | "authz_revoked"
36
+ /**
37
+ * The edge drained the session because the platform stopped confirming it,
38
+ * and said so (CONTROL `authz_expired`). Final: a new preview is a new link,
39
+ * so nothing is re-minted and nothing redials.
40
+ */
41
+ | "authz_lapsed"
42
+ /**
43
+ * The platform refused this CLI's version, on the heartbeat (426) or on a
44
+ * dial (410 + `X-Vincentt-Refusal: cli_upgrade_required`). One screen for
45
+ * both. No further platform call is made, the DELETE included.
46
+ */
47
+ | "upgrade_required"
35
48
  /**
36
49
  * The 30-minute heartbeat-silence flip. NEVER reported as `hard_max`: a
37
50
  * session ended at minute 30 has not reached its 12-hour ceiling, and saying
@@ -130,8 +143,11 @@ export interface StartPreviewOptions {
130
143
  onOriginError?: (label: string, code: string) => void;
131
144
  /** Their dev server answered again. Re-arms the advisory; prints NOTHING. */
132
145
  onOriginRecovered?: () => void;
133
- /** The session ended for a reason the creator did not cause. */
134
- onEnded?: (reason: PreviewEndReason) => void;
146
+ /**
147
+ * The session ended for a reason the creator did not cause. `refusal` carries
148
+ * the validated floor facts on `upgrade_required` only.
149
+ */
150
+ onEnded?: (reason: PreviewEndReason, refusal?: UpgradeFacts) => void;
135
151
  /** The hard max is approaching. A WARNING, never an ending (L20b). */
136
152
  onExpiring?: () => void;
137
153
  /** Injected for tests. */
@@ -154,11 +170,11 @@ export interface RunningPreview {
154
170
  }
155
171
  export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
156
172
  /**
157
- * Two of the three CONTROL kinds are endings. `expiring` is NOT: it is the
158
- * hard-max warning, and it arrives on the same channel. Mapping it to an ending
159
- * would take away a link that has half an hour left, at exactly the moment the
160
- * creator is being warned to plan around it — so it returns null and the caller
161
- * renders the warning instead.
173
+ * Three of the four kinds that reach onControl are endings. `expiring` is NOT:
174
+ * it is the hard-max warning, and it arrives on the same channel. Mapping it to
175
+ * an ending would take away a link that has half an hour left, at exactly the
176
+ * moment the creator is being warned to plan around it, so it returns null and
177
+ * the caller renders the warning instead. (`viewer_gone` never reaches here.)
162
178
  */
163
179
  export declare function controlToReason(kind: ControlKind): PreviewEndReason | null;
164
180
  /**
@@ -199,7 +215,7 @@ export declare function refusalToReason(status: number): PreviewEndReason | null
199
215
  * reason maps to its own screen. A 410 with no reason gets the cause-free
200
216
  * ending. See the note on PreviewEndedError.
201
217
  */
202
- export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason) => void): Promise<void>;
218
+ export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason, refusal?: UpgradeFacts) => void): Promise<void>;
203
219
  /**
204
220
  * The API's `endReason` → the CLI's ending screen.
205
221
  *
@@ -26,6 +26,7 @@ import { withoutCliCredentials } from "../shared/config.js";
26
26
  import { ProjectUnreachableError } from "../lib/backend.js";
27
27
  import { AuthExpiredError } from "../shared/authError.js";
28
28
  import { apiFetch } from "../shared/http.js";
29
+ import { UpgradeRequiredError, parseDeclared, parseMinimum, } from "../shared/upgradeError.js";
29
30
  import { terminalSafe } from "../lib/terminalText.js";
30
31
  /** The path a 404 advisory names: the request's own path, without its query. */
31
32
  export function requestPathOf(url) {
@@ -63,6 +64,17 @@ export async function startPreview(opts) {
63
64
  let heartbeat;
64
65
  let minted;
65
66
  let stopped = false;
67
+ // Set once the platform refuses this CLI's version: every later platform
68
+ // call would be refused too, so none is made.
69
+ let versionRefused = false;
70
+ const ended = (reason, refusal) => {
71
+ if (reason === "upgrade_required") {
72
+ versionRefused = true;
73
+ if (heartbeat !== undefined)
74
+ clearInterval(heartbeat);
75
+ }
76
+ opts.onEnded?.(reason, refusal);
77
+ };
66
78
  const stop = async () => {
67
79
  if (stopped)
68
80
  return;
@@ -84,8 +96,9 @@ export async function startPreview(opts) {
84
96
  // best-effort in intent even though it is in effect: the 12-hour ceiling
85
97
  // and the edge's 60-second fail-closed deadline both reap a session whose
86
98
  // DELETE never landed.
87
- if (minted)
99
+ if (minted && !versionRefused) {
88
100
  await endPreviewSession(config, minted.sessionId, fetchImpl);
101
+ }
89
102
  };
90
103
  try {
91
104
  // The MINT comes first. Everything below it costs the creator a dev-server
@@ -231,7 +244,7 @@ export async function startPreview(opts) {
231
244
  opts.onExpiring?.();
232
245
  return;
233
246
  }
234
- opts.onEnded?.(reason);
247
+ ended(reason);
235
248
  },
236
249
  onOriginError: opts.onOriginError,
237
250
  onStateChange: opts.onTunnelState,
@@ -241,8 +254,12 @@ export async function startPreview(opts) {
241
254
  onReconnectRefused: (status) => {
242
255
  const reason = refusalToReason(status);
243
256
  if (reason !== null)
244
- opts.onEnded?.(reason);
257
+ ended(reason);
245
258
  },
259
+ onRefused: (_reason, minimum, declared) => ended("upgrade_required", {
260
+ minimum: parseMinimum(minimum),
261
+ declared: parseDeclared(declared),
262
+ }),
246
263
  });
247
264
  tunnel.start();
248
265
  // The heartbeat is keepalive AND re-authorization: every beat re-runs
@@ -252,10 +269,10 @@ export async function startPreview(opts) {
252
269
  // One beat in flight at a time: a slow api must not stack up requests.
253
270
  let beating = false;
254
271
  heartbeat = setInterval(() => {
255
- if (beating)
272
+ if (beating || versionRefused)
256
273
  return;
257
274
  beating = true;
258
- void runHeartbeat(config, minted.sessionId, fetchImpl, opts.onEnded).finally(() => {
275
+ void runHeartbeat(config, minted.sessionId, fetchImpl, ended).finally(() => {
259
276
  beating = false;
260
277
  });
261
278
  }, minted.heartbeatSec * 1000);
@@ -284,18 +301,24 @@ export async function startPreview(opts) {
284
301
  }
285
302
  }
286
303
  /**
287
- * Two of the three CONTROL kinds are endings. `expiring` is NOT: it is the
288
- * hard-max warning, and it arrives on the same channel. Mapping it to an ending
289
- * would take away a link that has half an hour left, at exactly the moment the
290
- * creator is being warned to plan around it — so it returns null and the caller
291
- * renders the warning instead.
304
+ * Three of the four kinds that reach onControl are endings. `expiring` is NOT:
305
+ * it is the hard-max warning, and it arrives on the same channel. Mapping it to
306
+ * an ending would take away a link that has half an hour left, at exactly the
307
+ * moment the creator is being warned to plan around it, so it returns null and
308
+ * the caller renders the warning instead. (`viewer_gone` never reaches here.)
292
309
  */
293
310
  export function controlToReason(kind) {
294
- if (kind === "superseded")
295
- return "superseded";
296
- if (kind === "revoke")
297
- return "authz_revoked";
298
- return null;
311
+ switch (kind) {
312
+ case "superseded":
313
+ return "superseded";
314
+ case "revoke":
315
+ return "authz_revoked";
316
+ case "authz_expired":
317
+ return "authz_lapsed";
318
+ case "expiring":
319
+ case "viewer_gone":
320
+ return null;
321
+ }
299
322
  }
300
323
  /**
301
324
  * A refused RECONNECT dial → the ending screen it means.
@@ -346,6 +369,10 @@ export async function runHeartbeat(config, sessionId, fetchImpl, onEnded) {
346
369
  await heartbeatPreviewSession(config, sessionId, fetchImpl);
347
370
  }
348
371
  catch (err) {
372
+ if (err instanceof UpgradeRequiredError) {
373
+ onEnded?.("upgrade_required", { minimum: err.minimum, declared: err.declared });
374
+ return;
375
+ }
349
376
  if (err instanceof AuthExpiredError) {
350
377
  onEnded?.("auth_expired");
351
378
  return;