@valbuild/server 0.114.0 → 0.115.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.
@@ -114,6 +114,32 @@ type ValServerOverrides = Partial<{
114
114
  disableCache?: boolean;
115
115
  }>;
116
116
  export declare function createValServer(valModules: ValModules, route: string, opts: ValApiOptions, config: ValConfig, callbacks: ValServerCallbacks, formatter?: (code: string, filePath: string) => string | Promise<string>): Promise<ValServer>;
117
+ /**
118
+ * Hosts we send credentials to, and what each one puts at risk. They differ:
119
+ * only `valBuildUrl` hands back the app token that becomes the session cookie,
120
+ * so a single shared sentence would overstate one and understate the other.
121
+ */
122
+ type CredentialBearingUrl = "valBuildUrl" | "valContentUrl";
123
+ /**
124
+ * Returns a warning if `url` would send credentials somewhere they can be read
125
+ * off the wire, or null if it is fine.
126
+ *
127
+ * Both URLs default to https, but each is overridable - `opts.valBuildUrl` /
128
+ * `VAL_BUILD_URL`, `opts.valContentUrl` / `VAL_CONTENT_URL` - and neither
129
+ * override has ever been scheme-checked. Point one at a plain http host and the
130
+ * api key goes out in clear text, and whatever comes back is whatever the
131
+ * network says: for `valBuildUrl` that includes the app token this server
132
+ * re-signs into the session cookie.
133
+ *
134
+ * Loopback over http is exempt: that is a val.build running on the developer's
135
+ * own machine, and there is no network to be on the wrong side of.
136
+ *
137
+ * This warns rather than throws. Both overrides are set by the operator, not by
138
+ * an attacker, so this is a misconfiguration to surface - not untrusted input to
139
+ * reject - and refusing to boot would break anyone deliberately pointing at an
140
+ * internal http host today.
141
+ */
142
+ export declare function insecureUrlWarning(name: CredentialBearingUrl, url: string): string | null;
117
143
  export declare function safeReadGit(cwd: string): Promise<{
118
144
  commit?: string;
119
145
  branch?: string;
@@ -25,8 +25,8 @@ export { checkRemoteRef, downloadFileFromRemote, getCachedRemoteFileDir, getCach
25
25
  export { hasRemoteFileSchema } from "@valbuild/core";
26
26
  export { getFileExt } from "./getFileExt.js";
27
27
  export { evalValConfigFile, findAndEvalValConfigFile, } from "./evalValConfigFile.js";
28
- export { startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, } from "./login.js";
29
- export type { ValLoginErrorCode, ValLoginResult, ValLoginSession, } from "./login.js";
28
+ export { startValLogin, awaitValLoginConfirmation, persistPersonalAccessToken, ValLoginError, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, } from "./login.js";
29
+ export type { ValLoginErrorCode, ValLoginResult, ValDeviceAuthorization, } from "./login.js";
30
30
  export { createModulePathMap, createJsonEntryPathMap, getModulePathRange, } from "./modulePathMap.js";
31
31
  export { findJsonEntryFilePath } from "./jsonEntryLocation.js";
32
32
  export { classifyJsonValuesOp, rebaseContentOp } from "./patch/jsonValuesPatch.js";
@@ -1,5 +1,15 @@
1
1
  /**
2
- * The Val login device flow, as reusable primitives.
2
+ * The Val login flow, as reusable primitives.
3
+ *
4
+ * This is an RFC 8628 device authorization grant. The shape that matters:
5
+ * {@link ValDeviceAuthorization.deviceCode} is a secret this process holds and
6
+ * polls with, while {@link ValDeviceAuthorization.userCode} is the short string
7
+ * the human reads out of the terminal and types into a browser. Only the device
8
+ * code can collect a token.
9
+ *
10
+ * Keep them apart. Show the user code; never print, log or put the device code
11
+ * in a URL. An earlier version of this flow used one value for both jobs, which
12
+ * meant anyone who saw the verification link could collect the token it led to.
3
13
  *
4
14
  * The CLI wraps these with terminal output, and `@valbuild/language-server`
5
15
  * wraps them with LSP `window/showDocument` and progress reporting. Neither the
@@ -16,6 +26,10 @@ export type ValLoginErrorCode =
16
26
  | "unexpected-response"
17
27
  /** The server returned a 5xx. */
18
28
  | "server-error"
29
+ /** The user declined the login in the browser. */
30
+ | "access-denied"
31
+ /** The login was not approved before the code expired. */
32
+ | "expired"
19
33
  /** The user did not complete the login within the allotted time. */
20
34
  | "timeout"
21
35
  /** The caller aborted the flow. */
@@ -25,12 +39,23 @@ export declare class ValLoginError extends Error {
25
39
  readonly details?: string | undefined;
26
40
  constructor(code: ValLoginErrorCode, message: string, details?: string | undefined);
27
41
  }
28
- /** A login attempt that is waiting for the user to confirm in a browser. */
29
- export type ValLoginSession = {
30
- /** Opaque token used to poll for confirmation. */
31
- nonce: string;
32
- /** URL the user must open to confirm the login. */
33
- url: string;
42
+ /** A login attempt that is waiting for the user to approve it in a browser. */
43
+ export type ValDeviceAuthorization = {
44
+ /**
45
+ * Secret. Polls for the token. Do not display, log or transmit anywhere but
46
+ * the token endpoint.
47
+ */
48
+ deviceCode: string;
49
+ /** Short code for the user to compare and type. Safe to display. */
50
+ userCode: string;
51
+ /** Where the user goes to enter {@link userCode}. */
52
+ verificationUri: string;
53
+ /** {@link verificationUri} with the code prefilled, for convenience. */
54
+ verificationUriComplete: string;
55
+ /** Seconds until the code stops being approvable. */
56
+ expiresInSeconds: number;
57
+ /** Minimum seconds between polls, per the server. */
58
+ intervalSeconds: number;
34
59
  };
35
60
  export type ValLoginResult = {
36
61
  profile: {
@@ -39,24 +64,26 @@ export type ValLoginResult = {
39
64
  pat: string;
40
65
  };
41
66
  /**
42
- * Begin a login attempt. The caller is responsible for getting
43
- * {@link ValLoginSession.url} in front of the user.
67
+ * Begin a login attempt. The caller is responsible for getting the user code
68
+ * and verification URL in front of the user.
44
69
  */
45
70
  export declare function startValLogin(options?: {
46
71
  host?: string;
47
- }): Promise<ValLoginSession>;
48
- export declare const DEFAULT_LOGIN_MAX_DURATION: number;
49
- export declare const DEFAULT_LOGIN_POLL_INTERVAL = 1000;
72
+ deviceName?: string;
73
+ }): Promise<ValDeviceAuthorization>;
74
+ /** Fallbacks for a server that omits the optional RFC 8628 timing fields. */
75
+ export declare const DEFAULT_LOGIN_EXPIRES_IN_SECONDS = 600;
76
+ export declare const DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = 5;
50
77
  /**
51
- * Poll until the user confirms the login in their browser.
78
+ * Poll until the user approves the login in their browser.
52
79
  *
53
80
  * Accepts an `AbortSignal` so an editor can cancel the flow when the user
54
- * dismisses the prompt, instead of leaving a poll loop running for 5 minutes.
81
+ * dismisses the prompt, instead of leaving a poll loop running to expiry.
55
82
  */
56
- export declare function awaitValLoginConfirmation(nonce: string, options?: {
83
+ export declare function awaitValLoginConfirmation(authorization: ValDeviceAuthorization, options?: {
57
84
  host?: string;
85
+ /** Defaults to the authorization's own `expiresInSeconds`. */
58
86
  maxDurationMs?: number;
59
- pollIntervalMs?: number;
60
87
  signal?: AbortSignal;
61
88
  /** Wall clock, injectable for tests. */
62
89
  now?: () => number;
@@ -10347,6 +10347,10 @@ async function initHandlerOptions(route, opts, config) {
10347
10347
  const maybeValProject = opts.project || process.env.VAL_PROJECT;
10348
10348
  const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
10349
10349
  const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
10350
+ warnIfInsecureUrls({
10351
+ valBuildUrl,
10352
+ valContentUrl
10353
+ });
10350
10354
  if (isProxyMode) {
10351
10355
  var _opts$versions, _opts$versions2;
10352
10356
  if (!maybeApiKey || !maybeValSecret) {
@@ -10388,7 +10392,6 @@ async function initHandlerOptions(route, opts, config) {
10388
10392
  };
10389
10393
  } else {
10390
10394
  const cwd = process.cwd();
10391
- const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
10392
10395
  return {
10393
10396
  mode: "fs",
10394
10397
  cwd,
@@ -10405,6 +10408,84 @@ async function initHandlerOptions(route, opts, config) {
10405
10408
  }
10406
10409
  }
10407
10410
 
10411
+ /**
10412
+ * Hosts we send credentials to, and what each one puts at risk. They differ:
10413
+ * only `valBuildUrl` hands back the app token that becomes the session cookie,
10414
+ * so a single shared sentence would overstate one and understate the other.
10415
+ */
10416
+
10417
+ const CREDENTIAL_BEARING_URLS = ["valBuildUrl", "valContentUrl"];
10418
+ const WHAT_IS_AT_RISK = {
10419
+ valBuildUrl: "Val's api key is sent to this host, and the token it returns is what this server signs into the session cookie, " + "so both can be read - and the token replaced - by anyone on the network path.",
10420
+ valContentUrl: "Val's api key, or the caller's personal access token, is sent to this host, " + "so it can be read by anyone on the network path."
10421
+ };
10422
+
10423
+ // NOTE: `URL.hostname` keeps the brackets on an IPv6 literal, so this is
10424
+ // "[::1]" and not "::1" - and `http://[0:0:0:0:0:0:0:1]` normalises to the
10425
+ // same short form before it gets here. Dropping the brackets looks like a
10426
+ // tidy-up and silently stops matching IPv6 loopback.
10427
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
10428
+
10429
+ /**
10430
+ * The URL as it is safe to print. `http://user:pass@host` is a legal override,
10431
+ * and a warning about credential exposure that puts the password in the log
10432
+ * would be the very thing it is warning about.
10433
+ */
10434
+ function forLog(parsed) {
10435
+ if (!parsed.username && !parsed.password) {
10436
+ return parsed.href;
10437
+ }
10438
+ const redacted = new URL(parsed.href);
10439
+ redacted.username = "";
10440
+ redacted.password = "";
10441
+ return `${redacted.href} (credentials redacted)`;
10442
+ }
10443
+
10444
+ /**
10445
+ * Returns a warning if `url` would send credentials somewhere they can be read
10446
+ * off the wire, or null if it is fine.
10447
+ *
10448
+ * Both URLs default to https, but each is overridable - `opts.valBuildUrl` /
10449
+ * `VAL_BUILD_URL`, `opts.valContentUrl` / `VAL_CONTENT_URL` - and neither
10450
+ * override has ever been scheme-checked. Point one at a plain http host and the
10451
+ * api key goes out in clear text, and whatever comes back is whatever the
10452
+ * network says: for `valBuildUrl` that includes the app token this server
10453
+ * re-signs into the session cookie.
10454
+ *
10455
+ * Loopback over http is exempt: that is a val.build running on the developer's
10456
+ * own machine, and there is no network to be on the wrong side of.
10457
+ *
10458
+ * This warns rather than throws. Both overrides are set by the operator, not by
10459
+ * an attacker, so this is a misconfiguration to surface - not untrusted input to
10460
+ * reject - and refusing to boot would break anyone deliberately pointing at an
10461
+ * internal http host today.
10462
+ */
10463
+ function insecureUrlWarning(name, url) {
10464
+ let parsed;
10465
+ try {
10466
+ parsed = new URL(url);
10467
+ } catch {
10468
+ // NOTE: the URL is not echoed here. It did not parse, so there is nothing
10469
+ // to redact with, and an unparseable string can still hold a password.
10470
+ return `Val: ${name} is not a valid URL.`;
10471
+ }
10472
+ if (parsed.protocol === "https:") {
10473
+ return null;
10474
+ }
10475
+ if (parsed.protocol === "http:" && (LOOPBACK_HOSTNAMES.includes(parsed.hostname) || parsed.hostname.endsWith(".localhost"))) {
10476
+ return null;
10477
+ }
10478
+ return `Val: ${name} is set to ${forLog(parsed)}, which is not https. ` + `${WHAT_IS_AT_RISK[name]} ` + `Use https, or a loopback address for local development.`;
10479
+ }
10480
+ function warnIfInsecureUrls(urls) {
10481
+ for (const name of CREDENTIAL_BEARING_URLS) {
10482
+ const warning = insecureUrlWarning(name, urls[name]);
10483
+ if (warning) {
10484
+ console.warn(warning);
10485
+ }
10486
+ }
10487
+ }
10488
+
10408
10489
  // TODO: remove
10409
10490
  async function safeReadGit(cwd) {
10410
10491
  async function findGitHead(currentDir, depth) {
@@ -12408,7 +12489,17 @@ async function findAndEvalValConfigFile(projectRoot) {
12408
12489
  }
12409
12490
 
12410
12491
  /**
12411
- * The Val login device flow, as reusable primitives.
12492
+ * The Val login flow, as reusable primitives.
12493
+ *
12494
+ * This is an RFC 8628 device authorization grant. The shape that matters:
12495
+ * {@link ValDeviceAuthorization.deviceCode} is a secret this process holds and
12496
+ * polls with, while {@link ValDeviceAuthorization.userCode} is the short string
12497
+ * the human reads out of the terminal and types into a browser. Only the device
12498
+ * code can collect a token.
12499
+ *
12500
+ * Keep them apart. Show the user code; never print, log or put the device code
12501
+ * in a URL. An earlier version of this flow used one value for both jobs, which
12502
+ * meant anyone who saw the verification link could collect the token it led to.
12412
12503
  *
12413
12504
  * The CLI wraps these with terminal output, and `@valbuild/language-server`
12414
12505
  * wraps them with LSP `window/showDocument` and progress reporting. Neither the
@@ -12422,6 +12513,21 @@ const DEFAULT_LOGIN_HOST = "https://admin.val.build";
12422
12513
  function defaultHost() {
12423
12514
  return process.env.VAL_BUILD_URL || DEFAULT_LOGIN_HOST;
12424
12515
  }
12516
+
12517
+ /**
12518
+ * What gets shown on the approval screen so the person can tell which terminal
12519
+ * is asking. Self-reported and therefore a hint, not proof — the server treats
12520
+ * it as untrusted display text.
12521
+ */
12522
+ function defaultDeviceName() {
12523
+ try {
12524
+ return `${os__default["default"].hostname()} (${os__default["default"].platform()})`;
12525
+ } catch {
12526
+ // hostname() can throw on locked-down containers. A missing device name is
12527
+ // not worth failing a login over; the server renders "Unknown".
12528
+ return "";
12529
+ }
12530
+ }
12425
12531
  class ValLoginError extends Error {
12426
12532
  constructor(code, message, details) {
12427
12533
  super(message);
@@ -12431,20 +12537,24 @@ class ValLoginError extends Error {
12431
12537
  }
12432
12538
  }
12433
12539
 
12434
- /** A login attempt that is waiting for the user to confirm in a browser. */
12540
+ /** A login attempt that is waiting for the user to approve it in a browser. */
12435
12541
 
12436
12542
  /**
12437
- * Begin a login attempt. The caller is responsible for getting
12438
- * {@link ValLoginSession.url} in front of the user.
12543
+ * Begin a login attempt. The caller is responsible for getting the user code
12544
+ * and verification URL in front of the user.
12439
12545
  */
12440
12546
  async function startValLogin(options = {}) {
12441
12547
  var _response$headers$get;
12442
12548
  const host = options.host ?? defaultHost();
12549
+ const deviceName = options.deviceName ?? defaultDeviceName();
12443
12550
  const response = await fetch(`${host}/api/login`, {
12444
12551
  method: "POST",
12445
12552
  headers: {
12446
12553
  "Content-Type": "application/json"
12447
- }
12554
+ },
12555
+ body: JSON.stringify(deviceName ? {
12556
+ device_name: deviceName
12557
+ } : {})
12448
12558
  });
12449
12559
  if (response.status >= 500) {
12450
12560
  const text = await response.text().catch(() => "");
@@ -12455,42 +12565,63 @@ async function startValLogin(options = {}) {
12455
12565
  throw new ValLoginError("unexpected-content-type", "Unexpected failure while trying to login (content type was not JSON).", text ? `Server response: ${text} (status: ${response.status})` : `Status: ${response.status}`);
12456
12566
  }
12457
12567
  const json = await response.json();
12458
- const nonce = json === null || json === void 0 ? void 0 : json.nonce;
12459
- const url = json === null || json === void 0 ? void 0 : json.url;
12460
- if (typeof nonce !== "string" || typeof url !== "string") {
12461
- throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12568
+ const deviceCode = json === null || json === void 0 ? void 0 : json.device_code;
12569
+ const userCode = json === null || json === void 0 ? void 0 : json.user_code;
12570
+ const verificationUri = json === null || json === void 0 ? void 0 : json.verification_uri;
12571
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
12572
+ throw new ValLoginError("unexpected-response", "Unexpected response from the server. This version of Val may be too old for the login flow on this host — try updating @valbuild/cli.", JSON.stringify(json));
12462
12573
  }
12463
12574
  return {
12464
- nonce,
12465
- url
12575
+ deviceCode,
12576
+ userCode,
12577
+ verificationUri,
12578
+ verificationUriComplete: typeof (json === null || json === void 0 ? void 0 : json.verification_uri_complete) === "string" ? json.verification_uri_complete : verificationUri,
12579
+ expiresInSeconds: typeof (json === null || json === void 0 ? void 0 : json.expires_in) === "number" ? json.expires_in : DEFAULT_LOGIN_EXPIRES_IN_SECONDS,
12580
+ intervalSeconds: typeof (json === null || json === void 0 ? void 0 : json.interval) === "number" ? json.interval : DEFAULT_LOGIN_POLL_INTERVAL_SECONDS
12466
12581
  };
12467
12582
  }
12468
- const DEFAULT_LOGIN_MAX_DURATION = 5 * 60 * 1000; // 5 minutes
12469
- const DEFAULT_LOGIN_POLL_INTERVAL = 1000;
12583
+
12584
+ /** Fallbacks for a server that omits the optional RFC 8628 timing fields. */
12585
+ const DEFAULT_LOGIN_EXPIRES_IN_SECONDS = 600;
12586
+ const DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = 5;
12470
12587
 
12471
12588
  /**
12472
- * Poll until the user confirms the login in their browser.
12589
+ * How much to add to the poll interval when the server answers `slow_down`.
12590
+ * RFC 8628 section 3.5 specifies 5 seconds.
12591
+ */
12592
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
12593
+
12594
+ /**
12595
+ * Poll until the user approves the login in their browser.
12473
12596
  *
12474
12597
  * Accepts an `AbortSignal` so an editor can cancel the flow when the user
12475
- * dismisses the prompt, instead of leaving a poll loop running for 5 minutes.
12598
+ * dismisses the prompt, instead of leaving a poll loop running to expiry.
12476
12599
  */
12477
- async function awaitValLoginConfirmation(nonce, options = {}) {
12600
+ async function awaitValLoginConfirmation(authorization, options = {}) {
12478
12601
  const host = options.host ?? defaultHost();
12479
- const maxDuration = options.maxDurationMs ?? DEFAULT_LOGIN_MAX_DURATION;
12480
- const pollInterval = options.pollIntervalMs ?? DEFAULT_LOGIN_POLL_INTERVAL;
12602
+ const maxDuration = options.maxDurationMs ?? authorization.expiresInSeconds * 1000;
12481
12603
  const now = options.now ?? (() => Date.now());
12604
+ // Mutable: `slow_down` widens it as we go, and never narrows it again.
12605
+ let intervalMs = authorization.intervalSeconds * 1000;
12482
12606
  const start = now();
12483
12607
  while (now() - start < maxDuration) {
12484
12608
  var _options$signal, _options$signal2;
12485
12609
  if ((_options$signal = options.signal) !== null && _options$signal !== void 0 && _options$signal.aborted) {
12486
12610
  throw new ValLoginError("aborted", "Login was cancelled.");
12487
12611
  }
12488
- await new Promise(resolve => setTimeout(resolve, pollInterval));
12612
+ // Wait first: the user has not had time to approve anything yet.
12613
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
12489
12614
  if ((_options$signal2 = options.signal) !== null && _options$signal2 !== void 0 && _options$signal2.aborted) {
12490
12615
  throw new ValLoginError("aborted", "Login was cancelled.");
12491
12616
  }
12492
- const response = await fetch(`${host}/api/login?token=${nonce}&consume=true`, {
12493
- method: "POST"
12617
+ const response = await fetch(`${host}/api/login`, {
12618
+ method: "POST",
12619
+ headers: {
12620
+ "Content-Type": "application/json"
12621
+ },
12622
+ body: JSON.stringify({
12623
+ device_code: authorization.deviceCode
12624
+ })
12494
12625
  });
12495
12626
  if (response.status >= 500) {
12496
12627
  throw new ValLoginError("server-error", "An error occurred on the server.", `Status: ${response.status}`);
@@ -12508,6 +12639,23 @@ async function awaitValLoginConfirmation(nonce, options = {}) {
12508
12639
  }
12509
12640
  throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12510
12641
  }
12642
+ const json = await response.json().catch(() => null);
12643
+ const error = typeof (json === null || json === void 0 ? void 0 : json.error) === "string" ? json.error : null;
12644
+ const description = typeof (json === null || json === void 0 ? void 0 : json.error_description) === "string" ? json.error_description : undefined;
12645
+ if (error === "authorization_pending") {
12646
+ continue;
12647
+ }
12648
+ if (error === "slow_down" || response.status === 429) {
12649
+ intervalMs += SLOW_DOWN_INCREMENT_SECONDS * 1000;
12650
+ continue;
12651
+ }
12652
+ if (error === "access_denied") {
12653
+ throw new ValLoginError("access-denied", "The login was declined in the browser.", description);
12654
+ }
12655
+ if (error === "expired_token") {
12656
+ throw new ValLoginError("expired", "The login code expired before it was approved.", description);
12657
+ }
12658
+ throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12511
12659
  }
12512
12660
  throw new ValLoginError("timeout", "Login confirmation timed out.");
12513
12661
  }
@@ -12813,9 +12961,9 @@ Object.defineProperty(exports, 'hasRemoteFileSchema', {
12813
12961
  enumerable: true,
12814
12962
  get: function () { return core.hasRemoteFileSchema; }
12815
12963
  });
12964
+ exports.DEFAULT_LOGIN_EXPIRES_IN_SECONDS = DEFAULT_LOGIN_EXPIRES_IN_SECONDS;
12816
12965
  exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
12817
- exports.DEFAULT_LOGIN_MAX_DURATION = DEFAULT_LOGIN_MAX_DURATION;
12818
- exports.DEFAULT_LOGIN_POLL_INTERVAL = DEFAULT_LOGIN_POLL_INTERVAL;
12966
+ exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
12819
12967
  exports.Service = Service;
12820
12968
  exports.ValFSHost = ValFSHost;
12821
12969
  exports.ValLoginError = ValLoginError;
@@ -10347,6 +10347,10 @@ async function initHandlerOptions(route, opts, config) {
10347
10347
  const maybeValProject = opts.project || process.env.VAL_PROJECT;
10348
10348
  const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
10349
10349
  const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
10350
+ warnIfInsecureUrls({
10351
+ valBuildUrl,
10352
+ valContentUrl
10353
+ });
10350
10354
  if (isProxyMode) {
10351
10355
  var _opts$versions, _opts$versions2;
10352
10356
  if (!maybeApiKey || !maybeValSecret) {
@@ -10388,7 +10392,6 @@ async function initHandlerOptions(route, opts, config) {
10388
10392
  };
10389
10393
  } else {
10390
10394
  const cwd = process.cwd();
10391
- const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
10392
10395
  return {
10393
10396
  mode: "fs",
10394
10397
  cwd,
@@ -10405,6 +10408,84 @@ async function initHandlerOptions(route, opts, config) {
10405
10408
  }
10406
10409
  }
10407
10410
 
10411
+ /**
10412
+ * Hosts we send credentials to, and what each one puts at risk. They differ:
10413
+ * only `valBuildUrl` hands back the app token that becomes the session cookie,
10414
+ * so a single shared sentence would overstate one and understate the other.
10415
+ */
10416
+
10417
+ const CREDENTIAL_BEARING_URLS = ["valBuildUrl", "valContentUrl"];
10418
+ const WHAT_IS_AT_RISK = {
10419
+ valBuildUrl: "Val's api key is sent to this host, and the token it returns is what this server signs into the session cookie, " + "so both can be read - and the token replaced - by anyone on the network path.",
10420
+ valContentUrl: "Val's api key, or the caller's personal access token, is sent to this host, " + "so it can be read by anyone on the network path."
10421
+ };
10422
+
10423
+ // NOTE: `URL.hostname` keeps the brackets on an IPv6 literal, so this is
10424
+ // "[::1]" and not "::1" - and `http://[0:0:0:0:0:0:0:1]` normalises to the
10425
+ // same short form before it gets here. Dropping the brackets looks like a
10426
+ // tidy-up and silently stops matching IPv6 loopback.
10427
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
10428
+
10429
+ /**
10430
+ * The URL as it is safe to print. `http://user:pass@host` is a legal override,
10431
+ * and a warning about credential exposure that puts the password in the log
10432
+ * would be the very thing it is warning about.
10433
+ */
10434
+ function forLog(parsed) {
10435
+ if (!parsed.username && !parsed.password) {
10436
+ return parsed.href;
10437
+ }
10438
+ const redacted = new URL(parsed.href);
10439
+ redacted.username = "";
10440
+ redacted.password = "";
10441
+ return `${redacted.href} (credentials redacted)`;
10442
+ }
10443
+
10444
+ /**
10445
+ * Returns a warning if `url` would send credentials somewhere they can be read
10446
+ * off the wire, or null if it is fine.
10447
+ *
10448
+ * Both URLs default to https, but each is overridable - `opts.valBuildUrl` /
10449
+ * `VAL_BUILD_URL`, `opts.valContentUrl` / `VAL_CONTENT_URL` - and neither
10450
+ * override has ever been scheme-checked. Point one at a plain http host and the
10451
+ * api key goes out in clear text, and whatever comes back is whatever the
10452
+ * network says: for `valBuildUrl` that includes the app token this server
10453
+ * re-signs into the session cookie.
10454
+ *
10455
+ * Loopback over http is exempt: that is a val.build running on the developer's
10456
+ * own machine, and there is no network to be on the wrong side of.
10457
+ *
10458
+ * This warns rather than throws. Both overrides are set by the operator, not by
10459
+ * an attacker, so this is a misconfiguration to surface - not untrusted input to
10460
+ * reject - and refusing to boot would break anyone deliberately pointing at an
10461
+ * internal http host today.
10462
+ */
10463
+ function insecureUrlWarning(name, url) {
10464
+ let parsed;
10465
+ try {
10466
+ parsed = new URL(url);
10467
+ } catch {
10468
+ // NOTE: the URL is not echoed here. It did not parse, so there is nothing
10469
+ // to redact with, and an unparseable string can still hold a password.
10470
+ return `Val: ${name} is not a valid URL.`;
10471
+ }
10472
+ if (parsed.protocol === "https:") {
10473
+ return null;
10474
+ }
10475
+ if (parsed.protocol === "http:" && (LOOPBACK_HOSTNAMES.includes(parsed.hostname) || parsed.hostname.endsWith(".localhost"))) {
10476
+ return null;
10477
+ }
10478
+ return `Val: ${name} is set to ${forLog(parsed)}, which is not https. ` + `${WHAT_IS_AT_RISK[name]} ` + `Use https, or a loopback address for local development.`;
10479
+ }
10480
+ function warnIfInsecureUrls(urls) {
10481
+ for (const name of CREDENTIAL_BEARING_URLS) {
10482
+ const warning = insecureUrlWarning(name, urls[name]);
10483
+ if (warning) {
10484
+ console.warn(warning);
10485
+ }
10486
+ }
10487
+ }
10488
+
10408
10489
  // TODO: remove
10409
10490
  async function safeReadGit(cwd) {
10410
10491
  async function findGitHead(currentDir, depth) {
@@ -12408,7 +12489,17 @@ async function findAndEvalValConfigFile(projectRoot) {
12408
12489
  }
12409
12490
 
12410
12491
  /**
12411
- * The Val login device flow, as reusable primitives.
12492
+ * The Val login flow, as reusable primitives.
12493
+ *
12494
+ * This is an RFC 8628 device authorization grant. The shape that matters:
12495
+ * {@link ValDeviceAuthorization.deviceCode} is a secret this process holds and
12496
+ * polls with, while {@link ValDeviceAuthorization.userCode} is the short string
12497
+ * the human reads out of the terminal and types into a browser. Only the device
12498
+ * code can collect a token.
12499
+ *
12500
+ * Keep them apart. Show the user code; never print, log or put the device code
12501
+ * in a URL. An earlier version of this flow used one value for both jobs, which
12502
+ * meant anyone who saw the verification link could collect the token it led to.
12412
12503
  *
12413
12504
  * The CLI wraps these with terminal output, and `@valbuild/language-server`
12414
12505
  * wraps them with LSP `window/showDocument` and progress reporting. Neither the
@@ -12422,6 +12513,21 @@ const DEFAULT_LOGIN_HOST = "https://admin.val.build";
12422
12513
  function defaultHost() {
12423
12514
  return process.env.VAL_BUILD_URL || DEFAULT_LOGIN_HOST;
12424
12515
  }
12516
+
12517
+ /**
12518
+ * What gets shown on the approval screen so the person can tell which terminal
12519
+ * is asking. Self-reported and therefore a hint, not proof — the server treats
12520
+ * it as untrusted display text.
12521
+ */
12522
+ function defaultDeviceName() {
12523
+ try {
12524
+ return `${os__default["default"].hostname()} (${os__default["default"].platform()})`;
12525
+ } catch {
12526
+ // hostname() can throw on locked-down containers. A missing device name is
12527
+ // not worth failing a login over; the server renders "Unknown".
12528
+ return "";
12529
+ }
12530
+ }
12425
12531
  class ValLoginError extends Error {
12426
12532
  constructor(code, message, details) {
12427
12533
  super(message);
@@ -12431,20 +12537,24 @@ class ValLoginError extends Error {
12431
12537
  }
12432
12538
  }
12433
12539
 
12434
- /** A login attempt that is waiting for the user to confirm in a browser. */
12540
+ /** A login attempt that is waiting for the user to approve it in a browser. */
12435
12541
 
12436
12542
  /**
12437
- * Begin a login attempt. The caller is responsible for getting
12438
- * {@link ValLoginSession.url} in front of the user.
12543
+ * Begin a login attempt. The caller is responsible for getting the user code
12544
+ * and verification URL in front of the user.
12439
12545
  */
12440
12546
  async function startValLogin(options = {}) {
12441
12547
  var _response$headers$get;
12442
12548
  const host = options.host ?? defaultHost();
12549
+ const deviceName = options.deviceName ?? defaultDeviceName();
12443
12550
  const response = await fetch(`${host}/api/login`, {
12444
12551
  method: "POST",
12445
12552
  headers: {
12446
12553
  "Content-Type": "application/json"
12447
- }
12554
+ },
12555
+ body: JSON.stringify(deviceName ? {
12556
+ device_name: deviceName
12557
+ } : {})
12448
12558
  });
12449
12559
  if (response.status >= 500) {
12450
12560
  const text = await response.text().catch(() => "");
@@ -12455,42 +12565,63 @@ async function startValLogin(options = {}) {
12455
12565
  throw new ValLoginError("unexpected-content-type", "Unexpected failure while trying to login (content type was not JSON).", text ? `Server response: ${text} (status: ${response.status})` : `Status: ${response.status}`);
12456
12566
  }
12457
12567
  const json = await response.json();
12458
- const nonce = json === null || json === void 0 ? void 0 : json.nonce;
12459
- const url = json === null || json === void 0 ? void 0 : json.url;
12460
- if (typeof nonce !== "string" || typeof url !== "string") {
12461
- throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12568
+ const deviceCode = json === null || json === void 0 ? void 0 : json.device_code;
12569
+ const userCode = json === null || json === void 0 ? void 0 : json.user_code;
12570
+ const verificationUri = json === null || json === void 0 ? void 0 : json.verification_uri;
12571
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
12572
+ throw new ValLoginError("unexpected-response", "Unexpected response from the server. This version of Val may be too old for the login flow on this host — try updating @valbuild/cli.", JSON.stringify(json));
12462
12573
  }
12463
12574
  return {
12464
- nonce,
12465
- url
12575
+ deviceCode,
12576
+ userCode,
12577
+ verificationUri,
12578
+ verificationUriComplete: typeof (json === null || json === void 0 ? void 0 : json.verification_uri_complete) === "string" ? json.verification_uri_complete : verificationUri,
12579
+ expiresInSeconds: typeof (json === null || json === void 0 ? void 0 : json.expires_in) === "number" ? json.expires_in : DEFAULT_LOGIN_EXPIRES_IN_SECONDS,
12580
+ intervalSeconds: typeof (json === null || json === void 0 ? void 0 : json.interval) === "number" ? json.interval : DEFAULT_LOGIN_POLL_INTERVAL_SECONDS
12466
12581
  };
12467
12582
  }
12468
- const DEFAULT_LOGIN_MAX_DURATION = 5 * 60 * 1000; // 5 minutes
12469
- const DEFAULT_LOGIN_POLL_INTERVAL = 1000;
12583
+
12584
+ /** Fallbacks for a server that omits the optional RFC 8628 timing fields. */
12585
+ const DEFAULT_LOGIN_EXPIRES_IN_SECONDS = 600;
12586
+ const DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = 5;
12470
12587
 
12471
12588
  /**
12472
- * Poll until the user confirms the login in their browser.
12589
+ * How much to add to the poll interval when the server answers `slow_down`.
12590
+ * RFC 8628 section 3.5 specifies 5 seconds.
12591
+ */
12592
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
12593
+
12594
+ /**
12595
+ * Poll until the user approves the login in their browser.
12473
12596
  *
12474
12597
  * Accepts an `AbortSignal` so an editor can cancel the flow when the user
12475
- * dismisses the prompt, instead of leaving a poll loop running for 5 minutes.
12598
+ * dismisses the prompt, instead of leaving a poll loop running to expiry.
12476
12599
  */
12477
- async function awaitValLoginConfirmation(nonce, options = {}) {
12600
+ async function awaitValLoginConfirmation(authorization, options = {}) {
12478
12601
  const host = options.host ?? defaultHost();
12479
- const maxDuration = options.maxDurationMs ?? DEFAULT_LOGIN_MAX_DURATION;
12480
- const pollInterval = options.pollIntervalMs ?? DEFAULT_LOGIN_POLL_INTERVAL;
12602
+ const maxDuration = options.maxDurationMs ?? authorization.expiresInSeconds * 1000;
12481
12603
  const now = options.now ?? (() => Date.now());
12604
+ // Mutable: `slow_down` widens it as we go, and never narrows it again.
12605
+ let intervalMs = authorization.intervalSeconds * 1000;
12482
12606
  const start = now();
12483
12607
  while (now() - start < maxDuration) {
12484
12608
  var _options$signal, _options$signal2;
12485
12609
  if ((_options$signal = options.signal) !== null && _options$signal !== void 0 && _options$signal.aborted) {
12486
12610
  throw new ValLoginError("aborted", "Login was cancelled.");
12487
12611
  }
12488
- await new Promise(resolve => setTimeout(resolve, pollInterval));
12612
+ // Wait first: the user has not had time to approve anything yet.
12613
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
12489
12614
  if ((_options$signal2 = options.signal) !== null && _options$signal2 !== void 0 && _options$signal2.aborted) {
12490
12615
  throw new ValLoginError("aborted", "Login was cancelled.");
12491
12616
  }
12492
- const response = await fetch(`${host}/api/login?token=${nonce}&consume=true`, {
12493
- method: "POST"
12617
+ const response = await fetch(`${host}/api/login`, {
12618
+ method: "POST",
12619
+ headers: {
12620
+ "Content-Type": "application/json"
12621
+ },
12622
+ body: JSON.stringify({
12623
+ device_code: authorization.deviceCode
12624
+ })
12494
12625
  });
12495
12626
  if (response.status >= 500) {
12496
12627
  throw new ValLoginError("server-error", "An error occurred on the server.", `Status: ${response.status}`);
@@ -12508,6 +12639,23 @@ async function awaitValLoginConfirmation(nonce, options = {}) {
12508
12639
  }
12509
12640
  throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12510
12641
  }
12642
+ const json = await response.json().catch(() => null);
12643
+ const error = typeof (json === null || json === void 0 ? void 0 : json.error) === "string" ? json.error : null;
12644
+ const description = typeof (json === null || json === void 0 ? void 0 : json.error_description) === "string" ? json.error_description : undefined;
12645
+ if (error === "authorization_pending") {
12646
+ continue;
12647
+ }
12648
+ if (error === "slow_down" || response.status === 429) {
12649
+ intervalMs += SLOW_DOWN_INCREMENT_SECONDS * 1000;
12650
+ continue;
12651
+ }
12652
+ if (error === "access_denied") {
12653
+ throw new ValLoginError("access-denied", "The login was declined in the browser.", description);
12654
+ }
12655
+ if (error === "expired_token") {
12656
+ throw new ValLoginError("expired", "The login code expired before it was approved.", description);
12657
+ }
12658
+ throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12511
12659
  }
12512
12660
  throw new ValLoginError("timeout", "Login confirmation timed out.");
12513
12661
  }
@@ -12813,9 +12961,9 @@ Object.defineProperty(exports, 'hasRemoteFileSchema', {
12813
12961
  enumerable: true,
12814
12962
  get: function () { return core.hasRemoteFileSchema; }
12815
12963
  });
12964
+ exports.DEFAULT_LOGIN_EXPIRES_IN_SECONDS = DEFAULT_LOGIN_EXPIRES_IN_SECONDS;
12816
12965
  exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
12817
- exports.DEFAULT_LOGIN_MAX_DURATION = DEFAULT_LOGIN_MAX_DURATION;
12818
- exports.DEFAULT_LOGIN_POLL_INTERVAL = DEFAULT_LOGIN_POLL_INTERVAL;
12966
+ exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
12819
12967
  exports.Service = Service;
12820
12968
  exports.ValFSHost = ValFSHost;
12821
12969
  exports.ValLoginError = ValLoginError;
@@ -10313,6 +10313,10 @@ async function initHandlerOptions(route, opts, config) {
10313
10313
  const maybeValProject = opts.project || process.env.VAL_PROJECT;
10314
10314
  const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
10315
10315
  const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
10316
+ warnIfInsecureUrls({
10317
+ valBuildUrl,
10318
+ valContentUrl
10319
+ });
10316
10320
  if (isProxyMode) {
10317
10321
  var _opts$versions, _opts$versions2;
10318
10322
  if (!maybeApiKey || !maybeValSecret) {
@@ -10354,7 +10358,6 @@ async function initHandlerOptions(route, opts, config) {
10354
10358
  };
10355
10359
  } else {
10356
10360
  const cwd = process.cwd();
10357
- const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
10358
10361
  return {
10359
10362
  mode: "fs",
10360
10363
  cwd,
@@ -10371,6 +10374,84 @@ async function initHandlerOptions(route, opts, config) {
10371
10374
  }
10372
10375
  }
10373
10376
 
10377
+ /**
10378
+ * Hosts we send credentials to, and what each one puts at risk. They differ:
10379
+ * only `valBuildUrl` hands back the app token that becomes the session cookie,
10380
+ * so a single shared sentence would overstate one and understate the other.
10381
+ */
10382
+
10383
+ const CREDENTIAL_BEARING_URLS = ["valBuildUrl", "valContentUrl"];
10384
+ const WHAT_IS_AT_RISK = {
10385
+ valBuildUrl: "Val's api key is sent to this host, and the token it returns is what this server signs into the session cookie, " + "so both can be read - and the token replaced - by anyone on the network path.",
10386
+ valContentUrl: "Val's api key, or the caller's personal access token, is sent to this host, " + "so it can be read by anyone on the network path."
10387
+ };
10388
+
10389
+ // NOTE: `URL.hostname` keeps the brackets on an IPv6 literal, so this is
10390
+ // "[::1]" and not "::1" - and `http://[0:0:0:0:0:0:0:1]` normalises to the
10391
+ // same short form before it gets here. Dropping the brackets looks like a
10392
+ // tidy-up and silently stops matching IPv6 loopback.
10393
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
10394
+
10395
+ /**
10396
+ * The URL as it is safe to print. `http://user:pass@host` is a legal override,
10397
+ * and a warning about credential exposure that puts the password in the log
10398
+ * would be the very thing it is warning about.
10399
+ */
10400
+ function forLog(parsed) {
10401
+ if (!parsed.username && !parsed.password) {
10402
+ return parsed.href;
10403
+ }
10404
+ const redacted = new URL(parsed.href);
10405
+ redacted.username = "";
10406
+ redacted.password = "";
10407
+ return `${redacted.href} (credentials redacted)`;
10408
+ }
10409
+
10410
+ /**
10411
+ * Returns a warning if `url` would send credentials somewhere they can be read
10412
+ * off the wire, or null if it is fine.
10413
+ *
10414
+ * Both URLs default to https, but each is overridable - `opts.valBuildUrl` /
10415
+ * `VAL_BUILD_URL`, `opts.valContentUrl` / `VAL_CONTENT_URL` - and neither
10416
+ * override has ever been scheme-checked. Point one at a plain http host and the
10417
+ * api key goes out in clear text, and whatever comes back is whatever the
10418
+ * network says: for `valBuildUrl` that includes the app token this server
10419
+ * re-signs into the session cookie.
10420
+ *
10421
+ * Loopback over http is exempt: that is a val.build running on the developer's
10422
+ * own machine, and there is no network to be on the wrong side of.
10423
+ *
10424
+ * This warns rather than throws. Both overrides are set by the operator, not by
10425
+ * an attacker, so this is a misconfiguration to surface - not untrusted input to
10426
+ * reject - and refusing to boot would break anyone deliberately pointing at an
10427
+ * internal http host today.
10428
+ */
10429
+ function insecureUrlWarning(name, url) {
10430
+ let parsed;
10431
+ try {
10432
+ parsed = new URL(url);
10433
+ } catch {
10434
+ // NOTE: the URL is not echoed here. It did not parse, so there is nothing
10435
+ // to redact with, and an unparseable string can still hold a password.
10436
+ return `Val: ${name} is not a valid URL.`;
10437
+ }
10438
+ if (parsed.protocol === "https:") {
10439
+ return null;
10440
+ }
10441
+ if (parsed.protocol === "http:" && (LOOPBACK_HOSTNAMES.includes(parsed.hostname) || parsed.hostname.endsWith(".localhost"))) {
10442
+ return null;
10443
+ }
10444
+ return `Val: ${name} is set to ${forLog(parsed)}, which is not https. ` + `${WHAT_IS_AT_RISK[name]} ` + `Use https, or a loopback address for local development.`;
10445
+ }
10446
+ function warnIfInsecureUrls(urls) {
10447
+ for (const name of CREDENTIAL_BEARING_URLS) {
10448
+ const warning = insecureUrlWarning(name, urls[name]);
10449
+ if (warning) {
10450
+ console.warn(warning);
10451
+ }
10452
+ }
10453
+ }
10454
+
10374
10455
  // TODO: remove
10375
10456
  async function safeReadGit(cwd) {
10376
10457
  async function findGitHead(currentDir, depth) {
@@ -12374,7 +12455,17 @@ async function findAndEvalValConfigFile(projectRoot) {
12374
12455
  }
12375
12456
 
12376
12457
  /**
12377
- * The Val login device flow, as reusable primitives.
12458
+ * The Val login flow, as reusable primitives.
12459
+ *
12460
+ * This is an RFC 8628 device authorization grant. The shape that matters:
12461
+ * {@link ValDeviceAuthorization.deviceCode} is a secret this process holds and
12462
+ * polls with, while {@link ValDeviceAuthorization.userCode} is the short string
12463
+ * the human reads out of the terminal and types into a browser. Only the device
12464
+ * code can collect a token.
12465
+ *
12466
+ * Keep them apart. Show the user code; never print, log or put the device code
12467
+ * in a URL. An earlier version of this flow used one value for both jobs, which
12468
+ * meant anyone who saw the verification link could collect the token it led to.
12378
12469
  *
12379
12470
  * The CLI wraps these with terminal output, and `@valbuild/language-server`
12380
12471
  * wraps them with LSP `window/showDocument` and progress reporting. Neither the
@@ -12388,6 +12479,21 @@ const DEFAULT_LOGIN_HOST = "https://admin.val.build";
12388
12479
  function defaultHost() {
12389
12480
  return process.env.VAL_BUILD_URL || DEFAULT_LOGIN_HOST;
12390
12481
  }
12482
+
12483
+ /**
12484
+ * What gets shown on the approval screen so the person can tell which terminal
12485
+ * is asking. Self-reported and therefore a hint, not proof — the server treats
12486
+ * it as untrusted display text.
12487
+ */
12488
+ function defaultDeviceName() {
12489
+ try {
12490
+ return `${os.hostname()} (${os.platform()})`;
12491
+ } catch {
12492
+ // hostname() can throw on locked-down containers. A missing device name is
12493
+ // not worth failing a login over; the server renders "Unknown".
12494
+ return "";
12495
+ }
12496
+ }
12391
12497
  class ValLoginError extends Error {
12392
12498
  constructor(code, message, details) {
12393
12499
  super(message);
@@ -12397,20 +12503,24 @@ class ValLoginError extends Error {
12397
12503
  }
12398
12504
  }
12399
12505
 
12400
- /** A login attempt that is waiting for the user to confirm in a browser. */
12506
+ /** A login attempt that is waiting for the user to approve it in a browser. */
12401
12507
 
12402
12508
  /**
12403
- * Begin a login attempt. The caller is responsible for getting
12404
- * {@link ValLoginSession.url} in front of the user.
12509
+ * Begin a login attempt. The caller is responsible for getting the user code
12510
+ * and verification URL in front of the user.
12405
12511
  */
12406
12512
  async function startValLogin(options = {}) {
12407
12513
  var _response$headers$get;
12408
12514
  const host = options.host ?? defaultHost();
12515
+ const deviceName = options.deviceName ?? defaultDeviceName();
12409
12516
  const response = await fetch(`${host}/api/login`, {
12410
12517
  method: "POST",
12411
12518
  headers: {
12412
12519
  "Content-Type": "application/json"
12413
- }
12520
+ },
12521
+ body: JSON.stringify(deviceName ? {
12522
+ device_name: deviceName
12523
+ } : {})
12414
12524
  });
12415
12525
  if (response.status >= 500) {
12416
12526
  const text = await response.text().catch(() => "");
@@ -12421,42 +12531,63 @@ async function startValLogin(options = {}) {
12421
12531
  throw new ValLoginError("unexpected-content-type", "Unexpected failure while trying to login (content type was not JSON).", text ? `Server response: ${text} (status: ${response.status})` : `Status: ${response.status}`);
12422
12532
  }
12423
12533
  const json = await response.json();
12424
- const nonce = json === null || json === void 0 ? void 0 : json.nonce;
12425
- const url = json === null || json === void 0 ? void 0 : json.url;
12426
- if (typeof nonce !== "string" || typeof url !== "string") {
12427
- throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12534
+ const deviceCode = json === null || json === void 0 ? void 0 : json.device_code;
12535
+ const userCode = json === null || json === void 0 ? void 0 : json.user_code;
12536
+ const verificationUri = json === null || json === void 0 ? void 0 : json.verification_uri;
12537
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
12538
+ throw new ValLoginError("unexpected-response", "Unexpected response from the server. This version of Val may be too old for the login flow on this host — try updating @valbuild/cli.", JSON.stringify(json));
12428
12539
  }
12429
12540
  return {
12430
- nonce,
12431
- url
12541
+ deviceCode,
12542
+ userCode,
12543
+ verificationUri,
12544
+ verificationUriComplete: typeof (json === null || json === void 0 ? void 0 : json.verification_uri_complete) === "string" ? json.verification_uri_complete : verificationUri,
12545
+ expiresInSeconds: typeof (json === null || json === void 0 ? void 0 : json.expires_in) === "number" ? json.expires_in : DEFAULT_LOGIN_EXPIRES_IN_SECONDS,
12546
+ intervalSeconds: typeof (json === null || json === void 0 ? void 0 : json.interval) === "number" ? json.interval : DEFAULT_LOGIN_POLL_INTERVAL_SECONDS
12432
12547
  };
12433
12548
  }
12434
- const DEFAULT_LOGIN_MAX_DURATION = 5 * 60 * 1000; // 5 minutes
12435
- const DEFAULT_LOGIN_POLL_INTERVAL = 1000;
12549
+
12550
+ /** Fallbacks for a server that omits the optional RFC 8628 timing fields. */
12551
+ const DEFAULT_LOGIN_EXPIRES_IN_SECONDS = 600;
12552
+ const DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = 5;
12436
12553
 
12437
12554
  /**
12438
- * Poll until the user confirms the login in their browser.
12555
+ * How much to add to the poll interval when the server answers `slow_down`.
12556
+ * RFC 8628 section 3.5 specifies 5 seconds.
12557
+ */
12558
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
12559
+
12560
+ /**
12561
+ * Poll until the user approves the login in their browser.
12439
12562
  *
12440
12563
  * Accepts an `AbortSignal` so an editor can cancel the flow when the user
12441
- * dismisses the prompt, instead of leaving a poll loop running for 5 minutes.
12564
+ * dismisses the prompt, instead of leaving a poll loop running to expiry.
12442
12565
  */
12443
- async function awaitValLoginConfirmation(nonce, options = {}) {
12566
+ async function awaitValLoginConfirmation(authorization, options = {}) {
12444
12567
  const host = options.host ?? defaultHost();
12445
- const maxDuration = options.maxDurationMs ?? DEFAULT_LOGIN_MAX_DURATION;
12446
- const pollInterval = options.pollIntervalMs ?? DEFAULT_LOGIN_POLL_INTERVAL;
12568
+ const maxDuration = options.maxDurationMs ?? authorization.expiresInSeconds * 1000;
12447
12569
  const now = options.now ?? (() => Date.now());
12570
+ // Mutable: `slow_down` widens it as we go, and never narrows it again.
12571
+ let intervalMs = authorization.intervalSeconds * 1000;
12448
12572
  const start = now();
12449
12573
  while (now() - start < maxDuration) {
12450
12574
  var _options$signal, _options$signal2;
12451
12575
  if ((_options$signal = options.signal) !== null && _options$signal !== void 0 && _options$signal.aborted) {
12452
12576
  throw new ValLoginError("aborted", "Login was cancelled.");
12453
12577
  }
12454
- await new Promise(resolve => setTimeout(resolve, pollInterval));
12578
+ // Wait first: the user has not had time to approve anything yet.
12579
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
12455
12580
  if ((_options$signal2 = options.signal) !== null && _options$signal2 !== void 0 && _options$signal2.aborted) {
12456
12581
  throw new ValLoginError("aborted", "Login was cancelled.");
12457
12582
  }
12458
- const response = await fetch(`${host}/api/login?token=${nonce}&consume=true`, {
12459
- method: "POST"
12583
+ const response = await fetch(`${host}/api/login`, {
12584
+ method: "POST",
12585
+ headers: {
12586
+ "Content-Type": "application/json"
12587
+ },
12588
+ body: JSON.stringify({
12589
+ device_code: authorization.deviceCode
12590
+ })
12460
12591
  });
12461
12592
  if (response.status >= 500) {
12462
12593
  throw new ValLoginError("server-error", "An error occurred on the server.", `Status: ${response.status}`);
@@ -12474,6 +12605,23 @@ async function awaitValLoginConfirmation(nonce, options = {}) {
12474
12605
  }
12475
12606
  throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12476
12607
  }
12608
+ const json = await response.json().catch(() => null);
12609
+ const error = typeof (json === null || json === void 0 ? void 0 : json.error) === "string" ? json.error : null;
12610
+ const description = typeof (json === null || json === void 0 ? void 0 : json.error_description) === "string" ? json.error_description : undefined;
12611
+ if (error === "authorization_pending") {
12612
+ continue;
12613
+ }
12614
+ if (error === "slow_down" || response.status === 429) {
12615
+ intervalMs += SLOW_DOWN_INCREMENT_SECONDS * 1000;
12616
+ continue;
12617
+ }
12618
+ if (error === "access_denied") {
12619
+ throw new ValLoginError("access-denied", "The login was declined in the browser.", description);
12620
+ }
12621
+ if (error === "expired_token") {
12622
+ throw new ValLoginError("expired", "The login code expired before it was approved.", description);
12623
+ }
12624
+ throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
12477
12625
  }
12478
12626
  throw new ValLoginError("timeout", "Login confirmation timed out.");
12479
12627
  }
@@ -12775,4 +12923,4 @@ function readCapturedReport(snapshotDir) {
12775
12923
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
12776
12924
  }
12777
12925
 
12778
- export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
12926
+ export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.114.0",
19
+ "version": "0.115.0",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0"
@@ -29,8 +29,8 @@
29
29
  "typescript": "^6.0.3",
30
30
  "zod": "^4.4.3",
31
31
  "zod-validation-error": "^5.0.0",
32
- "@valbuild/core": "0.111.0",
33
32
  "@valbuild/shared": "0.114.0",
33
+ "@valbuild/core": "0.111.0",
34
34
  "@valbuild/ui": "0.114.0"
35
35
  },
36
36
  "engines": {