@vincentt-xr/cli 0.20.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 (59) hide show
  1. package/MIGRATION.md +15 -0
  2. package/dist/cli/index.js +63 -6
  3. package/dist/domain/api.js +16 -12
  4. package/dist/domain/command.js +9 -0
  5. package/dist/dx/command.js +3 -0
  6. package/dist/dx/consent.js +10 -14
  7. package/dist/dx/reportIo.js +6 -4
  8. package/dist/dx/send.js +6 -4
  9. package/dist/lib/backend.js +5 -10
  10. package/dist/lib/exitCodes.d.ts +6 -0
  11. package/dist/lib/exitCodes.js +6 -0
  12. package/dist/lib/terminalText.d.ts +6 -0
  13. package/dist/lib/terminalText.js +10 -0
  14. package/dist/lib/whoami.js +3 -0
  15. package/dist/library/assetsCommand.js +4 -0
  16. package/dist/library/client.js +6 -8
  17. package/dist/login/authBackend.js +18 -15
  18. package/dist/login/loginCommand.js +31 -4
  19. package/dist/login/pkceLogin.d.ts +10 -0
  20. package/dist/login/pkceLogin.js +24 -1
  21. package/dist/platform/releaseFetch.js +36 -69
  22. package/dist/preview/api.js +9 -12
  23. package/dist/preview/command.d.ts +3 -2
  24. package/dist/preview/command.js +8 -5
  25. package/dist/preview/copy.d.ts +8 -1
  26. package/dist/preview/copy.js +16 -3
  27. package/dist/preview/proxy.js +5 -39
  28. package/dist/preview/runner.d.ts +25 -9
  29. package/dist/preview/runner.js +42 -15
  30. package/dist/publish/api.js +14 -14
  31. package/dist/publish/command.d.ts +3 -0
  32. package/dist/publish/command.js +71 -7
  33. package/dist/publish/copy.d.ts +9 -0
  34. package/dist/publish/copy.js +83 -14
  35. package/dist/publish/keyScan.d.ts +25 -0
  36. package/dist/publish/keyScan.js +88 -0
  37. package/dist/publish/manifest.d.ts +51 -19
  38. package/dist/publish/manifest.js +246 -63
  39. package/dist/publish/upload.d.ts +8 -0
  40. package/dist/publish/upload.js +15 -0
  41. package/dist/publish/weight.d.ts +23 -0
  42. package/dist/publish/weight.js +113 -0
  43. package/dist/shared/credentialNames.d.ts +17 -0
  44. package/dist/shared/credentialNames.js +72 -0
  45. package/dist/shared/http.d.ts +34 -0
  46. package/dist/shared/http.js +98 -2
  47. package/dist/shared/upgradeError.d.ts +62 -0
  48. package/dist/shared/upgradeError.js +215 -0
  49. package/dist/tunnel/caps.d.ts +19 -0
  50. package/dist/tunnel/caps.js +52 -0
  51. package/dist/tunnel/cbor.d.ts +39 -12
  52. package/dist/tunnel/cbor.js +84 -10
  53. package/dist/tunnel/client.d.ts +48 -2
  54. package/dist/tunnel/client.js +157 -44
  55. package/dist/tunnel/frame.d.ts +1 -1
  56. package/dist/tunnel/frame.js +8 -4
  57. package/dist/tunnel/index.d.ts +3 -2
  58. package/dist/tunnel/index.js +3 -2
  59. package/package.json +4 -3
@@ -7,8 +7,10 @@
7
7
  // L1–L4 branch is unit-testable with no real browser, socket, or config file.
8
8
  import os from "node:os";
9
9
  import { isAuthRefused } from "../shared/authError.js";
10
+ import { UpgradeRequiredError, upgradeRefusalLines } from "../shared/upgradeError.js";
10
11
  import { EXIT } from "../lib/exitCodes.js";
11
12
  import { dayMonth, sanitizeServerLabel } from "./machineLabel.js";
13
+ import { SignedInThenRefusedError } from "./pkceLogin.js";
12
14
  /** Collapse the home dir to `~` for display. `~/.vincentt/config.json` in every transcript. */
