@valbuild/server 0.115.0 → 0.117.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.
@@ -17,6 +17,7 @@ var z = require('zod');
17
17
  var sizeOf = require('image-size');
18
18
  var zodValidationError = require('zod-validation-error');
19
19
  var os = require('os');
20
+ var node_crypto = require('node:crypto');
20
21
  var sucrase = require('sucrase');
21
22
  var http = require('http');
22
23
  var https = require('https');
@@ -1040,7 +1041,7 @@ function errorMessage(e) {
1040
1041
  return String(e);
1041
1042
  }
1042
1043
 
1043
- const jsonOps$2 = new patch.JSONOps();
1044
+ const jsonOps$3 = new patch.JSONOps();
1044
1045
 
1045
1046
  /**
1046
1047
  * Classification of a single patch op against a module's serialized schema,
@@ -1295,7 +1296,7 @@ function applyJsonValuesEntryPatches(args) {
1295
1296
  // on an entry that does not exist.
1296
1297
  continue;
1297
1298
  }
1298
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [{
1299
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$3, [{
1299
1300
  op: "add",
1300
1301
  path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
1301
1302
  value: patchId
@@ -1349,7 +1350,7 @@ function applyJsonValuesEntryPatches(args) {
1349
1350
  patchId
1350
1351
  };
1351
1352
  }
1352
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [rebased.value]);
1353
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$3, [rebased.value]);
1353
1354
  if (fp.result.isErr(applied)) {
1354
1355
  return {
1355
1356
  kind: "error",
@@ -1632,7 +1633,7 @@ function findJsonEntryFilePath(moduleFilePath, valTsSourceFile, entryKey) {
1632
1633
  return resolveExistingJsonPath(moduleFilePath, entry.importPath);
1633
1634
  }
1634
1635
 
1635
- const jsonOps$1 = new patch.JSONOps();
1636
+ const jsonOps$2 = new patch.JSONOps();
1636
1637
 
1637
1638
  /**
1638
1639
  * Substitutes loaded `.jsonValues()` entry content back into a module's root
@@ -1923,7 +1924,7 @@ class Service {
1923
1924
  if (fp.result.isErr(rebased)) {
1924
1925
  throw Error(`Could not apply ${op.op} to jsonValues entry '${entryKey}' of ${moduleFilePath}: ${rebased.error.message}`);
1925
1926
  }
1926
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps$1, [rebased.value]);
1927
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [rebased.value]);
1927
1928
  if (fp.result.isErr(applied)) {
1928
1929
  throw Error(`Could not apply ${op.op} to ${jsonPath}: ${applied.error.message}`);
1929
1930
  }
@@ -2092,7 +2093,7 @@ function encodeJwt(payload, sessionKey) {
2092
2093
  }
2093
2094
 
2094
2095
  /* eslint-disable @typescript-eslint/no-unused-vars */
