@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.
- package/dist/declarations/src/ValRouter.d.ts +26 -0
- package/dist/declarations/src/index.d.ts +2 -2
- package/dist/declarations/src/login.d.ts +43 -16
- package/dist/valbuild-server.cjs.dev.js +172 -24
- package/dist/valbuild-server.cjs.prod.js +172 -24
- package/dist/valbuild-server.esm.js +171 -23
- package/package.json +2 -2
|
@@ -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,
|
|
29
|
-
export type { ValLoginErrorCode, ValLoginResult,
|
|
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
|
|
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
|
|
29
|
-
export type
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
81
|
+
* dismisses the prompt, instead of leaving a poll loop running to expiry.
|
|
55
82
|
*/
|
|
56
|
-
export declare function awaitValLoginConfirmation(
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
12459
|
-
const
|
|
12460
|
-
|
|
12461
|
-
|
|
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
|
-
|
|
12465
|
-
|
|
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
|
-
|
|
12469
|
-
|
|
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
|
-
*
|
|
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
|
|
12598
|
+
* dismisses the prompt, instead of leaving a poll loop running to expiry.
|
|
12476
12599
|
*/
|
|
12477
|
-
async function awaitValLoginConfirmation(
|
|
12600
|
+
async function awaitValLoginConfirmation(authorization, options = {}) {
|
|
12478
12601
|
const host = options.host ?? defaultHost();
|
|
12479
|
-
const maxDuration = options.maxDurationMs ??
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
12459
|
-
const
|
|
12460
|
-
|
|
12461
|
-
|
|
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
|
-
|
|
12465
|
-
|
|
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
|
-
|
|
12469
|
-
|
|
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
|
-
*
|
|
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
|
|
12598
|
+
* dismisses the prompt, instead of leaving a poll loop running to expiry.
|
|
12476
12599
|
*/
|
|
12477
|
-
async function awaitValLoginConfirmation(
|
|
12600
|
+
async function awaitValLoginConfirmation(authorization, options = {}) {
|
|
12478
12601
|
const host = options.host ?? defaultHost();
|
|
12479
|
-
const maxDuration = options.maxDurationMs ??
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
12425
|
-
const
|
|
12426
|
-
|
|
12427
|
-
|
|
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
|
-
|
|
12431
|
-
|
|
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
|
-
|
|
12435
|
-
|
|
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
|
-
*
|
|
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
|
|
12564
|
+
* dismisses the prompt, instead of leaving a poll loop running to expiry.
|
|
12442
12565
|
*/
|
|
12443
|
-
async function awaitValLoginConfirmation(
|
|
12566
|
+
async function awaitValLoginConfirmation(authorization, options = {}) {
|
|
12444
12567
|
const host = options.host ?? defaultHost();
|
|
12445
|
-
const maxDuration = options.maxDurationMs ??
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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.
|
|
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": {
|