@valbuild/server 0.114.0 → 0.116.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/getValidationErrorFileRef.d.ts +1 -1
- package/dist/declarations/src/index.d.ts +5 -2
- package/dist/declarations/src/login.d.ts +43 -16
- package/dist/declarations/src/patchStore.d.ts +6 -6
- package/dist/declarations/src/tools/createValTools.d.ts +17 -0
- package/dist/declarations/src/tools/index.d.ts +2 -0
- package/dist/declarations/src/tools/types.d.ts +116 -0
- package/dist/declarations/src/valServerConfig.d.ts +81 -0
- package/dist/valbuild-server.cjs.dev.js +1475 -139
- package/dist/valbuild-server.cjs.prod.js +1475 -139
- package/dist/valbuild-server.esm.js +1473 -140
- package/package.json +3 -3
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { pipe, result, array } from '@valbuild/core/fp';
|
|
3
3
|
import { PatchError, deepEqual, parseAndValidateArrayIndex, isNotRoot, applyPatch, JSONOps, deepClone, sourceToPatchPath } from '@valbuild/core/patch';
|
|
4
|
-
import { derefPatch, Internal, RecordSchema, extractValModules, computeValModuleShas, VAL_EXTENSION, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema } from '@valbuild/core';
|
|
4
|
+
import { derefPatch, Internal, RecordSchema, extractValModules, computeValModuleShas, VAL_EXTENSION, ImageSchema, DEFAULT_CONTENT_HOST, hasRemoteFileSchema, getSourcePathFromRoute } from '@valbuild/core';
|
|
5
5
|
export { hasRemoteFileSchema } from '@valbuild/core';
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import path__default from 'path';
|
|
8
8
|
import fs, { promises } from 'fs';
|
|
9
9
|
import vm from 'node:vm';
|
|
10
10
|
import { Module, createRequire } from 'node:module';
|
|
11
|
-
import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
|
|
11
|
+
import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
|
|
12
12
|
import { createUIRequestHandler } from '@valbuild/ui/server';
|
|
13
13
|
import crypto$1 from 'crypto';
|
|
14
14
|
import z$1, { z } from 'zod';
|
|
15
15
|
import sizeOf from 'image-size';
|
|
16
16
|
import { fromError } from 'zod-validation-error';
|
|
17
17
|
import os from 'os';
|
|
18
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
18
19
|
import { transform } from 'sucrase';
|
|
19
20
|
import http from 'http';
|
|
20
21
|
import https from 'https';
|
|
@@ -1006,7 +1007,7 @@ function errorMessage(e) {
|
|
|
1006
1007
|
return String(e);
|
|
1007
1008
|
}
|
|
1008
1009
|
|
|
1009
|
-
const jsonOps$
|
|
1010
|
+
const jsonOps$3 = new JSONOps();
|
|
1010
1011
|
|
|
1011
1012
|
/**
|
|
1012
1013
|
* Classification of a single patch op against a module's serialized schema,
|
|
@@ -1261,7 +1262,7 @@ function applyJsonValuesEntryPatches(args) {
|
|
|
1261
1262
|
// on an entry that does not exist.
|
|
1262
1263
|
continue;
|
|
1263
1264
|
}
|
|
1264
|
-
const applied = applyPatch(deepClone(content), jsonOps$
|
|
1265
|
+
const applied = applyPatch(deepClone(content), jsonOps$3, [{
|
|
1265
1266
|
op: "add",
|
|
1266
1267
|
path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
|
|
1267
1268
|
value: patchId
|
|
@@ -1315,7 +1316,7 @@ function applyJsonValuesEntryPatches(args) {
|
|
|
1315
1316
|
patchId
|
|
1316
1317
|
};
|
|
1317
1318
|
}
|
|
1318
|
-
const applied = applyPatch(deepClone(content), jsonOps$
|
|
1319
|
+
const applied = applyPatch(deepClone(content), jsonOps$3, [rebased.value]);
|
|
1319
1320
|
if (result.isErr(applied)) {
|
|
1320
1321
|
return {
|
|
1321
1322
|
kind: "error",
|
|
@@ -1598,7 +1599,7 @@ function findJsonEntryFilePath(moduleFilePath, valTsSourceFile, entryKey) {
|
|
|
1598
1599
|
return resolveExistingJsonPath(moduleFilePath, entry.importPath);
|
|
1599
1600
|
}
|
|
1600
1601
|
|
|
1601
|
-
const jsonOps$
|
|
1602
|
+
const jsonOps$2 = new JSONOps();
|
|
1602
1603
|
|
|
1603
1604
|
/**
|
|
1604
1605
|
* Substitutes loaded `.jsonValues()` entry content back into a module's root
|
|
@@ -1889,7 +1890,7 @@ class Service {
|
|
|
1889
1890
|
if (result.isErr(rebased)) {
|
|
1890
1891
|
throw Error(`Could not apply ${op.op} to jsonValues entry '${entryKey}' of ${moduleFilePath}: ${rebased.error.message}`);
|
|
1891
1892
|
}
|
|
1892
|
-
const applied = applyPatch(deepClone(content), jsonOps$
|
|
1893
|
+
const applied = applyPatch(deepClone(content), jsonOps$2, [rebased.value]);
|
|
1893
1894
|
if (result.isErr(applied)) {
|
|
1894
1895
|
throw Error(`Could not apply ${op.op} to ${jsonPath}: ${applied.error.message}`);
|
|
1895
1896
|
}
|
|
@@ -2058,7 +2059,7 @@ function encodeJwt(payload, sessionKey) {
|
|
|
2058
2059
|
}
|
|
2059
2060
|
|
|
2060
2061
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2061
|
-
const jsonOps = new JSONOps();
|
|
2062
|
+
const jsonOps$1 = new JSONOps();
|
|
2062
2063
|
const tsOps = new TSOps(document => {
|
|
2063
2064
|
return pipe(analyzeValModule(document), result.map(({
|
|
2064
2065
|
source
|
|
@@ -2827,7 +2828,7 @@ class ValOps {
|
|
|
2827
2828
|
}
|
|
2828
2829
|
const patchRes = applyPatch(deepClone(patchedSources[path]),
|
|
2829
2830
|
// applyPatch mutates the source. On add operations it adds more than once? There is something strange going on... deepClone seems to fix, but is that the right solution?
|
|
2830
|
-
jsonOps, applicableOps.concat(...Object.values(fileFixOps)));
|
|
2831
|
+
jsonOps$1, applicableOps.concat(...Object.values(fileFixOps)));
|
|
2831
2832
|
if (result.isErr(patchRes)) {
|
|
2832
2833
|
console.error("Could not apply patch", JSON.stringify({
|
|
2833
2834
|
path,
|
|
@@ -3527,7 +3528,7 @@ class ValOps {
|
|
|
3527
3528
|
patchHadError = true;
|
|
3528
3529
|
break;
|
|
3529
3530
|
}
|
|
3530
|
-
const applied = applyPatch(deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
|
|
3531
|
+
const applied = applyPatch(deepClone(contentRes.value), jsonOps$1, [rebasedRes.value]);
|
|
3531
3532
|
if (result.isErr(applied)) {
|
|
3532
3533
|
collectPatchError(applied.error, patchId, op);
|
|
3533
3534
|
patchHadError = true;
|
|
@@ -7433,6 +7434,227 @@ async function getSettings(projectName, auth) {
|
|
|
7433
7434
|
}
|
|
7434
7435
|
}
|
|
7435
7436
|
|
|
7437
|
+
/**
|
|
7438
|
+
* Resolving how Val is configured, and building the data layer from it.
|
|
7439
|
+
*
|
|
7440
|
+
* Both live here rather than inside `createValApiRouter` because the MCP tool
|
|
7441
|
+
* registry needs exactly the same answers: which mode we are in, which
|
|
7442
|
+
* credential to use, and which `ValOps` implementation that implies. Two copies
|
|
7443
|
+
* of this would drift, and the failure would be quiet — a registry that decides
|
|
7444
|
+
* it is in fs mode while the Studio decides it is in proxy mode reads different
|
|
7445
|
+
* content from the same project.
|
|
7446
|
+
*
|
|
7447
|
+
* The type-only import from `./ValRouter` is deliberate: it keeps `ValApiOptions`
|
|
7448
|
+
* where its documentation lives without creating a runtime cycle.
|
|
7449
|
+
*
|
|
7450
|
+
* The credential-bearing URL check at the bottom of this file lives here for the
|
|
7451
|
+
* same reason: it is a check on the URLs `initHandlerOptions` resolves, and its
|
|
7452
|
+
* only caller is that function. Leaving it in `./ValRouter` would have meant
|
|
7453
|
+
* importing it back from there, which is the runtime cycle the paragraph above
|
|
7454
|
+
* exists to avoid.
|
|
7455
|
+
*/
|
|
7456
|
+
|
|
7457
|
+
const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
|
|
7458
|
+
|
|
7459
|
+
/**
|
|
7460
|
+
* Resolve options plus environment into a concrete {@link ValServerConfig}.
|
|
7461
|
+
*
|
|
7462
|
+
* Moved verbatim out of `createValApiRouter`; the precedence rules are load
|
|
7463
|
+
* bearing, so this is the one place they are written down. Note that "proxy"
|
|
7464
|
+
* mode is inferred when `VAL_API_KEY` or `VAL_SECRET` is present and no mode was
|
|
7465
|
+
* given, which is why a project can be pushed into proxy mode by setting an env
|
|
7466
|
+
* var alone.
|
|
7467
|
+
*/
|
|
7468
|
+
async function initHandlerOptions(route, opts, config) {
|
|
7469
|
+
const maybeApiKey = opts.apiKey || process.env.VAL_API_KEY;
|
|
7470
|
+
const maybeValSecret = opts.valSecret || process.env.VAL_SECRET;
|
|
7471
|
+
const isProxyMode = opts.mode === "proxy" || opts.mode === undefined && (maybeApiKey || maybeValSecret);
|
|
7472
|
+
const valEnableRedirectUrl = opts.valEnableRedirectUrl || process.env.VAL_ENABLE_REDIRECT_URL;
|
|
7473
|
+
const valDisableRedirectUrl = opts.valDisableRedirectUrl || process.env.VAL_DISABLE_REDIRECT_URL;
|
|
7474
|
+
const maybeValProject = opts.project || process.env.VAL_PROJECT;
|
|
7475
|
+
const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || DEFAULT_VAL_BUILD_URL;
|
|
7476
|
+
const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
|
|
7477
|
+
warnIfInsecureUrls({
|
|
7478
|
+
valBuildUrl,
|
|
7479
|
+
valContentUrl
|
|
7480
|
+
});
|
|
7481
|
+
if (isProxyMode) {
|
|
7482
|
+
var _opts$versions, _opts$versions2;
|
|
7483
|
+
if (!maybeApiKey || !maybeValSecret) {
|
|
7484
|
+
throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode");
|
|
7485
|
+
}
|
|
7486
|
+
const maybeGitCommit = opts.gitCommit || process.env.VAL_GIT_COMMIT;
|
|
7487
|
+
if (!maybeGitCommit) {
|
|
7488
|
+
throw new Error("VAL_GIT_COMMIT env var must be set in proxy mode");
|
|
7489
|
+
}
|
|
7490
|
+
const maybeGitBranch = opts.gitBranch || process.env.VAL_GIT_BRANCH;
|
|
7491
|
+
if (!maybeGitBranch) {
|
|
7492
|
+
throw new Error("VAL_GIT_BRANCH env var must be set in proxy mode");
|
|
7493
|
+
}
|
|
7494
|
+
if (!maybeValProject) {
|
|
7495
|
+
throw new Error("Proxy mode does not work unless the 'project' option in val.config is defined or the VAL_PROJECT env var is set.");
|
|
7496
|
+
}
|
|
7497
|
+
const coreVersion = (_opts$versions = opts.versions) === null || _opts$versions === void 0 ? void 0 : _opts$versions.core;
|
|
7498
|
+
if (!coreVersion) {
|
|
7499
|
+
throw new Error("Could not determine version of @valbuild/core");
|
|
7500
|
+
}
|
|
7501
|
+
const nextVersion = (_opts$versions2 = opts.versions) === null || _opts$versions2 === void 0 ? void 0 : _opts$versions2.next;
|
|
7502
|
+
if (!nextVersion) {
|
|
7503
|
+
throw new Error("Could not determine version of @valbuild/next");
|
|
7504
|
+
}
|
|
7505
|
+
return {
|
|
7506
|
+
mode: "http",
|
|
7507
|
+
route,
|
|
7508
|
+
apiKey: maybeApiKey,
|
|
7509
|
+
valSecret: maybeValSecret,
|
|
7510
|
+
commit: maybeGitCommit,
|
|
7511
|
+
branch: maybeGitBranch,
|
|
7512
|
+
root: opts.root,
|
|
7513
|
+
project: maybeValProject,
|
|
7514
|
+
valEnableRedirectUrl,
|
|
7515
|
+
valDisableRedirectUrl,
|
|
7516
|
+
valContentUrl,
|
|
7517
|
+
valBuildUrl,
|
|
7518
|
+
config
|
|
7519
|
+
};
|
|
7520
|
+
} else {
|
|
7521
|
+
const cwd = process.cwd();
|
|
7522
|
+
const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || DEFAULT_VAL_BUILD_URL;
|
|
7523
|
+
return {
|
|
7524
|
+
mode: "fs",
|
|
7525
|
+
cwd,
|
|
7526
|
+
route,
|
|
7527
|
+
valDisableRedirectUrl,
|
|
7528
|
+
valEnableRedirectUrl,
|
|
7529
|
+
valBuildUrl,
|
|
7530
|
+
valContentUrl,
|
|
7531
|
+
apiKey: maybeApiKey,
|
|
7532
|
+
valSecret: maybeValSecret,
|
|
7533
|
+
project: maybeValProject,
|
|
7534
|
+
config
|
|
7535
|
+
};
|
|
7536
|
+
}
|
|
7537
|
+
}
|
|
7538
|
+
|
|
7539
|
+
/**
|
|
7540
|
+
* Build the data layer a {@link ValServerConfig} calls for.
|
|
7541
|
+
*
|
|
7542
|
+
* `auth` decides *whose* credential the http backend sees. Left out, it is the
|
|
7543
|
+
* app's own API key — which is what the Studio wants, because there the app has
|
|
7544
|
+
* already verified a session cookie and is acting on the user's behalf under its
|
|
7545
|
+
* own authority.
|
|
7546
|
+
*
|
|
7547
|
+
* A caller acting for a user it has *not* authenticated itself must pass that
|
|
7548
|
+
* user's personal access token instead, so the backend is the one that decides
|
|
7549
|
+
* what the caller may do. That is the whole of `docs/plans/mcp.md` D.2: the app
|
|
7550
|
+
* stops being an authority and goes back to being a pipe. Passing the app's API
|
|
7551
|
+
* key on such a request is the D.6 confused deputy, and it is worth being blunt
|
|
7552
|
+
* about why it is tempting — it works, and it works for every project the key
|
|
7553
|
+
* can reach, including the ones the caller cannot.
|
|
7554
|
+
*/
|
|
7555
|
+
function createValOps(valModules, options, auth) {
|
|
7556
|
+
if (options.mode === "fs") {
|
|
7557
|
+
// No credential in fs mode: this reads and writes the developer's own
|
|
7558
|
+
// working tree, and there is no backend to authenticate to. A PAT handed in
|
|
7559
|
+
// here is not ignored quietly — the caller is told, in createValTools.
|
|
7560
|
+
return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
|
|
7561
|
+
formatter: options.formatter,
|
|
7562
|
+
config: options.config
|
|
7563
|
+
});
|
|
7564
|
+
}
|
|
7565
|
+
if (options.mode === "http") {
|
|
7566
|
+
return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, auth ?? {
|
|
7567
|
+
apiKey: options.apiKey
|
|
7568
|
+
}, valModules, {
|
|
7569
|
+
formatter: options.formatter,
|
|
7570
|
+
root: options.root,
|
|
7571
|
+
config: options.config
|
|
7572
|
+
});
|
|
7573
|
+
}
|
|
7574
|
+
throw new Error(
|
|
7575
|
+
// The union is exhausted above; this catches a config that came from
|
|
7576
|
+
// somewhere untyped.
|
|
7577
|
+
"Invalid mode: " + (options === null || options === void 0 ? void 0 : options.mode));
|
|
7578
|
+
}
|
|
7579
|
+
|
|
7580
|
+
/**
|
|
7581
|
+
* Hosts we send credentials to, and what each one puts at risk. They differ:
|
|
7582
|
+
* only `valBuildUrl` hands back the app token that becomes the session cookie,
|
|
7583
|
+
* so a single shared sentence would overstate one and understate the other.
|
|
7584
|
+
*/
|
|
7585
|
+
|
|
7586
|
+
const CREDENTIAL_BEARING_URLS = ["valBuildUrl", "valContentUrl"];
|
|
7587
|
+
const WHAT_IS_AT_RISK = {
|
|
7588
|
+
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.",
|
|
7589
|
+
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."
|
|
7590
|
+
};
|
|
7591
|
+
|
|
7592
|
+
// NOTE: `URL.hostname` keeps the brackets on an IPv6 literal, so this is
|
|
7593
|
+
// "[::1]" and not "::1" - and `http://[0:0:0:0:0:0:0:1]` normalises to the
|
|
7594
|
+
// same short form before it gets here. Dropping the brackets looks like a
|
|
7595
|
+
// tidy-up and silently stops matching IPv6 loopback.
|
|
7596
|
+
const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
|
|
7597
|
+
|
|
7598
|
+
/**
|
|
7599
|
+
* The URL as it is safe to print. `http://user:pass@host` is a legal override,
|
|
7600
|
+
* and a warning about credential exposure that puts the password in the log
|
|
7601
|
+
* would be the very thing it is warning about.
|
|
7602
|
+
*/
|
|
7603
|
+
function forLog(parsed) {
|
|
7604
|
+
if (!parsed.username && !parsed.password) {
|
|
7605
|
+
return parsed.href;
|
|
7606
|
+
}
|
|
7607
|
+
const redacted = new URL(parsed.href);
|
|
7608
|
+
redacted.username = "";
|
|
7609
|
+
redacted.password = "";
|
|
7610
|
+
return `${redacted.href} (credentials redacted)`;
|
|
7611
|
+
}
|
|
7612
|
+
|
|
7613
|
+
/**
|
|
7614
|
+
* Returns a warning if `url` would send credentials somewhere they can be read
|
|
7615
|
+
* off the wire, or null if it is fine.
|
|
7616
|
+
*
|
|
7617
|
+
* Both URLs default to https, but each is overridable - `opts.valBuildUrl` /
|
|
7618
|
+
* `VAL_BUILD_URL`, `opts.valContentUrl` / `VAL_CONTENT_URL` - and neither
|
|
7619
|
+
* override has ever been scheme-checked. Point one at a plain http host and the
|
|
7620
|
+
* api key goes out in clear text, and whatever comes back is whatever the
|
|
7621
|
+
* network says: for `valBuildUrl` that includes the app token this server
|
|
7622
|
+
* re-signs into the session cookie.
|
|
7623
|
+
*
|
|
7624
|
+
* Loopback over http is exempt: that is a val.build running on the developer's
|
|
7625
|
+
* own machine, and there is no network to be on the wrong side of.
|
|
7626
|
+
*
|
|
7627
|
+
* This warns rather than throws. Both overrides are set by the operator, not by
|
|
7628
|
+
* an attacker, so this is a misconfiguration to surface - not untrusted input to
|
|
7629
|
+
* reject - and refusing to boot would break anyone deliberately pointing at an
|
|
7630
|
+
* internal http host today.
|
|
7631
|
+
*/
|
|
7632
|
+
function insecureUrlWarning(name, url) {
|
|
7633
|
+
let parsed;
|
|
7634
|
+
try {
|
|
7635
|
+
parsed = new URL(url);
|
|
7636
|
+
} catch {
|
|
7637
|
+
// NOTE: the URL is not echoed here. It did not parse, so there is nothing
|
|
7638
|
+
// to redact with, and an unparseable string can still hold a password.
|
|
7639
|
+
return `Val: ${name} is not a valid URL.`;
|
|
7640
|
+
}
|
|
7641
|
+
if (parsed.protocol === "https:") {
|
|
7642
|
+
return null;
|
|
7643
|
+
}
|
|
7644
|
+
if (parsed.protocol === "http:" && (LOOPBACK_HOSTNAMES.includes(parsed.hostname) || parsed.hostname.endsWith(".localhost"))) {
|
|
7645
|
+
return null;
|
|
7646
|
+
}
|
|
7647
|
+
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.`;
|
|
7648
|
+
}
|
|
7649
|
+
function warnIfInsecureUrls(urls) {
|
|
7650
|
+
for (const name of CREDENTIAL_BEARING_URLS) {
|
|
7651
|
+
const warning = insecureUrlWarning(name, urls[name]);
|
|
7652
|
+
if (warning) {
|
|
7653
|
+
console.warn(warning);
|
|
7654
|
+
}
|
|
7655
|
+
}
|
|
7656
|
+
}
|
|
7657
|
+
|
|
7436
7658
|
function getPersonalAccessTokenPath(root) {
|
|
7437
7659
|
return path__default.join(path__default.resolve(root), ".val", "pat.json");
|
|
7438
7660
|
}
|
|
@@ -7506,24 +7728,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7506
7728
|
}).nullable()
|
|
7507
7729
|
}))
|
|
7508
7730
|
});
|
|
7509
|
-
|
|
7510
|
-
if (options.mode === "fs") {
|
|
7511
|
-
serverOps = new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
|
|
7512
|
-
formatter: options.formatter,
|
|
7513
|
-
config: options.config
|
|
7514
|
-
});
|
|
7515
|
-
} else if (options.mode === "http") {
|
|
7516
|
-
serverOps = new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
|
|
7517
|
-
apiKey: options.apiKey
|
|
7518
|
-
}, valModules, {
|
|
7519
|
-
formatter: options.formatter,
|
|
7520
|
-
root: options.root,
|
|
7521
|
-
config: options.config
|
|
7522
|
-
});
|
|
7523
|
-
} else {
|
|
7524
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7525
|
-
throw new Error("Invalid mode: " + (options === null || options === void 0 ? void 0 : options.mode));
|
|
7526
|
-
}
|
|
7731
|
+
const serverOps = createValOps(valModules, options);
|
|
7527
7732
|
const getAuthorizeUrl = (publicValApiRe, token) => {
|
|
7528
7733
|
if (!options.project) {
|
|
7529
7734
|
throw new Error("Project is not set");
|
|
@@ -10304,72 +10509,6 @@ async function createValServer(valModules, route, opts, config, callbacks, forma
|
|
|
10304
10509
|
...valServerConfig
|
|
10305
10510
|
}, callbacks);
|
|
10306
10511
|
}
|
|
10307
|
-
async function initHandlerOptions(route, opts, config) {
|
|
10308
|
-
const maybeApiKey = opts.apiKey || process.env.VAL_API_KEY;
|
|
10309
|
-
const maybeValSecret = opts.valSecret || process.env.VAL_SECRET;
|
|
10310
|
-
const isProxyMode = opts.mode === "proxy" || opts.mode === undefined && (maybeApiKey || maybeValSecret);
|
|
10311
|
-
const valEnableRedirectUrl = opts.valEnableRedirectUrl || process.env.VAL_ENABLE_REDIRECT_URL;
|
|
10312
|
-
const valDisableRedirectUrl = opts.valDisableRedirectUrl || process.env.VAL_DISABLE_REDIRECT_URL;
|
|
10313
|
-
const maybeValProject = opts.project || process.env.VAL_PROJECT;
|
|
10314
|
-
const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
|
|
10315
|
-
const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
|
|
10316
|
-
if (isProxyMode) {
|
|
10317
|
-
var _opts$versions, _opts$versions2;
|
|
10318
|
-
if (!maybeApiKey || !maybeValSecret) {
|
|
10319
|
-
throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode");
|
|
10320
|
-
}
|
|
10321
|
-
const maybeGitCommit = opts.gitCommit || process.env.VAL_GIT_COMMIT;
|
|
10322
|
-
if (!maybeGitCommit) {
|
|
10323
|
-
throw new Error("VAL_GIT_COMMIT env var must be set in proxy mode");
|
|
10324
|
-
}
|
|
10325
|
-
const maybeGitBranch = opts.gitBranch || process.env.VAL_GIT_BRANCH;
|
|
10326
|
-
if (!maybeGitBranch) {
|
|
10327
|
-
throw new Error("VAL_GIT_BRANCH env var must be set in proxy mode");
|
|
10328
|
-
}
|
|
10329
|
-
if (!maybeValProject) {
|
|
10330
|
-
throw new Error("Proxy mode does not work unless the 'project' option in val.config is defined or the VAL_PROJECT env var is set.");
|
|
10331
|
-
}
|
|
10332
|
-
const coreVersion = (_opts$versions = opts.versions) === null || _opts$versions === void 0 ? void 0 : _opts$versions.core;
|
|
10333
|
-
if (!coreVersion) {
|
|
10334
|
-
throw new Error("Could not determine version of @valbuild/core");
|
|
10335
|
-
}
|
|
10336
|
-
const nextVersion = (_opts$versions2 = opts.versions) === null || _opts$versions2 === void 0 ? void 0 : _opts$versions2.next;
|
|
10337
|
-
if (!nextVersion) {
|
|
10338
|
-
throw new Error("Could not determine version of @valbuild/next");
|
|
10339
|
-
}
|
|
10340
|
-
return {
|
|
10341
|
-
mode: "http",
|
|
10342
|
-
route,
|
|
10343
|
-
apiKey: maybeApiKey,
|
|
10344
|
-
valSecret: maybeValSecret,
|
|
10345
|
-
commit: maybeGitCommit,
|
|
10346
|
-
branch: maybeGitBranch,
|
|
10347
|
-
root: opts.root,
|
|
10348
|
-
project: maybeValProject,
|
|
10349
|
-
valEnableRedirectUrl,
|
|
10350
|
-
valDisableRedirectUrl,
|
|
10351
|
-
valContentUrl,
|
|
10352
|
-
valBuildUrl,
|
|
10353
|
-
config
|
|
10354
|
-
};
|
|
10355
|
-
} else {
|
|
10356
|
-
const cwd = process.cwd();
|
|
10357
|
-
const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
|
|
10358
|
-
return {
|
|
10359
|
-
mode: "fs",
|
|
10360
|
-
cwd,
|
|
10361
|
-
route,
|
|
10362
|
-
valDisableRedirectUrl,
|
|
10363
|
-
valEnableRedirectUrl,
|
|
10364
|
-
valBuildUrl,
|
|
10365
|
-
valContentUrl,
|
|
10366
|
-
apiKey: maybeApiKey,
|
|
10367
|
-
valSecret: maybeValSecret,
|
|
10368
|
-
project: maybeValProject,
|
|
10369
|
-
config
|
|
10370
|
-
};
|
|
10371
|
-
}
|
|
10372
|
-
}
|
|
10373
10512
|
|
|
10374
10513
|
// TODO: remove
|
|
10375
10514
|
async function safeReadGit(cwd) {
|
|
@@ -10663,32 +10802,1159 @@ function getCookies(req, cookiesDef) {
|
|
|
10663
10802
|
return z.object(cookiesDef).safeParse(input);
|
|
10664
10803
|
}
|
|
10665
10804
|
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
10805
|
+
/**
|
|
10806
|
+
* How a tool is written, and what it is handed.
|
|
10807
|
+
*
|
|
10808
|
+
* Tools are defined with {@link defineTool} so that the handler's `args` are
|
|
10809
|
+
* inferred from the tool's own `inputSchema`. Without that the array of tools
|
|
10810
|
+
* would have to be typed at its widest and every handler would start by
|
|
10811
|
+
* re-narrowing `unknown`, which is exactly where a tool and its schema drift
|
|
10812
|
+
* apart unnoticed.
|
|
10813
|
+
*/
|
|
10671
10814
|
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
|
|
10675
|
-
|
|
10676
|
-
|
|
10677
|
-
|
|
10678
|
-
|
|
10679
|
-
|
|
10680
|
-
|
|
10681
|
-
|
|
10682
|
-
|
|
10683
|
-
|
|
10684
|
-
|
|
10685
|
-
|
|
10686
|
-
|
|
10687
|
-
|
|
10688
|
-
|
|
10689
|
-
|
|
10815
|
+
/** Everything a tool is allowed to reach. Deliberately narrow. */
|
|
10816
|
+
|
|
10817
|
+
/**
|
|
10818
|
+
* Declare a tool, binding its handler to its input schema.
|
|
10819
|
+
*
|
|
10820
|
+
* The handler receives already-parsed arguments: the registry validates against
|
|
10821
|
+
* `inputSchema` before calling, so a handler never sees input its schema would
|
|
10822
|
+
* have rejected.
|
|
10823
|
+
*/
|
|
10824
|
+
function defineTool(definition, handler) {
|
|
10825
|
+
return {
|
|
10826
|
+
...definition,
|
|
10827
|
+
handler: (args, deps) => handler(args, deps)
|
|
10828
|
+
};
|
|
10829
|
+
}
|
|
10830
|
+
function ok(data) {
|
|
10831
|
+
return {
|
|
10832
|
+
status: "ok",
|
|
10833
|
+
data
|
|
10834
|
+
};
|
|
10835
|
+
}
|
|
10836
|
+
function err(code, message) {
|
|
10837
|
+
return {
|
|
10838
|
+
status: "error",
|
|
10839
|
+
code,
|
|
10840
|
+
message
|
|
10841
|
+
};
|
|
10842
|
+
}
|
|
10843
|
+
|
|
10844
|
+
/**
|
|
10845
|
+
* The tools that only read.
|
|
10846
|
+
*
|
|
10847
|
+
* Names match the Studio's chat tools exactly. MCP clients namespace by server,
|
|
10848
|
+
* so there is no `val_` prefix to add, and keeping the names identical means
|
|
10849
|
+
* converging the two definitions later is a move rather than a rename.
|
|
10850
|
+
*
|
|
10851
|
+
* All of these read from `deps.state`, which already has pending patches
|
|
10852
|
+
* applied — an agent should see the content as the Studio would show it, not the
|
|
10853
|
+
* last published version.
|
|
10854
|
+
*/
|
|
10855
|
+
|
|
10856
|
+
const ModuleFilePathSchema$1 = z.string().describe('Path of the Val module, e.g. "/content/pages.val.ts". Use get_all_schema to discover these.');
|
|
10857
|
+
function readTools() {
|
|
10858
|
+
return [defineTool({
|
|
10859
|
+
name: "get_all_schema",
|
|
10860
|
+
title: "Get all schemas",
|
|
10861
|
+
description: "List every Val module in the project and its schema. Start here: the module paths this returns are what every other tool takes.",
|
|
10862
|
+
inputSchema: z.object({}),
|
|
10863
|
+
annotations: {
|
|
10864
|
+
readOnlyHint: true,
|
|
10865
|
+
idempotentHint: true
|
|
10866
|
+
}
|
|
10867
|
+
}, async (_args, {
|
|
10868
|
+
state
|
|
10869
|
+
}) => ok(state.serializedSchemas)), defineTool({
|
|
10870
|
+
name: "get_source",
|
|
10871
|
+
title: "Get source",
|
|
10872
|
+
description: "Read the content of one Val module, with any unpublished changes already applied.",
|
|
10873
|
+
inputSchema: z.object({
|
|
10874
|
+
moduleFilePath: ModuleFilePathSchema$1
|
|
10875
|
+
}),
|
|
10876
|
+
annotations: {
|
|
10877
|
+
readOnlyHint: true,
|
|
10878
|
+
idempotentHint: true
|
|
10879
|
+
}
|
|
10880
|
+
}, async ({
|
|
10881
|
+
moduleFilePath
|
|
10882
|
+
}, {
|
|
10883
|
+
state
|
|
10884
|
+
}) => {
|
|
10885
|
+
const path = moduleFilePath;
|
|
10886
|
+
if (!(path in state.serializedSchemas)) {
|
|
10887
|
+
return err("not-found", unknownModuleMessage(path, state));
|
|
10888
|
+
}
|
|
10889
|
+
const source = state.sources[path];
|
|
10890
|
+
return ok(source === undefined ? null : source);
|
|
10891
|
+
}), defineTool({
|
|
10892
|
+
name: "get_record_keys",
|
|
10893
|
+
title: "Get record keys",
|
|
10894
|
+
description: "List the keys of the record or object at a path inside a module, a page at a time. Use this to enumerate entries without reading their contents — and before adding one, so you do not collide with an existing key. Fails on arrays, galleries, richtext and primitives: use count_entries for an array or richtext length, and get_source to read a gallery.",
|
|
10895
|
+
inputSchema: z.object({
|
|
10896
|
+
moduleFilePath: ModuleFilePathSchema$1,
|
|
10897
|
+
path: z.array(z.string()).default([]).describe("Path within the module to the record or object. Empty means the module root."),
|
|
10898
|
+
// Clamped by the schema rather than in the handler: a negative offset
|
|
10899
|
+
// makes `slice` read from the END and a negative limit makes it drop
|
|
10900
|
+
// the last N, so either would return a window that is not the page
|
|
10901
|
+
// asked for while `total` alongside implied it was.
|
|
10902
|
+
limit: z.number().int().min(1).default(100).describe("Maximum number of keys to return."),
|
|
10903
|
+
offset: z.number().int().min(0).default(0).describe("Number of keys to skip, for paging.")
|
|
10904
|
+
}),
|
|
10905
|
+
annotations: {
|
|
10906
|
+
readOnlyHint: true,
|
|
10907
|
+
idempotentHint: true
|
|
10690
10908
|
}
|
|
10691
|
-
},
|
|
10909
|
+
}, async ({
|
|
10910
|
+
moduleFilePath,
|
|
10911
|
+
path,
|
|
10912
|
+
limit,
|
|
10913
|
+
offset
|
|
10914
|
+
}, {
|
|
10915
|
+
state
|
|
10916
|
+
}) => {
|
|
10917
|
+
const described = describeContainer(state, moduleFilePath, path);
|
|
10918
|
+
if (described.kind !== "ok") {
|
|
10919
|
+
return described.result;
|
|
10920
|
+
}
|
|
10921
|
+
const {
|
|
10922
|
+
container,
|
|
10923
|
+
value
|
|
10924
|
+
} = described;
|
|
10925
|
+
// Records and objects only, matching the Studio's tool of the same name.
|
|
10926
|
+
// A gallery's keys are file paths whose bytes live elsewhere, and
|
|
10927
|
+
// richtext blocks are positional — neither is a key set to hand back.
|
|
10928
|
+
if (container !== "record" && container !== "object" || !isPlainObject(value)) {
|
|
10929
|
+
return err("invalid-args", `The value at that path is ${article(container)} ${container}. get_record_keys only works on a record or an object — ${container === "array" ? "use count_entries for the array length" : container === "richtext" ? "use count_entries for the number of blocks" : "use get_source to read the gallery's entries"}.`);
|
|
10930
|
+
}
|
|
10931
|
+
const keys = Object.keys(value);
|
|
10932
|
+
return ok({
|
|
10933
|
+
kind: container,
|
|
10934
|
+
keys: keys.slice(offset, offset + limit),
|
|
10935
|
+
// The unpaged size, so a caller can tell a short page from the end of
|
|
10936
|
+
// the record without asking for another one.
|
|
10937
|
+
total: keys.length
|
|
10938
|
+
});
|
|
10939
|
+
}), defineTool({
|
|
10940
|
+
name: "count_entries",
|
|
10941
|
+
title: "Count entries",
|
|
10942
|
+
description: "Count the entries at a path inside a module — record or gallery keys, object fields, array indices, or top-level richtext blocks — without reading them. Use this to answer 'how many?' or to size a record before paging through it.",
|
|
10943
|
+
inputSchema: z.object({
|
|
10944
|
+
moduleFilePath: ModuleFilePathSchema$1,
|
|
10945
|
+
path: z.array(z.string()).default([]).describe("Path within the module to count at. Empty means the module root.")
|
|
10946
|
+
}),
|
|
10947
|
+
annotations: {
|
|
10948
|
+
readOnlyHint: true,
|
|
10949
|
+
idempotentHint: true
|
|
10950
|
+
}
|
|
10951
|
+
}, async ({
|
|
10952
|
+
moduleFilePath,
|
|
10953
|
+
path
|
|
10954
|
+
}, {
|
|
10955
|
+
state
|
|
10956
|
+
}) => {
|
|
10957
|
+
const described = describeContainer(state, moduleFilePath, path);
|
|
10958
|
+
if (described.kind !== "ok") {
|
|
10959
|
+
return described.result;
|
|
10960
|
+
}
|
|
10961
|
+
const {
|
|
10962
|
+
container,
|
|
10963
|
+
value
|
|
10964
|
+
} = described;
|
|
10965
|
+
// Every container `describeContainerAtPath` admits can be counted, so
|
|
10966
|
+
// unlike get_record_keys this does not narrow further. Non-containers
|
|
10967
|
+
// never get this far.
|
|
10968
|
+
if (Array.isArray(value)) {
|
|
10969
|
+
return ok({
|
|
10970
|
+
kind: container,
|
|
10971
|
+
count: value.length
|
|
10972
|
+
});
|
|
10973
|
+
}
|
|
10974
|
+
if (isPlainObject(value)) {
|
|
10975
|
+
return ok({
|
|
10976
|
+
kind: container,
|
|
10977
|
+
count: Object.keys(value).length
|
|
10978
|
+
});
|
|
10979
|
+
}
|
|
10980
|
+
return err("invalid-args", `The value at that path is ${article(container)} ${container}, which has nothing to count.`);
|
|
10981
|
+
}), defineTool({
|
|
10982
|
+
name: "validate_content",
|
|
10983
|
+
title: "Validate content",
|
|
10984
|
+
description: "Check the project's content against its schemas, including unpublished changes. Returns only errors that would block publishing.",
|
|
10985
|
+
inputSchema: z.object({
|
|
10986
|
+
moduleFilePath: ModuleFilePathSchema$1.optional().describe("Limit the check to one module. Omit to validate everything.")
|
|
10987
|
+
}),
|
|
10988
|
+
annotations: {
|
|
10989
|
+
readOnlyHint: true
|
|
10990
|
+
}
|
|
10991
|
+
}, async ({
|
|
10992
|
+
moduleFilePath
|
|
10993
|
+
}, {
|
|
10994
|
+
ops,
|
|
10995
|
+
state
|
|
10996
|
+
}) => {
|
|
10997
|
+
const validation = await ops.validateSources(state.schemas, state.sources,
|
|
10998
|
+
// Every module. The third argument filters which modules are
|
|
10999
|
+
// validated at all, so passing the pending-patch analysis would make
|
|
11000
|
+
// a project with no pending changes report `valid: true` without
|
|
11001
|
+
// having checked anything. Scoping to one module, when asked, is done
|
|
11002
|
+
// on the results below.
|
|
11003
|
+
undefined);
|
|
11004
|
+
// `validateSources` hands back the files it could not check on its own;
|
|
11005
|
+
// running them is what turns "this path holds a file" into "that file is
|
|
11006
|
+
// actually there and matches its recorded metadata".
|
|
11007
|
+
const fileErrors = await ops.validateFiles(state.schemas, state.sources, validation.files, state.analysis.fileLastUpdatedByPatchId);
|
|
11008
|
+
|
|
11009
|
+
// Per-module results, flattened to the by-source-path shape the filter
|
|
11010
|
+
// takes. Merged rather than overwritten: a path can pick up an error
|
|
11011
|
+
// from validation and another from its file.
|
|
11012
|
+
const bySourcePath = {};
|
|
11013
|
+
const add = (path, errors) => {
|
|
11014
|
+
bySourcePath[path] = (bySourcePath[path] ?? []).concat(errors);
|
|
11015
|
+
};
|
|
11016
|
+
for (const moduleErrors of Object.values(validation.errors)) {
|
|
11017
|
+
for (const [path, errors] of Object.entries(moduleErrors.validations ?? {})) {
|
|
11018
|
+
add(path, errors);
|
|
11019
|
+
}
|
|
11020
|
+
}
|
|
11021
|
+
for (const [path, errors] of Object.entries(fileErrors)) {
|
|
11022
|
+
add(path, errors);
|
|
11023
|
+
}
|
|
11024
|
+
|
|
11025
|
+
// Drops the errors the Studio would not show either: ones whose only
|
|
11026
|
+
// effect is an offered fix. Left in, an agent would loop trying to
|
|
11027
|
+
// "repair" content that is already publishable.
|
|
11028
|
+
const blocking = filterBlockingValidationErrors(bySourcePath, state.serializedSchemas, state.sources);
|
|
11029
|
+
|
|
11030
|
+
// A module whose source could not be read at all has no source path to
|
|
11031
|
+
// hang an error on, so it is reported separately rather than lost.
|
|
11032
|
+
const unreadable = Object.entries(validation.errors).filter(([, moduleErrors]) => moduleErrors.invalidSource).map(([path, moduleErrors]) => {
|
|
11033
|
+
var _moduleErrors$invalid;
|
|
11034
|
+
return {
|
|
11035
|
+
moduleFilePath: path,
|
|
11036
|
+
message: ((_moduleErrors$invalid = moduleErrors.invalidSource) === null || _moduleErrors$invalid === void 0 ? void 0 : _moduleErrors$invalid.message) ?? "Invalid source"
|
|
11037
|
+
};
|
|
11038
|
+
});
|
|
11039
|
+
const scope = moduleFilePath;
|
|
11040
|
+
const errors = scope === undefined ? blocking : filterKeysByModule(blocking, scope);
|
|
11041
|
+
const unreadableInScope = scope === undefined ? unreadable : unreadable.filter(u => u.moduleFilePath === scope);
|
|
11042
|
+
return ok({
|
|
11043
|
+
valid: Object.keys(errors).length === 0 && unreadableInScope.length === 0,
|
|
11044
|
+
errors: Object.fromEntries(Object.entries(errors).map(([path, errs]) => [path, errs.map(toJsonValidationError)])),
|
|
11045
|
+
// Always present, empty when there are none: a caller should not have
|
|
11046
|
+
// to tell "absent" from "empty" to decide whether content is publishable.
|
|
11047
|
+
unreadableModules: unreadableInScope
|
|
11048
|
+
});
|
|
11049
|
+
}), defineTool({
|
|
11050
|
+
name: "get_patches",
|
|
11051
|
+
title: "Get patches",
|
|
11052
|
+
description: "List the unpublished changes in the project, oldest first, with who made each one. A change reported as not applying is why a module's content may not match what publishing would produce, and why writing to that module is refused.",
|
|
11053
|
+
inputSchema: z.object({
|
|
11054
|
+
moduleFilePath: ModuleFilePathSchema$1.optional().describe("Limit to changes touching one module.")
|
|
11055
|
+
}),
|
|
11056
|
+
annotations: {
|
|
11057
|
+
readOnlyHint: true
|
|
11058
|
+
}
|
|
11059
|
+
}, async ({
|
|
11060
|
+
moduleFilePath
|
|
11061
|
+
}, {
|
|
11062
|
+
state
|
|
11063
|
+
}) => {
|
|
11064
|
+
const wanted = moduleFilePath === undefined ? state.patches.patches : state.patches.patches.filter(p => p.path === moduleFilePath);
|
|
11065
|
+
// Which patches would not apply, flattened to one lookup by id: without
|
|
11066
|
+
// this a module's content can silently differ from what publishing
|
|
11067
|
+
// would produce, and nothing anywhere says why.
|
|
11068
|
+
const failures = new Map();
|
|
11069
|
+
for (const unapplied of Object.values(state.unappliedPatches)) {
|
|
11070
|
+
for (const failure of unapplied) {
|
|
11071
|
+
failures.set(failure.patchId, failure.error.message);
|
|
11072
|
+
}
|
|
11073
|
+
}
|
|
11074
|
+
return ok(wanted.map(patch => {
|
|
11075
|
+
const failure = failures.get(patch.patchId);
|
|
11076
|
+
return {
|
|
11077
|
+
patchId: patch.patchId,
|
|
11078
|
+
moduleFilePath: patch.path,
|
|
11079
|
+
createdAt: patch.createdAt,
|
|
11080
|
+
authorId: patch.authorId,
|
|
11081
|
+
// `appliedAt` non-null means this change is already committed, so
|
|
11082
|
+
// it is history rather than something still pending.
|
|
11083
|
+
published: patch.appliedAt !== null,
|
|
11084
|
+
// Always present, so "applies cleanly" is stated rather than
|
|
11085
|
+
// inferred from the absence of a field.
|
|
11086
|
+
appliesCleanly: failure === undefined,
|
|
11087
|
+
...(failure === undefined ? {} : {
|
|
11088
|
+
applyError: failure
|
|
11089
|
+
})
|
|
11090
|
+
};
|
|
11091
|
+
}));
|
|
11092
|
+
}), defineTool({
|
|
11093
|
+
name: "get_source_path_from_route",
|
|
11094
|
+
title: "Get source path from route",
|
|
11095
|
+
description: "Given a URL path on the site, find the Val module and source path that renders it. Use this when the user names a page rather than a module.",
|
|
11096
|
+
inputSchema: z.object({
|
|
11097
|
+
route: z.string().describe('A route on the site, e.g. "/blog/my-post".')
|
|
11098
|
+
}),
|
|
11099
|
+
annotations: {
|
|
11100
|
+
readOnlyHint: true,
|
|
11101
|
+
idempotentHint: true
|
|
11102
|
+
}
|
|
11103
|
+
}, async ({
|
|
11104
|
+
route
|
|
11105
|
+
}, {
|
|
11106
|
+
state
|
|
11107
|
+
}) => {
|
|
11108
|
+
const found = getSourcePathFromRoute(route, state.serializedSchemas);
|
|
11109
|
+
if (!found) {
|
|
11110
|
+
return err("not-found", `No Val module renders the route ${JSON.stringify(route)}. Routes come from modules with a router configured; get_all_schema shows which have one.`);
|
|
11111
|
+
}
|
|
11112
|
+
return ok(found);
|
|
11113
|
+
})];
|
|
11114
|
+
}
|
|
11115
|
+
|
|
11116
|
+
/**
|
|
11117
|
+
* Resolve a module and classify the value at a path inside it.
|
|
11118
|
+
*
|
|
11119
|
+
* Shared by `get_record_keys` and `count_entries` so the two cannot drift on
|
|
11120
|
+
* what counts as a missing module, and so both map the same failure to the same
|
|
11121
|
+
* error code: a path that is not there is `not-found`, while a path that is
|
|
11122
|
+
* there but holds a string or an image is `invalid-args` — the caller should
|
|
11123
|
+
* reach for a different tool, not go looking for the path again.
|
|
11124
|
+
*/
|
|
11125
|
+
function describeContainer(state, moduleFilePath, path) {
|
|
11126
|
+
const modulePath = moduleFilePath;
|
|
11127
|
+
const schema = state.serializedSchemas[modulePath];
|
|
11128
|
+
if (!schema) {
|
|
11129
|
+
return {
|
|
11130
|
+
kind: "error",
|
|
11131
|
+
result: err("not-found", unknownModuleMessage(modulePath, state))
|
|
11132
|
+
};
|
|
11133
|
+
}
|
|
11134
|
+
const described = describeContainerAtPath(schema, state.sources[modulePath], path);
|
|
11135
|
+
if (described.kind === "error") {
|
|
11136
|
+
return {
|
|
11137
|
+
kind: "error",
|
|
11138
|
+
result: err(described.reason === "missing" ? "not-found" : "invalid-args", described.message)
|
|
11139
|
+
};
|
|
11140
|
+
}
|
|
11141
|
+
return described;
|
|
11142
|
+
}
|
|
11143
|
+
|
|
11144
|
+
/** "a record", but "an object" and "an array". */
|
|
11145
|
+
function article(container) {
|
|
11146
|
+
return container === "object" || container === "array" ? "an" : "a";
|
|
11147
|
+
}
|
|
11148
|
+
function unknownModuleMessage(path, state) {
|
|
11149
|
+
const known = Object.keys(state.serializedSchemas);
|
|
11150
|
+
return `No Val module at ${JSON.stringify(path)}. Known modules: ${known.length === 0 ? "(none)" : known.join(", ")}`;
|
|
11151
|
+
}
|
|
11152
|
+
|
|
11153
|
+
/**
|
|
11154
|
+
* Project a validation error into something JSON-safe and worth reading.
|
|
11155
|
+
*
|
|
11156
|
+
* `ValidationError.value` is dropped rather than serialized: it is `unknown` (so
|
|
11157
|
+
* not `Json` to begin with) and it holds the offending source value, which can
|
|
11158
|
+
* be arbitrarily large. A caller already has the source path and can read the
|
|
11159
|
+
* value with `get_source` if it needs to — putting it here would bloat every
|
|
11160
|
+
* result for the rare case that wants it.
|
|
11161
|
+
*
|
|
11162
|
+
* `fixes` is kept, because it names what Val already knows how to repair, which
|
|
11163
|
+
* is directly actionable.
|
|
11164
|
+
*/
|
|
11165
|
+
function toJsonValidationError(error) {
|
|
11166
|
+
return {
|
|
11167
|
+
message: error.message,
|
|
11168
|
+
fixes: error.fixes ? [...error.fixes] : [],
|
|
11169
|
+
typeError: error.typeError === true,
|
|
11170
|
+
schemaError: error.schemaError === true,
|
|
11171
|
+
keyError: error.keyError === true
|
|
11172
|
+
};
|
|
11173
|
+
}
|
|
11174
|
+
function isPlainObject(value) {
|
|
11175
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11176
|
+
}
|
|
11177
|
+
|
|
11178
|
+
/**
|
|
11179
|
+
* Keep only the entries belonging to one module.
|
|
11180
|
+
*
|
|
11181
|
+
* Keyed by SourcePath, which begins with the module file path, so a prefix match
|
|
11182
|
+
* is the right test — there is no per-module grouping left to index by.
|
|
11183
|
+
*/
|
|
11184
|
+
function filterKeysByModule(record, moduleFilePath) {
|
|
11185
|
+
const out = {};
|
|
11186
|
+
for (const [path, value] of Object.entries(record)) {
|
|
11187
|
+
if (path.startsWith(moduleFilePath)) {
|
|
11188
|
+
out[path] = value;
|
|
11189
|
+
}
|
|
11190
|
+
}
|
|
11191
|
+
return out;
|
|
11192
|
+
}
|
|
11193
|
+
|
|
11194
|
+
/**
|
|
11195
|
+
* Everything the Studio does client-side before a patch can be saved, done
|
|
11196
|
+
* server-side.
|
|
11197
|
+
*
|
|
11198
|
+
* Three things had no server equivalent, and each is a way to be quietly wrong:
|
|
11199
|
+
* where the patch id comes from, what the patch says its parent is, and whether
|
|
11200
|
+
* the result would even be valid. `docs/plans/mcp.md` Part C is the design.
|
|
11201
|
+
*/
|
|
11202
|
+
|
|
11203
|
+
const jsonOps = new JSONOps();
|
|
11204
|
+
|
|
11205
|
+
/**
|
|
11206
|
+
* A patch id, minted before the write is attempted.
|
|
11207
|
+
*
|
|
11208
|
+
* Same shape the Studio mints (a v4 UUID), and minting one that never gets used
|
|
11209
|
+
* costs nothing — ids are not registered anywhere until a patch carries them.
|
|
11210
|
+
*/
|
|
11211
|
+
function mintPatchId() {
|
|
11212
|
+
// A branded string has no constructor; this is the same conversion the Studio
|
|
11213
|
+
// and ValServer both make.
|
|
11214
|
+
return randomUUID();
|
|
11215
|
+
}
|
|
11216
|
+
|
|
11217
|
+
/**
|
|
11218
|
+
* What the new patch should hang off.
|
|
11219
|
+
*
|
|
11220
|
+
* The last known patch if there is one, otherwise the current head. Note the
|
|
11221
|
+
* asymmetry between the two backends: `ValOpsFS` ignores `parentRef` entirely
|
|
11222
|
+
* because its append-only ordering log defines order, while `ValOpsHttp` sends
|
|
11223
|
+
* it up as `parentPatchId` for optimistic concurrency. So a wrong value here is
|
|
11224
|
+
* invisible locally and a conflict in production — which is why this is derived
|
|
11225
|
+
* fresh rather than remembered.
|
|
11226
|
+
*/
|
|
11227
|
+
async function deriveParentRef(ops,
|
|
11228
|
+
// Only the ids matter, so this accepts either shape `fetchPatches` can
|
|
11229
|
+
// return — the metadata-only variant omits the ops but keeps the ids.
|
|
11230
|
+
patches) {
|
|
11231
|
+
const last = patches.patches[patches.patches.length - 1];
|
|
11232
|
+
if (last) {
|
|
11233
|
+
return {
|
|
11234
|
+
type: "patch",
|
|
11235
|
+
patchId: last.patchId
|
|
11236
|
+
};
|
|
11237
|
+
}
|
|
11238
|
+
return {
|
|
11239
|
+
type: "head",
|
|
11240
|
+
headBaseSha: await ops.getBaseSha()
|
|
11241
|
+
};
|
|
11242
|
+
}
|
|
11243
|
+
|
|
11244
|
+
/**
|
|
11245
|
+
* Would this patch leave the content valid?
|
|
11246
|
+
*
|
|
11247
|
+
* Applied to a **clone** of the sources, never the real ones: `applyPatch`
|
|
11248
|
+
* mutates the document it is given, and ValOps carries a standing note that
|
|
11249
|
+
* add operations misbehave without a clone. Validating in place would corrupt
|
|
11250
|
+
* the sources every later call in this process reads.
|
|
11251
|
+
*
|
|
11252
|
+
* Server-side this is strictly better than the Studio's speculative check.
|
|
11253
|
+
* `getSchemas()` returns real `Schema` instances, so the user's own `validate`
|
|
11254
|
+
* closures run — and those are not carried by the serialized schema the browser
|
|
11255
|
+
* has, which means the browser cannot run them at all.
|
|
11256
|
+
*/
|
|
11257
|
+
async function validateSpeculatively(ops, state, moduleFilePath, patch) {
|
|
11258
|
+
const current = state.sources[moduleFilePath];
|
|
11259
|
+
if (current === undefined) {
|
|
11260
|
+
return {
|
|
11261
|
+
status: "unapplicable",
|
|
11262
|
+
result: {
|
|
11263
|
+
status: "error",
|
|
11264
|
+
code: "not-found",
|
|
11265
|
+
message: `No Val module at ${JSON.stringify(moduleFilePath)}.`
|
|
11266
|
+
}
|
|
11267
|
+
};
|
|
11268
|
+
}
|
|
11269
|
+
const applied = applyPatch(deepClone(current), jsonOps, patch);
|
|
11270
|
+
if (result.isErr(applied)) {
|
|
11271
|
+
return {
|
|
11272
|
+
status: "unapplicable",
|
|
11273
|
+
result: {
|
|
11274
|
+
status: "error",
|
|
11275
|
+
code: "invalid-args",
|
|
11276
|
+
message: `The patch cannot be applied to ${moduleFilePath}: ${applied.error.message}`
|
|
11277
|
+
}
|
|
11278
|
+
};
|
|
11279
|
+
}
|
|
11280
|
+
const speculativeSources = {
|
|
11281
|
+
...state.sources,
|
|
11282
|
+
[moduleFilePath]: applied.value
|
|
11283
|
+
};
|
|
11284
|
+
const after = await blockingErrorsIn(ops, state, speculativeSources, moduleFilePath);
|
|
11285
|
+
if (after.length === 0) {
|
|
11286
|
+
return {
|
|
11287
|
+
status: "valid"
|
|
11288
|
+
};
|
|
11289
|
+
}
|
|
11290
|
+
|
|
11291
|
+
// Only the errors this patch *introduces*. A module can already be broken for
|
|
11292
|
+
// reasons this change has nothing to do with -- the example app ships with a
|
|
11293
|
+
// missing image file -- and refusing on the total would make every such module
|
|
11294
|
+
// permanently read-only: an agent could not fix a typo in a file that also
|
|
11295
|
+
// holds a broken image reference. Paid for only when there is something to
|
|
11296
|
+
// refuse, so an ordinary clean edit still validates once.
|
|
11297
|
+
const before = await blockingErrorsIn(ops, state, state.sources, moduleFilePath);
|
|
11298
|
+
const existing = new Set(before.map(identify));
|
|
11299
|
+
const introduced = after.filter(error => !existing.has(identify(error)));
|
|
11300
|
+
if (introduced.length === 0) {
|
|
11301
|
+
return {
|
|
11302
|
+
status: "valid"
|
|
11303
|
+
};
|
|
11304
|
+
}
|
|
11305
|
+
return {
|
|
11306
|
+
status: "invalid",
|
|
11307
|
+
errors: describeErrors(introduced)
|
|
11308
|
+
};
|
|
11309
|
+
}
|
|
11310
|
+
/** Path and message together: the same message at another path is another problem. */
|
|
11311
|
+
function identify(error) {
|
|
11312
|
+
return `${error.path}\u0000${error.message}`;
|
|
11313
|
+
}
|
|
11314
|
+
|
|
11315
|
+
/**
|
|
11316
|
+
* The publishing-blocking errors in one module, for a given set of sources.
|
|
11317
|
+
*
|
|
11318
|
+
* Scoped to one module by source path, which starts with the module file path.
|
|
11319
|
+
* Errors elsewhere in the project are somebody else's: refusing on them would
|
|
11320
|
+
* let the first broken module in a repo make every other module read-only.
|
|
11321
|
+
*/
|
|
11322
|
+
async function blockingErrorsIn(ops, state, sources, moduleFilePath) {
|
|
11323
|
+
const validation = await ops.validateSources(state.schemas, sources,
|
|
11324
|
+
// Every module, deliberately -- `patchesByModule` is a FILTER on which
|
|
11325
|
+
// modules get validated, not context for validating them. Passing the
|
|
11326
|
+
// analysis from before this write skips the very module being written
|
|
11327
|
+
// whenever it had no pending patch, so the first change to a module went
|
|
11328
|
+
// unchecked; and a change that breaks a `keyOf` or a router in a *different*
|
|
11329
|
+
// module reports its error there, which a filtered run never visits.
|
|
11330
|
+
undefined);
|
|
11331
|
+
// `validateSources` hands back the files it could not check on its own;
|
|
11332
|
+
// running them is what turns "this path holds a file" into "that file is
|
|
11333
|
+
// actually there and matches its recorded metadata".
|
|
11334
|
+
const fileErrors = await ops.validateFiles(state.schemas, sources, validation.files, state.analysis.fileLastUpdatedByPatchId);
|
|
11335
|
+
|
|
11336
|
+
// Merged rather than overwritten: a path can pick up an error from validation
|
|
11337
|
+
// and another from its file.
|
|
11338
|
+
const bySourcePath = {};
|
|
11339
|
+
const add = (path, errors) => {
|
|
11340
|
+
bySourcePath[path] = (bySourcePath[path] ?? []).concat(errors);
|
|
11341
|
+
};
|
|
11342
|
+
for (const moduleErrors of Object.values(validation.errors)) {
|
|
11343
|
+
for (const [path, errors] of Object.entries(moduleErrors.validations ?? {})) {
|
|
11344
|
+
add(path, errors);
|
|
11345
|
+
}
|
|
11346
|
+
}
|
|
11347
|
+
for (const [path, errors] of Object.entries(fileErrors)) {
|
|
11348
|
+
add(path, errors);
|
|
11349
|
+
}
|
|
11350
|
+
const blocking = filterBlockingValidationErrors(bySourcePath, state.serializedSchemas, sources);
|
|
11351
|
+
const located = [];
|
|
11352
|
+
for (const [path, errors] of Object.entries(blocking)) {
|
|
11353
|
+
if (!path.startsWith(moduleFilePath)) {
|
|
11354
|
+
continue;
|
|
11355
|
+
}
|
|
11356
|
+
for (const error of errors) {
|
|
11357
|
+
located.push({
|
|
11358
|
+
path: path,
|
|
11359
|
+
message: error.message
|
|
11360
|
+
});
|
|
11361
|
+
}
|
|
11362
|
+
}
|
|
11363
|
+
return located;
|
|
11364
|
+
}
|
|
11365
|
+
function describeErrors(errors) {
|
|
11366
|
+
return errors.map(e => `${e.path}: ${e.message}`).join("; ");
|
|
11367
|
+
}
|
|
11368
|
+
|
|
11369
|
+
/**
|
|
11370
|
+
* What to do when the change would leave the content invalid.
|
|
11371
|
+
*
|
|
11372
|
+
* `"reject"` for a tool that is editing existing content: an agent should not be
|
|
11373
|
+
* able to break a site, and a rejected patch stores nothing.
|
|
11374
|
+
*
|
|
11375
|
+
* `"report"` for a tool whose whole purpose is to create something incomplete.
|
|
11376
|
+
* `empty_at_path` scaffolds an entry the caller is then expected to fill in, so
|
|
11377
|
+
* on most real schemas — anything with a non-empty string — the value it creates
|
|
11378
|
+
* is invalid by construction. Rejecting that would make the tool useless on
|
|
11379
|
+
* exactly the schemas it exists for, so instead the patch is saved and the
|
|
11380
|
+
* remaining errors come back as a to-do list. This mirrors the Studio, where
|
|
11381
|
+
* creating an empty entry is normal and the errors show until it is filled in.
|
|
11382
|
+
*/
|
|
11383
|
+
|
|
11384
|
+
/**
|
|
11385
|
+
* Validate, then save — and retry once if someone else got there first.
|
|
11386
|
+
*
|
|
11387
|
+
* The retry exists because the parent ref is derived from a read that happened
|
|
11388
|
+
* before the write. A conflict means the chain moved underneath us, and
|
|
11389
|
+
* re-deriving is usually enough. Once only: a loop here would be an agent
|
|
11390
|
+
* fighting a human editor in the Studio, and losing slowly is worse than
|
|
11391
|
+
* failing clearly.
|
|
11392
|
+
*/
|
|
11393
|
+
async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
|
|
11394
|
+
const {
|
|
11395
|
+
ops,
|
|
11396
|
+
ctx,
|
|
11397
|
+
state
|
|
11398
|
+
} = deps;
|
|
11399
|
+
const unapplied = state.unappliedPatches[moduleFilePath];
|
|
11400
|
+
if (unapplied && unapplied.length > 0) {
|
|
11401
|
+
// Refused before anything is validated, because the state to validate
|
|
11402
|
+
// against is wrong. `sources` for this module silently lacks these pending
|
|
11403
|
+
// changes, so a patch built on it would be based on content that will never
|
|
11404
|
+
// exist -- and its parent ref would chain onto changes that do not apply.
|
|
11405
|
+
return {
|
|
11406
|
+
status: "error",
|
|
11407
|
+
code: "internal",
|
|
11408
|
+
message: `Cannot write to ${moduleFilePath}: it has ${unapplied.length} pending change${unapplied.length === 1 ? "" : "s"} that will not apply, so what is stored is not what publishing would produce. Resolve or discard ${unapplied.map(u => u.patchId).join(", ")} first -- get_patches shows them.`
|
|
11409
|
+
};
|
|
11410
|
+
}
|
|
11411
|
+
const speculative = await validateSpeculatively(ops, state, moduleFilePath, patch);
|
|
11412
|
+
if (speculative.status === "unapplicable") {
|
|
11413
|
+
// Never negotiable: the patch does not fit the content, so there is nothing
|
|
11414
|
+
// to save whatever the caller's tolerance for invalid results.
|
|
11415
|
+
return speculative.result;
|
|
11416
|
+
}
|
|
11417
|
+
let unresolved = null;
|
|
11418
|
+
if (speculative.status === "invalid") {
|
|
11419
|
+
if (onInvalid === "reject") {
|
|
11420
|
+
return {
|
|
11421
|
+
status: "error",
|
|
11422
|
+
code: "validation-failed",
|
|
11423
|
+
message: `The change was rejected and nothing was saved, because it would leave the content invalid: ${speculative.errors}`
|
|
11424
|
+
};
|
|
11425
|
+
}
|
|
11426
|
+
unresolved = speculative.errors;
|
|
11427
|
+
}
|
|
11428
|
+
|
|
11429
|
+
// Always null, and deliberately so. The app cannot resolve a PAT to a
|
|
11430
|
+
// profile, so any id it put here would be an unverified claim dressed up as a
|
|
11431
|
+
// checked one — and the request already carries the caller's own token, which
|
|
11432
|
+
// is a better answer to "who did this" than anything the app could assert.
|
|
11433
|
+
// Attributing the patch from that token is a backend concern; see
|
|
11434
|
+
// `docs/plans/mcp.md` D.3.
|
|
11435
|
+
const authorId = null;
|
|
11436
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
11437
|
+
const patchId = mintPatchId();
|
|
11438
|
+
// Re-derived on the retry rather than reused: reusing the ref that just
|
|
11439
|
+
// conflicted would conflict again by definition.
|
|
11440
|
+
const patches = attempt === 0 ? state.patches : await ops.fetchPatches({
|
|
11441
|
+
excludePatchOps: true
|
|
11442
|
+
});
|
|
11443
|
+
const parentRef = await deriveParentRef(ops, patches);
|
|
11444
|
+
const saved = await ops.createPatch(moduleFilePath, patch, patchId, parentRef, ctx.sessionId, authorId);
|
|
11445
|
+
if (result.isOk(saved)) {
|
|
11446
|
+
return {
|
|
11447
|
+
status: "ok",
|
|
11448
|
+
data: {
|
|
11449
|
+
patchId: saved.value.patchId,
|
|
11450
|
+
moduleFilePath,
|
|
11451
|
+
createdAt: saved.value.createdAt,
|
|
11452
|
+
// Always present, so a caller does not have to tell "absent" from
|
|
11453
|
+
// "nothing left to do" to know whether the content is publishable.
|
|
11454
|
+
unresolvedValidationErrors: unresolved
|
|
11455
|
+
}
|
|
11456
|
+
};
|
|
11457
|
+
}
|
|
11458
|
+
if (saved.error.errorType === "patch-head-conflict") {
|
|
11459
|
+
continue;
|
|
11460
|
+
}
|
|
11461
|
+
return {
|
|
11462
|
+
status: "error",
|
|
11463
|
+
code: "internal",
|
|
11464
|
+
// Note the nesting: createPatch wraps the underlying flat error as
|
|
11465
|
+
// `{ errorType: "other", error: <that> }`.
|
|
11466
|
+
message: saved.error.error.message
|
|
11467
|
+
};
|
|
11468
|
+
}
|
|
11469
|
+
return {
|
|
11470
|
+
status: "error",
|
|
11471
|
+
code: "conflict",
|
|
11472
|
+
message: "Another change was saved while this one was being written, twice in a row. Read the content again before retrying — it has moved."
|
|
11473
|
+
};
|
|
11474
|
+
}
|
|
11475
|
+
|
|
11476
|
+
/**
|
|
11477
|
+
* The tools that change content.
|
|
11478
|
+
*
|
|
11479
|
+
* Every one of them goes through {@link savePatch}, so they all inherit the same
|
|
11480
|
+
* guarantees: the change is validated against the real schemas before anything
|
|
11481
|
+
* is stored, a rejected change stores nothing, and a lost race with another
|
|
11482
|
+
* writer is retried once and then reported rather than looped on.
|
|
11483
|
+
*
|
|
11484
|
+
* Images are not here. The Studio's image tools work from a handle into Val's
|
|
11485
|
+
* AI session store — bytes the browser got from the vision system — and MCP has
|
|
11486
|
+
* no equivalent, so they need a different affordance (a local file path, or
|
|
11487
|
+
* inline base64) rather than a port. `docs/plans/mcp.md` Part B has the reasoning.
|
|
11488
|
+
*/
|
|
11489
|
+
|
|
11490
|
+
const ModuleFilePathSchema = z.string().describe('Path of the Val module, e.g. "/content/pages.val.ts".');
|
|
11491
|
+
function writeTools() {
|
|
11492
|
+
return [defineTool({
|
|
11493
|
+
name: "create_patch",
|
|
11494
|
+
title: "Create patch",
|
|
11495
|
+
description: "Change content in a Val module by applying JSON Patch operations. The change is validated first and is rejected outright if it would make the content invalid. Text and JSON values only — not files or images.",
|
|
11496
|
+
inputSchema: z.object({
|
|
11497
|
+
moduleFilePath: ModuleFilePathSchema,
|
|
11498
|
+
patch: z.array(z.unknown()).describe('JSON Patch operations, e.g. [{"op":"replace","path":["title"],"value":"New title"}]. Paths are arrays of keys, not slash-separated strings.')
|
|
11499
|
+
}),
|
|
11500
|
+
annotations: {
|
|
11501
|
+
idempotentHint: false
|
|
11502
|
+
}
|
|
11503
|
+
}, async ({
|
|
11504
|
+
moduleFilePath,
|
|
11505
|
+
patch
|
|
11506
|
+
}, deps) => {
|
|
11507
|
+
// Before parsing, not after: a file op that is also malformed should be
|
|
11508
|
+
// told that files are not supported, rather than handed a schema error
|
|
11509
|
+
// about the shape of a thing it was never going to be allowed to do.
|
|
11510
|
+
const rejected = rejectFileOps(patch);
|
|
11511
|
+
if (rejected) {
|
|
11512
|
+
return rejected;
|
|
11513
|
+
}
|
|
11514
|
+
const parsed = safeParsePatch(patch);
|
|
11515
|
+
if (parsed.kind !== "ok") {
|
|
11516
|
+
return fromBuildResult(parsed);
|
|
11517
|
+
}
|
|
11518
|
+
return savePatch(deps, moduleFilePath, parsed.patch);
|
|
11519
|
+
}), defineTool({
|
|
11520
|
+
name: "duplicate_source",
|
|
11521
|
+
title: "Duplicate source",
|
|
11522
|
+
description: "Copy the value at one path in a module to another path. Use this to add an entry modelled on an existing one, rather than composing it field by field.",
|
|
11523
|
+
inputSchema: z.object({
|
|
11524
|
+
moduleFilePath: ModuleFilePathSchema,
|
|
11525
|
+
sourcePath: z.array(z.string()).describe("Path of the value to copy."),
|
|
11526
|
+
destinationPath: z.array(z.string()).describe("Path to copy it to. Must not already exist.")
|
|
11527
|
+
}),
|
|
11528
|
+
annotations: {
|
|
11529
|
+
idempotentHint: false
|
|
11530
|
+
}
|
|
11531
|
+
}, async ({
|
|
11532
|
+
moduleFilePath,
|
|
11533
|
+
sourcePath,
|
|
11534
|
+
destinationPath
|
|
11535
|
+
}, deps) => {
|
|
11536
|
+
const modulePath = moduleFilePath;
|
|
11537
|
+
const schema = deps.state.serializedSchemas[modulePath];
|
|
11538
|
+
if (!schema) {
|
|
11539
|
+
return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
|
|
11540
|
+
}
|
|
11541
|
+
const built = buildDuplicatePatch({
|
|
11542
|
+
sourcePath,
|
|
11543
|
+
destinationPath
|
|
11544
|
+
}, schema, deps.state.sources[modulePath]);
|
|
11545
|
+
if (built.kind !== "ok") {
|
|
11546
|
+
return fromBuildResult(built);
|
|
11547
|
+
}
|
|
11548
|
+
return savePatch(deps, modulePath, built.patch);
|
|
11549
|
+
}), defineTool({
|
|
11550
|
+
name: "empty_at_path",
|
|
11551
|
+
title: "Create an empty value at a path",
|
|
11552
|
+
description: "Create a new, schema-correct empty value at a path — an empty entry in a record or array, for instance. Prefer this over composing one by hand: it derives the shape from the schema, including required fields. The value it creates is usually not yet publishable; the result lists what still needs filling in, which you can then do with create_patch.",
|
|
11553
|
+
inputSchema: z.object({
|
|
11554
|
+
moduleFilePath: ModuleFilePathSchema,
|
|
11555
|
+
destinationPath: z.array(z.string()).describe("Path to create the empty value at.")
|
|
11556
|
+
}),
|
|
11557
|
+
annotations: {
|
|
11558
|
+
idempotentHint: false
|
|
11559
|
+
}
|
|
11560
|
+
}, async ({
|
|
11561
|
+
moduleFilePath,
|
|
11562
|
+
destinationPath
|
|
11563
|
+
}, deps) => {
|
|
11564
|
+
const modulePath = moduleFilePath;
|
|
11565
|
+
const schema = deps.state.serializedSchemas[modulePath];
|
|
11566
|
+
if (!schema) {
|
|
11567
|
+
return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
|
|
11568
|
+
}
|
|
11569
|
+
const built = buildEmptyAtPathPatch({
|
|
11570
|
+
destinationPath
|
|
11571
|
+
}, schema, deps.state.sources[modulePath]);
|
|
11572
|
+
if (built.kind !== "ok") {
|
|
11573
|
+
return fromBuildResult(built);
|
|
11574
|
+
}
|
|
11575
|
+
// "report", not "reject": an empty entry is invalid by construction on
|
|
11576
|
+
// any schema with a required non-empty field, which is most of them.
|
|
11577
|
+
// See OnInvalid in writePath.ts.
|
|
11578
|
+
return savePatch(deps, modulePath, built.patch, "report");
|
|
11579
|
+
}), defineTool({
|
|
11580
|
+
name: "remove_image_gallery_entry",
|
|
11581
|
+
title: "Remove an image gallery entry",
|
|
11582
|
+
description: "Remove one image from an image gallery module by its file path. This deletes the entry and the file it refers to.",
|
|
11583
|
+
inputSchema: z.object({
|
|
11584
|
+
moduleFilePath: ModuleFilePathSchema.describe("The gallery module, i.e. one declared with s.images() or s.files()."),
|
|
11585
|
+
filePath: z.string().describe('The gallery key to remove, e.g. "/public/val/photo_a1b2c.jpg".')
|
|
11586
|
+
}),
|
|
11587
|
+
// Destructive: it removes content and the underlying file, so a host
|
|
11588
|
+
// that asks for confirmation should ask here.
|
|
11589
|
+
annotations: {
|
|
11590
|
+
destructiveHint: true,
|
|
11591
|
+
idempotentHint: false
|
|
11592
|
+
}
|
|
11593
|
+
}, async ({
|
|
11594
|
+
moduleFilePath,
|
|
11595
|
+
filePath
|
|
11596
|
+
}, deps) => {
|
|
11597
|
+
const modulePath = moduleFilePath;
|
|
11598
|
+
const schema = deps.state.serializedSchemas[modulePath];
|
|
11599
|
+
if (!schema) {
|
|
11600
|
+
return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
|
|
11601
|
+
}
|
|
11602
|
+
const built = buildRemoveImageGalleryEntryPatch({
|
|
11603
|
+
filePath
|
|
11604
|
+
}, schema, deps.state.sources[modulePath]);
|
|
11605
|
+
if (built.kind !== "ok") {
|
|
11606
|
+
return fromBuildResult(built);
|
|
11607
|
+
}
|
|
11608
|
+
return savePatch(deps, modulePath, built.patch);
|
|
11609
|
+
})];
|
|
11610
|
+
}
|
|
11611
|
+
|
|
11612
|
+
/**
|
|
11613
|
+
* Turn a helper's build failure into a tool error.
|
|
11614
|
+
*
|
|
11615
|
+
* `wrong-tool` is worth keeping distinct: the helpers can tell that the caller
|
|
11616
|
+
* reached for the wrong tool and which one it should have used, and passing that
|
|
11617
|
+
* through is what lets a model correct itself in one step instead of retrying
|
|
11618
|
+
* the same call.
|
|
11619
|
+
*/
|
|
11620
|
+
function fromBuildResult(built) {
|
|
11621
|
+
if (built.kind === "wrong-tool") {
|
|
11622
|
+
return {
|
|
11623
|
+
status: "error",
|
|
11624
|
+
code: "invalid-args",
|
|
11625
|
+
message: `${built.reason} Use the ${built.suggestedTool} tool instead.`
|
|
11626
|
+
};
|
|
11627
|
+
}
|
|
11628
|
+
return {
|
|
11629
|
+
status: "error",
|
|
11630
|
+
code: "invalid-args",
|
|
11631
|
+
message: built.message
|
|
11632
|
+
};
|
|
11633
|
+
}
|
|
11634
|
+
|
|
11635
|
+
/**
|
|
11636
|
+
* File operations are refused rather than half-supported.
|
|
11637
|
+
*
|
|
11638
|
+
* A `file` op carries binary content that has to be uploaded before the patch
|
|
11639
|
+
* is synced — a two-phase flow this pass does not implement. Letting one through
|
|
11640
|
+
* would store a patch referring to bytes that were never uploaded, which fails
|
|
11641
|
+
* later and a long way from the cause.
|
|
11642
|
+
*
|
|
11643
|
+
* Takes the unparsed patch, so this answer does not depend on the op being
|
|
11644
|
+
* otherwise well formed. All it needs is the caller's own claim about what the
|
|
11645
|
+
* op is.
|
|
11646
|
+
*/
|
|
11647
|
+
function rejectFileOps(patch) {
|
|
11648
|
+
const hasFileOp = patch.some(op => typeof op === "object" && op !== null && "op" in op && op.op === "file");
|
|
11649
|
+
if (!hasFileOp) {
|
|
11650
|
+
return null;
|
|
11651
|
+
}
|
|
11652
|
+
return {
|
|
11653
|
+
status: "error",
|
|
11654
|
+
code: "unsupported",
|
|
11655
|
+
message: "This patch contains a file operation. Uploading files is not supported over MCP yet — only text and JSON values can be changed."
|
|
11656
|
+
};
|
|
11657
|
+
}
|
|
11658
|
+
|
|
11659
|
+
/**
|
|
11660
|
+
* How many callers' data layers to keep around in proxy mode.
|
|
11661
|
+
*
|
|
11662
|
+
* Each entry holds one `ValOpsHttp`, and each of those caches the project's
|
|
11663
|
+
* evaluated modules once `initSources` has run — so this bounds memory, not just
|
|
11664
|
+
* entry count. Small on purpose: the cost of a miss is re-evaluating the
|
|
11665
|
+
* modules on the next call, which is what happened on *every* call before this
|
|
11666
|
+
* cache existed.
|
|
11667
|
+
*/
|
|
11668
|
+
const MAX_CACHED_OPS = 8;
|
|
11669
|
+
|
|
11670
|
+
/**
|
|
11671
|
+
* Val's server-side tool registry.
|
|
11672
|
+
*
|
|
11673
|
+
* This is the piece Val did not have: the Studio's chat tools are defined *and
|
|
11674
|
+
* executed in the browser*, against its client stores, so nothing here could be
|
|
11675
|
+
* re-exposed. These tools run against {@link ValOps} instead, which is what lets
|
|
11676
|
+
* an MCP server — or a stdio transport, or anything else — drive Val content
|
|
11677
|
+
* without a browser.
|
|
11678
|
+
*
|
|
11679
|
+
* Transport-agnostic on purpose: nothing under `tools/` imports an MCP SDK. A
|
|
11680
|
+
* host adapts {@link ValToolResult} at its own edge.
|
|
11681
|
+
*/
|
|
11682
|
+
function createValTools(valModules, options) {
|
|
11683
|
+
const resolveOps = createOpsResolver(valModules, options);
|
|
11684
|
+
const tools = [...readTools(), ...writeTools()];
|
|
11685
|
+
const byName = new Map(tools.map(tool => [tool.name, tool]));
|
|
11686
|
+
return {
|
|
11687
|
+
list() {
|
|
11688
|
+
return tools.map(({
|
|
11689
|
+
handler: _handler,
|
|
11690
|
+
...definition
|
|
11691
|
+
}) => definition);
|
|
11692
|
+
},
|
|
11693
|
+
listJsonSchema() {
|
|
11694
|
+
return tools.map(({
|
|
11695
|
+
handler: _handler,
|
|
11696
|
+
inputSchema,
|
|
11697
|
+
...rest
|
|
11698
|
+
}) => ({
|
|
11699
|
+
...rest,
|
|
11700
|
+
// zod 4 derives this itself, so there is no JSON-Schema-to-zod
|
|
11701
|
+
// converter anywhere in the stack and no second description of the
|
|
11702
|
+
// same input to keep in step.
|
|
11703
|
+
inputSchema: z.toJSONSchema(inputSchema, {
|
|
11704
|
+
io: "input"
|
|
11705
|
+
})
|
|
11706
|
+
}));
|
|
11707
|
+
},
|
|
11708
|
+
async call(name, args, ctx) {
|
|
11709
|
+
const tool = byName.get(name);
|
|
11710
|
+
if (!tool) {
|
|
11711
|
+
return {
|
|
11712
|
+
status: "error",
|
|
11713
|
+
code: "unknown-tool",
|
|
11714
|
+
message: `No tool named ${JSON.stringify(name)}. Available: ${tools.map(t => t.name).join(", ")}`
|
|
11715
|
+
};
|
|
11716
|
+
}
|
|
11717
|
+
const parsed = tool.inputSchema.safeParse(args ?? {});
|
|
11718
|
+
if (!parsed.success) {
|
|
11719
|
+
return {
|
|
11720
|
+
status: "error",
|
|
11721
|
+
code: "invalid-args",
|
|
11722
|
+
message: describeZodError(parsed.error)
|
|
11723
|
+
};
|
|
11724
|
+
}
|
|
11725
|
+
const resolved = resolveOps(ctx);
|
|
11726
|
+
if (resolved.status === "error") {
|
|
11727
|
+
return resolved.result;
|
|
11728
|
+
}
|
|
11729
|
+
const ops = resolved.ops;
|
|
11730
|
+
try {
|
|
11731
|
+
const state = await loadState(ops);
|
|
11732
|
+
if (state.status === "error") {
|
|
11733
|
+
return state.result;
|
|
11734
|
+
}
|
|
11735
|
+
const deps = {
|
|
11736
|
+
ops,
|
|
11737
|
+
ctx,
|
|
11738
|
+
state: state.state
|
|
11739
|
+
};
|
|
11740
|
+
return await tool.handler(parsed.data, deps);
|
|
11741
|
+
} catch (error) {
|
|
11742
|
+
// A thrown error here is a bug or an unreachable backend, not something
|
|
11743
|
+
// the model can act on — but it still comes back in-band so the client
|
|
11744
|
+
// sees a tool failure rather than a dead transport.
|
|
11745
|
+
return {
|
|
11746
|
+
status: "error",
|
|
11747
|
+
code: "internal",
|
|
11748
|
+
message: error instanceof Error ? error.message : String(error)
|
|
11749
|
+
};
|
|
11750
|
+
}
|
|
11751
|
+
},
|
|
11752
|
+
async dispose() {
|
|
11753
|
+
// Nothing to release today: ValOps holds no handle that needs closing, and
|
|
11754
|
+
// the fs watcher it can start is owned by the Studio's server. Kept in the
|
|
11755
|
+
// contract so hosts wire up teardown now rather than when it starts to
|
|
11756
|
+
// matter.
|
|
11757
|
+
}
|
|
11758
|
+
};
|
|
11759
|
+
}
|
|
11760
|
+
|
|
11761
|
+
/**
|
|
11762
|
+
* Pick the data layer for a call, which in proxy mode means picking whose
|
|
11763
|
+
* credential the backend will see.
|
|
11764
|
+
*
|
|
11765
|
+
* This is the one place authorization is decided, and it decides it by *not*
|
|
11766
|
+
* deciding: in proxy mode the caller's own personal access token goes to the
|
|
11767
|
+
* backend, which is the only party that can say what that token may do. The app
|
|
11768
|
+
* never inspects it, never caches a verdict about it, and never substitutes its
|
|
11769
|
+
* own API key for a missing one — see `docs/plans/mcp.md` D.2.
|
|
11770
|
+
*
|
|
11771
|
+
* The alternative shape, and the reason this function exists at all, is an
|
|
11772
|
+
* `authenticate()` that checks the PAT once and then acts under the app's key.
|
|
11773
|
+
* That reads as more secure and is strictly less so: the check happens in the
|
|
11774
|
+
* app, so every bug in it becomes full access to every project the app's key
|
|
11775
|
+
* can reach, and the backend's own permission model stops being consulted (D.6).
|
|
11776
|
+
*/
|
|
11777
|
+
function createOpsResolver(valModules, options) {
|
|
11778
|
+
if (options.mode === "fs") {
|
|
11779
|
+
// One instance, built once: fs mode is a developer's own working tree, so
|
|
11780
|
+
// there is no credential to vary by and no reason to re-evaluate modules.
|
|
11781
|
+
const ops = createValOps(valModules, options);
|
|
11782
|
+
return ctx => {
|
|
11783
|
+
if (ctx.auth) {
|
|
11784
|
+
// Refused rather than ignored. A host that thinks it is passing a
|
|
11785
|
+
// credential should not silently get local filesystem access instead —
|
|
11786
|
+
// and the difference matters, because fs mode writes straight to disk
|
|
11787
|
+
// with no backend permission check at all.
|
|
11788
|
+
return {
|
|
11789
|
+
status: "error",
|
|
11790
|
+
result: {
|
|
11791
|
+
status: "error",
|
|
11792
|
+
code: "unsupported",
|
|
11793
|
+
message: "This Val project is running in local filesystem mode, where there is nothing to authenticate against. Do not send a credential."
|
|
11794
|
+
}
|
|
11795
|
+
};
|
|
11796
|
+
}
|
|
11797
|
+
return {
|
|
11798
|
+
status: "ok",
|
|
11799
|
+
ops
|
|
11800
|
+
};
|
|
11801
|
+
};
|
|
11802
|
+
}
|
|
11803
|
+
|
|
11804
|
+
// Keyed by a hash of the PAT, so the same caller reuses their own instance and
|
|
11805
|
+
// two callers can never share one. Hashing is not a security boundary — the
|
|
11806
|
+
// instance holds the token regardless — but it keeps credentials out of the
|
|
11807
|
+
// key set, which is the thing that ends up in a heap dump or an error dump.
|
|
11808
|
+
const byPatHash = new Map();
|
|
11809
|
+
return ctx => {
|
|
11810
|
+
if (!ctx.auth) {
|
|
11811
|
+
return {
|
|
11812
|
+
status: "error",
|
|
11813
|
+
result: {
|
|
11814
|
+
status: "error",
|
|
11815
|
+
code: "forbidden",
|
|
11816
|
+
message: "This Val project talks to the Val content backend, so every call needs the caller's own personal access token. Run `val login` to get one."
|
|
11817
|
+
}
|
|
11818
|
+
};
|
|
11819
|
+
}
|
|
11820
|
+
const key = createHash("sha256").update(ctx.auth.pat).digest("hex");
|
|
11821
|
+
const cached = byPatHash.get(key);
|
|
11822
|
+
if (cached) {
|
|
11823
|
+
// Re-inserted so eviction drops the least recently used rather than the
|
|
11824
|
+
// oldest — a long-running caller should not be evicted by a burst of
|
|
11825
|
+
// one-off ones.
|
|
11826
|
+
byPatHash.delete(key);
|
|
11827
|
+
byPatHash.set(key, cached);
|
|
11828
|
+
return {
|
|
11829
|
+
status: "ok",
|
|
11830
|
+
ops: cached
|
|
11831
|
+
};
|
|
11832
|
+
}
|
|
11833
|
+
const ops = createValOps(valModules, options, {
|
|
11834
|
+
pat: ctx.auth.pat
|
|
11835
|
+
});
|
|
11836
|
+
byPatHash.set(key, ops);
|
|
11837
|
+
while (byPatHash.size > MAX_CACHED_OPS) {
|
|
11838
|
+
const oldest = byPatHash.keys().next();
|
|
11839
|
+
if (oldest.done) {
|
|
11840
|
+
break;
|
|
11841
|
+
}
|
|
11842
|
+
byPatHash.delete(oldest.value);
|
|
11843
|
+
}
|
|
11844
|
+
return {
|
|
11845
|
+
status: "ok",
|
|
11846
|
+
ops
|
|
11847
|
+
};
|
|
11848
|
+
};
|
|
11849
|
+
}
|
|
11850
|
+
/**
|
|
11851
|
+
* The content as the caller should see it, loaded once per call.
|
|
11852
|
+
*
|
|
11853
|
+
* Pending patches are applied, because an agent looking at a project mid-edit
|
|
11854
|
+
* should see what the Studio would show rather than the last published state.
|
|
11855
|
+
*
|
|
11856
|
+
* Deliberately not cached across calls. In fs mode a save recomputes the base
|
|
11857
|
+
* sha within the same process, so a cached view would go stale silently — and
|
|
11858
|
+
* the cost of being wrong here is an agent writing a patch against content that
|
|
11859
|
+
* has already moved.
|
|
11860
|
+
*/
|
|
11861
|
+
async function loadState(ops) {
|
|
11862
|
+
const patches = await ops.fetchPatches({
|
|
11863
|
+
excludePatchOps: false
|
|
11864
|
+
});
|
|
11865
|
+
// fetchPatches resolves with its failures on the result rather than rejecting,
|
|
11866
|
+
// so not checking these reads as "no pending changes" — which would quietly
|
|
11867
|
+
// hand back published content and let a write be based on it.
|
|
11868
|
+
if (patches.unauthorized) {
|
|
11869
|
+
return {
|
|
11870
|
+
status: "error",
|
|
11871
|
+
result: {
|
|
11872
|
+
status: "error",
|
|
11873
|
+
code: "forbidden",
|
|
11874
|
+
message: "Not authorized to read this project's pending changes. Check that the credential is valid and has access."
|
|
11875
|
+
}
|
|
11876
|
+
};
|
|
11877
|
+
}
|
|
11878
|
+
if (patches.networkError) {
|
|
11879
|
+
return {
|
|
11880
|
+
status: "error",
|
|
11881
|
+
result: {
|
|
11882
|
+
status: "error",
|
|
11883
|
+
code: "internal",
|
|
11884
|
+
message: "Could not reach the Val content backend."
|
|
11885
|
+
}
|
|
11886
|
+
};
|
|
11887
|
+
}
|
|
11888
|
+
if (patches.error) {
|
|
11889
|
+
return {
|
|
11890
|
+
status: "error",
|
|
11891
|
+
result: {
|
|
11892
|
+
status: "error",
|
|
11893
|
+
code: "internal",
|
|
11894
|
+
message: patches.error.message
|
|
11895
|
+
}
|
|
11896
|
+
};
|
|
11897
|
+
}
|
|
11898
|
+
const analysis = ops.analyzePatches(patches.patches);
|
|
11899
|
+
// getSourcesWithPatchesApplied, not getSources(analysis): the latter returns
|
|
11900
|
+
// only the modules that had patches, and validating that subset reports
|
|
11901
|
+
// spurious errors for anything that looks across modules, like keyOf or a
|
|
11902
|
+
// router.
|
|
11903
|
+
const sourcesRes = await ops.getSourcesWithPatchesApplied({
|
|
11904
|
+
...analysis,
|
|
11905
|
+
...patches
|
|
11906
|
+
});
|
|
11907
|
+
const [schemas, serializedSchemas] = await Promise.all([ops.getSchemas(), ops.getSerializedSchemas()]);
|
|
11908
|
+
return {
|
|
11909
|
+
status: "ok",
|
|
11910
|
+
state: {
|
|
11911
|
+
schemas,
|
|
11912
|
+
serializedSchemas,
|
|
11913
|
+
sources: sourcesRes.sources,
|
|
11914
|
+
patches,
|
|
11915
|
+
analysis,
|
|
11916
|
+
// Which modules hold a pending patch that would not apply. Carried rather
|
|
11917
|
+
// than discarded because their `sources` silently lack that change: the
|
|
11918
|
+
// content here is not what publishing would produce, so a write against
|
|
11919
|
+
// it would be based on a state that does not exist. See
|
|
11920
|
+
// `unappliedPatchesFor`.
|
|
11921
|
+
unappliedPatches: sourcesRes.errors
|
|
11922
|
+
}
|
|
11923
|
+
};
|
|
11924
|
+
}
|
|
11925
|
+
function describeZodError(error) {
|
|
11926
|
+
return error.issues.map(issue => {
|
|
11927
|
+
const path = issue.path.join(".");
|
|
11928
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
11929
|
+
}).join("; ");
|
|
11930
|
+
}
|
|
11931
|
+
|
|
11932
|
+
const JsFileLookupMapping = [
|
|
11933
|
+
// NOTE: first one matching will be used
|
|
11934
|
+
[".cjs.d.ts", [".esm.js", ".mjs.js"]], [".cjs.js", [".esm.js", ".mjs.js"]], [".cjs", [".mjs"]], [".d.ts", [".js", ".esm.js", ".mjs.js"]]];
|
|
11935
|
+
const MAX_CACHE_SIZE = 100 * 1024 * 1024; // 100 mb
|
|
11936
|
+
const MAX_OBJECT_KEY_SIZE = 2 ** 27; // https://stackoverflow.com/questions/13367391/is-there-a-limit-on-length-of-the-key-string-in-js-object
|
|
11937
|
+
|
|
11938
|
+
class ValModuleLoader {
|
|
11939
|
+
constructor(projectRoot, compilerOptions,
|
|
11940
|
+
// TODO: remove this?
|
|
11941
|
+
sourceFileHandler, host = {
|
|
11942
|
+
...ts.sys,
|
|
11943
|
+
writeFile: (fileName, data, encoding) => {
|
|
11944
|
+
fs.mkdirSync(path__default.dirname(fileName), {
|
|
11945
|
+
recursive: true
|
|
11946
|
+
});
|
|
11947
|
+
fs.writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
|
|
11948
|
+
},
|
|
11949
|
+
rmFile: fs.rmSync,
|
|
11950
|
+
readBuffer: fileName => {
|
|
11951
|
+
try {
|
|
11952
|
+
return fs.readFileSync(fileName);
|
|
11953
|
+
} catch {
|
|
11954
|
+
return undefined;
|
|
11955
|
+
}
|
|
11956
|
+
}
|
|
11957
|
+
}, disableCache = false) {
|
|
10692
11958
|
this.projectRoot = projectRoot;
|
|
10693
11959
|
this.compilerOptions = compilerOptions;
|
|
10694
11960
|
this.sourceFileHandler = sourceFileHandler;
|
|
@@ -12374,7 +13640,17 @@ async function findAndEvalValConfigFile(projectRoot) {
|
|
|
12374
13640
|
}
|
|
12375
13641
|
|
|
12376
13642
|
/**
|
|
12377
|
-
* The Val login
|
|
13643
|
+
* The Val login flow, as reusable primitives.
|
|
13644
|
+
*
|
|
13645
|
+
* This is an RFC 8628 device authorization grant. The shape that matters:
|
|
13646
|
+
* {@link ValDeviceAuthorization.deviceCode} is a secret this process holds and
|
|
13647
|
+
* polls with, while {@link ValDeviceAuthorization.userCode} is the short string
|
|
13648
|
+
* the human reads out of the terminal and types into a browser. Only the device
|
|
13649
|
+
* code can collect a token.
|
|
13650
|
+
*
|
|
13651
|
+
* Keep them apart. Show the user code; never print, log or put the device code
|
|
13652
|
+
* in a URL. An earlier version of this flow used one value for both jobs, which
|
|
13653
|
+
* meant anyone who saw the verification link could collect the token it led to.
|
|
12378
13654
|
*
|
|
12379
13655
|
* The CLI wraps these with terminal output, and `@valbuild/language-server`
|
|
12380
13656
|
* wraps them with LSP `window/showDocument` and progress reporting. Neither the
|
|
@@ -12388,6 +13664,21 @@ const DEFAULT_LOGIN_HOST = "https://admin.val.build";
|
|
|
12388
13664
|
function defaultHost() {
|
|
12389
13665
|
return process.env.VAL_BUILD_URL || DEFAULT_LOGIN_HOST;
|
|
12390
13666
|
}
|
|
13667
|
+
|
|
13668
|
+
/**
|
|
13669
|
+
* What gets shown on the approval screen so the person can tell which terminal
|
|
13670
|
+
* is asking. Self-reported and therefore a hint, not proof — the server treats
|
|
13671
|
+
* it as untrusted display text.
|
|
13672
|
+
*/
|
|
13673
|
+
function defaultDeviceName() {
|
|
13674
|
+
try {
|
|
13675
|
+
return `${os.hostname()} (${os.platform()})`;
|
|
13676
|
+
} catch {
|
|
13677
|
+
// hostname() can throw on locked-down containers. A missing device name is
|
|
13678
|
+
// not worth failing a login over; the server renders "Unknown".
|
|
13679
|
+
return "";
|
|
13680
|
+
}
|
|
13681
|
+
}
|
|
12391
13682
|
class ValLoginError extends Error {
|
|
12392
13683
|
constructor(code, message, details) {
|
|
12393
13684
|
super(message);
|
|
@@ -12397,20 +13688,24 @@ class ValLoginError extends Error {
|
|
|
12397
13688
|
}
|
|
12398
13689
|
}
|
|
12399
13690
|
|
|
12400
|
-
/** A login attempt that is waiting for the user to
|
|
13691
|
+
/** A login attempt that is waiting for the user to approve it in a browser. */
|
|
12401
13692
|
|
|
12402
13693
|
/**
|
|
12403
|
-
* Begin a login attempt. The caller is responsible for getting
|
|
12404
|
-
*
|
|
13694
|
+
* Begin a login attempt. The caller is responsible for getting the user code
|
|
13695
|
+
* and verification URL in front of the user.
|
|
12405
13696
|
*/
|
|
12406
13697
|
async function startValLogin(options = {}) {
|
|
12407
13698
|
var _response$headers$get;
|
|
12408
13699
|
const host = options.host ?? defaultHost();
|
|
13700
|
+
const deviceName = options.deviceName ?? defaultDeviceName();
|
|
12409
13701
|
const response = await fetch(`${host}/api/login`, {
|
|
12410
13702
|
method: "POST",
|
|
12411
13703
|
headers: {
|
|
12412
13704
|
"Content-Type": "application/json"
|
|
12413
|
-
}
|
|
13705
|
+
},
|
|
13706
|
+
body: JSON.stringify(deviceName ? {
|
|
13707
|
+
device_name: deviceName
|
|
13708
|
+
} : {})
|
|
12414
13709
|
});
|
|
12415
13710
|
if (response.status >= 500) {
|
|
12416
13711
|
const text = await response.text().catch(() => "");
|
|
@@ -12421,42 +13716,63 @@ async function startValLogin(options = {}) {
|
|
|
12421
13716
|
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
13717
|
}
|
|
12423
13718
|
const json = await response.json();
|
|
12424
|
-
const
|
|
12425
|
-
const
|
|
12426
|
-
|
|
12427
|
-
|
|
13719
|
+
const deviceCode = json === null || json === void 0 ? void 0 : json.device_code;
|
|
13720
|
+
const userCode = json === null || json === void 0 ? void 0 : json.user_code;
|
|
13721
|
+
const verificationUri = json === null || json === void 0 ? void 0 : json.verification_uri;
|
|
13722
|
+
if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
|
|
13723
|
+
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
13724
|
}
|
|
12429
13725
|
return {
|
|
12430
|
-
|
|
12431
|
-
|
|
13726
|
+
deviceCode,
|
|
13727
|
+
userCode,
|
|
13728
|
+
verificationUri,
|
|
13729
|
+
verificationUriComplete: typeof (json === null || json === void 0 ? void 0 : json.verification_uri_complete) === "string" ? json.verification_uri_complete : verificationUri,
|
|
13730
|
+
expiresInSeconds: typeof (json === null || json === void 0 ? void 0 : json.expires_in) === "number" ? json.expires_in : DEFAULT_LOGIN_EXPIRES_IN_SECONDS,
|
|
13731
|
+
intervalSeconds: typeof (json === null || json === void 0 ? void 0 : json.interval) === "number" ? json.interval : DEFAULT_LOGIN_POLL_INTERVAL_SECONDS
|
|
12432
13732
|
};
|
|
12433
13733
|
}
|
|
12434
|
-
|
|
12435
|
-
|
|
13734
|
+
|
|
13735
|
+
/** Fallbacks for a server that omits the optional RFC 8628 timing fields. */
|
|
13736
|
+
const DEFAULT_LOGIN_EXPIRES_IN_SECONDS = 600;
|
|
13737
|
+
const DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = 5;
|
|
12436
13738
|
|
|
12437
13739
|
/**
|
|
12438
|
-
*
|
|
13740
|
+
* How much to add to the poll interval when the server answers `slow_down`.
|
|
13741
|
+
* RFC 8628 section 3.5 specifies 5 seconds.
|
|
13742
|
+
*/
|
|
13743
|
+
const SLOW_DOWN_INCREMENT_SECONDS = 5;
|
|
13744
|
+
|
|
13745
|
+
/**
|
|
13746
|
+
* Poll until the user approves the login in their browser.
|
|
12439
13747
|
*
|
|
12440
13748
|
* Accepts an `AbortSignal` so an editor can cancel the flow when the user
|
|
12441
|
-
* dismisses the prompt, instead of leaving a poll loop running
|
|
13749
|
+
* dismisses the prompt, instead of leaving a poll loop running to expiry.
|
|
12442
13750
|
*/
|
|
12443
|
-
async function awaitValLoginConfirmation(
|
|
13751
|
+
async function awaitValLoginConfirmation(authorization, options = {}) {
|
|
12444
13752
|
const host = options.host ?? defaultHost();
|
|
12445
|
-
const maxDuration = options.maxDurationMs ??
|
|
12446
|
-
const pollInterval = options.pollIntervalMs ?? DEFAULT_LOGIN_POLL_INTERVAL;
|
|
13753
|
+
const maxDuration = options.maxDurationMs ?? authorization.expiresInSeconds * 1000;
|
|
12447
13754
|
const now = options.now ?? (() => Date.now());
|
|
13755
|
+
// Mutable: `slow_down` widens it as we go, and never narrows it again.
|
|
13756
|
+
let intervalMs = authorization.intervalSeconds * 1000;
|
|
12448
13757
|
const start = now();
|
|
12449
13758
|
while (now() - start < maxDuration) {
|
|
12450
13759
|
var _options$signal, _options$signal2;
|
|
12451
13760
|
if ((_options$signal = options.signal) !== null && _options$signal !== void 0 && _options$signal.aborted) {
|
|
12452
13761
|
throw new ValLoginError("aborted", "Login was cancelled.");
|
|
12453
13762
|
}
|
|
12454
|
-
|
|
13763
|
+
// Wait first: the user has not had time to approve anything yet.
|
|
13764
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
12455
13765
|
if ((_options$signal2 = options.signal) !== null && _options$signal2 !== void 0 && _options$signal2.aborted) {
|
|
12456
13766
|
throw new ValLoginError("aborted", "Login was cancelled.");
|
|
12457
13767
|
}
|
|
12458
|
-
const response = await fetch(`${host}/api/login
|
|
12459
|
-
method: "POST"
|
|
13768
|
+
const response = await fetch(`${host}/api/login`, {
|
|
13769
|
+
method: "POST",
|
|
13770
|
+
headers: {
|
|
13771
|
+
"Content-Type": "application/json"
|
|
13772
|
+
},
|
|
13773
|
+
body: JSON.stringify({
|
|
13774
|
+
device_code: authorization.deviceCode
|
|
13775
|
+
})
|
|
12460
13776
|
});
|
|
12461
13777
|
if (response.status >= 500) {
|
|
12462
13778
|
throw new ValLoginError("server-error", "An error occurred on the server.", `Status: ${response.status}`);
|
|
@@ -12474,6 +13790,23 @@ async function awaitValLoginConfirmation(nonce, options = {}) {
|
|
|
12474
13790
|
}
|
|
12475
13791
|
throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
|
|
12476
13792
|
}
|
|
13793
|
+
const json = await response.json().catch(() => null);
|
|
13794
|
+
const error = typeof (json === null || json === void 0 ? void 0 : json.error) === "string" ? json.error : null;
|
|
13795
|
+
const description = typeof (json === null || json === void 0 ? void 0 : json.error_description) === "string" ? json.error_description : undefined;
|
|
13796
|
+
if (error === "authorization_pending") {
|
|
13797
|
+
continue;
|
|
13798
|
+
}
|
|
13799
|
+
if (error === "slow_down" || response.status === 429) {
|
|
13800
|
+
intervalMs += SLOW_DOWN_INCREMENT_SECONDS * 1000;
|
|
13801
|
+
continue;
|
|
13802
|
+
}
|
|
13803
|
+
if (error === "access_denied") {
|
|
13804
|
+
throw new ValLoginError("access-denied", "The login was declined in the browser.", description);
|
|
13805
|
+
}
|
|
13806
|
+
if (error === "expired_token") {
|
|
13807
|
+
throw new ValLoginError("expired", "The login code expired before it was approved.", description);
|
|
13808
|
+
}
|
|
13809
|
+
throw new ValLoginError("unexpected-response", "Unexpected response from the server.", JSON.stringify(json));
|
|
12477
13810
|
}
|
|
12478
13811
|
throw new ValLoginError("timeout", "Login confirmation timed out.");
|
|
12479
13812
|
}
|
|
@@ -12775,4 +14108,4 @@ function readCapturedReport(snapshotDir) {
|
|
|
12775
14108
|
return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
|
|
12776
14109
|
}
|
|
12777
14110
|
|
|
12778
|
-
export {
|
|
14111
|
+
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, createValOps, createValServer, createValTools, 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, initHandlerOptions, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
|