@valbuild/server 0.122.0 → 0.123.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,7 +17,6 @@ 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');
21
20
  var sucrase = require('sucrase');
22
21
  var http = require('http');
23
22
  var https = require('https');
@@ -1043,7 +1042,7 @@ function errorMessage(e) {
1043
1042
  return String(e);
1044
1043
  }
1045
1044
 
1046
- const jsonOps$3 = new patch.JSONOps();
1045
+ const jsonOps$2 = new patch.JSONOps();
1047
1046
 
1048
1047
  /**
1049
1048
  * Classification of a single patch op against a module's serialized schema,
@@ -1298,7 +1297,7 @@ function applyJsonValuesEntryPatches(args) {
1298
1297
  // on an entry that does not exist.
1299
1298
  continue;
1300
1299
  }
1301
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps$3, [{
1300
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [{
1302
1301
  op: "add",
1303
1302
  path: cls.subPath.concat(...(op.nestedFilePath ?? [])).concat("patch_id"),
1304
1303
  value: patchId
@@ -1352,7 +1351,7 @@ function applyJsonValuesEntryPatches(args) {
1352
1351
  patchId
1353
1352
  };
1354
1353
  }
1355
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps$3, [rebased.value]);
1354
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [rebased.value]);
1356
1355
  if (fp.result.isErr(applied)) {
1357
1356
  return {
1358
1357
  kind: "error",
@@ -1635,7 +1634,7 @@ function findJsonEntryFilePath(moduleFilePath, valTsSourceFile, entryKey) {
1635
1634
  return resolveExistingJsonPath(moduleFilePath, entry.importPath);
1636
1635
  }
1637
1636
 
1638
- const jsonOps$2 = new patch.JSONOps();
1637
+ const jsonOps$1 = new patch.JSONOps();
1639
1638
 
1640
1639
  /**
1641
1640
  * Substitutes loaded `.jsonValues()` entry content back into a module's root
@@ -1941,7 +1940,7 @@ class Service {
1941
1940
  if (fp.result.isErr(rebased)) {
1942
1941
  throw Error(`Could not apply ${op.op} to jsonValues entry '${entryKey}' of ${moduleFilePath}: ${rebased.error.message}`);
1943
1942
  }
1944
- const applied = patch.applyPatch(patch.deepClone(content), jsonOps$2, [rebased.value]);
1943
+ const applied = patch.applyPatch(patch.deepClone(content), jsonOps$1, [rebased.value]);
1945
1944
  if (fp.result.isErr(applied)) {
1946
1945
  throw Error(`Could not apply ${op.op} to ${jsonPath}: ${applied.error.message}`);
1947
1946
  }
@@ -2015,7 +2014,7 @@ const EXTERNAL_RESULT = Symbol.for("@valbuild/server/ExternalResult");
2015
2014
  * fails where the helper is used rather than where it is written. Annotate the
2016
2015
  * helper's return type, or inline it.
2017
2016
  */