2095
- const jsonOps = new patch.JSONOps();
2096
+ const jsonOps$1 = new patch.JSONOps();
2096
2097
  const tsOps = new TSOps(document => {
2097
2098
  return fp.pipe(analyzeValModule(document), fp.result.map(({
2098
2099
  source
@@ -2861,7 +2862,7 @@ class ValOps {
2861
2862
  }
2862
2863
  const patchRes = patch.applyPatch(patch.deepClone(patchedSources[path]),
2863
2864
  // 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?
2864
- jsonOps, applicableOps.concat(...Object.values(fileFixOps)));
2865
+ jsonOps$1, applicableOps.concat(...Object.values(fileFixOps)));
2865
2866
  if (fp.result.isErr(patchRes)) {
2866
2867
  console.error("Could not apply patch", JSON.stringify({
2867
2868
  path,
@@ -3561,7 +3562,7 @@ class ValOps {
3561
3562
  patchHadError = true;
3562
3563
  break;
3563
3564
  }
3564
- const applied = patch.applyPatch(patch.deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
3565
+ const applied = patch.applyPatch(patch.deepClone(contentRes.value), jsonOps$1, [rebasedRes.value]);
3565
3566
  if (fp.result.isErr(applied)) {
3566
3567
  collectPatchError(applied.error, patchId, op);
3567
3568
  patchHadError = true;
@@ -7467,6 +7468,227 @@ async function getSettings(projectName, auth) {
7467
7468
  }
7468
7469
  }
7469
7470
 
7471
+ /**
7472
+ * Resolving how Val is configured, and building the data layer from it.
7473
+ *
7474
+ * Both live here rather than inside `createValApiRouter` because the MCP tool
7475
+ * registry needs exactly the same answers: which mode we are in, which
7476
+ * credential to use, and which `ValOps` implementation that implies. Two copies
7477
+ * of this would drift, and the failure would be quiet — a registry that decides
7478
+ * it is in fs mode while the Studio decides it is in proxy mode reads different
7479
+ * content from the same project.
7480
+ *
7481
+ * The type-only import from `./ValRouter` is deliberate: it keeps `ValApiOptions`
7482
+ * where its documentation lives without creating a runtime cycle.
7483
+ *
7484
+ * The credential-bearing URL check at the bottom of this file lives here for the
7485
+ * same reason: it is a check on the URLs `initHandlerOptions` resolves, and its
7486
+ * only caller is that function. Leaving it in `./ValRouter` would have meant
7487
+ * importing it back from there, which is the runtime cycle the paragraph above
7488
+ * exists to avoid.
7489
+ */
7490
+
7491
+ const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
7492
+
7493
+ /**
7494
+ * Resolve options plus environment into a concrete {@link ValServerConfig}.
7495
+ *
7496
+ * Moved verbatim out of `createValApiRouter`; the precedence rules are load
7497
+ * bearing, so this is the one place they are written down. Note that "proxy"
7498
+ * mode is inferred when `VAL_API_KEY` or `VAL_SECRET` is present and no mode was
7499
+ * given, which is why a project can be pushed into proxy mode by setting an env
7500
+ * var alone.
7501
+ */
7502
+ async function initHandlerOptions(route, opts, config) {
7503
+ const maybeApiKey = opts.apiKey || process.env.VAL_API_KEY;
7504
+ const maybeValSecret = opts.valSecret || process.env.VAL_SECRET;
7505
+ const isProxyMode = opts.mode === "proxy" || opts.mode === undefined && (maybeApiKey || maybeValSecret);
7506
+ const valEnableRedirectUrl = opts.valEnableRedirectUrl || process.env.VAL_ENABLE_REDIRECT_URL;
7507
+ const valDisableRedirectUrl = opts.valDisableRedirectUrl || process.env.VAL_DISABLE_REDIRECT_URL;
7508
+ const maybeValProject = opts.project || process.env.VAL_PROJECT;
7509
+ const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || DEFAULT_VAL_BUILD_URL;
7510
+ const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
7511
+ warnIfInsecureUrls({
7512
+ valBuildUrl,
7513
+ valContentUrl
7514
+ });
7515
+ if (isProxyMode) {
7516
+ var _opts$versions, _opts$versions2;
7517
+ if (!maybeApiKey || !maybeValSecret) {
7518
+ throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode");
7519
+ }
7520
+ const maybeGitCommit = opts.gitCommit || process.env.VAL_GIT_COMMIT;
7521
+ if (!maybeGitCommit) {
7522
+ throw new Error("VAL_GIT_COMMIT env var must be set in proxy mode");
7523
+ }
7524
+ const maybeGitBranch = opts.gitBranch || process.env.VAL_GIT_BRANCH;
7525
+ if (!maybeGitBranch) {
7526
+ throw new Error("VAL_GIT_BRANCH env var must be set in proxy mode");
7527
+ }
7528
+ if (!maybeValProject) {
7529
+ 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.");
7530
+ }
7531
+ const coreVersion = (_opts$versions = opts.versions) === null || _opts$versions === void 0 ? void 0 : _opts$versions.core;
7532
+ if (!coreVersion) {
7533
+ throw new Error("Could not determine version of @valbuild/core");
7534
+ }
7535
+ const nextVersion = (_opts$versions2 = opts.versions) === null || _opts$versions2 === void 0 ? void 0 : _opts$versions2.next;
7536
+ if (!nextVersion) {
7537
+ throw new Error("Could not determine version of @valbuild/next");
7538
+ }
7539
+ return {
7540
+ mode: "http",
7541
+ route,
7542
+ apiKey: maybeApiKey,
7543
+ valSecret: maybeValSecret,
7544
+ commit: maybeGitCommit,
7545
+ branch: maybeGitBranch,
7546
+ root: opts.root,
7547
+ project: maybeValProject,
7548
+ valEnableRedirectUrl,
7549
+ valDisableRedirectUrl,
7550
+ valContentUrl,
7551
+ valBuildUrl,
7552
+ config
7553
+ };
7554
+ } else {
7555
+ const cwd = process.cwd();
7556
+ const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || DEFAULT_VAL_BUILD_URL;
7557
+ return {
7558
+ mode: "fs",
7559
+ cwd,
7560
+ route,
7561
+ valDisableRedirectUrl,
7562
+ valEnableRedirectUrl,
7563
+ valBuildUrl,
7564
+ valContentUrl,
7565
+ apiKey: maybeApiKey,
7566
+ valSecret: maybeValSecret,
7567
+ project: maybeValProject,
7568
+ config
7569
+ };
7570
+ }
7571
+ }
7572
+
7573
+ /**
7574
+ * Build the data layer a {@link ValServerConfig} calls for.
7575
+ *
7576
+ * `auth` decides *whose* credential the http backend sees. Left out, it is the
7577
+ * app's own API key — which is what the Studio wants, because there the app has
7578
+ * already verified a session cookie and is acting on the user's behalf under its
7579
+ * own authority.
7580
+ *
7581
+ * A caller acting for a user it has *not* authenticated itself must pass that
7582
+ * user's personal access token instead, so the backend is the one that decides
7583
+ * what the caller may do. That is the whole of `docs/plans/mcp.md` D.2: the app
7584
+ * stops being an authority and goes back to being a pipe. Passing the app's API
7585
+ * key on such a request is the D.6 confused deputy, and it is worth being blunt
7586
+ * about why it is tempting — it works, and it works for every project the key
7587
+ * can reach, including the ones the caller cannot.
7588
+ */
7589
+ function createValOps(valModules, options, auth) {
7590
+ if (options.mode === "fs") {
7591
+ // No credential in fs mode: this reads and writes the developer's own
7592
+ // working tree, and there is no backend to authenticate to. A PAT handed in
7593
+ // here is not ignored quietly — the caller is told, in createValTools.
7594
+ return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
7595
+ formatter: options.formatter,
7596
+ config: options.config
7597
+ });
7598
+ }
7599
+ if (options.mode === "http") {
7600
+ return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, auth ?? {
7601
+ apiKey: options.apiKey
7602
+ }, valModules, {
7603
+ formatter: options.formatter,
7604
+ root: options.root,
7605
+ config: options.config
7606
+ });
7607
+ }
7608
+ throw new Error(
7609
+ // The union is exhausted above; this catches a config that came from
7610
+ // somewhere untyped.
7611
+ "Invalid mode: " + (options === null || options === void 0 ? void 0 : options.mode));
7612
+ }
7613
+
7614
+ /**
7615
+ * Hosts we send credentials to, and what each one puts at risk. They differ:
7616
+ * only `valBuildUrl` hands back the app token that becomes the session cookie,
7617
+ * so a single shared sentence would overstate one and understate the other.
7618
+ */
7619
+
7620
+ const CREDENTIAL_BEARING_URLS = ["valBuildUrl", "valContentUrl"];
7621
+ const WHAT_IS_AT_RISK = {
7622
+ 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.",
7623
+ 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."
7624
+ };
7625
+
7626
+ // NOTE: `URL.hostname` keeps the brackets on an IPv6 literal, so this is
7627
+ // "[::1]" and not "::1" - and `http://[0:0:0:0:0:0:0:1]` normalises to the
7628
+ // same short form before it gets here. Dropping the brackets looks like a
7629
+ // tidy-up and silently stops matching IPv6 loopback.
7630
+ const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
7631
+
7632
+ /**
7633
+ * The URL as it is safe to print. `http://user:pass@host` is a legal override,
7634
+ * and a warning about credential exposure that puts the password in the log
7635
+ * would be the very thing it is warning about.
7636
+ */
7637
+ function forLog(parsed) {
7638
+ if (!parsed.username && !parsed.password) {
7639
+ return parsed.href;
7640
+ }
7641
+ const redacted = new URL(parsed.href);
7642
+ redacted.username = "";
7643
+ redacted.password = "";
7644
+ return `${redacted.href} (credentials redacted)`;
7645
+ }
7646
+
7647
+ /**
7648
+ * Returns a warning if `url` would send credentials somewhere they can be read
7649
+ * off the wire, or null if it is fine.
7650
+ *
7651
+ * Both URLs default to https, but each is overridable - `opts.valBuildUrl` /
7652
+ * `VAL_BUILD_URL`, `opts.valContentUrl` / `VAL_CONTENT_URL` - and neither
7653
+ * override has ever been scheme-checked. Point one at a plain http host and the
7654
+ * api key goes out in clear text, and whatever comes back is whatever the
7655
+ * network says: for `valBuildUrl` that includes the app token this server
7656
+ * re-signs into the session cookie.
7657
+ *
7658
+ * Loopback over http is exempt: that is a val.build running on the developer's
7659
+ * own machine, and there is no network to be on the wrong side of.
7660
+ *
7661
+ * This warns rather than throws. Both overrides are set by the operator, not by
7662
+ * an attacker, so this is a misconfiguration to surface - not untrusted input to
7663
+ * reject - and refusing to boot would break anyone deliberately pointing at an
7664
+ * internal http host today.
7665
+ */
7666
+ function insecureUrlWarning(name, url) {
7667
+ let parsed;
7668
+ try {
7669
+ parsed = new URL(url);
7670
+ } catch {
7671
+ // NOTE: the URL is not echoed here. It did not parse, so there is nothing
7672
+ // to redact with, and an unparseable string can still hold a password.
7673
+ return `Val: ${name} is not a valid URL.`;
7674
+ }
7675
+ if (parsed.protocol === "https:") {
7676
+ return null;
7677
+ }
7678
+ if (parsed.protocol === "http:" && (LOOPBACK_HOSTNAMES.includes(parsed.hostname) || parsed.hostname.endsWith(".localhost"))) {
7679
+ return null;
7680
+ }
7681
+ 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.`;
7682
+ }
7683
+ function warnIfInsecureUrls(urls) {
7684
+ for (const name of CREDENTIAL_BEARING_URLS) {
7685
+ const warning = insecureUrlWarning(name, urls[name]);
7686
+ if (warning) {
7687
+ console.warn(warning);
7688
+ }
7689
+ }
7690
+ }
7691
+
7470
7692
  function getPersonalAccessTokenPath(root) {
7471
7693
  return path__namespace["default"].join(path__namespace["default"].resolve(root), ".val", "pat.json");
7472
7694
  }
@@ -7540,24 +7762,7 @@ const ValServer = (valModules, options, callbacks) => {
7540
7762
  }).nullable()
7541
7763
  }))
7542
7764
  });
7543
- let serverOps;
7544
- if (options.mode === "fs") {
7545
- serverOps = new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
7546
- formatter: options.formatter,
7547
- config: options.config
7548
- });
7549
- } else if (options.mode === "http") {
7550
- serverOps = new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
7551
- apiKey: options.apiKey
7552
- }, valModules, {
7553
- formatter: options.formatter,
7554
- root: options.root,
7555
- config: options.config
7556
- });
7557
- } else {
7558
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7559
- throw new Error("Invalid mode: " + (options === null || options === void 0 ? void 0 : options.mode));
7560
- }
7765
+ const serverOps = createValOps(valModules, options);
7561
7766
  const getAuthorizeUrl = (publicValApiRe, token) => {
7562
7767
  if (!options.project) {
7563
7768
  throw new Error("Project is not set");
@@ -10338,191 +10543,44 @@ async function createValServer(valModules, route, opts, config, callbacks, forma
10338
10543
  ...valServerConfig
10339
10544
  }, callbacks);
10340
10545
  }
10341
- async function initHandlerOptions(route, opts, config) {
10342
- const maybeApiKey = opts.apiKey || process.env.VAL_API_KEY;
10343
- const maybeValSecret = opts.valSecret || process.env.VAL_SECRET;
10344
- const isProxyMode = opts.mode === "proxy" || opts.mode === undefined && (maybeApiKey || maybeValSecret);
10345
- const valEnableRedirectUrl = opts.valEnableRedirectUrl || process.env.VAL_ENABLE_REDIRECT_URL;
10346
- const valDisableRedirectUrl = opts.valDisableRedirectUrl || process.env.VAL_DISABLE_REDIRECT_URL;
10347
- const maybeValProject = opts.project || process.env.VAL_PROJECT;
10348
- const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build";
10349
- const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
10350
- warnIfInsecureUrls({
10351
- valBuildUrl,
10352
- valContentUrl
10353
- });
10354
- if (isProxyMode) {
10355
- var _opts$versions, _opts$versions2;
10356
- if (!maybeApiKey || !maybeValSecret) {
10357
- throw new Error("VAL_API_KEY and VAL_SECRET env vars must both be set in proxy mode");
10358
- }
10359
- const maybeGitCommit = opts.gitCommit || process.env.VAL_GIT_COMMIT;
10360
- if (!maybeGitCommit) {
10361
- throw new Error("VAL_GIT_COMMIT env var must be set in proxy mode");
10546
+
10547
+ // TODO: remove
10548
+ async function safeReadGit(cwd) {
10549
+ async function findGitHead(currentDir, depth) {
10550
+ const gitHeadPath = path__namespace.join(currentDir, ".git", "HEAD");
10551
+ if (depth > 1000) {
10552
+ console.error(`Reached max depth while scanning for .git folder. Current working dir: ${cwd}.`);
10553
+ return {
10554
+ commit: undefined,
10555
+ branch: undefined
10556
+ };
10362
10557
  }
10363
- const maybeGitBranch = opts.gitBranch || process.env.VAL_GIT_BRANCH;
10364
- if (!maybeGitBranch) {
10365
- throw new Error("VAL_GIT_BRANCH env var must be set in proxy mode");
10366
- }
10367
- if (!maybeValProject) {
10368
- 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.");
10369
- }
10370
- const coreVersion = (_opts$versions = opts.versions) === null || _opts$versions === void 0 ? void 0 : _opts$versions.core;
10371
- if (!coreVersion) {
10372
- throw new Error("Could not determine version of @valbuild/core");
10373
- }
10374
- const nextVersion = (_opts$versions2 = opts.versions) === null || _opts$versions2 === void 0 ? void 0 : _opts$versions2.next;
10375
- if (!nextVersion) {
10376
- throw new Error("Could not determine version of @valbuild/next");
10377
- }
10378
- return {
10379
- mode: "http",
10380
- route,
10381
- apiKey: maybeApiKey,
10382
- valSecret: maybeValSecret,
10383
- commit: maybeGitCommit,
10384
- branch: maybeGitBranch,
10385
- root: opts.root,
10386
- project: maybeValProject,
10387
- valEnableRedirectUrl,
10388
- valDisableRedirectUrl,
10389
- valContentUrl,
10390
- valBuildUrl,
10391
- config
10392
- };
10393
- } else {
10394
- const cwd = process.cwd();
10395
- return {
10396
- mode: "fs",
10397
- cwd,
10398
- route,
10399
- valDisableRedirectUrl,
10400
- valEnableRedirectUrl,
10401
- valBuildUrl,
10402
- valContentUrl,
10403
- apiKey: maybeApiKey,
10404
- valSecret: maybeValSecret,
10405
- project: maybeValProject,
10406
- config
10407
- };
10408
- }
10409
- }
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
-
10489
- // TODO: remove
10490
- async function safeReadGit(cwd) {
10491
- async function findGitHead(currentDir, depth) {
10492
- const gitHeadPath = path__namespace.join(currentDir, ".git", "HEAD");
10493
- if (depth > 1000) {
10494
- console.error(`Reached max depth while scanning for .git folder. Current working dir: ${cwd}.`);
10495
- return {
10496
- commit: undefined,
10497
- branch: undefined
10498
- };
10499
- }
10500
- try {
10501
- const headContents = await fs.promises.readFile(gitHeadPath, "utf-8");
10502
- const match = headContents.match(/^ref: refs\/heads\/(.+)/);
10503
- if (match) {
10504
- const branchName = match[1];
10505
- return {
10506
- branch: branchName,
10507
- commit: await readCommit(currentDir, branchName)
10508
- };
10509
- } else {
10510
- return {
10511
- commit: undefined,
10512
- branch: undefined
10513
- };
10514
- }
10515
- } catch {
10516
- const parentDir = path__namespace.dirname(currentDir);
10517
-
10518
- // We've reached the root directory
10519
- if (parentDir === currentDir) {
10520
- return {
10521
- commit: undefined,
10522
- branch: undefined
10523
- };
10524
- }
10525
- return findGitHead(parentDir, depth + 1);
10558
+ try {
10559
+ const headContents = await fs.promises.readFile(gitHeadPath, "utf-8");
10560
+ const match = headContents.match(/^ref: refs\/heads\/(.+)/);
10561
+ if (match) {
10562
+ const branchName = match[1];
10563
+ return {
10564
+ branch: branchName,
10565
+ commit: await readCommit(currentDir, branchName)
10566
+ };
10567
+ } else {
10568
+ return {
10569
+ commit: undefined,
10570
+ branch: undefined
10571
+ };
10572
+ }
10573
+ } catch {
10574
+ const parentDir = path__namespace.dirname(currentDir);
10575
+
10576
+ // We've reached the root directory
10577
+ if (parentDir === currentDir) {
10578
+ return {
10579
+ commit: undefined,
10580
+ branch: undefined
10581
+ };
10582
+ }
10583
+ return findGitHead(parentDir, depth + 1);
10526
10584
  }
10527
10585
  }
10528
10586
  try {
@@ -10778,6 +10836,1288 @@ function getCookies(req, cookiesDef) {
10778
10836
  return z.z.object(cookiesDef).safeParse(input);
10779
10837
  }
10780
10838
 
10839
+ /**
10840
+ * How a tool is written, and what it is handed.
10841
+ *
10842
+ * Tools are defined with {@link defineTool} so that the handler's `args` are
10843
+ * inferred from the tool's own `inputSchema`. Without that the array of tools
10844
+ * would have to be typed at its widest and every handler would start by
10845
+ * re-narrowing `unknown`, which is exactly where a tool and its schema drift
10846
+ * apart unnoticed.
10847
+ */
10848
+
10849
+ /** Everything a tool is allowed to reach. Deliberately narrow. */
10850
+
10851
+ /**
10852
+ * Declare a tool, binding its handler to its input schema.
10853
+ *
10854
+ * The handler receives already-parsed arguments: the registry validates against
10855
+ * `inputSchema` before calling, so a handler never sees input its schema would
10856
+ * have rejected.
10857
+ */
10858
+ function defineTool(definition, handler) {
10859
+ return {
10860
+ ...definition,
10861
+ handler: (args, deps) => handler(args, deps)
10862
+ };
10863
+ }
10864
+ function ok(data) {
10865
+ return {
10866
+ status: "ok",
10867
+ data
10868
+ };
10869
+ }
10870
+ function err(code, message) {
10871
+ return {
10872
+ status: "error",
10873
+ code,
10874
+ message
10875
+ };
10876
+ }
10877
+
10878
+ /**
10879
+ * The tools that only read.
10880
+ *
10881
+ * Names match the Studio's chat tools exactly. MCP clients namespace by server,
10882
+ * so there is no `val_` prefix to add, and keeping the names identical means
10883
+ * converging the two definitions later is a move rather than a rename.
10884
+ *
10885
+ * All of these read from `deps.state`, which already has pending patches
10886
+ * applied — an agent should see the content as the Studio would show it, not the
10887
+ * last published version.
10888
+ */
10889
+
10890
+ const ModuleFilePathSchema$1 = z.z.string().describe('Path of the Val module, e.g. "/content/pages.val.ts". Use get_all_schema to discover these.');
10891
+ function readTools() {
10892
+ return [defineTool({
10893
+ name: "get_all_schema",
10894
+ title: "Get all schemas",
10895
+ description: "List every Val module in the project and its schema. Start here: the module paths this returns are what every other tool takes.",
10896
+ inputSchema: z.z.object({}),
10897
+ annotations: {
10898
+ readOnlyHint: true,
10899
+ idempotentHint: true
10900
+ }
10901
+ }, async (_args, {
10902
+ state
10903
+ }) => ok(state.serializedSchemas)), defineTool({
10904
+ name: "get_source",
10905
+ title: "Get source",
10906
+ description: "Read the content of one Val module, with any unpublished changes already applied.",
10907
+ inputSchema: z.z.object({
10908
+ moduleFilePath: ModuleFilePathSchema$1
10909
+ }),
10910
+ annotations: {
10911
+ readOnlyHint: true,
10912
+ idempotentHint: true
10913
+ }
10914
+ }, async ({
10915
+ moduleFilePath
10916
+ }, {
10917
+ state
10918
+ }) => {
10919
+ const path = moduleFilePath;
10920
+ if (!(path in state.serializedSchemas)) {
10921
+ return err("not-found", unknownModuleMessage(path, state));
10922
+ }
10923
+ const source = state.sources[path];
10924
+ return ok(source === undefined ? null : source);
10925
+ }), defineTool({
10926
+ name: "get_record_keys",
10927
+ title: "Get record keys",
10928
+ 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.",
10929
+ inputSchema: z.z.object({
10930
+ moduleFilePath: ModuleFilePathSchema$1,
10931
+ path: z.z.array(z.z.string()).default([]).describe("Path within the module to the record or object. Empty means the module root."),
10932
+ // Clamped by the schema rather than in the handler: a negative offset
10933
+ // makes `slice` read from the END and a negative limit makes it drop
10934
+ // the last N, so either would return a window that is not the page
10935
+ // asked for while `total` alongside implied it was.
10936
+ limit: z.z.number().int().min(1).default(100).describe("Maximum number of keys to return."),
10937
+ offset: z.z.number().int().min(0).default(0).describe("Number of keys to skip, for paging.")
10938
+ }),
10939
+ annotations: {
10940
+ readOnlyHint: true,
10941
+ idempotentHint: true
10942
+ }
10943
+ }, async ({
10944
+ moduleFilePath,
10945
+ path,
10946
+ limit,
10947
+ offset
10948
+ }, {
10949
+ state
10950
+ }) => {
10951
+ const described = describeContainer(state, moduleFilePath, path);
10952
+ if (described.kind !== "ok") {
10953
+ return described.result;
10954
+ }
10955
+ const {
10956
+ container,
10957
+ value
10958
+ } = described;
10959
+ // Records and objects only, matching the Studio's tool of the same name.
10960
+ // A gallery's keys are file paths whose bytes live elsewhere, and
10961
+ // richtext blocks are positional — neither is a key set to hand back.
10962
+ if (container !== "record" && container !== "object" || !isPlainObject(value)) {
10963
+ 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"}.`);
10964
+ }
10965
+ const keys = Object.keys(value);
10966
+ return ok({
10967
+ kind: container,
10968
+ keys: keys.slice(offset, offset + limit),
10969
+ // The unpaged size, so a caller can tell a short page from the end of
10970
+ // the record without asking for another one.
10971
+ total: keys.length
10972
+ });
10973
+ }), defineTool({
10974
+ name: "count_entries",
10975
+ title: "Count entries",
10976
+ 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.",
10977
+ inputSchema: z.z.object({
10978
+ moduleFilePath: ModuleFilePathSchema$1,
10979
+ path: z.z.array(z.z.string()).default([]).describe("Path within the module to count at. Empty means the module root.")
10980
+ }),
10981
+ annotations: {
10982
+ readOnlyHint: true,
10983
+ idempotentHint: true
10984
+ }
10985
+ }, async ({
10986
+ moduleFilePath,
10987
+ path
10988
+ }, {
10989
+ state
10990
+ }) => {
10991
+ const described = describeContainer(state, moduleFilePath, path);
10992
+ if (described.kind !== "ok") {
10993
+ return described.result;
10994
+ }
10995
+ const {
10996
+ container,
10997
+ value
10998
+ } = described;
10999
+ // Every container `describeContainerAtPath` admits can be counted, so
11000
+ // unlike get_record_keys this does not narrow further. Non-containers
11001
+ // never get this far.
11002
+ if (Array.isArray(value)) {
11003
+ return ok({
11004
+ kind: container,
11005
+ count: value.length
11006
+ });
11007
+ }
11008
+ if (isPlainObject(value)) {
11009
+ return ok({
11010
+ kind: container,
11011
+ count: Object.keys(value).length
11012
+ });
11013
+ }
11014
+ return err("invalid-args", `The value at that path is ${article(container)} ${container}, which has nothing to count.`);
11015
+ }), defineTool({
11016
+ name: "validate_content",
11017
+ title: "Validate content",
11018
+ description: "Check the project's content against its schemas, including unpublished changes. Returns only errors that would block publishing.",
11019
+ inputSchema: z.z.object({
11020
+ moduleFilePath: ModuleFilePathSchema$1.optional().describe("Limit the check to one module. Omit to validate everything.")
11021
+ }),
11022
+ annotations: {
11023
+ readOnlyHint: true
11024
+ }
11025
+ }, async ({
11026
+ moduleFilePath
11027
+ }, {
11028
+ ops,
11029
+ state
11030
+ }) => {
11031
+ const validation = await ops.validateSources(state.schemas, state.sources,
11032
+ // Every module. The third argument filters which modules are
11033
+ // validated at all, so passing the pending-patch analysis would make
11034
+ // a project with no pending changes report `valid: true` without
11035
+ // having checked anything. Scoping to one module, when asked, is done
11036
+ // on the results below.
11037
+ undefined);
11038
+ // `validateSources` hands back the files it could not check on its own;
11039
+ // running them is what turns "this path holds a file" into "that file is
11040
+ // actually there and matches its recorded metadata".
11041
+ const fileErrors = await ops.validateFiles(state.schemas, state.sources, validation.files, state.analysis.fileLastUpdatedByPatchId);
11042
+
11043
+ // Per-module results, flattened to the by-source-path shape the filter
11044
+ // takes. Merged rather than overwritten: a path can pick up an error
11045
+ // from validation and another from its file.
11046
+ const bySourcePath = {};
11047
+ const add = (path, errors) => {
11048
+ bySourcePath[path] = (bySourcePath[path] ?? []).concat(errors);
11049
+ };
11050
+ for (const moduleErrors of Object.values(validation.errors)) {
11051
+ for (const [path, errors] of Object.entries(moduleErrors.validations ?? {})) {
11052
+ add(path, errors);
11053
+ }
11054
+ }
11055
+ for (const [path, errors] of Object.entries(fileErrors)) {
11056
+ add(path, errors);
11057
+ }
11058
+
11059
+ // Drops the errors the Studio would not show either: ones whose only
11060
+ // effect is an offered fix. Left in, an agent would loop trying to
11061
+ // "repair" content that is already publishable.
11062
+ const blocking = internal.filterBlockingValidationErrors(bySourcePath, state.serializedSchemas, state.sources);
11063
+
11064
+ // A module whose source could not be read at all has no source path to
11065
+ // hang an error on, so it is reported separately rather than lost.
11066
+ const unreadable = Object.entries(validation.errors).filter(([, moduleErrors]) => moduleErrors.invalidSource).map(([path, moduleErrors]) => {
11067
+ var _moduleErrors$invalid;
11068
+ return {
11069
+ moduleFilePath: path,
11070
+ message: ((_moduleErrors$invalid = moduleErrors.invalidSource) === null || _moduleErrors$invalid === void 0 ? void 0 : _moduleErrors$invalid.message) ?? "Invalid source"
11071
+ };
11072
+ });
11073
+ const scope = moduleFilePath;
11074
+ const errors = scope === undefined ? blocking : filterKeysByModule(blocking, scope);
11075
+ const unreadableInScope = scope === undefined ? unreadable : unreadable.filter(u => u.moduleFilePath === scope);
11076
+ return ok({
11077
+ valid: Object.keys(errors).length === 0 && unreadableInScope.length === 0,
11078
+ errors: Object.fromEntries(Object.entries(errors).map(([path, errs]) => [path, errs.map(toJsonValidationError)])),
11079
+ // Always present, empty when there are none: a caller should not have
11080
+ // to tell "absent" from "empty" to decide whether content is publishable.
11081
+ unreadableModules: unreadableInScope
11082
+ });
11083
+ }), defineTool({
11084
+ name: "get_patches",
11085
+ title: "Get patches",
11086
+ 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.",
11087
+ inputSchema: z.z.object({
11088
+ moduleFilePath: ModuleFilePathSchema$1.optional().describe("Limit to changes touching one module.")
11089
+ }),
11090
+ annotations: {
11091
+ readOnlyHint: true
11092
+ }
11093
+ }, async ({
11094
+ moduleFilePath
11095
+ }, {
11096
+ state
11097
+ }) => {
11098
+ const wanted = moduleFilePath === undefined ? state.patches.patches : state.patches.patches.filter(p => p.path === moduleFilePath);
11099
+ // Which patches would not apply, flattened to one lookup by id: without
11100
+ // this a module's content can silently differ from what publishing
11101
+ // would produce, and nothing anywhere says why.
11102
+ const failures = new Map();
11103
+ for (const unapplied of Object.values(state.unappliedPatches)) {
11104
+ for (const failure of unapplied) {
11105
+ failures.set(failure.patchId, failure.error.message);
11106
+ }
11107
+ }
11108
+ return ok(wanted.map(patch => {
11109
+ const failure = failures.get(patch.patchId);
11110
+ return {
11111
+ patchId: patch.patchId,
11112
+ moduleFilePath: patch.path,
11113
+ createdAt: patch.createdAt,
11114
+ authorId: patch.authorId,
11115
+ // `appliedAt` non-null means this change is already committed, so
11116
+ // it is history rather than something still pending.
11117
+ published: patch.appliedAt !== null,
11118
+ // Always present, so "applies cleanly" is stated rather than
11119
+ // inferred from the absence of a field.
11120
+ appliesCleanly: failure === undefined,
11121
+ ...(failure === undefined ? {} : {
11122
+ applyError: failure
11123
+ })
11124
+ };
11125
+ }));
11126
+ }), defineTool({
11127
+ name: "get_source_path_from_route",
11128
+ title: "Get source path from route",
11129
+ 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.",
11130
+ inputSchema: z.z.object({
11131
+ route: z.z.string().describe('A route on the site, e.g. "/blog/my-post".')
11132
+ }),
11133
+ annotations: {
11134
+ readOnlyHint: true,
11135
+ idempotentHint: true
11136
+ }
11137
+ }, async ({
11138
+ route
11139
+ }, {
11140
+ state
11141
+ }) => {
11142
+ const found = core.getSourcePathFromRoute(route, state.serializedSchemas);
11143
+ if (!found) {
11144
+ 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.`);
11145
+ }
11146
+ return ok(found);
11147
+ })];
11148
+ }
11149
+
11150
+ /**
11151
+ * Resolve a module and classify the value at a path inside it.
11152
+ *
11153
+ * Shared by `get_record_keys` and `count_entries` so the two cannot drift on
11154
+ * what counts as a missing module, and so both map the same failure to the same
11155
+ * error code: a path that is not there is `not-found`, while a path that is
11156
+ * there but holds a string or an image is `invalid-args` — the caller should
11157
+ * reach for a different tool, not go looking for the path again.
11158
+ */
11159
+ function describeContainer(state, moduleFilePath, path) {
11160
+ const modulePath = moduleFilePath;
11161
+ const schema = state.serializedSchemas[modulePath];
11162
+ if (!schema) {
11163
+ return {
11164
+ kind: "error",
11165
+ result: err("not-found", unknownModuleMessage(modulePath, state))
11166
+ };
11167
+ }
11168
+ const described = internal.describeContainerAtPath(schema, state.sources[modulePath], path);
11169
+ if (described.kind === "error") {
11170
+ return {
11171
+ kind: "error",
11172
+ result: err(described.reason === "missing" ? "not-found" : "invalid-args", described.message)
11173
+ };
11174
+ }
11175
+ return described;
11176
+ }
11177
+
11178
+ /** "a record", but "an object" and "an array". */
11179
+ function article(container) {
11180
+ return container === "object" || container === "array" ? "an" : "a";
11181
+ }
11182
+ function unknownModuleMessage(path, state) {
11183
+ const known = Object.keys(state.serializedSchemas);
11184
+ return `No Val module at ${JSON.stringify(path)}. Known modules: ${known.length === 0 ? "(none)" : known.join(", ")}`;
11185
+ }
11186
+
11187
+ /**
11188
+ * Project a validation error into something JSON-safe and worth reading.
11189
+ *
11190
+ * `ValidationError.value` is dropped rather than serialized: it is `unknown` (so
11191
+ * not `Json` to begin with) and it holds the offending source value, which can
11192
+ * be arbitrarily large. A caller already has the source path and can read the
11193
+ * value with `get_source` if it needs to — putting it here would bloat every
11194
+ * result for the rare case that wants it.
11195
+ *
11196
+ * `fixes` is kept, because it names what Val already knows how to repair, which
11197
+ * is directly actionable.
11198
+ */
11199
+ function toJsonValidationError(error) {
11200
+ return {
11201
+ message: error.message,
11202
+ fixes: error.fixes ? [...error.fixes] : [],
11203
+ typeError: error.typeError === true,
11204
+ schemaError: error.schemaError === true,
11205
+ keyError: error.keyError === true
11206
+ };
11207
+ }
11208
+ function isPlainObject(value) {
11209
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11210
+ }
11211
+
11212
+ /**
11213
+ * Keep only the entries belonging to one module.
11214
+ *
11215
+ * Keyed by SourcePath, which begins with the module file path, so a prefix match
11216
+ * is the right test — there is no per-module grouping left to index by.
11217
+ */
11218
+ function filterKeysByModule(record, moduleFilePath) {
11219
+ const out = {};
11220
+ for (const [path, value] of Object.entries(record)) {
11221
+ if (path.startsWith(moduleFilePath)) {
11222
+ out[path] = value;
11223
+ }
11224
+ }
11225
+ return out;
11226
+ }
11227
+
11228
+ /**
11229
+ * Everything the Studio does client-side before a patch can be saved, done
11230
+ * server-side.
11231
+ *
11232
+ * Three things had no server equivalent, and each is a way to be quietly wrong:
11233
+ * where the patch id comes from, what the patch says its parent is, and whether
11234
+ * the result would even be valid. `docs/plans/mcp.md` Part C is the design.
11235
+ */
11236
+
11237
+ const jsonOps = new patch.JSONOps();
11238
+
11239
+ /**
11240
+ * A patch id, minted before the write is attempted.
11241
+ *
11242
+ * Same shape the Studio mints (a v4 UUID), and minting one that never gets used
11243
+ * costs nothing — ids are not registered anywhere until a patch carries them.
11244
+ */
11245
+ function mintPatchId() {
11246
+ // A branded string has no constructor; this is the same conversion the Studio
11247
+ // and ValServer both make.
11248
+ return node_crypto.randomUUID();
11249
+ }
11250
+
11251
+ /**
11252
+ * What the new patch should hang off.
11253
+ *
11254
+ * The last known patch if there is one, otherwise the current head. Note the
11255
+ * asymmetry between the two backends: `ValOpsFS` ignores `parentRef` entirely
11256
+ * because its append-only ordering log defines order, while `ValOpsHttp` sends
11257
+ * it up as `parentPatchId` for optimistic concurrency. So a wrong value here is
11258
+ * invisible locally and a conflict in production — which is why this is derived
11259
+ * fresh rather than remembered.
11260
+ */
11261
+ async function deriveParentRef(ops,
11262
+ // Only the ids matter, so this accepts either shape `fetchPatches` can
11263
+ // return — the metadata-only variant omits the ops but keeps the ids.
11264
+ patches) {
11265
+ const last = patches.patches[patches.patches.length - 1];
11266
+ if (last) {
11267
+ return {
11268
+ type: "patch",
11269
+ patchId: last.patchId
11270
+ };
11271
+ }
11272
+ return {
11273
+ type: "head",
11274
+ headBaseSha: await ops.getBaseSha()
11275
+ };
11276
+ }
11277
+
11278
+ /**
11279
+ * Would this patch leave the content valid?
11280
+ *
11281
+ * Applied to a **clone** of the sources, never the real ones: `applyPatch`
11282
+ * mutates the document it is given, and ValOps carries a standing note that
11283
+ * add operations misbehave without a clone. Validating in place would corrupt
11284
+ * the sources every later call in this process reads.
11285
+ *
11286
+ * Server-side this is strictly better than the Studio's speculative check.
11287
+ * `getSchemas()` returns real `Schema` instances, so the user's own `validate`
11288
+ * closures run — and those are not carried by the serialized schema the browser
11289
+ * has, which means the browser cannot run them at all.
11290
+ */
11291
+ async function validateSpeculatively(ops, state, moduleFilePath, patch$1) {
11292
+ const current = state.sources[moduleFilePath];
11293
+ if (current === undefined) {
11294
+ return {
11295
+ status: "unapplicable",
11296
+ result: {
11297
+ status: "error",
11298
+ code: "not-found",
11299
+ message: `No Val module at ${JSON.stringify(moduleFilePath)}.`
11300
+ }
11301
+ };
11302
+ }
11303
+ const applied = patch.applyPatch(patch.deepClone(current), jsonOps, patch$1);
11304
+ if (fp.result.isErr(applied)) {
11305
+ return {
11306
+ status: "unapplicable",
11307
+ result: {
11308
+ status: "error",
11309
+ code: "invalid-args",
11310
+ message: `The patch cannot be applied to ${moduleFilePath}: ${applied.error.message}`
11311
+ }
11312
+ };
11313
+ }
11314
+ const speculativeSources = {
11315
+ ...state.sources,
11316
+ [moduleFilePath]: applied.value
11317
+ };
11318
+ const after = await blockingErrorsIn(ops, state, speculativeSources, moduleFilePath);
11319
+ if (after.length === 0) {
11320
+ return {
11321
+ status: "valid"
11322
+ };
11323
+ }
11324
+
11325
+ // Only the errors this patch *introduces*. A module can already be broken for
11326
+ // reasons this change has nothing to do with -- the example app ships with a
11327
+ // missing image file -- and refusing on the total would make every such module
11328
+ // permanently read-only: an agent could not fix a typo in a file that also
11329
+ // holds a broken image reference. Paid for only when there is something to
11330
+ // refuse, so an ordinary clean edit still validates once.
11331
+ const before = await blockingErrorsIn(ops, state, state.sources, moduleFilePath);
11332
+ const existing = new Set(before.map(identify));
11333
+ const introduced = after.filter(error => !existing.has(identify(error)));
11334
+ if (introduced.length === 0) {
11335
+ return {
11336
+ status: "valid"
11337
+ };
11338
+ }
11339
+ return {
11340
+ status: "invalid",
11341
+ errors: describeErrors(introduced)
11342
+ };
11343
+ }
11344
+ /** Path and message together: the same message at another path is another problem. */
11345
+ function identify(error) {
11346
+ return `${error.path}\u0000${error.message}`;
11347
+ }
11348
+
11349
+ /**
11350
+ * The publishing-blocking errors in one module, for a given set of sources.
11351
+ *
11352
+ * Scoped to one module by source path, which starts with the module file path.
11353
+ * Errors elsewhere in the project are somebody else's: refusing on them would
11354
+ * let the first broken module in a repo make every other module read-only.
11355
+ */
11356
+ async function blockingErrorsIn(ops, state, sources, moduleFilePath) {
11357
+ const validation = await ops.validateSources(state.schemas, sources,
11358
+ // Every module, deliberately -- `patchesByModule` is a FILTER on which
11359
+ // modules get validated, not context for validating them. Passing the
11360
+ // analysis from before this write skips the very module being written
11361
+ // whenever it had no pending patch, so the first change to a module went
11362
+ // unchecked; and a change that breaks a `keyOf` or a router in a *different*
11363
+ // module reports its error there, which a filtered run never visits.
11364
+ undefined);
11365
+ // `validateSources` hands back the files it could not check on its own;
11366
+ // running them is what turns "this path holds a file" into "that file is
11367
+ // actually there and matches its recorded metadata".
11368
+ const fileErrors = await ops.validateFiles(state.schemas, sources, validation.files, state.analysis.fileLastUpdatedByPatchId);
11369
+
11370
+ // Merged rather than overwritten: a path can pick up an error from validation
11371
+ // and another from its file.
11372
+ const bySourcePath = {};
11373
+ const add = (path, errors) => {
11374
+ bySourcePath[path] = (bySourcePath[path] ?? []).concat(errors);
11375
+ };
11376
+ for (const moduleErrors of Object.values(validation.errors)) {
11377
+ for (const [path, errors] of Object.entries(moduleErrors.validations ?? {})) {
11378
+ add(path, errors);
11379
+ }
11380
+ }
11381
+ for (const [path, errors] of Object.entries(fileErrors)) {
11382
+ add(path, errors);
11383
+ }
11384
+ const blocking = internal.filterBlockingValidationErrors(bySourcePath, state.serializedSchemas, sources);
11385
+ const located = [];
11386
+ for (const [path, errors] of Object.entries(blocking)) {
11387
+ if (!path.startsWith(moduleFilePath)) {
11388
+ continue;
11389
+ }
11390
+ for (const error of errors) {
11391
+ located.push({
11392
+ path: path,
11393
+ message: error.message
11394
+ });
11395
+ }
11396
+ }
11397
+ return located;
11398
+ }
11399
+ function describeErrors(errors) {
11400
+ return errors.map(e => `${e.path}: ${e.message}`).join("; ");
11401
+ }
11402
+
11403
+ /**
11404
+ * What to do when the change would leave the content invalid.
11405
+ *
11406
+ * `"reject"` for a tool that is editing existing content: an agent should not be
11407
+ * able to break a site, and a rejected patch stores nothing.
11408
+ *
11409
+ * `"report"` for a tool whose whole purpose is to create something incomplete.
11410
+ * `empty_at_path` scaffolds an entry the caller is then expected to fill in, so
11411
+ * on most real schemas — anything with a non-empty string — the value it creates
11412
+ * is invalid by construction. Rejecting that would make the tool useless on
11413
+ * exactly the schemas it exists for, so instead the patch is saved and the
11414
+ * remaining errors come back as a to-do list. This mirrors the Studio, where
11415
+ * creating an empty entry is normal and the errors show until it is filled in.
11416
+ */
11417
+
11418
+ /**
11419
+ * Validate, then save — and retry once if someone else got there first.
11420
+ *
11421
+ * The retry exists because the parent ref is derived from a read that happened
11422
+ * before the write. A conflict means the chain moved underneath us, and
11423
+ * re-deriving is usually enough. Once only: a loop here would be an agent
11424
+ * fighting a human editor in the Studio, and losing slowly is worse than
11425
+ * failing clearly.
11426
+ */
11427
+ async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
11428
+ var _ctx$auth;
11429
+ const {
11430
+ ops,
11431
+ ctx,
11432
+ state
11433
+ } = deps;
11434
+ const unapplied = state.unappliedPatches[moduleFilePath];
11435
+ if (unapplied && unapplied.length > 0) {
11436
+ // Refused before anything is validated, because the state to validate
11437
+ // against is wrong. `sources` for this module silently lacks these pending
11438
+ // changes, so a patch built on it would be based on content that will never
11439
+ // exist -- and its parent ref would chain onto changes that do not apply.
11440
+ return {
11441
+ status: "error",
11442
+ code: "internal",
11443
+ 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.`
11444
+ };
11445
+ }
11446
+ const speculative = await validateSpeculatively(ops, state, moduleFilePath, patch);
11447
+ if (speculative.status === "unapplicable") {
11448
+ // Never negotiable: the patch does not fit the content, so there is nothing
11449
+ // to save whatever the caller's tolerance for invalid results.
11450
+ return speculative.result;
11451
+ }
11452
+ let unresolved = null;
11453
+ if (speculative.status === "invalid") {
11454
+ if (onInvalid === "reject") {
11455
+ return {
11456
+ status: "error",
11457
+ code: "validation-failed",
11458
+ message: `The change was rejected and nothing was saved, because it would leave the content invalid: ${speculative.errors}`
11459
+ };
11460
+ }
11461
+ unresolved = speculative.errors;
11462
+ }
11463
+
11464
+ /**
11465
+ * Null on the PAT path, and the verified profile on the token path.
11466
+ *
11467
+ * The PAT case is unchanged and still deliberate: the app cannot resolve a
11468
+ * PAT, so any id it wrote here would be an unverified claim dressed up as a
11469
+ * checked one — and the request already carries the caller's own token, which
11470
+ * is a better answer to "who did this" than anything the app could assert.
11471
+ * Attributing that patch is the backend's job.
11472
+ *
11473
+ * The token case is the opposite situation, which is why it gets the opposite
11474
+ * answer. The host verified a signature over a key it does not hold, so the
11475
+ * profile is checked rather than claimed, and the backend has no token of its
11476
+ * own to attribute from — the call reaches it under the app's API key. If this
11477
+ * stayed null, every edit made through a signed-in editor's own session would
11478
+ * land with no author at all, which is worse than useless on a CMS whose
11479
+ * review screen is organised by who changed what.
11480
+ */
11481
+ const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
11482
+ for (let attempt = 0; attempt < 2; attempt++) {
11483
+ const patchId = mintPatchId();
11484
+ // Re-derived on the retry rather than reused: reusing the ref that just
11485
+ // conflicted would conflict again by definition.
11486
+ const patches = attempt === 0 ? state.patches : await ops.fetchPatches({
11487
+ excludePatchOps: true
11488
+ });
11489
+ const parentRef = await deriveParentRef(ops, patches);
11490
+ const saved = await ops.createPatch(moduleFilePath, patch, patchId, parentRef, ctx.sessionId, authorId);
11491
+ if (fp.result.isOk(saved)) {
11492
+ return {
11493
+ status: "ok",
11494
+ data: {
11495
+ patchId: saved.value.patchId,
11496
+ moduleFilePath,
11497
+ createdAt: saved.value.createdAt,
11498
+ // Always present, so a caller does not have to tell "absent" from
11499
+ // "nothing left to do" to know whether the content is publishable.
11500
+ unresolvedValidationErrors: unresolved
11501
+ }
11502
+ };
11503
+ }
11504
+ if (saved.error.errorType === "patch-head-conflict") {
11505
+ continue;
11506
+ }
11507
+ return {
11508
+ status: "error",
11509
+ code: "internal",
11510
+ // Note the nesting: createPatch wraps the underlying flat error as
11511
+ // `{ errorType: "other", error: <that> }`.
11512
+ message: saved.error.error.message
11513
+ };
11514
+ }
11515
+ return {
11516
+ status: "error",
11517
+ code: "conflict",
11518
+ message: "Another change was saved while this one was being written, twice in a row. Read the content again before retrying — it has moved."
11519
+ };
11520
+ }
11521
+
11522
+ /**
11523
+ * The tools that change content.
11524
+ *
11525
+ * Every one of them goes through {@link savePatch}, so they all inherit the same
11526
+ * guarantees: the change is validated against the real schemas before anything
11527
+ * is stored, a rejected change stores nothing, and a lost race with another
11528
+ * writer is retried once and then reported rather than looped on.
11529
+ *
11530
+ * Images are not here. The Studio's image tools work from a handle into Val's
11531
+ * AI session store — bytes the browser got from the vision system — and MCP has
11532
+ * no equivalent, so they need a different affordance (a local file path, or
11533
+ * inline base64) rather than a port. `docs/plans/mcp.md` Part B has the reasoning.
11534
+ */
11535
+
11536
+ const ModuleFilePathSchema = z.z.string().describe('Path of the Val module, e.g. "/content/pages.val.ts".');
11537
+ function writeTools() {
11538
+ return [defineTool({
11539
+ name: "create_patch",
11540
+ title: "Create patch",
11541
+ 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.",
11542
+ inputSchema: z.z.object({
11543
+ moduleFilePath: ModuleFilePathSchema,
11544
+ patch: z.z.array(z.z.unknown()).describe('JSON Patch operations, e.g. [{"op":"replace","path":["title"],"value":"New title"}]. Paths are arrays of keys, not slash-separated strings.')
11545
+ }),
11546
+ annotations: {
11547
+ idempotentHint: false
11548
+ }
11549
+ }, async ({
11550
+ moduleFilePath,
11551
+ patch
11552
+ }, deps) => {
11553
+ // Before parsing, not after: a file op that is also malformed should be
11554
+ // told that files are not supported, rather than handed a schema error
11555
+ // about the shape of a thing it was never going to be allowed to do.
11556
+ const rejected = rejectFileOps(patch);
11557
+ if (rejected) {
11558
+ return rejected;
11559
+ }
11560
+ const parsed = internal.safeParsePatch(patch);
11561
+ if (parsed.kind !== "ok") {
11562
+ return fromBuildResult(parsed);
11563
+ }
11564
+ return savePatch(deps, moduleFilePath, parsed.patch);
11565
+ }), defineTool({
11566
+ name: "duplicate_source",
11567
+ title: "Duplicate source",
11568
+ 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.",
11569
+ inputSchema: z.z.object({
11570
+ moduleFilePath: ModuleFilePathSchema,
11571
+ sourcePath: z.z.array(z.z.string()).describe("Path of the value to copy."),
11572
+ destinationPath: z.z.array(z.z.string()).describe("Path to copy it to. Must not already exist.")
11573
+ }),
11574
+ annotations: {
11575
+ idempotentHint: false
11576
+ }
11577
+ }, async ({
11578
+ moduleFilePath,
11579
+ sourcePath,
11580
+ destinationPath
11581
+ }, deps) => {
11582
+ const modulePath = moduleFilePath;
11583
+ const schema = deps.state.serializedSchemas[modulePath];
11584
+ if (!schema) {
11585
+ return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
11586
+ }
11587
+ const built = internal.buildDuplicatePatch({
11588
+ sourcePath,
11589
+ destinationPath
11590
+ }, schema, deps.state.sources[modulePath]);
11591
+ if (built.kind !== "ok") {
11592
+ return fromBuildResult(built);
11593
+ }
11594
+ return savePatch(deps, modulePath, built.patch);
11595
+ }), defineTool({
11596
+ name: "empty_at_path",
11597
+ title: "Create an empty value at a path",
11598
+ 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.",
11599
+ inputSchema: z.z.object({
11600
+ moduleFilePath: ModuleFilePathSchema,
11601
+ destinationPath: z.z.array(z.z.string()).describe("Path to create the empty value at.")
11602
+ }),
11603
+ annotations: {
11604
+ idempotentHint: false
11605
+ }
11606
+ }, async ({
11607
+ moduleFilePath,
11608
+ destinationPath
11609
+ }, deps) => {
11610
+ const modulePath = moduleFilePath;
11611
+ const schema = deps.state.serializedSchemas[modulePath];
11612
+ if (!schema) {
11613
+ return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
11614
+ }
11615
+ const built = internal.buildEmptyAtPathPatch({
11616
+ destinationPath
11617
+ }, schema, deps.state.sources[modulePath]);
11618
+ if (built.kind !== "ok") {
11619
+ return fromBuildResult(built);
11620
+ }
11621
+ // "report", not "reject": an empty entry is invalid by construction on
11622
+ // any schema with a required non-empty field, which is most of them.
11623
+ // See OnInvalid in writePath.ts.
11624
+ return savePatch(deps, modulePath, built.patch, "report");
11625
+ }), defineTool({
11626
+ name: "remove_image_gallery_entry",
11627
+ title: "Remove an image gallery entry",
11628
+ description: "Remove one image from an image gallery module by its file path. This deletes the entry and the file it refers to.",
11629
+ inputSchema: z.z.object({
11630
+ moduleFilePath: ModuleFilePathSchema.describe("The gallery module, i.e. one declared with s.images() or s.files()."),
11631
+ filePath: z.z.string().describe('The gallery key to remove, e.g. "/public/val/photo_a1b2c.jpg".')
11632
+ }),
11633
+ // Destructive: it removes content and the underlying file, so a host
11634
+ // that asks for confirmation should ask here.
11635
+ annotations: {
11636
+ destructiveHint: true,
11637
+ idempotentHint: false
11638
+ }
11639
+ }, async ({
11640
+ moduleFilePath,
11641
+ filePath
11642
+ }, deps) => {
11643
+ const modulePath = moduleFilePath;
11644
+ const schema = deps.state.serializedSchemas[modulePath];
11645
+ if (!schema) {
11646
+ return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
11647
+ }
11648
+ const built = internal.buildRemoveImageGalleryEntryPatch({
11649
+ filePath
11650
+ }, schema, deps.state.sources[modulePath]);
11651
+ if (built.kind !== "ok") {
11652
+ return fromBuildResult(built);
11653
+ }
11654
+ return savePatch(deps, modulePath, built.patch);
11655
+ })];
11656
+ }
11657
+
11658
+ /**
11659
+ * Turn a helper's build failure into a tool error.
11660
+ *
11661
+ * `wrong-tool` is worth keeping distinct: the helpers can tell that the caller
11662
+ * reached for the wrong tool and which one it should have used, and passing that
11663
+ * through is what lets a model correct itself in one step instead of retrying
11664
+ * the same call.
11665
+ */
11666
+ function fromBuildResult(built) {
11667
+ if (built.kind === "wrong-tool") {
11668
+ return {
11669
+ status: "error",
11670
+ code: "invalid-args",
11671
+ message: `${built.reason} Use the ${built.suggestedTool} tool instead.`
11672
+ };
11673
+ }
11674
+ return {
11675
+ status: "error",
11676
+ code: "invalid-args",
11677
+ message: built.message
11678
+ };
11679
+ }
11680
+
11681
+ /**
11682
+ * File operations are refused rather than half-supported.
11683
+ *
11684
+ * A `file` op carries binary content that has to be uploaded before the patch
11685
+ * is synced — a two-phase flow this pass does not implement. Letting one through
11686
+ * would store a patch referring to bytes that were never uploaded, which fails
11687
+ * later and a long way from the cause.
11688
+ *
11689
+ * Takes the unparsed patch, so this answer does not depend on the op being
11690
+ * otherwise well formed. All it needs is the caller's own claim about what the
11691
+ * op is.
11692
+ */
11693
+ function rejectFileOps(patch) {
11694
+ const hasFileOp = patch.some(op => typeof op === "object" && op !== null && "op" in op && op.op === "file");
11695
+ if (!hasFileOp) {
11696
+ return null;
11697
+ }
11698
+ return {
11699
+ status: "error",
11700
+ code: "unsupported",
11701
+ message: "This patch contains a file operation. Uploading files is not supported over MCP yet — only text and JSON values can be changed."
11702
+ };
11703
+ }
11704
+
11705
+ /**
11706
+ * The public surface of Val's server-side tool registry.
11707
+ *
11708
+ * Types only, deliberately: this file is the contract that the MCP hosts, the
11709
+ * CLI's stdio transport and the tools themselves are all written against, and
11710
+ * keeping it free of implementation means those can be built in any order
11711
+ * without one of them owning the shape.
11712
+ *
11713
+ * The design this implements is `docs/plans/mcp.md`, Part A. Two constraints
11714
+ * from it are load-bearing and easy to break by accident:
11715
+ *
11716
+ * 1. **Nothing here may import an MCP SDK.** That is what lets hosts other
11717
+ * than the template consume these tools, and it is not hypothetical
11718
+ * hygiene — the TypeScript SDK reorganised itself at v2.0.0, and a registry
11719
+ * coupled to it would have moved with it.
11720
+ * 2. **The result type is deliberately not MCP's `CallToolResult`.** Each host
11721
+ * adapts {@link ValToolResult} at its own edge, which is also where an
11722
+ * error becomes an in-band `isError` result the model can recover from
11723
+ * rather than a transport failure.
11724
+ */
11725
+
11726
+ /** Why a tool call failed, in a form a host can map onto its own errors. */
11727
+
11728
+ /**
11729
+ * The same definition with `inputSchema` as JSON Schema, for hosts that want the
11730
+ * wire shape rather than a Standard Schema.
11731
+ *
11732
+ * Typed as whatever zod's own converter produces, so deriving it needs no cast
11733
+ * and no second hand-written description of the same input.
11734
+ */
11735
+
11736
+ /**
11737
+ * How the caller was established, and it is a union because there are two
11738
+ * genuinely different answers — with different consequences downstream.
11739
+ *
11740
+ * The distinction that matters is **who checked**. A PAT is forwarded to the
11741
+ * backend unchecked, because the app cannot resolve one; an access token is
11742
+ * verified by the app itself, against a public key it does not hold and
11743
+ * therefore cannot forge. The first is a credential being relayed. The second
11744
+ * is a signature that has already been checked.
11745
+ */
11746
+
11747
+ /**
11748
+ * Who is calling, established once per request by the host.
11749
+ *
11750
+ * `null` means local fs mode, where there is no credential to hold and patches
11751
+ * are written with no author, exactly as the Studio does locally (D.1). In
11752
+ * proxy mode `null` is refused rather than falling back to the app's own API
11753
+ * key: that key can do more than any single user, and quietly substituting it
11754
+ * would turn a missing credential into full access.
11755
+ */
11756
+
11757
+ /**
11758
+ * Brand a verified subject as an {@link AuthorId}.
11759
+ *
11760
+ * `AuthorId` is a branded string so that an id cannot be conjured from any
11761
+ * string that happens to be lying around — which is exactly the mistake this
11762
+ * type is guarding against. That makes one assertion unavoidable at the boundary
11763
+ * where a real id enters the system, so it lives here, once, with a name that
11764
+ * says what makes it legitimate: the caller has *verified* this subject, not
11765
+ * received it.
11766
+ *
11767
+ * Do not reach for this to satisfy a type. If you are holding a string you did
11768
+ * not verify, the honest value is `null`.
11769
+ */
11770
+ function authorIdFromVerifiedSubject(subject) {
11771
+ return subject;
11772
+ }
11773
+
11774
+ /** Read access. Every call needs it, the writes included. */
11775
+ const VAL_SCOPE_READ = "val:read";
11776
+ /** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
11777
+ const VAL_SCOPE_WRITE = "val:write";
11778
+
11779
+ /**
11780
+ * How many callers' data layers to keep around in proxy mode.
11781
+ *
11782
+ * Each entry holds one `ValOpsHttp`, and each of those caches the project's
11783
+ * evaluated modules once `initSources` has run — so this bounds memory, not just
11784
+ * entry count. Small on purpose: the cost of a miss is re-evaluating the
11785
+ * modules on the next call, which is what happened on *every* call before this
11786
+ * cache existed.
11787
+ */
11788
+ const MAX_CACHED_OPS = 8;
11789
+
11790
+ /**
11791
+ * Val's server-side tool registry.
11792
+ *
11793
+ * This is the piece Val did not have: the Studio's chat tools are defined *and
11794
+ * executed in the browser*, against its client stores, so nothing here could be
11795
+ * re-exposed. These tools run against {@link ValOps} instead, which is what lets
11796
+ * an MCP server — or a stdio transport, or anything else — drive Val content
11797
+ * without a browser.
11798
+ *
11799
+ * Transport-agnostic on purpose: nothing under `tools/` imports an MCP SDK. A
11800
+ * host adapts {@link ValToolResult} at its own edge.
11801
+ */
11802
+ function createValTools(valModules, options) {
11803
+ const resolveOps = createOpsResolver(valModules, options);
11804
+ const tools = [...readTools(), ...writeTools()];
11805
+ const byName = new Map(tools.map(tool => [tool.name, tool]));
11806
+ return {
11807
+ list() {
11808
+ return tools.map(({
11809
+ handler: _handler,
11810
+ ...definition
11811
+ }) => definition);
11812
+ },
11813
+ listJsonSchema() {
11814
+ return tools.map(({
11815
+ handler: _handler,
11816
+ inputSchema,
11817
+ ...rest
11818
+ }) => ({
11819
+ ...rest,
11820
+ // zod 4 derives this itself, so there is no JSON-Schema-to-zod
11821
+ // converter anywhere in the stack and no second description of the
11822
+ // same input to keep in step.
11823
+ inputSchema: z.z.toJSONSchema(inputSchema, {
11824
+ io: "input"
11825
+ })
11826
+ }));
11827
+ },
11828
+ async call(name, args, ctx) {
11829
+ const tool = byName.get(name);
11830
+ if (!tool) {
11831
+ return {
11832
+ status: "error",
11833
+ code: "unknown-tool",
11834
+ message: `No tool named ${JSON.stringify(name)}. Available: ${tools.map(t => t.name).join(", ")}`
11835
+ };
11836
+ }
11837
+ const parsed = tool.inputSchema.safeParse(args ?? {});
11838
+ if (!parsed.success) {
11839
+ return {
11840
+ status: "error",
11841
+ code: "invalid-args",
11842
+ message: describeZodError(parsed.error)
11843
+ };
11844
+ }
11845
+ const insufficient = refuseInsufficientScope(tool, ctx);
11846
+ if (insufficient) {
11847
+ return insufficient;
11848
+ }
11849
+ const resolved = resolveOps(ctx);
11850
+ if (resolved.status === "error") {
11851
+ return resolved.result;
11852
+ }
11853
+ const ops = resolved.ops;
11854
+ try {
11855
+ const state = await loadState(ops);
11856
+ if (state.status === "error") {
11857
+ return state.result;
11858
+ }
11859
+ const deps = {
11860
+ ops,
11861
+ ctx,
11862
+ state: state.state
11863
+ };
11864
+ return await tool.handler(parsed.data, deps);
11865
+ } catch (error) {
11866
+ // A thrown error here is a bug or an unreachable backend, not something
11867
+ // the model can act on — but it still comes back in-band so the client
11868
+ // sees a tool failure rather than a dead transport.
11869
+ return {
11870
+ status: "error",
11871
+ code: "internal",
11872
+ message: error instanceof Error ? error.message : String(error)
11873
+ };
11874
+ }
11875
+ },
11876
+ async dispose() {
11877
+ // Nothing to release today: ValOps holds no handle that needs closing, and
11878
+ // the fs watcher it can start is owned by the Studio's server. Kept in the
11879
+ // contract so hosts wire up teardown now rather than when it starts to
11880
+ // matter.
11881
+ }
11882
+ };
11883
+ }
11884
+
11885
+ /**
11886
+ * Pick the data layer for a call, which in proxy mode means picking whose
11887
+ * credential the backend will see.
11888
+ *
11889
+ * This is the one place authorization is decided, and it decides it by *not*
11890
+ * deciding: in proxy mode the caller's own personal access token goes to the
11891
+ * backend, which is the only party that can say what that token may do. The app
11892
+ * never inspects it, never caches a verdict about it, and never substitutes its
11893
+ * own API key for a missing one — see `docs/plans/mcp.md` D.2.
11894
+ *
11895
+ * The alternative shape, and the reason this function exists at all, is an
11896
+ * `authenticate()` that checks the PAT once and then acts under the app's key.
11897
+ * That reads as more secure and is strictly less so: the check happens in the
11898
+ * app, so every bug in it becomes full access to every project the app's key
11899
+ * can reach, and the backend's own permission model stops being consulted (D.6).
11900
+ */
11901
+ function createOpsResolver(valModules, options) {
11902
+ if (options.mode === "fs") {
11903
+ // One instance, built once: fs mode is a developer's own working tree, so
11904
+ // there is no credential to vary by and no reason to re-evaluate modules.
11905
+ const ops = createValOps(valModules, options);
11906
+ return ctx => {
11907
+ if (ctx.auth) {
11908
+ // Refused rather than ignored. A host that thinks it is passing a
11909
+ // credential should not silently get local filesystem access instead —
11910
+ // and the difference matters, because fs mode writes straight to disk
11911
+ // with no backend permission check at all.
11912
+ return {
11913
+ status: "error",
11914
+ result: {
11915
+ status: "error",
11916
+ code: "unsupported",
11917
+ message: "This Val project is running in local filesystem mode, where there is nothing to authenticate against. Do not send a credential."
11918
+ }
11919
+ };
11920
+ }
11921
+ return {
11922
+ status: "ok",
11923
+ ops
11924
+ };
11925
+ };
11926
+ }
11927
+
11928
+ // Keyed by a hash of the PAT, so the same caller reuses their own instance and
11929
+ // two callers can never share one. Hashing is not a security boundary — the
11930
+ // instance holds the token regardless — but it keeps credentials out of the
11931
+ // key set, which is the thing that ends up in a heap dump or an error dump.
11932
+ const byPatHash = new Map();
11933
+ /**
11934
+ * One instance for every verified caller, and unlike the PAT map that is
11935
+ * correct rather than a shortcut: this instance authenticates with the app's
11936
+ * own API key, so there is nothing per-caller in it to keep apart. Who did
11937
+ * what travels as the patch's `authorId` instead — see `writePath`.
11938
+ */
11939
+ let sharedOps = null;
11940
+ return ctx => {
11941
+ if (!ctx.auth) {
11942
+ return {
11943
+ status: "error",
11944
+ result: {
11945
+ status: "error",
11946
+ code: "forbidden",
11947
+ message: "This Val project talks to the Val content backend, so every call needs a credential: an access token from the Val authorization server, or the caller's own personal access token from `val login`."
11948
+ }
11949
+ };
11950
+ }
11951
+ if (ctx.auth.type === "verified-profile") {
11952
+ if (!options.apiKey) {
11953
+ // Proxy mode is inferred from the api key being present, so this is
11954
+ // unreachable through `initHandlerOptions`. It stays because the
11955
+ // alternative to refusing is building ops with no credential at all.
11956
+ return {
11957
+ status: "error",
11958
+ result: {
11959
+ status: "error",
11960
+ code: "forbidden",
11961
+ message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
11962
+ }
11963
+ };
11964
+ }
11965
+ if (!sharedOps) {
11966
+ sharedOps = createValOps(valModules, options);
11967
+ }
11968
+ return {
11969
+ status: "ok",
11970
+ ops: sharedOps
11971
+ };
11972
+ }
11973
+ const key = node_crypto.createHash("sha256").update(ctx.auth.pat).digest("hex");
11974
+ const cached = byPatHash.get(key);
11975
+ if (cached) {
11976
+ // Re-inserted so eviction drops the least recently used rather than the
11977
+ // oldest — a long-running caller should not be evicted by a burst of
11978
+ // one-off ones.
11979
+ byPatHash.delete(key);
11980
+ byPatHash.set(key, cached);
11981
+ return {
11982
+ status: "ok",
11983
+ ops: cached
11984
+ };
11985
+ }
11986
+ const ops = createValOps(valModules, options, {
11987
+ pat: ctx.auth.pat
11988
+ });
11989
+ byPatHash.set(key, ops);
11990
+ while (byPatHash.size > MAX_CACHED_OPS) {
11991
+ const oldest = byPatHash.keys().next();
11992
+ if (oldest.done) {
11993
+ break;
11994
+ }
11995
+ byPatHash.delete(oldest.value);
11996
+ }
11997
+ return {
11998
+ status: "ok",
11999
+ ops
12000
+ };
12001
+ };
12002
+ }
12003
+ /**
12004
+ * The content as the caller should see it, loaded once per call.
12005
+ *
12006
+ * Pending patches are applied, because an agent looking at a project mid-edit
12007
+ * should see what the Studio would show rather than the last published state.
12008
+ *
12009
+ * Deliberately not cached across calls. In fs mode a save recomputes the base
12010
+ * sha within the same process, so a cached view would go stale silently — and
12011
+ * the cost of being wrong here is an agent writing a patch against content that
12012
+ * has already moved.
12013
+ */
12014
+ async function loadState(ops) {
12015
+ const patches = await ops.fetchPatches({
12016
+ excludePatchOps: false
12017
+ });
12018
+ // fetchPatches resolves with its failures on the result rather than rejecting,
12019
+ // so not checking these reads as "no pending changes" — which would quietly
12020
+ // hand back published content and let a write be based on it.
12021
+ if (patches.unauthorized) {
12022
+ return {
12023
+ status: "error",
12024
+ result: {
12025
+ status: "error",
12026
+ code: "forbidden",
12027
+ message: "Not authorized to read this project's pending changes. Check that the credential is valid and has access."
12028
+ }
12029
+ };
12030
+ }
12031
+ if (patches.networkError) {
12032
+ return {
12033
+ status: "error",
12034
+ result: {
12035
+ status: "error",
12036
+ code: "internal",
12037
+ message: "Could not reach the Val content backend."
12038
+ }
12039
+ };
12040
+ }
12041
+ if (patches.error) {
12042
+ return {
12043
+ status: "error",
12044
+ result: {
12045
+ status: "error",
12046
+ code: "internal",
12047
+ message: patches.error.message
12048
+ }
12049
+ };
12050
+ }
12051
+ const analysis = ops.analyzePatches(patches.patches);
12052
+ // getSourcesWithPatchesApplied, not getSources(analysis): the latter returns
12053
+ // only the modules that had patches, and validating that subset reports
12054
+ // spurious errors for anything that looks across modules, like keyOf or a
12055
+ // router.
12056
+ const sourcesRes = await ops.getSourcesWithPatchesApplied({
12057
+ ...analysis,
12058
+ ...patches
12059
+ });
12060
+ const [schemas, serializedSchemas] = await Promise.all([ops.getSchemas(), ops.getSerializedSchemas()]);
12061
+ return {
12062
+ status: "ok",
12063
+ state: {
12064
+ schemas,
12065
+ serializedSchemas,
12066
+ sources: sourcesRes.sources,
12067
+ patches,
12068
+ analysis,
12069
+ // Which modules hold a pending patch that would not apply. Carried rather
12070
+ // than discarded because their `sources` silently lack that change: the
12071
+ // content here is not what publishing would produce, so a write against
12072
+ // it would be based on a state that does not exist. See
12073
+ // `unappliedPatchesFor`.
12074
+ unappliedPatches: sourcesRes.errors
12075
+ }
12076
+ };
12077
+ }
12078
+ function describeZodError(error) {
12079
+ return error.issues.map(issue => {
12080
+ const path = issue.path.join(".");
12081
+ return path ? `${path}: ${issue.message}` : issue.message;
12082
+ }).join("; ");
12083
+ }
12084
+
12085
+ /**
12086
+ * Refuse a call the token was not granted, before anything is attempted.
12087
+ *
12088
+ * Derived from `readOnlyHint` rather than from a second list of tool names,
12089
+ * because a second list is a thing that drifts. The derivation also fails in
12090
+ * the safe direction: a tool that forgets the hint is treated as a write and
12091
+ * demands the wider scope, rather than a write slipping through as a read.
12092
+ *
12093
+ * Only the verified-token path is checked. A PAT carries no scopes here by
12094
+ * design — the backend resolves it and decides — so there is nothing to
12095
+ * enforce, and inventing a default would be this app claiming an authority it
12096
+ * does not have.
12097
+ */
12098
+ function refuseInsufficientScope(tool, ctx) {
12099
+ var _ctx$auth, _tool$annotations;
12100
+ if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
12101
+ return null;
12102
+ }
12103
+ // Read is needed by every call, including the writes: a tool that changes
12104
+ // content reads it first, and `ValToolAuth` says as much. Checking only the
12105
+ // wider scope would let a write-but-not-read token through here — today's
12106
+ // verifier refuses such a token before this point, but `createValTools` is
12107
+ // exported and another host may not.
12108
+ const needed = (_tool$annotations = tool.annotations) !== null && _tool$annotations !== void 0 && _tool$annotations.readOnlyHint ? [VAL_SCOPE_READ] : [VAL_SCOPE_READ, VAL_SCOPE_WRITE];
12109
+ const granted = ctx.auth.scopes;
12110
+ const missing = needed.filter(scope => !granted.includes(scope));
12111
+ if (missing.length === 0) {
12112
+ return null;
12113
+ }
12114
+ return {
12115
+ status: "error",
12116
+ code: "forbidden",
12117
+ message: `This access token does not have the ${missing.join(" and ")} scope, which ${tool.name} requires. Granted: ${granted.length > 0 ? granted.join(" ") : "(none)"}.`
12118
+ };
12119
+ }
12120
+
10781
12121
  const JsFileLookupMapping = [
10782
12122
  // NOTE: first one matching will be used
10783
12123
  [".cjs.d.ts", [".esm.js", ".mjs.js"]], [".cjs.js", [".esm.js", ".mjs.js"]], [".cjs", [".mjs"]], [".d.ts", [".js", ".esm.js", ".mjs.js"]]];
@@ -12965,6 +14305,8 @@ exports.DEFAULT_LOGIN_EXPIRES_IN_SECONDS = DEFAULT_LOGIN_EXPIRES_IN_SECONDS;
12965
14305
  exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
12966
14306
  exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
12967
14307
  exports.Service = Service;
14308
+ exports.VAL_SCOPE_READ = VAL_SCOPE_READ;
14309
+ exports.VAL_SCOPE_WRITE = VAL_SCOPE_WRITE;
12968
14310
  exports.ValFSHost = ValFSHost;
12969
14311
  exports.ValLoginError = ValLoginError;
12970
14312
  exports.ValModuleLoader = ValModuleLoader;
@@ -12972,6 +14314,7 @@ exports.ValOpsFS = ValOpsFS;
12972
14314
  exports.ValOpsHttp = ValOpsHttp;
12973
14315
  exports.ValSourceFileHandler = ValSourceFileHandler;
12974
14316
  exports.analyzeValModule = analyzeValModule;
14317
+ exports.authorIdFromVerifiedSubject = authorIdFromVerifiedSubject;
12975
14318
  exports.awaitValLoginConfirmation = awaitValLoginConfirmation;
12976
14319
  exports.checkRemoteRef = checkRemoteRef;
12977
14320
  exports.classifyJsonValuesOp = classifyJsonValuesOp;
@@ -12983,7 +14326,9 @@ exports.createModulePathMap = createModulePathMap;
12983
14326
  exports.createService = createService;
12984
14327
  exports.createValApiRouter = createValApiRouter;
12985
14328
  exports.createValModuleFileInspector = createValModuleFileInspector;
14329
+ exports.createValOps = createValOps;
12986
14330
  exports.createValServer = createValServer;
14331
+ exports.createValTools = createValTools;
12987
14332
  exports.currentFixHandlers = currentFixHandlers;
12988
14333
  exports.decodeJwtWithoutVerifying = decodeJwtWithoutVerifying;
12989
14334
  exports.describePatchStoreProblems = describePatchStoreProblems;
@@ -13015,6 +14360,7 @@ exports.handleRemoteFileDownload = handleRemoteFileDownload;
13015
14360
  exports.handleRemoteFileUpload = handleRemoteFileUpload;
13016
14361
  exports.handleRemoteGalleryFileUpload = handleRemoteGalleryFileUpload;
13017
14362
  exports.handleUniqueFolderCheck = handleUniqueFolderCheck;
14363
+ exports.initHandlerOptions = initHandlerOptions;
13018
14364
  exports.loadValModules = loadValModules;
13019
14365
  exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
13020
14366
  exports.patchSourceFile = patchSourceFile;