13
15
  export function displayConfigPath(absPath, home) {
14
16
  if (home && absPath.startsWith(home))
@@ -58,8 +60,10 @@ export async function loginCommand(deps, opts = {}) {
58
60
  // The old token stays on disk until the new one is saved: a failed login must
59
61
  // never leave the machine with neither.
60
62
  const old = deps.storedToken ? await deps.storedToken() : undefined;
61
- const code = await runLoginFlow(deps);
62
- if (code === EXIT.OK && old && deps.revokeToken) {
63
+ const { code, saved } = await signIn(deps);
64
+ // Keyed on the new token being SAVED, not on success: a version refusal
65
+ // after the save still replaced the old token, which must not stay live.
66
+ if (saved && old && deps.revokeToken) {
63
67
  try {
64
68
  await deps.revokeToken(old);
65
69
  }
@@ -78,6 +82,8 @@ export async function loginCommand(deps, opts = {}) {
78
82
  who = await deps.whoAmI();
79
83
  }
80
84
  catch (err) {
85
+ if (err instanceof UpgradeRequiredError)
86
+ return refusedVersion(deps, err);
81
87
  if (!isTokenRejected(err)) {
82
88
  // A down api or a dead network says nothing about the token, so it stays.
83
89
  deps.print("✗ Could not check the account for the token on this machine.");
@@ -102,6 +108,10 @@ function isTokenRejected(err) {
102
108
  (err instanceof Error && err.status === 403));
103
109
  }
104
110
  async function runLoginFlow(deps) {
111
+ return (await signIn(deps)).code;
112
+ }
113
+ /** The flow's exit code, and whether a new token was written to this machine. */
114
+ async function signIn(deps) {
105
115
  // L1 / L4 — run the loopback + PKCE flow.
106
116
  try {
107
117
  const { identity, configPath, label, evicted } = await deps.runLogin();
@@ -116,16 +126,33 @@ async function runLoginFlow(deps) {
116
126
  deps.print(` Token saved to ${displayConfigPath(configPath, deps.home)}`);
117
127
  deps.print("");
118
128
  deps.print("Run `vincentt --help` to see what you can do.");
119
- return 0;
129
+ return { code: EXIT.OK, saved: true };
120
130
  }
121
131
  catch (err) {
132
+ // Refused after the exchange: the token is on disk, so the screen says so
133
+ // and names any machine this sign-in signed out.
134
+ if (err instanceof SignedInThenRefusedError) {
135
+ const arm = { kind: "login_saved", evictionLines: err.evicted.map(evictionLine) };
136
+ for (const l of upgradeRefusalLines(err, arm))
137
+ deps.print(l);
138
+ return { code: EXIT.UPGRADE_REQUIRED, saved: true };
139
+ }
140
+ // The exchange is judged before anything is minted, so no token was written.
141
+ if (err instanceof UpgradeRequiredError) {
142
+ return { code: refusedVersion(deps, err), saved: false };
143
+ }
122
144
  // L4 — timeout / callback failure: ✗ line + the exact next command. No token written.
123
145
  const message = err instanceof Error ? err.message : "Sign-in failed.";
124
146
  deps.print(`✗ ${message}`);
125
147
  deps.print(" Run `vincentt login` to try again.");
126
- return 1;
148
+ return { code: EXIT.FAILED, saved: false };
127
149
  }
128
150
  }
151
+ function refusedVersion(deps, err) {
152
+ for (const l of upgradeRefusalLines(err, { kind: "login" }))
153
+ deps.print(l);
154
+ return EXIT.UPGRADE_REQUIRED;
155
+ }
129
156
  /**
130
157
  * `vincentt logout` (Flow L7): the platform sign-out was already attempted; the
131
158
  * local token is ALWAYS deleted, and only an api that could not be reached adds
@@ -1,4 +1,5 @@
1
1
  import { type AuthBackend, type EvictedMachine, type Identity } from "./authBackend.js";
2
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
2
3
  export interface PkceLoginOptions {
3
4
  /** Console origin that serves /cli-auth (browser target). */
4
5
  consoleUrl: string;
@@ -31,6 +32,15 @@ export interface PkceLoginResult {
31
32
  export declare class LoginTimeoutError extends Error {
32
33
  constructor();
33
34
  }
35
+ /**
36
+ * The exchange was admitted and the token SAVED, then the identity check was
37
+ * refused for this CLI's version. Distinct from a refused exchange: a sign-in
38
+ * now exists on this machine, and machines may have been signed out for it.
39
+ */
40
+ export declare class SignedInThenRefusedError extends UpgradeRequiredError {
41
+ readonly evicted: EvictedMachine[];
42
+ constructor(refused: UpgradeRequiredError, evicted?: EvictedMachine[]);
43
+ }
34
44
  /** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
35
45
  export declare class LoginCallbackError extends Error {
36
46
  constructor(message: string);
@@ -17,6 +17,7 @@ import { writeMachineConfig } from "../shared/config.js";
17
17
  import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
18
18
  import { httpAuthBackend, } from "./authBackend.js";
19
19
  import { waitForLoopbackCallback } from "./loopback.js";
20
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
20
21
  /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
21
22
  export class LoginTimeoutError extends Error {
22
23
  constructor() {
@@ -24,6 +25,19 @@ export class LoginTimeoutError extends Error {
24
25
  this.name = "LoginTimeoutError";
25
26
  }
26
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
+ }
27
41
  /** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
28
42
  export class LoginCallbackError extends Error {
29
43
  constructor(message) {
@@ -170,6 +184,15 @@ export async function runPkceLogin(opts) {
170
184
  const exchanged = await backend.exchange(code, material.codeVerifier, opts.label);
171
185
  const pat = exchanged.token;
172
186
  const configPath = await saveToken(opts.apiUrl.replace(/\/$/, ""), pat);
173
- const identity = await backend.me(pat);
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
+ }
174
197
  return { identity, configPath, label: exchanged.label, evicted: exchanged.evicted };
175
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`).
@@ -455,12 +455,25 @@ export function endedAuthExpired() {
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
  *