2018
- function ok$1(value, warnings) {
2017
+ function ok(value, warnings) {
2019
2018
  return warnings && warnings.length > 0 ? {
2020
2019
  [EXTERNAL_RESULT]: true,
2021
2020
  kind: "ok",
@@ -2027,7 +2026,7 @@ function ok$1(value, warnings) {
2027
2026
  value
2028
2027
  };
2029
2028
  }
2030
- function err$1(issue) {
2029
+ function err(issue) {
2031
2030
  return {
2032
2031
  [EXTERNAL_RESULT]: true,
2033
2032
  kind: "err",
@@ -2287,7 +2286,7 @@ function encodeJwt(payload, sessionKey) {
2287
2286
  }
2288
2287
 
2289
2288
  /* eslint-disable @typescript-eslint/no-unused-vars */
2290
- const jsonOps$1 = new patch.JSONOps();
2289
+ const jsonOps = new patch.JSONOps();
2291
2290
  const tsOps = new TSOps(document => {
2292
2291
  return fp.pipe(analyzeValModule(document), fp.result.map(({
2293
2292
  source
@@ -3057,7 +3056,7 @@ class ValOps {
3057
3056
  }
3058
3057
  const patchRes = patch.applyPatch(patch.deepClone(patchedSources[path]),
3059
3058
  // 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?
3060
- jsonOps$1, applicableOps.concat(...Object.values(fileFixOps)));
3059
+ jsonOps, applicableOps.concat(...Object.values(fileFixOps)));
3061
3060
  if (fp.result.isErr(patchRes)) {
3062
3061
  console.error("Could not apply patch", JSON.stringify({
3063
3062
  path,
@@ -3757,7 +3756,7 @@ class ValOps {
3757
3756
  patchHadError = true;
3758
3757
  break;
3759
3758
  }
3760
- const applied = patch.applyPatch(patch.deepClone(contentRes.value), jsonOps$1, [rebasedRes.value]);
3759
+ const applied = patch.applyPatch(patch.deepClone(contentRes.value), jsonOps, [rebasedRes.value]);
3761
3760
  if (fp.result.isErr(applied)) {
3762
3761
  collectPatchError(applied.error, patchId, op);
3763
3762
  patchHadError = true;
@@ -9030,6 +9029,59 @@ async function getSettings(projectName, auth) {
9030
9029
  }
9031
9030
  }
9032
9031
 
9032
+ function getPersonalAccessTokenPath(root) {
9033
+ return path__namespace["default"].join(path__namespace["default"].resolve(root), ".val", "pat.json");
9034
+ }
9035
+ function parsePersonalAccessTokenFile(content) {
9036
+ if (!content) {
9037
+ return {
9038
+ success: false,
9039
+ error: "Invalid content: undefined"
9040
+ };
9041
+ }
9042
+ let patFileContent;
9043
+ try {
9044
+ patFileContent = JSON.parse(content);
9045
+ } catch {
9046
+ return {
9047
+ success: false,
9048
+ error: `Invalid content: file is not a valid JSON file`
9049
+ };
9050
+ }
9051
+ if (typeof patFileContent !== "object") {
9052
+ return {
9053
+ success: false,
9054
+ error: "Invalid content: not an object"
9055
+ };
9056
+ }
9057
+ if (!patFileContent) {
9058
+ return {
9059
+ success: false,
9060
+ error: "Invalid content: null"
9061
+ };
9062
+ }
9063
+ if (!("pat" in patFileContent)) {
9064
+ return {
9065
+ success: false,
9066
+ error: "Invalid content: key 'pat' is missing"
9067
+ };
9068
+ }
9069
+ const patField = patFileContent.pat;
9070
+ if (typeof patField === "string") {
9071
+ return {
9072
+ success: true,
9073
+ data: {
9074
+ pat: patField
9075
+ }
9076
+ };
9077
+ } else {
9078
+ return {
9079
+ success: false,
9080
+ error: "Invalid content: pat is not a string"
9081
+ };
9082
+ }
9083
+ }
9084
+
9033
9085
  /**
9034
9086
  * Resolving how Val is configured, and building the data layer from it.
9035
9087
  *
@@ -9259,57 +9311,77 @@ function warnIfInsecureUrls(urls) {
9259
9311
  }
9260
9312
  }
9261
9313
 
9262
- function getPersonalAccessTokenPath(root) {
9263
- return path__namespace["default"].join(path__namespace["default"].resolve(root), ".val", "pat.json");
9264
- }
9265
- function parsePersonalAccessTokenFile(content) {
9266
- if (!content) {
9267
- return {
9268
- success: false,
9269
- error: "Invalid content: undefined"
9270
- };
9271
- }
9272
- let patFileContent;
9273
- try {
9274
- patFileContent = JSON.parse(content);
9275
- } catch {
9276
- return {
9277
- success: false,
9278
- error: `Invalid content: file is not a valid JSON file`
9279
- };
9280
- }
9281
- if (typeof patFileContent !== "object") {
9314
+ /**
9315
+ * Which credential talks to the content host about REMOTE FILES.
9316
+ *
9317
+ * A separate question from the one `createValOps` answers, and it has to be:
9318
+ * `ValOps` is authenticated per caller, but remote files are project-level —
9319
+ * looking up a project's public id and its buckets, and later pushing bytes to
9320
+ * them, is the same operation whoever asked for it.
9321
+ *
9322
+ * The rule, in order:
9323
+ *
9324
+ * 1. The app's API key, if there is one. Proxy mode always has one; fs mode has
9325
+ * one when `VAL_API_KEY` is set.
9326
+ * 2. In fs mode, the developer's own `val login` token, read off disk. This is
9327
+ * the same file `val validate --fix` reads, and it is why local remote
9328
+ * uploads work with no configuration beyond having logged in.
9329
+ * 3. Nothing, which is an error rather than a fallback.
9330
+ *
9331
+ * Lives here rather than inside `createValServer` because the MCP image tool
9332
+ * needs the same answer, and this is the file that exists so that two callers
9333
+ * cannot disagree about how a project is configured. A registry that decided it
9334
+ * had no credential while the Studio in the same process had one would be a
9335
+ * genuinely confusing afternoon.
9336
+ */
9337
+
9338
+ async function resolveRemoteFileAuth(options) {
9339
+ if (options.apiKey) {
9282
9340
  return {
9283
- success: false,
9284
- error: "Invalid content: not an object"
9341
+ status: "success",
9342
+ auth: {
9343
+ apiKey: options.apiKey
9344
+ }
9285
9345
  };
9286
9346
  }
9287
- if (!patFileContent) {
9347
+ if (options.mode !== "fs") {
9348
+ // Unreachable through `initHandlerOptions`, which refuses to build a proxy
9349
+ // config without an api key. Kept because this is exported.
9288
9350
  return {
9289
- success: false,
9290
- error: "Invalid content: null"
9351
+ status: "error",
9352
+ errorCode: "project-not-configured",
9353
+ message: "Remote file auth is not configured"
9291
9354
  };
9292
9355
  }
9293
- if (!("pat" in patFileContent)) {
9356
+ // `options.cwd`, which `initHandlerOptions` sets from `process.cwd()`. The
9357
+ // token lives in the project's own `.val/pat.json`, so this is the same file
9358
+ // `val login` wrote and `val validate --fix` reads.
9359
+ const patPath = getPersonalAccessTokenPath(options.cwd);
9360
+ const fs = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
9361
+ let patFile;
9362
+ try {
9363
+ patFile = await fs.promises.readFile(patPath, "utf-8");
9364
+ } catch {
9294
9365
  return {
9295
- success: false,
9296
- error: "Invalid content: key 'pat' is missing"
9366
+ status: "error",
9367
+ errorCode: "pat-error",
9368
+ message: "Could not read personal access token file"
9297
9369
  };
9298
9370
  }
9299
- const patField = patFileContent.pat;
9300
- if (typeof patField === "string") {
9371
+ const patRes = parsePersonalAccessTokenFile(patFile);
9372
+ if (!patRes.success) {
9301
9373
  return {
9302
- success: true,
9303
- data: {
9304
- pat: patField
9305
- }
9306
- };
9307
- } else {
9308
- return {
9309
- success: false,
9310
- error: "Invalid content: pat is not a string"
9374
+ status: "error",
9375
+ errorCode: "pat-error",
9376
+ message: "Could not parse personal access token file"
9311
9377
  };
9312
9378
  }
9379
+ return {
9380
+ status: "success",
9381
+ auth: {
9382
+ pat: patRes.data.pat
9383
+ }
9384
+ };
9313
9385
  }
9314
9386
 
9315
9387
  /* eslint-disable @typescript-eslint/no-unused-vars */
@@ -9484,6 +9556,14 @@ const ValServer = (valModules, options, callbacks) => {
9484
9556
  };
9485
9557
  };
9486
9558
  let remoteFileAuth = null;
9559
+ /**
9560
+ * The credential for remote-file work, memoised for the life of the server.
9561
+ *
9562
+ * The rule itself is `resolveRemoteFileAuth` in `valServerConfig`, shared with
9563
+ * the MCP image tool — what is left here is the memoisation and the shape the
9564
+ * api routes answer in. Memoised because in fs mode it reads a file off disk,
9565
+ * and every remote image in a gallery would otherwise read it again.
9566
+ */
9487
9567
  const getRemoteFileAuth = async () => {
9488
9568
  if (remoteFileAuth) {
9489
9569
  return {
@@ -9493,59 +9573,17 @@ const ValServer = (valModules, options, callbacks) => {
9493
9573
  }
9494
9574
  };
9495
9575
  }
9496
- if (options.apiKey) {
9497
- remoteFileAuth = {
9498
- apiKey: options.apiKey
9499
- };
9500
- } else if (serverOps instanceof ValOpsFS) {
9501
- const projectRootDir = options.config.root || ".";
9502
- if (!projectRootDir) {
9503
- return {
9504
- status: 400,
9505
- json: {
9506
- errorCode: "project-not-configured",
9507
- message: "Root directory was empty"
9508
- }
9509
- };
9510
- }
9511
- const fs = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
9512
- const patPath = getPersonalAccessTokenPath(path__namespace["default"].join(process.cwd()));
9513
- let patFile;
9514
- try {
9515
- patFile = await fs.promises.readFile(patPath, "utf-8");
9516
- } catch (err) {
9517
- return {
9518
- status: 400,
9519
- json: {
9520
- errorCode: "pat-error",
9521
- message: "Could not read personal access token file"
9522
- }
9523
- };
9524
- }
9525
- const patRes = parsePersonalAccessTokenFile(patFile);
9526
- if (patRes.success) {
9527
- remoteFileAuth = {
9528
- pat: patRes.data.pat
9529
- };
9530
- } else {
9531
- return {
9532
- status: 400,
9533
- json: {
9534
- errorCode: "pat-error",
9535
- message: "Could not parse personal access token file"
9536
- }
9537
- };
9538
- }
9539
- }
9540
- if (!remoteFileAuth) {
9576
+ const resolved = await resolveRemoteFileAuth(options);
9577
+ if (resolved.status === "error") {
9541
9578
  return {
9542
9579
  status: 400,
9543
9580
  json: {
9544
- errorCode: "project-not-configured",
9545
- message: "Remote file auth is not configured"
9581
+ errorCode: resolved.errorCode,
9582
+ message: resolved.message
9546
9583
  }
9547
9584
  };
9548
9585
  }
9586
+ remoteFileAuth = resolved.auth;
9549
9587
  return {
9550
9588
  status: 200,
9551
9589
  json: {
@@ -13258,1275 +13296,6 @@ function getCookies(req, cookiesDef) {
13258
13296
  return z.z.object(cookiesDef).safeParse(input);
13259
13297
  }
13260
13298
 
13261
- /**
13262
- * How a tool is written, and what it is handed.
13263
- *
13264
- * Tools are defined with {@link defineTool} so that the handler's `args` are
13265
- * inferred from the tool's own `inputSchema`. Without that the array of tools
13266
- * would have to be typed at its widest and every handler would start by
13267
- * re-narrowing `unknown`, which is exactly where a tool and its schema drift
13268
- * apart unnoticed.
13269
- */
13270
-
13271
- /** Everything a tool is allowed to reach. Deliberately narrow. */
13272
-
13273
- /**
13274
- * Declare a tool, binding its handler to its input schema.
13275
- *
13276
- * The handler receives already-parsed arguments: the registry validates against
13277
- * `inputSchema` before calling, so a handler never sees input its schema would
13278
- * have rejected.
13279
- */
13280
- function defineTool(definition, handler) {
13281
- return {
13282
- ...definition,
13283
- handler: (args, deps) => handler(args, deps)
13284
- };
13285
- }
13286
- function ok(data) {
13287
- return {
13288
- status: "ok",
13289
- data
13290
- };
13291
- }
13292
- function err(code, message) {
13293
- return {
13294
- status: "error",
13295
- code,
13296
- message
13297
- };
13298
- }
13299
-
13300
- /**
13301
- * The tools that only read.
13302
- *
13303
- * Names match the Studio's chat tools exactly. MCP clients namespace by server,
13304
- * so there is no `val_` prefix to add, and keeping the names identical means
13305
- * converging the two definitions later is a move rather than a rename.
13306
- *
13307
- * All of these read from `deps.state`, which already has pending patches
13308
- * applied — an agent should see the content as the Studio would show it, not the
13309
- * last published version.
13310
- */
13311
-
13312
- 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.');
13313
- function readTools() {
13314
- return [defineTool({
13315
- name: "get_all_schema",
13316
- title: "Get all schemas",
13317
- description: "List every Val module in the project and its schema. Start here: the module paths this returns are what every other tool takes.",
13318
- inputSchema: z.z.object({}),
13319
- annotations: {
13320
- readOnlyHint: true,
13321
- idempotentHint: true
13322
- }
13323
- }, async (_args, {
13324
- state
13325
- }) => ok(state.serializedSchemas)), defineTool({
13326
- name: "get_source",
13327
- title: "Get source",
13328
- description: "Read the content of one Val module, with any unpublished changes already applied.",
13329
- inputSchema: z.z.object({
13330
- moduleFilePath: ModuleFilePathSchema$1
13331
- }),
13332
- annotations: {
13333
- readOnlyHint: true,
13334
- idempotentHint: true
13335
- }
13336
- }, async ({
13337
- moduleFilePath
13338
- }, {
13339
- state
13340
- }) => {
13341
- const path = moduleFilePath;
13342
- if (!(path in state.serializedSchemas)) {
13343
- return err("not-found", unknownModuleMessage(path, state));
13344
- }
13345
- const source = state.sources[path];
13346
- return ok(source === undefined ? null : source);
13347
- }), defineTool({
13348
- name: "get_record_keys",
13349
- title: "Get record keys",
13350
- 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.",
13351
- inputSchema: z.z.object({
13352
- moduleFilePath: ModuleFilePathSchema$1,
13353
- path: z.z.array(z.z.string()).default([]).describe("Path within the module to the record or object. Empty means the module root."),
13354
- // Clamped by the schema rather than in the handler: a negative offset
13355
- // makes `slice` read from the END and a negative limit makes it drop
13356
- // the last N, so either would return a window that is not the page
13357
- // asked for while `total` alongside implied it was.
13358
- limit: z.z.number().int().min(1).default(100).describe("Maximum number of keys to return."),
13359
- offset: z.z.number().int().min(0).default(0).describe("Number of keys to skip, for paging.")
13360
- }),
13361
- annotations: {
13362
- readOnlyHint: true,
13363
- idempotentHint: true
13364
- }
13365
- }, async ({
13366
- moduleFilePath,
13367
- path,
13368
- limit,
13369
- offset
13370
- }, {
13371
- state
13372
- }) => {
13373
- const described = describeContainer(state, moduleFilePath, path);
13374
- if (described.kind !== "ok") {
13375
- return described.result;
13376
- }
13377
- const {
13378
- container,
13379
- value
13380
- } = described;
13381
- // Records and objects only, matching the Studio's tool of the same name.
13382
- // A gallery's keys are file paths whose bytes live elsewhere, and
13383
- // richtext blocks are positional — neither is a key set to hand back.
13384
- if (container !== "record" && container !== "object" || !isPlainObject(value)) {
13385
- 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"}.`);
13386
- }
13387
- const keys = Object.keys(value);
13388
- return ok({
13389
- kind: container,
13390
- keys: keys.slice(offset, offset + limit),
13391
- // The unpaged size, so a caller can tell a short page from the end of
13392
- // the record without asking for another one.
13393
- total: keys.length
13394
- });
13395
- }), defineTool({
13396
- name: "count_entries",
13397
- title: "Count entries",
13398
- 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.",
13399
- inputSchema: z.z.object({
13400
- moduleFilePath: ModuleFilePathSchema$1,
13401
- path: z.z.array(z.z.string()).default([]).describe("Path within the module to count at. Empty means the module root.")
13402
- }),
13403
- annotations: {
13404
- readOnlyHint: true,
13405
- idempotentHint: true
13406
- }
13407
- }, async ({
13408
- moduleFilePath,
13409
- path
13410
- }, {
13411
- state
13412
- }) => {
13413
- const described = describeContainer(state, moduleFilePath, path);
13414
- if (described.kind !== "ok") {
13415
- return described.result;
13416
- }
13417
- const {
13418
- container,
13419
- value
13420
- } = described;
13421
- // Every container `describeContainerAtPath` admits can be counted, so
13422
- // unlike get_record_keys this does not narrow further. Non-containers
13423
- // never get this far.
13424
- if (Array.isArray(value)) {
13425
- return ok({
13426
- kind: container,
13427
- count: value.length
13428
- });
13429
- }
13430
- if (isPlainObject(value)) {
13431
- return ok({
13432
- kind: container,
13433
- count: Object.keys(value).length
13434
- });
13435
- }
13436
- return err("invalid-args", `The value at that path is ${article(container)} ${container}, which has nothing to count.`);
13437
- }), defineTool({
13438
- name: "validate_content",
13439
- title: "Validate content",
13440
- description: "Check the project's content against its schemas, including unpublished changes. Returns only errors that would block publishing.",
13441
- inputSchema: z.z.object({
13442
- moduleFilePath: ModuleFilePathSchema$1.optional().describe("Limit the check to one module. Omit to validate everything.")
13443
- }),
13444
- annotations: {
13445
- readOnlyHint: true
13446
- }
13447
- }, async ({
13448
- moduleFilePath
13449
- }, {
13450
- ops,
13451
- state
13452
- }) => {
13453
- const validation = await ops.validateSources(state.schemas, state.sources,
13454
- // Every module. The third argument filters which modules are
13455
- // validated at all, so passing the pending-patch analysis would make
13456
- // a project with no pending changes report `valid: true` without
13457
- // having checked anything. Scoping to one module, when asked, is done
13458
- // on the results below.
13459
- undefined);
13460
- // `validateSources` hands back the files it could not check on its own;
13461
- // running them is what turns "this path holds a file" into "that file is
13462
- // actually there and matches its recorded metadata".
13463
- const fileErrors = await ops.validateFiles(state.schemas, state.sources, validation.files, state.analysis.fileLastUpdatedByPatchId);
13464
-
13465
- // Per-module results, flattened to the by-source-path shape the filter
13466
- // takes. Merged rather than overwritten: a path can pick up an error
13467
- // from validation and another from its file.
13468
- const bySourcePath = {};
13469
- const add = (path, errors) => {
13470
- bySourcePath[path] = (bySourcePath[path] ?? []).concat(errors);
13471
- };
13472
- for (const moduleErrors of Object.values(validation.errors)) {
13473
- for (const [path, errors] of Object.entries(moduleErrors.validations ?? {})) {
13474
- add(path, errors);
13475
- }
13476
- }
13477
- for (const [path, errors] of Object.entries(fileErrors)) {
13478
- add(path, errors);
13479
- }
13480
-
13481
- // Drops the errors the Studio would not show either: ones whose only
13482
- // effect is an offered fix. Left in, an agent would loop trying to
13483
- // "repair" content that is already publishable.
13484
- const blocking = internal.filterBlockingValidationErrors(bySourcePath, state.serializedSchemas, state.sources);
13485
-
13486
- // A module whose source could not be read at all has no source path to
13487
- // hang an error on, so it is reported separately rather than lost.
13488
- const unreadable = Object.entries(validation.errors).filter(([, moduleErrors]) => moduleErrors.invalidSource).map(([path, moduleErrors]) => {
13489
- var _moduleErrors$invalid;
13490
- return {
13491
- moduleFilePath: path,
13492
- message: ((_moduleErrors$invalid = moduleErrors.invalidSource) === null || _moduleErrors$invalid === void 0 ? void 0 : _moduleErrors$invalid.message) ?? "Invalid source"
13493
- };
13494
- });
13495
- const scope = moduleFilePath;
13496
- const errors = scope === undefined ? blocking : filterKeysByModule(blocking, scope);
13497
- const unreadableInScope = scope === undefined ? unreadable : unreadable.filter(u => u.moduleFilePath === scope);
13498
- return ok({
13499
- valid: Object.keys(errors).length === 0 && unreadableInScope.length === 0,
13500
- errors: Object.fromEntries(Object.entries(errors).map(([path, errs]) => [path, errs.map(toJsonValidationError)])),
13501
- // Always present, empty when there are none: a caller should not have
13502
- // to tell "absent" from "empty" to decide whether content is publishable.
13503
- unreadableModules: unreadableInScope
13504
- });
13505
- }), defineTool({
13506
- name: "get_patches",
13507
- title: "Get patches",
13508
- 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.",
13509
- inputSchema: z.z.object({
13510
- moduleFilePath: ModuleFilePathSchema$1.optional().describe("Limit to changes touching one module.")
13511
- }),
13512
- annotations: {
13513
- readOnlyHint: true
13514
- }
13515
- }, async ({
13516
- moduleFilePath
13517
- }, {
13518
- state
13519
- }) => {
13520
- const wanted = moduleFilePath === undefined ? state.patches.patches : state.patches.patches.filter(p => p.path === moduleFilePath);
13521
- // Which patches would not apply, flattened to one lookup by id: without
13522
- // this a module's content can silently differ from what publishing
13523
- // would produce, and nothing anywhere says why.
13524
- const failures = new Map();
13525
- for (const unapplied of Object.values(state.unappliedPatches)) {
13526
- for (const failure of unapplied) {
13527
- failures.set(failure.patchId, failure.error.message);
13528
- }
13529
- }
13530
- return ok(wanted.map(patch => {
13531
- const failure = failures.get(patch.patchId);
13532
- return {
13533
- patchId: patch.patchId,
13534
- moduleFilePath: patch.path,
13535
- createdAt: patch.createdAt,
13536
- authorId: patch.authorId,
13537
- // `appliedAt` non-null means this change is already committed, so
13538
- // it is history rather than something still pending.
13539
- published: patch.appliedAt !== null,
13540
- // Always present, so "applies cleanly" is stated rather than
13541
- // inferred from the absence of a field.
13542
- appliesCleanly: failure === undefined,
13543
- ...(failure === undefined ? {} : {
13544
- applyError: failure
13545
- })
13546
- };
13547
- }));
13548
- }), defineTool({
13549
- name: "get_source_path_from_route",
13550
- title: "Get source path from route",
13551
- 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.",
13552
- inputSchema: z.z.object({
13553
- route: z.z.string().describe('A route on the site, e.g. "/blog/my-post".')
13554
- }),
13555
- annotations: {
13556
- readOnlyHint: true,
13557
- idempotentHint: true
13558
- }
13559
- }, async ({
13560
- route
13561
- }, {
13562
- state
13563
- }) => {
13564
- const found = core.getSourcePathFromRoute(route, state.serializedSchemas);
13565
- if (!found) {
13566
- 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.`);
13567
- }
13568
- return ok(found);
13569
- })];
13570
- }
13571
-
13572
- /**
13573
- * Resolve a module and classify the value at a path inside it.
13574
- *
13575
- * Shared by `get_record_keys` and `count_entries` so the two cannot drift on
13576
- * what counts as a missing module, and so both map the same failure to the same
13577
- * error code: a path that is not there is `not-found`, while a path that is
13578
- * there but holds a string or an image is `invalid-args` — the caller should
13579
- * reach for a different tool, not go looking for the path again.
13580
- */
13581
- function describeContainer(state, moduleFilePath, path) {
13582
- const modulePath = moduleFilePath;
13583
- const schema = state.serializedSchemas[modulePath];
13584
- if (!schema) {
13585
- return {
13586
- kind: "error",
13587
- result: err("not-found", unknownModuleMessage(modulePath, state))
13588
- };
13589
- }
13590
- const described = internal.describeContainerAtPath(schema, state.sources[modulePath], path);
13591
- if (described.kind === "error") {
13592
- return {
13593
- kind: "error",
13594
- result: err(described.reason === "missing" ? "not-found" : "invalid-args", described.message)
13595
- };
13596
- }
13597
- return described;
13598
- }
13599
-
13600
- /** "a record", but "an object" and "an array". */
13601
- function article(container) {
13602
- return container === "object" || container === "array" ? "an" : "a";
13603
- }
13604
- function unknownModuleMessage(path, state) {
13605
- const known = Object.keys(state.serializedSchemas);
13606
- return `No Val module at ${JSON.stringify(path)}. Known modules: ${known.length === 0 ? "(none)" : known.join(", ")}`;
13607
- }
13608
-
13609
- /**
13610
- * Project a validation error into something JSON-safe and worth reading.
13611
- *
13612
- * `ValidationError.value` is dropped rather than serialized: it is `unknown` (so
13613
- * not `Json` to begin with) and it holds the offending source value, which can
13614
- * be arbitrarily large. A caller already has the source path and can read the
13615
- * value with `get_source` if it needs to — putting it here would bloat every
13616
- * result for the rare case that wants it.
13617
- *
13618
- * `fixes` is kept, because it names what Val already knows how to repair, which
13619
- * is directly actionable.
13620
- */
13621
- function toJsonValidationError(error) {
13622
- return {
13623
- message: error.message,
13624
- fixes: error.fixes ? [...error.fixes] : [],
13625
- typeError: error.typeError === true,
13626
- schemaError: error.schemaError === true,
13627
- keyError: error.keyError === true
13628
- };
13629
- }
13630
- function isPlainObject(value) {
13631
- return typeof value === "object" && value !== null && !Array.isArray(value);
13632
- }
13633
-
13634
- /**
13635
- * Keep only the entries belonging to one module.
13636
- *
13637
- * Keyed by SourcePath, which begins with the module file path, so a prefix match
13638
- * is the right test — there is no per-module grouping left to index by.
13639
- */
13640
- function filterKeysByModule(record, moduleFilePath) {
13641
- const out = {};
13642
- for (const [path, value] of Object.entries(record)) {
13643
- if (path.startsWith(moduleFilePath)) {
13644
- out[path] = value;
13645
- }
13646
- }
13647
- return out;
13648
- }
13649
-
13650
- /**
13651
- * Everything the Studio does client-side before a patch can be saved, done
13652
- * server-side.
13653
- *
13654
- * Three things had no server equivalent, and each is a way to be quietly wrong:
13655
- * where the patch id comes from, what the patch says its parent is, and whether
13656
- * the result would even be valid. `docs/plans/mcp.md` Part C is the design.
13657
- */
13658
-
13659
- const jsonOps = new patch.JSONOps();
13660
-
13661
- /**
13662
- * A patch id, minted before the write is attempted.
13663
- *
13664
- * Same shape the Studio mints (a v4 UUID), and minting one that never gets used
13665
- * costs nothing — ids are not registered anywhere until a patch carries them.
13666
- */
13667
- function mintPatchId() {
13668
- // A branded string has no constructor; this is the same conversion the Studio
13669
- // and ValServer both make.
13670
- return node_crypto.randomUUID();
13671
- }
13672
-
13673
- /**
13674
- * What the new patch should hang off.
13675
- *
13676
- * The last known patch if there is one, otherwise the current head. Note the
13677
- * asymmetry between the two backends: `ValOpsFS` ignores `parentRef` entirely
13678
- * because its append-only ordering log defines order, while `ValOpsHttp` sends
13679
- * it up as `parentPatchId` for optimistic concurrency. So a wrong value here is
13680
- * invisible locally and a conflict in production — which is why this is derived
13681
- * fresh rather than remembered.
13682
- */
13683
- async function deriveParentRef(ops,
13684
- // Only the ids matter, so this accepts either shape `fetchPatches` can
13685
- // return — the metadata-only variant omits the ops but keeps the ids.
13686
- patches) {
13687
- const last = patches.patches[patches.patches.length - 1];
13688
- if (last) {
13689
- return {
13690
- type: "patch",
13691
- patchId: last.patchId
13692
- };
13693
- }
13694
- return {
13695
- type: "head",
13696
- headBaseSha: await ops.getBaseSha()
13697
- };
13698
- }
13699
-
13700
- /**
13701
- * Would this patch leave the content valid?
13702
- *
13703
- * Applied to a **clone** of the sources, never the real ones: `applyPatch`
13704
- * mutates the document it is given, and ValOps carries a standing note that
13705
- * add operations misbehave without a clone. Validating in place would corrupt
13706
- * the sources every later call in this process reads.
13707
- *
13708
- * Server-side this is strictly better than the Studio's speculative check.
13709
- * `getSchemas()` returns real `Schema` instances, so the user's own `validate`
13710
- * closures run — and those are not carried by the serialized schema the browser
13711
- * has, which means the browser cannot run them at all.
13712
- */
13713
- async function validateSpeculatively(ops, state, moduleFilePath, patch$1) {
13714
- const current = state.sources[moduleFilePath];
13715
- if (current === undefined) {
13716
- return {
13717
- status: "unapplicable",
13718
- result: {
13719
- status: "error",
13720
- code: "not-found",
13721
- message: `No Val module at ${JSON.stringify(moduleFilePath)}.`
13722
- }
13723
- };
13724
- }
13725
- const applied = patch.applyPatch(patch.deepClone(current), jsonOps, patch$1);
13726
- if (fp.result.isErr(applied)) {
13727
- return {
13728
- status: "unapplicable",
13729
- result: {
13730
- status: "error",
13731
- code: "invalid-args",
13732
- message: `The patch cannot be applied to ${moduleFilePath}: ${applied.error.message}`
13733
- }
13734
- };
13735
- }
13736
- const speculativeSources = {
13737
- ...state.sources,
13738
- [moduleFilePath]: applied.value
13739
- };
13740
- const after = await blockingErrorsIn(ops, state, speculativeSources, moduleFilePath);
13741
- if (after.length === 0) {
13742
- return {
13743
- status: "valid"
13744
- };
13745
- }
13746
-
13747
- // Only the errors this patch *introduces*. A module can already be broken for
13748
- // reasons this change has nothing to do with -- the example app ships with a
13749
- // missing image file -- and refusing on the total would make every such module
13750
- // permanently read-only: an agent could not fix a typo in a file that also
13751
- // holds a broken image reference. Paid for only when there is something to
13752
- // refuse, so an ordinary clean edit still validates once.
13753
- const before = await blockingErrorsIn(ops, state, state.sources, moduleFilePath);
13754
- const existing = new Set(before.map(identify));
13755
- const introduced = after.filter(error => !existing.has(identify(error)));
13756
- if (introduced.length === 0) {
13757
- return {
13758
- status: "valid"
13759
- };
13760
- }
13761
- return {
13762
- status: "invalid",
13763
- errors: describeErrors(introduced)
13764
- };
13765
- }
13766
- /** Path and message together: the same message at another path is another problem. */
13767
- function identify(error) {
13768
- return `${error.path}\u0000${error.message}`;
13769
- }
13770
-
13771
- /**
13772
- * The publishing-blocking errors in one module, for a given set of sources.
13773
- *
13774
- * Scoped to one module by source path, which starts with the module file path.
13775
- * Errors elsewhere in the project are somebody else's: refusing on them would
13776
- * let the first broken module in a repo make every other module read-only.
13777
- */
13778
- async function blockingErrorsIn(ops, state, sources, moduleFilePath) {
13779
- const validation = await ops.validateSources(state.schemas, sources,
13780
- // Every module, deliberately -- `patchesByModule` is a FILTER on which
13781
- // modules get validated, not context for validating them. Passing the
13782
- // analysis from before this write skips the very module being written
13783
- // whenever it had no pending patch, so the first change to a module went
13784
- // unchecked; and a change that breaks a `keyOf` or a router in a *different*
13785
- // module reports its error there, which a filtered run never visits.
13786
- undefined);
13787
- // `validateSources` hands back the files it could not check on its own;
13788
- // running them is what turns "this path holds a file" into "that file is
13789
- // actually there and matches its recorded metadata".
13790
- const fileErrors = await ops.validateFiles(state.schemas, sources, validation.files, state.analysis.fileLastUpdatedByPatchId);
13791
-
13792
- // Merged rather than overwritten: a path can pick up an error from validation
13793
- // and another from its file.
13794
- const bySourcePath = {};
13795
- const add = (path, errors) => {
13796
- bySourcePath[path] = (bySourcePath[path] ?? []).concat(errors);
13797
- };
13798
- for (const moduleErrors of Object.values(validation.errors)) {
13799
- for (const [path, errors] of Object.entries(moduleErrors.validations ?? {})) {
13800
- add(path, errors);
13801
- }
13802
- }
13803
- for (const [path, errors] of Object.entries(fileErrors)) {
13804
- add(path, errors);
13805
- }
13806
- const blocking = internal.filterBlockingValidationErrors(bySourcePath, state.serializedSchemas, sources);
13807
- const located = [];
13808
- for (const [path, errors] of Object.entries(blocking)) {
13809
- if (!path.startsWith(moduleFilePath)) {
13810
- continue;
13811
- }
13812
- for (const error of errors) {
13813
- located.push({
13814
- path: path,
13815
- message: error.message
13816
- });
13817
- }
13818
- }
13819
- return located;
13820
- }
13821
- function describeErrors(errors) {
13822
- return errors.map(e => `${e.path}: ${e.message}`).join("; ");
13823
- }
13824
-
13825
- /**
13826
- * What to do when the change would leave the content invalid.
13827
- *
13828
- * `"reject"` for a tool that is editing existing content: an agent should not be
13829
- * able to break a site, and a rejected patch stores nothing.
13830
- *
13831
- * `"report"` for a tool whose whole purpose is to create something incomplete.
13832
- * `empty_at_path` scaffolds an entry the caller is then expected to fill in, so
13833
- * on most real schemas — anything with a non-empty string — the value it creates
13834
- * is invalid by construction. Rejecting that would make the tool useless on
13835
- * exactly the schemas it exists for, so instead the patch is saved and the
13836
- * remaining errors come back as a to-do list. This mirrors the Studio, where
13837
- * creating an empty entry is normal and the errors show until it is filled in.
13838
- */
13839
-
13840
- /**
13841
- * Validate, then save — and retry once if someone else got there first.
13842
- *
13843
- * The retry exists because the parent ref is derived from a read that happened
13844
- * before the write. A conflict means the chain moved underneath us, and
13845
- * re-deriving is usually enough. Once only: a loop here would be an agent
13846
- * fighting a human editor in the Studio, and losing slowly is worse than
13847
- * failing clearly.
13848
- */
13849
- async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
13850
- var _ctx$auth;
13851
- const {
13852
- ops,
13853
- ctx,
13854
- state
13855
- } = deps;
13856
- const unapplied = state.unappliedPatches[moduleFilePath];
13857
- if (unapplied && unapplied.length > 0) {
13858
- // Refused before anything is validated, because the state to validate
13859
- // against is wrong. `sources` for this module silently lacks these pending
13860
- // changes, so a patch built on it would be based on content that will never
13861
- // exist -- and its parent ref would chain onto changes that do not apply.
13862
- return {
13863
- status: "error",
13864
- code: "internal",
13865
- 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.`
13866
- };
13867
- }
13868
- const speculative = await validateSpeculatively(ops, state, moduleFilePath, patch);
13869
- if (speculative.status === "unapplicable") {
13870
- // Never negotiable: the patch does not fit the content, so there is nothing
13871
- // to save whatever the caller's tolerance for invalid results.
13872
- return speculative.result;
13873
- }
13874
- let unresolved = null;
13875
- if (speculative.status === "invalid") {
13876
- if (onInvalid === "reject") {
13877
- return {
13878
- status: "error",
13879
- code: "validation-failed",
13880
- message: `The change was rejected and nothing was saved, because it would leave the content invalid: ${speculative.errors}`
13881
- };
13882
- }
13883
- unresolved = speculative.errors;
13884
- }
13885
-
13886
- /**
13887
- * The verified profile, or null when there was nothing to verify.
13888
- *
13889
- * An author is written only when somebody checked it. On the token path the
13890
- * host verified a signature over a key it does not hold, so the profile is
13891
- * checked rather than claimed, and the backend has no token of its own to
13892
- * attribute from — the call reaches it under the app's API key. If this
13893
- * stayed null there, every edit made through a signed-in editor's own session
13894
- * would land with no author at all, which is worse than useless on a CMS
13895
- * whose review screen is organised by who changed what.
13896
- *
13897
- * Null is what local filesystem mode gets, where there is no credential to
13898
- * resolve, exactly as the Studio does locally. It is also what the removed
13899
- * personal-access-token path got, and for a reason worth keeping in view: an
13900
- * id derived from a credential the app cannot resolve is an unverified claim
13901
- * dressed up as a checked one. Should another unverified credential ever
13902
- * reach here, null remains its only honest author.
13903
- */
13904
- const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
13905
- for (let attempt = 0; attempt < 2; attempt++) {
13906
- const patchId = mintPatchId();
13907
- // Re-derived on the retry rather than reused: reusing the ref that just
13908
- // conflicted would conflict again by definition.
13909
- const patches = attempt === 0 ? state.patches : await ops.fetchPatches({
13910
- excludePatchOps: true
13911
- });
13912
- const parentRef = await deriveParentRef(ops, patches);
13913
- const saved = await ops.createPatch(moduleFilePath, patch, patchId, parentRef, ctx.sessionId, authorId);
13914
- if (fp.result.isOk(saved)) {
13915
- return {
13916
- status: "ok",
13917
- data: {
13918
- patchId: saved.value.patchId,
13919
- moduleFilePath,
13920
- createdAt: saved.value.createdAt,
13921
- // Always present, so a caller does not have to tell "absent" from
13922
- // "nothing left to do" to know whether the content is publishable.
13923
- unresolvedValidationErrors: unresolved
13924
- }
13925
- };
13926
- }
13927
- if (saved.error.errorType === "patch-head-conflict") {
13928
- continue;
13929
- }
13930
- return {
13931
- status: "error",
13932
- code: "internal",
13933
- // Note the nesting: createPatch wraps the underlying flat error as
13934
- // `{ errorType: "other", error: <that> }`.
13935
- message: saved.error.error.message
13936
- };
13937
- }
13938
- return {
13939
- status: "error",
13940
- code: "conflict",
13941
- message: "Another change was saved while this one was being written, twice in a row. Read the content again before retrying — it has moved."
13942
- };
13943
- }
13944
-
13945
- /**
13946
- * The tools that change content.
13947
- *
13948
- * Every one of them goes through {@link savePatch}, so they all inherit the same
13949
- * guarantees: the change is validated against the real schemas before anything
13950
- * is stored, a rejected change stores nothing, and a lost race with another
13951
- * writer is retried once and then reported rather than looped on.
13952
- *
13953
- * Images are not here. The Studio's image tools work from a handle into Val's
13954
- * AI session store — bytes the browser got from the vision system — and MCP has
13955
- * no equivalent, so they need a different affordance (a local file path, or
13956
- * inline base64) rather than a port. `docs/plans/mcp.md` Part B has the reasoning.
13957
- */
13958
-
13959
- const ModuleFilePathSchema = z.z.string().describe('Path of the Val module, e.g. "/content/pages.val.ts".');
13960
- function writeTools() {
13961
- return [defineTool({
13962
- name: "create_patch",
13963
- title: "Create patch",
13964
- 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.",
13965
- inputSchema: z.z.object({
13966
- moduleFilePath: ModuleFilePathSchema,
13967
- 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.')
13968
- }),
13969
- annotations: {
13970
- idempotentHint: false
13971
- }
13972
- }, async ({
13973
- moduleFilePath,
13974
- patch
13975
- }, deps) => {
13976
- // Before parsing, not after: a file op that is also malformed should be
13977
- // told that files are not supported, rather than handed a schema error
13978
- // about the shape of a thing it was never going to be allowed to do.
13979
- const rejected = rejectFileOps(patch);
13980
- if (rejected) {
13981
- return rejected;
13982
- }
13983
- const parsed = internal.safeParsePatch(patch);
13984
- if (parsed.kind !== "ok") {
13985
- return fromBuildResult(parsed);
13986
- }
13987
- return savePatch(deps, moduleFilePath, parsed.patch);
13988
- }), defineTool({
13989
- name: "duplicate_source",
13990
- title: "Duplicate source",
13991
- 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.",
13992
- inputSchema: z.z.object({
13993
- moduleFilePath: ModuleFilePathSchema,
13994
- sourcePath: z.z.array(z.z.string()).describe("Path of the value to copy."),
13995
- destinationPath: z.z.array(z.z.string()).describe("Path to copy it to. Must not already exist.")
13996
- }),
13997
- annotations: {
13998
- idempotentHint: false
13999
- }
14000
- }, async ({
14001
- moduleFilePath,
14002
- sourcePath,
14003
- destinationPath
14004
- }, deps) => {
14005
- const modulePath = moduleFilePath;
14006
- const schema = deps.state.serializedSchemas[modulePath];
14007
- if (!schema) {
14008
- return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
14009
- }
14010
- const built = internal.buildDuplicatePatch({
14011
- sourcePath,
14012
- destinationPath
14013
- }, schema, deps.state.sources[modulePath]);
14014
- if (built.kind !== "ok") {
14015
- return fromBuildResult(built);
14016
- }
14017
- return savePatch(deps, modulePath, built.patch);
14018
- }), defineTool({
14019
- name: "empty_at_path",
14020
- title: "Create an empty value at a path",
14021
- 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.",
14022
- inputSchema: z.z.object({
14023
- moduleFilePath: ModuleFilePathSchema,
14024
- destinationPath: z.z.array(z.z.string()).describe("Path to create the empty value at.")
14025
- }),
14026
- annotations: {
14027
- idempotentHint: false
14028
- }
14029
- }, async ({
14030
- moduleFilePath,
14031
- destinationPath
14032
- }, deps) => {
14033
- const modulePath = moduleFilePath;
14034
- const schema = deps.state.serializedSchemas[modulePath];
14035
- if (!schema) {
14036
- return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
14037
- }
14038
- const built = internal.buildEmptyAtPathPatch({
14039
- destinationPath
14040
- }, schema, deps.state.sources[modulePath]);
14041
- if (built.kind !== "ok") {
14042
- return fromBuildResult(built);
14043
- }
14044
- // "report", not "reject": an empty entry is invalid by construction on
14045
- // any schema with a required non-empty field, which is most of them.
14046
- // See OnInvalid in writePath.ts.
14047
- return savePatch(deps, modulePath, built.patch, "report");
14048
- }), defineTool({
14049
- name: "remove_image_gallery_entry",
14050
- title: "Remove an image gallery entry",
14051
- description: "Remove one image from an image gallery module by its file path. This deletes the entry and the file it refers to.",
14052
- inputSchema: z.z.object({
14053
- moduleFilePath: ModuleFilePathSchema.describe("The gallery module, i.e. one declared with s.images() or s.files()."),
14054
- filePath: z.z.string().describe('The gallery key to remove, e.g. "/public/val/photo_a1b2c.jpg".')
14055
- }),
14056
- // Destructive: it removes content and the underlying file, so a host
14057
- // that asks for confirmation should ask here.
14058
- annotations: {
14059
- destructiveHint: true,
14060
- idempotentHint: false
14061
- }
14062
- }, async ({
14063
- moduleFilePath,
14064
- filePath
14065
- }, deps) => {
14066
- const modulePath = moduleFilePath;
14067
- const schema = deps.state.serializedSchemas[modulePath];
14068
- if (!schema) {
14069
- return err("not-found", `No Val module at ${JSON.stringify(modulePath)}.`);
14070
- }
14071
- const built = internal.buildRemoveImageGalleryEntryPatch({
14072
- filePath
14073
- }, schema, deps.state.sources[modulePath]);
14074
- if (built.kind !== "ok") {
14075
- return fromBuildResult(built);
14076
- }
14077
- return savePatch(deps, modulePath, built.patch);
14078
- })];
14079
- }
14080
-
14081
- /**
14082
- * Turn a helper's build failure into a tool error.
14083
- *
14084
- * `wrong-tool` is worth keeping distinct: the helpers can tell that the caller
14085
- * reached for the wrong tool and which one it should have used, and passing that
14086
- * through is what lets a model correct itself in one step instead of retrying
14087
- * the same call.
14088
- */
14089
- function fromBuildResult(built) {
14090
- if (built.kind === "wrong-tool") {
14091
- return {
14092
- status: "error",
14093
- code: "invalid-args",
14094
- message: `${built.reason} Use the ${built.suggestedTool} tool instead.`
14095
- };
14096
- }
14097
- return {
14098
- status: "error",
14099
- code: "invalid-args",
14100
- message: built.message
14101
- };
14102
- }
14103
-
14104
- /**
14105
- * File operations are refused rather than half-supported.
14106
- *
14107
- * A `file` op carries binary content that has to be uploaded before the patch
14108
- * is synced — a two-phase flow this pass does not implement. Letting one through
14109
- * would store a patch referring to bytes that were never uploaded, which fails
14110
- * later and a long way from the cause.
14111
- *
14112
- * Takes the unparsed patch, so this answer does not depend on the op being
14113
- * otherwise well formed. All it needs is the caller's own claim about what the
14114
- * op is.
14115
- */
14116
- function rejectFileOps(patch) {
14117
- const hasFileOp = patch.some(op => typeof op === "object" && op !== null && "op" in op && op.op === "file");
14118
- if (!hasFileOp) {
14119
- return null;
14120
- }
14121
- return {
14122
- status: "error",
14123
- code: "unsupported",
14124
- message: "This patch contains a file operation. Uploading files is not supported over MCP yet — only text and JSON values can be changed."
14125
- };
14126
- }
14127
-
14128
- /**
14129
- * The public surface of Val's server-side tool registry.
14130
- *
14131
- * Types only, deliberately: this file is the contract that the MCP hosts, the
14132
- * CLI's stdio transport and the tools themselves are all written against, and
14133
- * keeping it free of implementation means those can be built in any order
14134
- * without one of them owning the shape.
14135
- *
14136
- * The design this implements is `docs/plans/mcp.md`, Part A. Two constraints
14137
- * from it are load-bearing and easy to break by accident:
14138
- *
14139
- * 1. **Nothing here may import an MCP SDK.** That is what lets hosts other
14140
- * than the template consume these tools, and it is not hypothetical
14141
- * hygiene — the TypeScript SDK reorganised itself at v2.0.0, and a registry
14142
- * coupled to it would have moved with it.
14143
- * 2. **The result type is deliberately not MCP's `CallToolResult`.** Each host
14144
- * adapts {@link ValToolResult} at its own edge, which is also where an
14145
- * error becomes an in-band `isError` result the model can recover from
14146
- * rather than a transport failure.
14147
- */
14148
-
14149
- /** Why a tool call failed, in a form a host can map onto its own errors. */
14150
-
14151
- /**
14152
- * The same definition with `inputSchema` as JSON Schema, for hosts that want the
14153
- * wire shape rather than a Standard Schema.
14154
- *
14155
- * Typed as whatever zod's own converter produces, so deriving it needs no cast
14156
- * and no second hand-written description of the same input.
14157
- */
14158
-
14159
- /**
14160
- * How the caller was established, and there is one acceptable answer: the host
14161
- * **checked a signature**.
14162
- *
14163
- * A union of one, deliberately. It carried a second variant — a personal access
14164
- * token relayed to the backend unchecked, on the reasoning that the app cannot
14165
- * resolve one and the backend can. The reasoning held; the shape did not. A
14166
- * credential the host cannot check is one it also cannot refuse, so accepting
14167
- * one made "a deployed endpoint that authenticates nobody" a supported
14168
- * configuration, and it let a host serve these tools without ever being told
14169
- * where callers should authorize. The discriminant stays so that adding a
14170
- * second *verified* kind stays a one-line change at every call site.
14171
- */
14172
-
14173
- /**
14174
- * Who is calling, established once per request by the host.
14175
- *
14176
- * `null` means local fs mode, where there is no credential to hold and patches
14177
- * are written with no author, exactly as the Studio does locally (D.1). In
14178
- * proxy mode `null` is refused rather than falling back to the app's own API
14179
- * key: that key can do more than any single user, and quietly substituting it
14180
- * would turn a missing credential into full access.
14181
- */
14182
-
14183
- /**
14184
- * Brand a verified subject as an {@link AuthorId}.
14185
- *
14186
- * `AuthorId` is a branded string so that an id cannot be conjured from any
14187
- * string that happens to be lying around — which is exactly the mistake this
14188
- * type is guarding against. That makes one assertion unavoidable at the boundary
14189
- * where a real id enters the system, so it lives here, once, with a name that
14190
- * says what makes it legitimate: the caller has *verified* this subject, not
14191
- * received it.
14192
- *
14193
- * Do not reach for this to satisfy a type. If you are holding a string you did
14194
- * not verify, the honest value is `null`.
14195
- */
14196
- function authorIdFromVerifiedSubject(subject) {
14197
- return subject;
14198
- }
14199
-
14200
- /** Read access. Every call needs it, the writes included. */
14201
- const VAL_SCOPE_READ = "val:read";
14202
- /** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
14203
- const VAL_SCOPE_WRITE = "val:write";
14204
-
14205
- /**
14206
- * Val's server-side tool registry.
14207
- *
14208
- * This is the piece Val did not have: the Studio's chat tools are defined *and
14209
- * executed in the browser*, against its client stores, so nothing here could be
14210
- * re-exposed. These tools run against {@link ValOps} instead, which is what lets
14211
- * an MCP server — or a stdio transport, or anything else — drive Val content
14212
- * without a browser.
14213
- *
14214
- * Transport-agnostic on purpose: nothing under `tools/` imports an MCP SDK. A
14215
- * host adapts {@link ValToolResult} at its own edge.
14216
- */
14217
- function createValTools(valModules, options) {
14218
- const resolveOps = createOpsResolver(valModules, options);
14219
- const tools = [...readTools(), ...writeTools()];
14220
- const byName = new Map(tools.map(tool => [tool.name, tool]));
14221
- return {
14222
- list() {
14223
- return tools.map(({
14224
- handler: _handler,
14225
- ...definition
14226
- }) => definition);
14227
- },
14228
- listJsonSchema() {
14229
- return tools.map(({
14230
- handler: _handler,
14231
- inputSchema,
14232
- ...rest
14233
- }) => ({
14234
- ...rest,
14235
- // zod 4 derives this itself, so there is no JSON-Schema-to-zod
14236
- // converter anywhere in the stack and no second description of the
14237
- // same input to keep in step.
14238
- inputSchema: z.z.toJSONSchema(inputSchema, {
14239
- io: "input"
14240
- })
14241
- }));
14242
- },
14243
- async call(name, args, ctx) {
14244
- const tool = byName.get(name);
14245
- if (!tool) {
14246
- return {
14247
- status: "error",
14248
- code: "unknown-tool",
14249
- message: `No tool named ${JSON.stringify(name)}. Available: ${tools.map(t => t.name).join(", ")}`
14250
- };
14251
- }
14252
- const parsed = tool.inputSchema.safeParse(args ?? {});
14253
- if (!parsed.success) {
14254
- return {
14255
- status: "error",
14256
- code: "invalid-args",
14257
- message: describeZodError(parsed.error)
14258
- };
14259
- }
14260
- const insufficient = refuseInsufficientScope(tool, ctx);
14261
- if (insufficient) {
14262
- return insufficient;
14263
- }
14264
- const resolved = resolveOps(ctx);
14265
- if (resolved.status === "error") {
14266
- return resolved.result;
14267
- }
14268
- const ops = resolved.ops;
14269
- try {
14270
- const state = await loadState(ops);
14271
- if (state.status === "error") {
14272
- return state.result;
14273
- }
14274
- const deps = {
14275
- ops,
14276
- ctx,
14277
- state: state.state
14278
- };
14279
- return await tool.handler(parsed.data, deps);
14280
- } catch (error) {
14281
- // A thrown error here is a bug or an unreachable backend, not something
14282
- // the model can act on — but it still comes back in-band so the client
14283
- // sees a tool failure rather than a dead transport.
14284
- return {
14285
- status: "error",
14286
- code: "internal",
14287
- message: error instanceof Error ? error.message : String(error)
14288
- };
14289
- }
14290
- },
14291
- async dispose() {
14292
- // Nothing to release today: ValOps holds no handle that needs closing, and
14293
- // the fs watcher it can start is owned by the Studio's server. Kept in the
14294
- // contract so hosts wire up teardown now rather than when it starts to
14295
- // matter.
14296
- }
14297
- };
14298
- }
14299
-
14300
- /**
14301
- * Pick the data layer for a call, which in proxy mode means picking whose
14302
- * credential the backend will see.
14303
- *
14304
- * This is the one place authorization is decided, and in proxy mode there is
14305
- * exactly one credential it will act on: an access token whose signature,
14306
- * issuer, audience and expiry the host verified against the authorization
14307
- * server's published key. Anything less is refused here rather than forwarded.
14308
- *
14309
- * There used to be a second route — the caller's personal access token, passed
14310
- * through unread on the reasoning that the backend, not the app, is the
14311
- * authority on what it may do. That was true, and it was still the wrong shape:
14312
- * it made an unauthenticated bearer token on a deployed endpoint a supported
14313
- * configuration, and it meant `initValMcp` had a path where an app served MCP
14314
- * without ever being told where to authorize. A host that has not verified
14315
- * anything now gets a refusal that names the missing `oauth` config.
14316
- *
14317
- * What has *not* changed is why a verified token does not become the app's own
14318
- * API key by some other name. The app authenticates to the backend with its own
14319
- * key here, and who did what travels as the patch's `authorId` — so the
14320
- * profile has to be one the host checked cryptographically, never one it was
14321
- * handed. An `authenticate()` that decided a credential's rights inside the app
14322
- * would make every bug in it full access to every project that key can reach.
14323
- */
14324
- function createOpsResolver(valModules, options) {
14325
- if (options.mode === "fs") {
14326
- // One instance, built once: fs mode is a developer's own working tree, so
14327
- // there is no credential to vary by and no reason to re-evaluate modules.
14328
- const ops = createValOps(valModules, options);
14329
- return ctx => {
14330
- if (ctx.auth) {
14331
- // Refused rather than ignored. A host that thinks it is passing a
14332
- // credential should not silently get local filesystem access instead —
14333
- // and the difference matters, because fs mode writes straight to disk
14334
- // with no backend permission check at all.
14335
- //
14336
- // A verified access token is not something the caller chose to send: it
14337
- // only exists because this app advertised an authorization server, so
14338
- // the developer seeing this did not do anything wrong — a config file
14339
- // did, and naming it is the difference between a two-minute fix and an
14340
- // afternoon.
14341
- return {
14342
- status: "error",
14343
- result: {
14344
- status: "error",
14345
- code: "unsupported",
14346
- message: "This Val project is running in local filesystem mode, so there is nothing to authenticate against, but it is configured with an `oauth` issuer and is therefore asking clients for an access token it cannot use. Remove the `oauth` config (or `VAL_OAUTH_ISSUER` from your local `.env`) for local development."
14347
- }
14348
- };
14349
- }
14350
- return {
14351
- status: "ok",
14352
- ops
14353
- };
14354
- };
14355
- }
14356
-
14357
- /**
14358
- * One instance for every verified caller, and that is correct rather than a
14359
- * shortcut: this instance authenticates with the app's own API key, so there
14360
- * is nothing per-caller in it to keep apart. Who did what travels as the
14361
- * patch's `authorId` instead — see `writePath`.
14362
- *
14363
- * One instance is also all proxy mode keeps now. It could not share while a
14364
- * personal access token reached this function: each token needed its own
14365
- * `ValOpsHttp` to hold it, each of those cached the project's evaluated
14366
- * modules, and the bounded cache that kept that memory in check turned an
14367
- * eviction into a re-evaluation of every module on the next call.
14368
- */
14369
- let sharedOps = null;
14370
- return ctx => {
14371
- var _ctx$auth;
14372
- if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
14373
- return {
14374
- status: "error",
14375
- result: {
14376
- status: "error",
14377
- code: "forbidden",
14378
- message: "This Val project talks to the Val content backend, so every call needs an access token from the Val authorization server. If this endpoint is not asking clients to authorize, it has no `oauth` config — give `initValMcp` one, or run the project in local filesystem mode for development."
14379
- }
14380
- };
14381
- }
14382
- if (!options.apiKey) {
14383
- // Proxy mode is inferred from the api key being present, so this is
14384
- // unreachable through `initHandlerOptions`. It stays because the
14385
- // alternative to refusing is building ops with no credential at all.
14386
- return {
14387
- status: "error",
14388
- result: {
14389
- status: "error",
14390
- code: "forbidden",
14391
- message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
14392
- }
14393
- };
14394
- }
14395
- if (!sharedOps) {
14396
- sharedOps = createValOps(valModules, options);
14397
- }
14398
- return {
14399
- status: "ok",
14400
- ops: sharedOps
14401
- };
14402
- };
14403
- }
14404
- /**
14405
- * The content as the caller should see it, loaded once per call.
14406
- *
14407
- * Pending patches are applied, because an agent looking at a project mid-edit
14408
- * should see what the Studio would show rather than the last published state.
14409
- *
14410
- * Deliberately not cached across calls. In fs mode a save recomputes the base
14411
- * sha within the same process, so a cached view would go stale silently — and
14412
- * the cost of being wrong here is an agent writing a patch against content that
14413
- * has already moved.
14414
- *
14415
- * Exported for `toolsFixture`, not for consumers: `tools/index.ts` re-exports by
14416
- * name, so this stays inside the package. A test that assembled its own state
14417
- * would be asserting against a view no real call ever sees.
14418
- */
14419
- async function loadState(ops) {
14420
- const patches = await ops.fetchPatches({
14421
- excludePatchOps: false
14422
- });
14423
- // fetchPatches resolves with its failures on the result rather than rejecting,
14424
- // so not checking these reads as "no pending changes" — which would quietly
14425
- // hand back published content and let a write be based on it.
14426
- if (patches.unauthorized) {
14427
- return {
14428
- status: "error",
14429
- result: {
14430
- status: "error",
14431
- code: "forbidden",
14432
- message: "Not authorized to read this project's pending changes. Check that the credential is valid and has access."
14433
- }
14434
- };
14435
- }
14436
- if (patches.networkError) {
14437
- return {
14438
- status: "error",
14439
- result: {
14440
- status: "error",
14441
- code: "internal",
14442
- message: "Could not reach the Val content backend."
14443
- }
14444
- };
14445
- }
14446
- if (patches.error) {
14447
- return {
14448
- status: "error",
14449
- result: {
14450
- status: "error",
14451
- code: "internal",
14452
- message: patches.error.message
14453
- }
14454
- };
14455
- }
14456
- const analysis = ops.analyzePatches(patches.patches);
14457
- // getSourcesWithPatchesApplied, not getSources(analysis): the latter returns
14458
- // only the modules that had patches, and validating that subset reports
14459
- // spurious errors for anything that looks across modules, like keyOf or a
14460
- // router.
14461
- const sourcesRes = await ops.getSourcesWithPatchesApplied({
14462
- ...analysis,
14463
- ...patches
14464
- });
14465
- const [schemas, serializedSchemas] = await Promise.all([ops.getSchemas(), ops.getSerializedSchemas()]);
14466
- return {
14467
- status: "ok",
14468
- state: {
14469
- schemas,
14470
- serializedSchemas,
14471
- sources: sourcesRes.sources,
14472
- patches,
14473
- analysis,
14474
- // Which modules hold a pending patch that would not apply. Carried rather
14475
- // than discarded because their `sources` silently lack that change: the
14476
- // content here is not what publishing would produce, so a write against
14477
- // it would be based on a state that does not exist. See
14478
- // `unappliedPatchesFor`.
14479
- unappliedPatches: sourcesRes.errors
14480
- }
14481
- };
14482
- }
14483
- function describeZodError(error) {
14484
- return error.issues.map(issue => {
14485
- const path = issue.path.join(".");
14486
- return path ? `${path}: ${issue.message}` : issue.message;
14487
- }).join("; ");
14488
- }
14489
-
14490
- /**
14491
- * Refuse a call the token was not granted, before anything is attempted.
14492
- *
14493
- * Derived from `readOnlyHint` rather than from a second list of tool names,
14494
- * because a second list is a thing that drifts. The derivation also fails in
14495
- * the safe direction: a tool that forgets the hint is treated as a write and
14496
- * demands the wider scope, rather than a write slipping through as a read.
14497
- *
14498
- * The early return is a call carrying no verified credential, and there is no
14499
- * scope to check because nothing granted one. In Val's own host that means
14500
- * local filesystem mode, where a project writing a developer's own working tree
14501
- * has no wider authority to withhold. A host assembling its own context can
14502
- * also reach it with an unauthenticated proxy-mode call — refused a few lines
14503
- * later, by `resolveOps`, for the credential rather than the scope. Every other
14504
- * caller arrives as a verified profile, carrying the scopes its token was
14505
- * issued with.
14506
- */
14507
- function refuseInsufficientScope(tool, ctx) {
14508
- var _ctx$auth2, _tool$annotations;
14509
- if (((_ctx$auth2 = ctx.auth) === null || _ctx$auth2 === void 0 ? void 0 : _ctx$auth2.type) !== "verified-profile") {
14510
- return null;
14511
- }
14512
- // Read is needed by every call, including the writes: a tool that changes
14513
- // content reads it first, and `ValToolAuth` says as much. Checking only the
14514
- // wider scope would let a write-but-not-read token through here — today's
14515
- // verifier refuses such a token before this point, but `createValTools` is
14516
- // exported and another host may not.
14517
- const needed = (_tool$annotations = tool.annotations) !== null && _tool$annotations !== void 0 && _tool$annotations.readOnlyHint ? [VAL_SCOPE_READ] : [VAL_SCOPE_READ, VAL_SCOPE_WRITE];
14518
- const granted = ctx.auth.scopes;
14519
- const missing = needed.filter(scope => !granted.includes(scope));
14520
- if (missing.length === 0) {
14521
- return null;
14522
- }
14523
- return {
14524
- status: "error",
14525
- code: "forbidden",
14526
- message: `This access token does not have the ${missing.join(" and ")} scope, which ${tool.name} requires. Granted: ${granted.length > 0 ? granted.join(" ") : "(none)"}.`
14527
- };
14528
- }
14529
-
14530
13299
  const JsFileLookupMapping = [
14531
13300
  // NOTE: first one matching will be used
14532
13301
  [".cjs.d.ts", [".esm.js", ".mjs.js"]], [".cjs.js", [".esm.js", ".mjs.js"]], [".cjs", [".mjs"]], [".d.ts", [".js", ".esm.js", ".mjs.js"]]];
@@ -16735,8 +15504,6 @@ exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
16735
15504
  exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
16736
15505
  exports.EXTERNAL_RESULT = EXTERNAL_RESULT;
16737
15506
  exports.Service = Service;
16738
- exports.VAL_SCOPE_READ = VAL_SCOPE_READ;
16739
- exports.VAL_SCOPE_WRITE = VAL_SCOPE_WRITE;
16740
15507
  exports.ValFSHost = ValFSHost;
16741
15508
  exports.ValLoginError = ValLoginError;
16742
15509
  exports.ValModuleLoader = ValModuleLoader;
@@ -16744,7 +15511,6 @@ exports.ValOpsFS = ValOpsFS;
16744
15511
  exports.ValOpsHttp = ValOpsHttp;
16745
15512
  exports.ValSourceFileHandler = ValSourceFileHandler;
16746
15513
  exports.analyzeValModule = analyzeValModule;
16747
- exports.authorIdFromVerifiedSubject = authorIdFromVerifiedSubject;
16748
15514
  exports.awaitValLoginConfirmation = awaitValLoginConfirmation;
16749
15515
  exports.checkRemoteRef = checkRemoteRef;
16750
15516
  exports.classifyJsonValuesOp = classifyJsonValuesOp;
@@ -16758,14 +15524,13 @@ exports.createValApiRouter = createValApiRouter;
16758
15524
  exports.createValModuleFileInspector = createValModuleFileInspector;
16759
15525
  exports.createValOps = createValOps;
16760
15526
  exports.createValServer = createValServer;
16761
- exports.createValTools = createValTools;
16762
15527
  exports.currentFixHandlers = currentFixHandlers;
16763
15528
  exports.decodeJwtWithoutVerifying = decodeJwtWithoutVerifying;
16764
15529
  exports.defineExternal = defineExternal;
16765
15530
  exports.describePatchStoreProblems = describePatchStoreProblems;
16766
15531
  exports.downloadFileFromRemote = downloadFileFromRemote;
16767
15532
  exports.encodeJwt = encodeJwt;
16768
- exports.err = err$1;
15533
+ exports.err = err;
16769
15534
  exports.evalValConfigFile = evalValConfigFile;
16770
15535
  exports.extractFileMetadata = extractFileMetadata;
16771
15536
  exports.extractImageMetadata = extractImageMetadata;
@@ -16796,7 +15561,7 @@ exports.handleUniqueFolderCheck = handleUniqueFolderCheck;
16796
15561
  exports.initHandlerOptions = initHandlerOptions;
16797
15562
  exports.isExternalResult = isExternalResult;
16798
15563
  exports.loadValModules = loadValModules;
16799
- exports.ok = ok$1;
15564
+ exports.ok = ok;
16800
15565
  exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
16801
15566
  exports.patchSourceFile = patchSourceFile;
16802
15567
  exports.persistPersonalAccessToken = persistPersonalAccessToken;
@@ -16804,6 +15569,7 @@ exports.readCapturedReport = readCapturedReport;
16804
15569
  exports.readPatchStore = readPatchStore;
16805
15570
  exports.rebaseContentOp = rebaseContentOp;
16806
15571
  exports.replaySnapshot = replaySnapshot;
15572
+ exports.resolveRemoteFileAuth = resolveRemoteFileAuth;
16807
15573
  exports.safeReadGit = safeReadGit;
16808
15574
  exports.startValLogin = startValLogin;
16809
15575
  exports.uploadRemoteFile = uploadRemoteFile;