@valbuild/server 0.121.0 → 0.122.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.
@@ -8,14 +8,14 @@ import path__default from 'path';
8
8
  import fs, { promises } from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module, createRequire } from 'node:module';
11
- import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, PatchGroup, newestCommitSha, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
11
+ import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
13
  import crypto$1 from 'crypto';
14
14
  import z$1, { z } from 'zod';
15
15
  import sizeOf from 'image-size';
16
16
  import { fromError } from 'zod-validation-error';
17
17
  import os from 'os';
18
- import { randomUUID, createHash } from 'node:crypto';
18
+ import { randomUUID } from 'node:crypto';
19
19
  import { transform } from 'sucrase';
20
20
  import http from 'http';
21
21
  import https from 'https';
@@ -565,11 +565,13 @@ const patchValFile = async (id, rootDir, patch, sourceFileHandler) => {
565
565
  // );
566
566
  // sourceFileHandler.moveFile(tempFilePath, "." + filePath);
567
567
  // TODO: ensure that directory exists
568
- if (content.startsWith("data:/image/svg+xml")) {
569
- sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("utf8"), "utf8");
570
- } else {
571
- sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
572
- }
568
+ // NOTE: there used to be a branch here testing
569
+ // `content.startsWith("data:/image/svg+xml")` - with a stray slash after
570
+ // `data:`, so it could never match a real data URL and SVGs took the
571
+ // "binary" path anyway. Removed rather than fixed: the branch existed to
572
+ // write SVG as utf8, and writing the decoded bytes is correct for every
573
+ // type including SVG.
574
+ sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
573
575
  }
574
576
  sourceFileHandler.writeSourceFile(newSourceFile.value);
575
577
  // console.timeEnd("patchValFile" + timeId);
@@ -1918,6 +1920,183 @@ class Service {
1918
1920
  }
1919
1921
  }
1920
1922
 
1923
+ /**
1924
+ * The type surface of an external record's adapter.
1925
+ *
1926
+ * Nothing here runs: phase 0 is the contract, and the registry that executes it
1927
+ * arrives with the read endpoints. What the contract has to get right now is the
1928
+ * shape, because every adapter written against it is a compatibility promise.
1929
+ *
1930
+ * Four kinds of method, and each is required or not for one reason:
1931
+ *
1932
+ * - **Read** — `keys`, `get`. Cannot be built out of anything else, so always
1933
+ * required.
1934
+ * - **Write** — `put`, `delete`. Likewise, and they move together: required on a
1935
+ * writable record, forbidden on a `.readonly()` one.
1936
+ * - **Derived** — `count`, `search`. Computable from the read methods, so
1937
+ * omitting one costs performance, never capability. `false` declines the
1938
+ * fallback; that is the only thing `false` ever means here.
1939
+ * - **Media** — `putFile`, `getFile`. A pair, and required by the item SCHEMA
1940
+ * rather than by this type (see `hasMediaSchema`).
1941
+ */
1942
+
1943
+ // #region result
1944
+
1945
+ /**
1946
+ * Brands the result envelope so a bare value can be accepted alongside it.
1947
+ *
1948
+ * A symbol rather than a property name because `get` returns
1949
+ * `Record<key, Item>` — a record that can perfectly well contain a key called
1950
+ * `kind`. The envelope never crosses the wire (it travels in-process between
1951
+ * adapter and Val), so there is no serialization cost to it.
1952
+ */
1953
+ const EXTERNAL_RESULT = Symbol.for("@valbuild/server/ExternalResult");
1954
+
1955
+ /**
1956
+ * What every adapter method may return: the value on its own, or the envelope.
1957
+ *
1958
+ * A bare value means "ok, nothing to report", which keeps the common path one
1959
+ * line. Errors can be thrown instead of returned — the envelope is for a failure
1960
+ * worth classifying, or a success worth annotating.
1961
+ */
1962
+
1963
+ /**
1964
+ * Wrap a value as a successful result, optionally with warnings.
1965
+ *
1966
+ * `NoInfer` is load-bearing, not decoration. Without it `T` is inferred FROM the
1967
+ * argument, and an object literal that infers its own type is not contextually
1968
+ * typed by the adapter's contract — so `title` inside `ok({ a: { title } })` is a
1969
+ * fresh property that happens to be named `title`, with no declaration link back
1970
+ * to `title: s.string()` in the schema. "Find all references" on the schema field
1971
+ * then stops at the page that reads it and never reaches the adapter that
1972
+ * produces it. `NoInfer` blocks that inference, leaving the contextual return
1973
+ * type as the only source for `T`, which is what restores the link (and, as a
1974
+ * bonus, makes a typo report the offending property rather than the whole
1975
+ * return value).
1976
+ *
1977
+ * `externalNavigation.test.ts` guards this; nothing else would notice.
1978
+ *
1979
+ * The cost: `ok(...)` in a position with NO contextual type — a helper without a
1980
+ * return type annotation — infers `unknown` instead of the argument's type, and
1981
+ * fails where the helper is used rather than where it is written. Annotate the
1982
+ * helper's return type, or inline it.
1983
+ */
1984
+ function ok$1(value, warnings) {
1985
+ return warnings && warnings.length > 0 ? {
1986
+ [EXTERNAL_RESULT]: true,
1987
+ kind: "ok",
1988
+ value,
1989
+ warnings
1990
+ } : {
1991
+ [EXTERNAL_RESULT]: true,
1992
+ kind: "ok",
1993
+ value
1994
+ };
1995
+ }
1996
+ function err$1(issue) {
1997
+ return {
1998
+ [EXTERNAL_RESULT]: true,
1999
+ kind: "err",
2000
+ error: issue
2001
+ };
2002
+ }
2003
+ function isExternalResult(value) {
2004
+ return typeof value === "object" && value !== null && value[EXTERNAL_RESULT] === true;
2005
+ }
2006
+
2007
+ // #endregion
2008
+
2009
+ // #region context
2010
+
2011
+ /**
2012
+ * What every adapter method is told about the call it is serving.
2013
+ *
2014
+ * `tx` is present only when the adapter declared an `around`; a store with no
2015
+ * transaction seam has nothing to put there and nothing to ignore.
2016
+ */
2017
+
2018
+ // #endregion
2019
+
2020
+ // #region paging, sorting, searching
2021
+
2022
+ /**
2023
+ * How a page should be ordered.
2024
+ *
2025
+ * Records are unordered today and sorting is coming; the parameter lands now
2026
+ * because adding one to `keys` later would break every adapter that exists by
2027
+ * then. Val passes `undefined` until sorting ships, and an adapter may ignore it.
2028
+ *
2029
+ * Two rules for whoever implements it:
2030
+ *
2031
+ * - **A cursor is only valid for the sort that issued it.** Key-ordered paging
2032
+ * is `where key > cursor`; sorted paging is `where (field, key) > (…, …)`.
2033
+ * Val pairs the cursor with a hash of the sort and restarts rather than
2034
+ * replaying a mismatched one.
2035
+ * - **The key is always the last sort term.** Ordering by a non-unique field
2036
+ * without a tiebreaker lets rows shift between pages, so page 2 can skip or
2037
+ * repeat an entry while both pages look fine on their own.
2038
+ */
2039
+
2040
+ // #endregion
2041
+
2042
+ // #region media
2043
+
2044
+ // #endregion
2045
+
2046
+ // #region the adapter
2047
+
2048
+ /** Read methods. Always required. */
2049
+
2050
+ /**
2051
+ * Write methods. Required together, and forbidden together on `.readonly()`.
2052
+ *
2053
+ * `put` must be an UPSERT keyed by the entry key and `delete` must tolerate an
2054
+ * absent key, because a publish may be replayed: retry re-runs the whole scope
2055
+ * where there is a transaction, and the individual call where there is not.
2056
+ */
2057
+
2058
+ /**
2059
+ * Named so the compiler prints the reason. A bare `never` would report only
2060
+ * "not assignable to type 'undefined'", which tells nobody anything.
2061
+ */
2062
+
2063
+ /** Derived and media methods, and the sort declaration. */
2064
+
2065
+ /** The item type an external module's entries hold, loosened as JSON allows. */
2066
+
2067
+ /**
2068
+ * The adapter a given external module needs.
2069
+ *
2070
+ * Writes are required or forbidden by the module's own `.readonly()`, read off
2071
+ * the source marker's phantom.
2072
+ */
2073
+
2074
+ /** What `entry()` returns: a module and its adapter, checked against each other. */
2075
+
2076
+ /**
2077
+ * Declare the adapters for this project's external records.
2078
+ *
2079
+ * `Tx` is given explicitly: `around` offers the compiler no inference site,
2080
+ * since `run` is a callback you CALL rather than one whose signature you write.
2081
+ * One type argument, in one place, and every inline adapter then gets `tx`,
2082
+ * `cursor`, `limit` and the row shape correctly typed.
2083
+ *
2084
+ * @example
2085
+ * const { entry, modules } = defineExternal<typeof sql>({
2086
+ * around: (run) => sql.begin(run),
2087
+ * });
2088
+ *
2089
+ * export default modules({
2090
+ * posts: entry(postsVal, { keys, get, put, delete: del, search: false }),
2091
+ * });
2092
+ *
2093
+ * @example a store with no transaction
2094
+ * const { entry, modules } = defineExternal();
2095
+ */
2096
+ function defineExternal(definition) {
2097
+ throw new Error("defineExternal is not implemented yet: phase 0 lands the contract, the registry that executes it arrives with the read endpoints.");
2098
+ }
2099
+
1921
2100
  const JwtPayloadSchema = z.object({
1922
2101
  sub: z.string(),
1923
2102
  exp: z.number(),
@@ -3702,6 +3881,47 @@ class ValOps {
3702
3881
  };
3703
3882
  }
3704
3883
  }));
3884
+
3885
+ /*
3886
+ * What each changed module IS after this commit, and the schema it is under.
3887
+ *
3888
+ * The data rather than the file's text, because that is the half git cannot
3889
+ * give back: a `.val.ts` in git is code, and turning code back into data
3890
+ * means parsing it, which is best-effort and rots across TypeScript,
3891
+ * runtime and Val versions. The schema comes along because a value on its
3892
+ * own cannot be RENDERED - showing a module as it was at a commit whose
3893
+ * schema has since changed needs the schema of that commit, and nothing in
3894
+ * the current checkout has it.
3895
+ *
3896
+ * Taken from `getSources(analysis)` rather than re-derived here so the data
3897
+ * stored is the same data the Studio shows, produced by the one
3898
+ * implementation of "apply these ops".
3899
+ */
3900
+ const moduleVersions = {};
3901
+ const {
3902
+ sources: sourcesAfter
3903
+ } = await this.getSources(patchAnalysis);
3904
+ for (const path of Object.keys(patchesByModule)) {
3905
+ let serialized;
3906
+ try {
3907
+ var _schemas$path3;
3908
+ serialized = (_schemas$path3 = schemas[path]) === null || _schemas$path3 === void 0 ? void 0 : _schemas$path3["executeSerialize"]();
3909
+ } catch {
3910
+ // Same guard as above: one unserializable schema must not cost every
3911
+ // other module its history.
3912
+ serialized = undefined;
3913
+ }
3914
+ if (!serialized) {
3915
+ continue;
3916
+ }
3917
+ const source = sourcesAfter[path];
3918
+ moduleVersions[path] = {
3919
+ // `undefined` here means the module is gone, which is a state history
3920
+ // has to be able to show. `null` is how that travels over the wire.
3921
+ source: source === undefined ? null : source,
3922
+ schema: serialized
3923
+ };
3924
+ }
3705
3925
  const res = {
3706
3926
  hasErrors,
3707
3927
  sourceFilePatchErrors,
@@ -3714,7 +3934,8 @@ class ValOps {
3714
3934
  patchedBinaryFilesDescriptors,
3715
3935
  appliedPatches,
3716
3936
  skippedPatches,
3717
- triedPatches
3937
+ triedPatches,
3938
+ moduleVersions
3718
3939
  };
3719
3940
  return res;
3720
3941
  }
@@ -3771,6 +3992,66 @@ class ValOps {
3771
3992
  }
3772
3993
 
3773
3994
  // #region abstract ops
3995
+
3996
+ /**
3997
+ * Save a patch's binary file from a `data:...;base64,...` URL.
3998
+ *
3999
+ * The wire form: `FileReader.readAsDataURL` is what the browser produces, and
4000
+ * published `@valbuild/server` versions send it. Code that already HAS bytes
4001
+ * should call {@link saveBinaryFileFromPatch} instead of wrapping them in a
4002
+ * data URL just to have this unwrap them again.
4003
+ *
4004
+ * A `null` `data` records a DELETION, which is why this cannot simply be
4005
+ * replaced by the byte-taking sibling: there is nothing to hand it.
4006
+ */
4007
+
4008
+ /**
4009
+ * The same, for a caller that already has the bytes.
4010
+ *
4011
+ * Default implementation wraps them back into a data URL so every backend
4012
+ * gets this for free; a backend that can take bytes straight through should
4013
+ * override it.
4014
+ */
4015
+ async saveBinaryFileFromPatch(filePath, parentRef, patchId, bytes, mimeType, type, metadata) {
4016
+ return this.saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, `data:${mimeType};base64,${bytes.toString("base64")}`, type, metadata);
4017
+ }
4018
+
4019
+ // #region history
4020
+ //
4021
+ // Reading the past, as opposed to reading the present with pending patches
4022
+ // applied. Every one of these is Result-typed against `HistoryError`, because
4023
+ // the ways this can fail - an unreadable record, a source that no longer
4024
+ // parses, an op that will not replay, a schema that has moved on - are the
4025
+ // interesting part rather than an edge case, and a caller deciding whether to
4026
+ // offer a RESTORE has to know which one it hit.
4027
+ //
4028
+ // Only implemented where there is a service holding the history:
4029
+ // `ValOpsHttp`. `ValOpsFS` answers `not-supported-in-fs-mode`, because local
4030
+ // dev has git for this and no commit records of its own.
4031
+
4032
+ /** One page of a branch's commits, newest first. See history/listCommits. */
4033
+
4034
+ /** The patches that produced one commit, with their ops. */
4035
+
4036
+ /**
4037
+ * How each `.val.ts` the commit changed looked BEFORE it, keyed by module
4038
+ * file path. Empty for a commit made before this was recorded - which the
4039
+ * caller reports as `source-unavailable` rather than as an empty module.
4040
+ */
4041
+ /**
4042
+ * Each module a commit changed: its data, and the schema it was under.
4043
+ *
4044
+ * `asOf` widens it from "what this commit changed" to "the whole project as
4045
+ * this commit left it", which is what reverting everything to a point in time
4046
+ * needs; `moduleFilePath` narrows it to one module, for navigating the
4047
+ * history pane off the changed set.
4048
+ */
4049
+
4050
+ /** Which files the commit touched, and how. Names them; does not fetch them. */
4051
+
4052
+ /** One file's bytes as they were at one commit. */
4053
+
4054
+ // #endregion history
3774
4055
  }
3775
4056
  function isOnlyFileCheckValidationError(validationError) {
3776
4057
  var _validationError$fixe9;
@@ -6376,6 +6657,46 @@ class ValOpsFS extends ValOps {
6376
6657
  getPatchLockFile() {
6377
6658
  return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
6378
6659
  }
6660
+
6661
+ // #region history
6662
+ //
6663
+ // Not available locally, and deliberately not faked from git.
6664
+ //
6665
+ // History is a record of what VAL did: which patches produced a commit, who
6666
+ // wrote them, and what each module looked like immediately before. Git has
6667
+ // the files but not that - it cannot say which of a commit's changes were one
6668
+ // editor's patch set, so a "restore this change" built on it would be
6669
+ // guesswork wearing the same UI.
6670
+ //
6671
+ // A single, honest error rather than five different ones: the caller's
6672
+ // question is "is history available here", and the answer is no.
6673
+
6674
+ async listCommits() {
6675
+ return result.err({
6676
+ kind: "not-supported-in-fs-mode"
6677
+ });
6678
+ }
6679
+ async getCommitPatches() {
6680
+ return result.err({
6681
+ kind: "not-supported-in-fs-mode"
6682
+ });
6683
+ }
6684
+ async getCommitModules() {
6685
+ return result.err({
6686
+ kind: "not-supported-in-fs-mode"
6687
+ });
6688
+ }
6689
+ async getCommitAffectedFiles() {
6690
+ return result.err({
6691
+ kind: "not-supported-in-fs-mode"
6692
+ });
6693
+ }
6694
+ async getFileAtCommit() {
6695
+ return result.err({
6696
+ kind: "not-supported-in-fs-mode"
6697
+ });
6698
+ }
6699
+ // #endregion history
6379
6700
  }
6380
6701
  class FSOpsHost {
6381
6702
  constructor() {}
@@ -6663,6 +6984,99 @@ const CommitResponse = z.object({
6663
6984
  commit: CommitSha,
6664
6985
  branch: z.string()
6665
6986
  });
6987
+ // #region history wire schemas
6988
+ //
6989
+ // Validated on arrival rather than trusted: these come from a service that
6990
+ // versions separately, and a silently mis-shaped commit record reads as "this
6991
+ // commit changed nothing", which is indistinguishable from a real answer.
6992
+ const HistoricalCommitResponse = z.object({
6993
+ commitSha: z.string(),
6994
+ parentCommitSha: z.string(),
6995
+ clientCommitSha: z.string(),
6996
+ branch: z.string(),
6997
+ createdBranch: z.string().nullable(),
6998
+ creator: z.string().nullable(),
6999
+ message: z.string().nullable(),
7000
+ createdAt: z.string(),
7001
+ seqNum: z.string(),
7002
+ patchCount: z.number(),
7003
+ hasArchive: z.boolean()
7004
+ });
7005
+ const ListCommitsResponse = z.object({
7006
+ commits: z.array(HistoricalCommitResponse),
7007
+ nextCursor: z.string().nullable()
7008
+ });
7009
+ const CommitPatchesResponse = z.object({
7010
+ commitSha: z.string(),
7011
+ commit: z.object({
7012
+ commitSha: z.string(),
7013
+ parentCommitSha: z.string(),
7014
+ clientCommitSha: z.string(),
7015
+ branch: z.string(),
7016
+ createdBranch: z.string().nullable(),
7017
+ creator: z.string().nullable(),
7018
+ message: z.string().nullable(),
7019
+ createdAt: z.string(),
7020
+ seqNum: z.string(),
7021
+ hasArchive: z.boolean()
7022
+ }),
7023
+ patches: z.array(z.object({
7024
+ patchId: z.string(),
7025
+ path: z.string(),
7026
+ patch: z.unknown(),
7027
+ authorId: z.string().nullable(),
7028
+ createdAt: z.string(),
7029
+ baseSha: z.string(),
7030
+ coreVersion: z.string()
7031
+ }))
7032
+ });
7033
+
7034
+ /**
7035
+ * `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
7036
+ *
7037
+ * `schema` stays `unknown` here on purpose. The content service stores it
7038
+ * opaquely and cannot vouch for it, so validating it at the transport boundary
7039
+ * would turn "a schema written by a different version of Val" into a failed
7040
+ * REQUEST rather than one module that cannot be shown. It is checked in
7041
+ * `getHistoricalPatchSet`, per module, where a failure degrades that module and
7042
+ * leaves the commit readable.
7043
+ */
7044
+ const CommitModulesResponse = z.object({
7045
+ commitSha: z.string(),
7046
+ parentCommitSha: z.string(),
7047
+ /**
7048
+ * Whether an `asOf` read covered the whole project.
7049
+ *
7050
+ * Optional so an older content server still parses. False means modules last
7051
+ * edited before history started being recorded are missing from the answer -
7052
+ * which a whole-project revert has to say out loud rather than silently skip.
7053
+ */
7054
+ complete: z.boolean().optional(),
7055
+ modules: z.array(z.object({
7056
+ moduleFilePath: z.string(),
7057
+ commitSha: z.string(),
7058
+ sourceSha: z.string().nullable(),
7059
+ schemaSha: z.string(),
7060
+ // Validated, because a Source IS just JSON and this side knows that much.
7061
+ source: JSONValue.nullable(),
7062
+ schema: z.unknown(),
7063
+ unavailable: z.boolean()
7064
+ }))
7065
+ });
7066
+ const CommitAffectedFilesResponse = z.object({
7067
+ commitSha: z.string(),
7068
+ files: z.array(z.union([z.object({
7069
+ kind: z.union([z.literal("module-source"), z.literal("json-entry"), z.literal("binary")]),
7070
+ gitPath: z.string(),
7071
+ change: z.union([z.literal("added"), z.literal("modified"), z.literal("deleted")])
7072
+ }), z.object({
7073
+ kind: z.literal("remote-binary"),
7074
+ ref: z.string(),
7075
+ change: z.union([z.literal("added"), z.literal("modified"), z.literal("deleted")])
7076
+ })]))
7077
+ });
7078
+ // #endregion history wire schemas
7079
+
6666
7080
  /*
6667
7081
  * The shared schema, not a copy of it.
6668
7082
  *
@@ -7682,7 +8096,12 @@ class ValOpsHttp extends ValOps {
7682
8096
  if (!file) {
7683
8097
  return null;
7684
8098
  }
7685
- return bufferFromDataUrl(file.value) ?? null;
8099
+ // Plain base64, the same as the `repo` branch of getBinaryFile above.
8100
+ //
8101
+ // `value` used to be a data: URL here and plain base64 there - two
8102
+ // encodings in one field, told apart only by which branch produced them.
8103
+ // The content service answers base64 for both now.
8104
+ return Buffer.from(file.value, "base64");
7686
8105
  }
7687
8106
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, remote) {
7688
8107
  const params = new URLSearchParams();
@@ -7851,6 +8270,19 @@ class ValOpsHttp extends ValOps {
7851
8270
  patchedSourceFiles: prepared.patchedSourceFiles,
7852
8271
  patchedBinaryFilesDescriptors: prepared.patchedBinaryFilesDescriptors,
7853
8272
  appliedPatches: prepared.appliedPatches,
8273
+ /*
8274
+ * What each changed module IS after this commit, as DATA, with the
8275
+ * schema it is under.
8276
+ *
8277
+ * The half git cannot give back. Git keeps the `.val.ts`, but that is
8278
+ * code: turning it back into data means parsing it, which is
8279
+ * best-effort and rots across TypeScript, runtime and Val versions -
8280
+ * so a commit that reads today can quietly stop reading later. And
8281
+ * git has no copy at all of the SCHEMA a commit was written under,
8282
+ * which is what showing a module as it was needs once the schema has
8283
+ * moved on.
8284
+ */
8285
+ modules: prepared.moduleVersions,
7854
8286
  commit: this.commitSha,
7855
8287
  root: this.root,
7856
8288
  filesDirectory,
@@ -7911,80 +8343,681 @@ class ValOpsHttp extends ValOps {
7911
8343
  };
7912
8344
  }
7913
8345
  }
7914
- }
7915
8346
 
7916
- const host = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
7917
- const SettingsSchema = z.object({
7918
- publicProjectId: z.string(),
7919
- remoteFileBuckets: z.array(z.object({
7920
- bucket: z.string()
7921
- }))
7922
- });
7923
- async function getSettings(projectName, auth) {
7924
- try {
7925
- const response = await fetch(`${host}/v1/${projectName}/settings`, {
7926
- headers: "pat" in auth ? {
7927
- "x-val-pat": auth.pat,
7928
- "Content-Type": "application/json"
7929
- } : {
7930
- Authorization: `Bearer ${auth.apiKey}`,
7931
- "Content-Type": "application/json"
8347
+ // #region history
8348
+
8349
+ /**
8350
+ * One GET against the content service, parsed and Result-typed.
8351
+ *
8352
+ * Every history read has the same three failure modes - could not reach the
8353
+ * service, the commit is not there, the answer was not what was expected -
8354
+ * and each of them means something different to a caller deciding whether to
8355
+ * offer a restore. Doing it once here is what keeps that consistent across
8356
+ * the five endpoints.
8357
+ */
8358
+ async getHistory(path, schema, commitShaForErrors) {
8359
+ let res;
8360
+ try {
8361
+ res = await fetch(`${this.contentUrl}/v1/${this.project}${path}`, {
8362
+ headers: {
8363
+ ...this.authHeaders,
8364
+ "Content-Type": "application/json"
8365
+ }
8366
+ });
8367
+ } catch (err) {
8368
+ return result.err({
8369
+ kind: "transport",
8370
+ message: err instanceof Error ? err.message : String(err)
8371
+ });
8372
+ }
8373
+ if (res.status === 404) {
8374
+ return result.err({
8375
+ kind: "commit-not-found",
8376
+ commitSha: commitShaForErrors
8377
+ });
8378
+ }
8379
+ if (!res.ok) {
8380
+ var _res$headers$get4;
8381
+ let message = `${res.status} ${res.statusText}`;
8382
+ if ((_res$headers$get4 = res.headers.get("Content-Type")) !== null && _res$headers$get4 !== void 0 && _res$headers$get4.includes("application/json")) {
8383
+ message = getErrorMessageFromUnknownJson(await res.json(), message);
7932
8384
  }
8385
+ // A 5xx from the service reading a record it says it has is a real
8386
+ // failure of that record, not a network problem - keep them apart.
8387
+ return result.err({
8388
+ kind: "archive-unreadable",
8389
+ commitSha: commitShaForErrors,
8390
+ message
8391
+ });
8392
+ }
8393
+ // A 200 is not a promise of JSON: a proxy or gateway in front of the
8394
+ // service answers HTML, and an unguarded `.json()` would reject straight
8395
+ // out of this Result-typed API and 500 the route.
8396
+ let body;
8397
+ try {
8398
+ body = await res.json();
8399
+ } catch (err) {
8400
+ return result.err({
8401
+ kind: "archive-unreadable",
8402
+ commitSha: commitShaForErrors,
8403
+ message: `response was not JSON: ${err instanceof Error ? err.message : String(err)}`
8404
+ });
8405
+ }
8406
+ const parsed = schema.safeParse(body);
8407
+ if (!parsed.success) {
8408
+ return result.err({
8409
+ kind: "archive-unreadable",
8410
+ commitSha: commitShaForErrors,
8411
+ message: `unexpected response shape: ${fromError(parsed.error)}`
8412
+ });
8413
+ }
8414
+ return result.ok(parsed.data);
8415
+ }
8416
+ async listCommits(branch, options) {
8417
+ const params = new URLSearchParams({
8418
+ branch
7933
8419
  });
7934
- if (response.status === 404) {
7935
- return {
7936
- success: false,
7937
- message: `Project '${projectName}' not found: verify that the name of the project is correct and that you have access to it.`
7938
- };
8420
+ if ((options === null || options === void 0 ? void 0 : options.limit) !== undefined) {
8421
+ params.set("limit", String(options.limit));
7939
8422
  }
7940
- if (response.status !== 200) {
7941
- return {
7942
- success: false,
7943
- message: `Failed to get project id: ${response.statusText}`
7944
- };
8423
+ if ((options === null || options === void 0 ? void 0 : options.cursor) !== undefined) {
8424
+ params.set("cursor", options.cursor);
7945
8425
  }
7946
- const json = await response.json();
7947
- const parseRes = SettingsSchema.safeParse(json);
7948
- if (!parseRes.success) {
7949
- return {
7950
- success: false,
7951
- message: `Failed to parse settings data: ${parseRes.error.message}`
7952
- };
8426
+ const res = await this.getHistory(`/commits?${params}`, ListCommitsResponse, branch);
8427
+ if (result.isErr(res)) {
8428
+ return res;
7953
8429
  }
7954
- return {
7955
- success: true,
7956
- data: parseRes.data
7957
- };
7958
- } catch {
7959
- return {
7960
- success: false,
7961
- message: `Failed to get project id. Check network connection and try again.`
7962
- };
8430
+ return result.ok({
8431
+ commits: res.value.commits,
8432
+ nextCursor: res.value.nextCursor
8433
+ });
7963
8434
  }
7964
- }
7965
-
7966
- /**
7967
- * Resolving how Val is configured, and building the data layer from it.
7968
- *
7969
- * Both live here rather than inside `createValApiRouter` because the MCP tool
7970
- * registry needs exactly the same answers: which mode we are in, which
7971
- * credential to use, and which `ValOps` implementation that implies. Two copies
7972
- * of this would drift, and the failure would be quiet — a registry that decides
7973
- * it is in fs mode while the Studio decides it is in proxy mode reads different
7974
- * content from the same project.
7975
- *
7976
- * The type-only import from `./ValRouter` is deliberate: it keeps `ValApiOptions`
7977
- * where its documentation lives without creating a runtime cycle.
7978
- *
7979
- * The credential-bearing URL check at the bottom of this file lives here for the
7980
- * same reason: it is a check on the URLs `initHandlerOptions` resolves, and its
7981
- * only caller is that function. Leaving it in `./ValRouter` would have meant
7982
- * importing it back from there, which is the runtime cycle the paragraph above
7983
- * exists to avoid.
7984
- */
7985
-
7986
- const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
7987
-
8435
+ async getCommitPatches(commitSha) {
8436
+ // One request. The endpoint returns the commit's own summary alongside its
8437
+ // patches, because it already has the row - this used to page the commit
8438
+ // LISTING until the sha turned up, which cost up to twenty requests to open
8439
+ // an old commit and could not find one on another branch at all.
8440
+ const patchesRes = await this.getHistory(`/commits/${commitSha}/patches`, CommitPatchesResponse, commitSha);
8441
+ if (result.isErr(patchesRes)) {
8442
+ return patchesRes;
8443
+ }
8444
+ return result.ok({
8445
+ commit: {
8446
+ ...patchesRes.value.commit,
8447
+ patchCount: patchesRes.value.patches.length
8448
+ },
8449
+ patches: patchesRes.value.patches.map(patch => ({
8450
+ patchId: patch.patchId,
8451
+ moduleFilePath: patch.path,
8452
+ patch: patch.patch,
8453
+ authorId: patch.authorId,
8454
+ createdAt: patch.createdAt,
8455
+ baseSha: patch.baseSha,
8456
+ coreVersion: patch.coreVersion
8457
+ }))
8458
+ });
8459
+ }
8460
+ async getCommitModules(commitSha, options) {
8461
+ const query = new URLSearchParams();
8462
+ if (options !== null && options !== void 0 && options.asOf) {
8463
+ query.set("as_of", "1");
8464
+ }
8465
+ if (options !== null && options !== void 0 && options.moduleFilePath) {
8466
+ query.set("path", options.moduleFilePath);
8467
+ }
8468
+ const search = query.toString();
8469
+ const res = await this.getHistory(`/commits/${commitSha}/modules${search ? `?${search}` : ""}`, CommitModulesResponse, commitSha);
8470
+ if (result.isErr(res)) {
8471
+ return res;
8472
+ }
8473
+ return result.ok({
8474
+ modules: res.value.modules.map(module => ({
8475
+ ...module,
8476
+ moduleFilePath: module.moduleFilePath
8477
+ })),
8478
+ // Absent from an older content server, which only ever answered "what
8479
+ // this commit changed" - and that answer is always whole.
8480
+ complete: res.value.complete ?? !(options !== null && options !== void 0 && options.asOf)
8481
+ });
8482
+ }
8483
+ async getCommitAffectedFiles(commitSha) {
8484
+ const res = await this.getHistory(`/commits/${commitSha}/affected-files`, CommitAffectedFilesResponse, commitSha);
8485
+ if (result.isErr(res)) {
8486
+ return res;
8487
+ }
8488
+ return result.ok(res.value.files);
8489
+ }
8490
+ async getFileAtCommit(commitSha, filePath, remote) {
8491
+ const params = new URLSearchParams({
8492
+ path: filePath
8493
+ });
8494
+ if (remote) {
8495
+ params.set("remote", "true");
8496
+ }
8497
+ let res;
8498
+ try {
8499
+ res = await fetch(`${this.contentUrl}/v1/${this.project}/commits/${commitSha}/file?${params}`, {
8500
+ headers: {
8501
+ ...this.authHeaders
8502
+ }
8503
+ });
8504
+ } catch (err) {
8505
+ return result.err({
8506
+ kind: "transport",
8507
+ message: err instanceof Error ? err.message : String(err)
8508
+ });
8509
+ }
8510
+ if (!res.ok) {
8511
+ // A missing file is about the FILE, not the commit: the commit may be
8512
+ // perfectly readable and this one blob gone. Saying "commit not found"
8513
+ // here would send a caller looking in the wrong place.
8514
+ return result.err({
8515
+ kind: "file-unavailable",
8516
+ gitPath: filePath,
8517
+ message: `${res.status} ${res.statusText}`
8518
+ });
8519
+ }
8520
+ return result.ok(Buffer.from(await res.arrayBuffer()));
8521
+ }
8522
+ // #endregion history
8523
+ }
8524
+
8525
+ /**
8526
+ * Everything that can go wrong reading, replaying or restoring history.
8527
+ *
8528
+ * There is a lot of it, which is the point of making it one closed union rather
8529
+ * than strings: reading a commit means reaching a content host, finding the
8530
+ * commit, reading what was recorded for each of its modules, and asking whether
8531
+ * this version of Val can make sense of the schema it was stored under. Each of
8532
+ * those fails differently, and a caller deciding what to SHOW needs to know
8533
+ * which.
8534
+ *
8535
+ * Every member has a producer. The union once carried five more - a module
8536
+ * removed from the project, an op that would not replay, a value that no longer
8537
+ * fits today's schema, a field the schema no longer defines, and ops from a
8538
+ * core version known to replay wrongly. All five belonged to reconstructing a
8539
+ * commit by replaying patches against a parsed source; storing the data ended
8540
+ * that, and a member nothing can produce is a state the Studio writes handling
8541
+ * for and never sees.
8542
+ *
8543
+ * ## The rule
8544
+ *
8545
+ * Whole-commit failures are the `err` channel. Per-module and per-patch
8546
+ * failures ride along inside the `ok` payload.
8547
+ *
8548
+ * A commit that cannot be found or read at all has nothing to show. But one
8549
+ * unparseable module out of ten does not make the other nine unreadable, and
8550
+ * collapsing the whole view because of it would hide exactly the information
8551
+ * someone needs to see - that this module is the broken one.
8552
+ */
8553
+
8554
+ function historyErrorMessage(error) {
8555
+ switch (error.kind) {
8556
+ case "commit-not-found":
8557
+ return `No commit ${error.commitSha} created by Val in this project`;
8558
+ case "archive-unreadable":
8559
+ return `Could not read the stored record of commit ${error.commitSha}: ${error.message}`;
8560
+ case "source-unavailable":
8561
+ return `No stored source for ${error.moduleFilePath} at this commit (it predates history being recorded)`;
8562
+ case "schema-unreadable":
8563
+ return `The schema stored for ${error.moduleFilePath} at this commit is not one this version of Val can read: ${error.message}`;
8564
+ case "file-unavailable":
8565
+ return `Could not read ${error.gitPath} at this commit: ${error.message}`;
8566
+ case "not-supported-in-fs-mode":
8567
+ return "History is only available for projects connected to Val's content service";
8568
+ case "transport":
8569
+ return `Could not reach the content service: ${error.message}`;
8570
+ }
8571
+ }
8572
+
8573
+ /**
8574
+ * Everything the content service knows about one commit, in one step.
8575
+ *
8576
+ * Three endpoints, requested together rather than in sequence: they are
8577
+ * independent, they all resolve from the same stored record, and the round
8578
+ * trips are what a user waits through when opening a commit.
8579
+ *
8580
+ * The commit itself comes from the patches call, which is the one that fails
8581
+ * usefully when the commit does not exist.
8582
+ */
8583
+ async function fetchCommitRecord(ops, commitSha) {
8584
+ const [patchesRes, modulesRes, filesRes] = await Promise.all([ops.getCommitPatches(commitSha), ops.getCommitModules(commitSha), ops.getCommitAffectedFiles(commitSha)]);
8585
+ if (result.isErr(patchesRes)) {
8586
+ return patchesRes;
8587
+ }
8588
+ if (result.isErr(modulesRes)) {
8589
+ return modulesRes;
8590
+ }
8591
+ if (result.isErr(filesRes)) {
8592
+ return filesRes;
8593
+ }
8594
+ return result.ok({
8595
+ commit: patchesRes.value.commit,
8596
+ patches: patchesRes.value.patches,
8597
+ modules: modulesRes.value.modules,
8598
+ affectedFiles: filesRes.value
8599
+ });
8600
+ }
8601
+
8602
+ /**
8603
+ * The `*.val.json` entries a commit touched, read from git at that commit.
8604
+ *
8605
+ * Not stored in the commit record, unlike the `.val.ts` sources: an entry is a
8606
+ * plain JSON file that git has at every commit, so a second copy could only
8607
+ * drift from the first. A `.val.ts` is different - it is what a restore
8608
+ * replays patches against, and reading it from git would make every look at
8609
+ * history depend on the repository still being there.
8610
+ *
8611
+ * Reads at the commit itself rather than its parent: an entry's content AT a
8612
+ * commit is what that commit produced, which is the "after" side. The "before"
8613
+ * side is the same file at the parent commit, and a caller that wants it asks
8614
+ * for the parent.
8615
+ */
8616
+ async function resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles) {
8617
+ const entryPaths = affectedFiles.filter(file => file.kind === "json-entry")
8618
+ // A deleted entry has no content at this commit; asking for it would be a
8619
+ // guaranteed 404 reported as a failure, which is noise rather than news.
8620
+ .filter(file => file.change !== "deleted").map(file => file.gitPath);
8621
+ if (entryPaths.length === 0) {
8622
+ return result.ok({
8623
+ entries: {},
8624
+ failures: []
8625
+ });
8626
+ }
8627
+ const entries = {};
8628
+ const failures = [];
8629
+ const fetched = await Promise.all(entryPaths.map(async gitPath => ({
8630
+ gitPath,
8631
+ res: await ops.getFileAtCommit(commitSha, gitPath, false)
8632
+ })));
8633
+ for (const {
8634
+ gitPath,
8635
+ res
8636
+ } of fetched) {
8637
+ if (result.isErr(res)) {
8638
+ failures.push({
8639
+ kind: "file-unavailable",
8640
+ gitPath,
8641
+ message: res.error.kind === "file-unavailable" ? res.error.message : `could not read entry: ${res.error.kind}`
8642
+ });
8643
+ continue;
8644
+ }
8645
+ try {
8646
+ entries[gitPath] = JSON.parse(res.value.toString("utf-8"));
8647
+ } catch (err) {
8648
+ failures.push({
8649
+ kind: "file-unavailable",
8650
+ gitPath,
8651
+ message: `not valid JSON at this commit: ${err instanceof Error ? err.message : String(err)}`
8652
+ });
8653
+ }
8654
+ }
8655
+ return result.ok({
8656
+ entries,
8657
+ failures
8658
+ });
8659
+ }
8660
+
8661
+ /**
8662
+ * Turn a commit's binary files into descriptors, WITHOUT fetching any of them.
8663
+ *
8664
+ * Pure, and that is the whole design. Showing that a commit changed six images
8665
+ * needs six rows, not six downloads; only an `<img>` that actually mounts pays
8666
+ * for its bytes. `url` points at the app's own history file route, which is
8667
+ * immutable for a given commit, so the browser's HTTP cache handles flipping
8668
+ * between commits with no JS cache at all.
8669
+ */
8670
+ function describeBinaryFilesAtCommit(commitSha, affectedFiles, /** Where the app serves `/api/val` from, e.g. "/api/val". */
8671
+ apiBasePath) {
8672
+ const refs = [];
8673
+ for (const file of affectedFiles) {
8674
+ if (file.kind === "remote-binary") {
8675
+ refs.push({
8676
+ gitPath: file.ref,
8677
+ change: file.change,
8678
+ remote: true,
8679
+ url: historyFileUrl(apiBasePath, commitSha, file.ref, true)
8680
+ });
8681
+ continue;
8682
+ }
8683
+ if (file.kind !== "binary") {
8684
+ continue;
8685
+ }
8686
+ refs.push({
8687
+ gitPath: file.gitPath,
8688
+ change: file.change,
8689
+ remote: false,
8690
+ url: historyFileUrl(apiBasePath, commitSha, file.gitPath, false)
8691
+ });
8692
+ }
8693
+ return refs;
8694
+ }
8695
+ function historyFileUrl(apiBasePath, commitSha, filePath, remote) {
8696
+ const params = new URLSearchParams({
8697
+ commit_sha: commitSha,
8698
+ path: filePath
8699
+ });
8700
+ if (remote) {
8701
+ params.set("remote", "true");
8702
+ }
8703
+ return `${apiBasePath}/history/files?${params.toString()}`;
8704
+ }
8705
+
8706
+ /**
8707
+ * The paths a commit's ops touched, from the ops themselves.
8708
+ *
8709
+ * The alternative was reconstructing the module's previous state and diffing it
8710
+ * against the new one - which needed the pre-commit `.val.ts`, a static parse
8711
+ * of it, and a replay of every op, three fragile steps to rediscover something
8712
+ * the ops already say outright. An op carries the path it applies to. That is
8713
+ * the answer.
8714
+ *
8715
+ * Deduplicated and returned in first-touched order, because a module edited
8716
+ * eight times in one publish should list a field once, where it first changed.
8717
+ */
8718
+ function changedPathsOf(moduleFilePath, patches) {
8719
+ const seen = new Set();
8720
+ const paths = [];
8721
+ for (const {
8722
+ patch
8723
+ } of patches) {
8724
+ for (const op of patch) {
8725
+ // A `file` op names a file, not a place in the source; the `replace` that
8726
+ // points the field at it is in the same patch and is the real change.
8727
+ if (op.op === "file") {
8728
+ continue;
8729
+ }
8730
+ /*
8731
+ * The canonical patch-path-to-source-path conversion, not a hand-rolled
8732
+ * join.
8733
+ *
8734
+ * `createValPathOfItem` JSON-quotes the ONE key it is handed, so joining
8735
+ * the segments with "." first produced `?p="teddy.name"` — a single
8736
+ * segment with a dot in its name — rather than `?p="teddy"."name"`. Only
8737
+ * top-level fields came out right, a record key containing a dot was
8738
+ * split in the other direction, and an op at the module root became
8739
+ * `?p=""`.
8740
+ *
8741
+ * `patchPathToModulePath` is what the rest of the Studio produces and
8742
+ * parses, integer segments left unquoted (`"items".2."label"`).
8743
+ */
8744
+ const sourcePath = op.path.length === 0 ? moduleFilePath : Internal.joinModuleFilePathAndModulePath(moduleFilePath, Internal.patchPathToModulePath(op.path));
8745
+ if (!seen.has(sourcePath)) {
8746
+ seen.add(sourcePath);
8747
+ paths.push(sourcePath);
8748
+ }
8749
+ }
8750
+ }
8751
+ return paths;
8752
+ }
8753
+
8754
+ /**
8755
+ * Reconstruct one commit: how each module looked before it, and after it.
8756
+ *
8757
+ * Deliberately says NOTHING about the current source or the current schema. For
8758
+ * a given commit sha this can never change, which is what lets its result be
8759
+ * cached forever and what makes flipping between commits cheap. The comparison
8760
+ * against today is `compareWithCurrent`, and it is deliberately the cheap half.
8761
+ *
8762
+ * Nothing is parsed and nothing is replayed: the module's data was recorded at
8763
+ * the commit, so reading it back is a read. What IS done here is validating the
8764
+ * stored schema against this version of Val, because that is the one thing the
8765
+ * content service could not check for us.
8766
+ *
8767
+ * Failures are collected per module, not thrown - one module this Val cannot
8768
+ * read leaves the other nine readable, and knowing WHICH one is the thing
8769
+ * someone opening history actually needs. Only a commit that cannot be read at
8770
+ * all is an `err`.
8771
+ */
8772
+ async function getHistoricalPatchSet(ops, commitSha, options) {
8773
+ const recordRes = await fetchCommitRecord(ops, commitSha);
8774
+ if (result.isErr(recordRes)) {
8775
+ return recordRes;
8776
+ }
8777
+ const {
8778
+ commit,
8779
+ patches,
8780
+ modules: stored,
8781
+ affectedFiles
8782
+ } = recordRes.value;
8783
+ const warnings = [];
8784
+
8785
+ // Group the commit's patches by module. Not to replay them - the stored
8786
+ // source already IS the result - but because the ops name the paths this
8787
+ // commit touched, which is what the view highlights.
8788
+ const patchesByModule = new Map();
8789
+ for (const patch of patches) {
8790
+ const existing = patchesByModule.get(patch.moduleFilePath) ?? [];
8791
+ existing.push({
8792
+ patchId: patch.patchId,
8793
+ coreVersion: patch.coreVersion,
8794
+ // The wire type is `unknown` because the content service does not know
8795
+ // Val's patch type; it has been validated on the way out of the archive.
8796
+ patch: patch.patch
8797
+ });
8798
+ patchesByModule.set(patch.moduleFilePath, existing);
8799
+ }
8800
+ const storedByPath = new Map(stored.map(module => [module.moduleFilePath, module]));
8801
+
8802
+ // Every module this commit touched: one it patched, and one we hold a stored
8803
+ // version of. Usually the same set - but a commit made by a Val too old to
8804
+ // send its modules has the first and not the second, and that has to read as
8805
+ // `source-unavailable` rather than as "no modules changed".
8806
+ const touched = new Set([...patchesByModule.keys(), ...storedByPath.keys()]);
8807
+ const modules = {};
8808
+ for (const moduleFilePath of touched) {
8809
+ const failures = [];
8810
+ const modulePatches = patchesByModule.get(moduleFilePath) ?? [];
8811
+ const patchIds = modulePatches.map(patch => patch.patchId);
8812
+ const changedPaths = changedPathsOf(moduleFilePath, modulePatches);
8813
+ const version = storedByPath.get(moduleFilePath);
8814
+ if (version === undefined || version.unavailable) {
8815
+ failures.push({
8816
+ kind: "source-unavailable",
8817
+ moduleFilePath
8818
+ });
8819
+ modules[moduleFilePath] = {
8820
+ source: null,
8821
+ schema: null,
8822
+ patchIds,
8823
+ changedPaths,
8824
+ failures
8825
+ };
8826
+ continue;
8827
+ }
8828
+
8829
+ /*
8830
+ * The schema is validated here, and nowhere earlier.
8831
+ *
8832
+ * It was stored as written and served back opaquely, so this is the first
8833
+ * place that knows what a schema is supposed to look like. A failure is
8834
+ * NOT damage and not anyone's mistake - Val's schema format is allowed to
8835
+ * move, and an older project opened in a newer Val (or the reverse) lands
8836
+ * exactly here. The module reads as one this version cannot show, and the
8837
+ * rest of the commit is unaffected.
8838
+ */
8839
+ const schemaRes = SerializedSchema.safeParse(version.schema);
8840
+ if (!schemaRes.success) {
8841
+ failures.push({
8842
+ kind: "schema-unreadable",
8843
+ moduleFilePath,
8844
+ message: fromError(schemaRes.error).toString()
8845
+ });
8846
+ modules[moduleFilePath] = {
8847
+ source: version.source,
8848
+ schema: null,
8849
+ patchIds,
8850
+ changedPaths,
8851
+ failures
8852
+ };
8853
+ continue;
8854
+ }
8855
+ modules[moduleFilePath] = {
8856
+ source: version.source,
8857
+ schema: schemaRes.data,
8858
+ patchIds,
8859
+ changedPaths,
8860
+ failures
8861
+ };
8862
+ }
8863
+ const entriesRes = await resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles);
8864
+ let jsonEntries = {};
8865
+ if (result.isErr(entriesRes)) {
8866
+ // Entry contents are supporting detail, not the commit. Losing them should
8867
+ // narrow what can be shown, not hide the commit entirely.
8868
+ warnings.push(entriesRes.error);
8869
+ } else {
8870
+ jsonEntries = entriesRes.value.entries;
8871
+ warnings.push(...entriesRes.value.failures);
8872
+ }
8873
+ return result.ok({
8874
+ commit,
8875
+ modules,
8876
+ patches,
8877
+ jsonEntries,
8878
+ binaryFiles: describeBinaryFilesAtCommit(commitSha, affectedFiles, (options === null || options === void 0 ? void 0 : options.apiBasePath) ?? "/api/val"),
8879
+ warnings
8880
+ });
8881
+ }
8882
+
8883
+ /**
8884
+ * One module, as of a commit — including a commit that never touched it.
8885
+ *
8886
+ * `getHistoricalPatchSet` answers "what did this commit change", which is what
8887
+ * a commit IS. This answers "how did this module look at that point", which is
8888
+ * what someone comparing two panes is asking as soon as they navigate off the
8889
+ * changed set — and without it the history pane can only show a handful of
8890
+ * modules per commit.
8891
+ *
8892
+ * `null` means history has no record at or before that commit: the module was
8893
+ * last edited before recording started, or never. That is deliberately NOT the
8894
+ * same as a module the commit deleted, which is a recorded fact with a source
8895
+ * of `null`, and not the same as an error.
8896
+ */
8897
+ async function getModuleAtCommit(ops, commitSha, moduleFilePath) {
8898
+ const res = await ops.getCommitModules(commitSha, {
8899
+ asOf: true,
8900
+ moduleFilePath
8901
+ });
8902
+ if (result.isErr(res)) {
8903
+ return res;
8904
+ }
8905
+ const version = res.value.modules.find(module => module.moduleFilePath === moduleFilePath);
8906
+ if (version === undefined) {
8907
+ return result.ok(null);
8908
+ }
8909
+ if (version.unavailable) {
8910
+ return result.ok({
8911
+ source: null,
8912
+ schema: null,
8913
+ patchIds: [],
8914
+ changedPaths: [],
8915
+ failures: [{
8916
+ kind: "source-unavailable",
8917
+ moduleFilePath
8918
+ }]
8919
+ });
8920
+ }
8921
+ // Validated here, not at the transport boundary, for the same reason as in
8922
+ // `getHistoricalPatchSet`: a schema this Val cannot read is one module it
8923
+ // cannot show, not a failed request.
8924
+ const schemaRes = SerializedSchema.safeParse(version.schema);
8925
+ if (!schemaRes.success) {
8926
+ return result.ok({
8927
+ source: version.source,
8928
+ schema: null,
8929
+ patchIds: [],
8930
+ changedPaths: [],
8931
+ failures: [{
8932
+ kind: "schema-unreadable",
8933
+ moduleFilePath,
8934
+ message: fromError(schemaRes.error).toString()
8935
+ }]
8936
+ });
8937
+ }
8938
+ return result.ok({
8939
+ source: version.source,
8940
+ schema: schemaRes.data,
8941
+ // Empty, and correct: this module may well have been changed by an EARLIER
8942
+ // commit than the one asked about, and those are that commit's patches.
8943
+ patchIds: [],
8944
+ changedPaths: [],
8945
+ failures: []
8946
+ });
8947
+ }
8948
+
8949
+ const host = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
8950
+ const SettingsSchema = z.object({
8951
+ publicProjectId: z.string(),
8952
+ remoteFileBuckets: z.array(z.object({
8953
+ bucket: z.string()
8954
+ }))
8955
+ });
8956
+ async function getSettings(projectName, auth) {
8957
+ try {
8958
+ const response = await fetch(`${host}/v1/${projectName}/settings`, {
8959
+ headers: "pat" in auth ? {
8960
+ "x-val-pat": auth.pat,
8961
+ "Content-Type": "application/json"
8962
+ } : {
8963
+ Authorization: `Bearer ${auth.apiKey}`,
8964
+ "Content-Type": "application/json"
8965
+ }
8966
+ });
8967
+ if (response.status === 404) {
8968
+ return {
8969
+ success: false,
8970
+ message: `Project '${projectName}' not found: verify that the name of the project is correct and that you have access to it.`
8971
+ };
8972
+ }
8973
+ if (response.status !== 200) {
8974
+ return {
8975
+ success: false,
8976
+ message: `Failed to get project id: ${response.statusText}`
8977
+ };
8978
+ }
8979
+ const json = await response.json();
8980
+ const parseRes = SettingsSchema.safeParse(json);
8981
+ if (!parseRes.success) {
8982
+ return {
8983
+ success: false,
8984
+ message: `Failed to parse settings data: ${parseRes.error.message}`
8985
+ };
8986
+ }
8987
+ return {
8988
+ success: true,
8989
+ data: parseRes.data
8990
+ };
8991
+ } catch {
8992
+ return {
8993
+ success: false,
8994
+ message: `Failed to get project id. Check network connection and try again.`
8995
+ };
8996
+ }
8997
+ }
8998
+
8999
+ /**
9000
+ * Resolving how Val is configured, and building the data layer from it.
9001
+ *
9002
+ * Both live here rather than inside `createValApiRouter` because the MCP tool
9003
+ * registry needs exactly the same answers: which mode we are in, which
9004
+ * credential to use, and which `ValOps` implementation that implies. Two copies
9005
+ * of this would drift, and the failure would be quiet — a registry that decides
9006
+ * it is in fs mode while the Studio decides it is in proxy mode reads different
9007
+ * content from the same project.
9008
+ *
9009
+ * The type-only import from `./ValRouter` is deliberate: it keeps `ValApiOptions`
9010
+ * where its documentation lives without creating a runtime cycle.
9011
+ *
9012
+ * The credential-bearing URL check at the bottom of this file lives here for the
9013
+ * same reason: it is a check on the URLs `initHandlerOptions` resolves, and its
9014
+ * only caller is that function. Leaving it in `./ValRouter` would have meant
9015
+ * importing it back from there, which is the runtime cycle the paragraph above
9016
+ * exists to avoid.
9017
+ */
9018
+
9019
+ const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
9020
+
7988
9021
  /**
7989
9022
  * Resolve options plus environment into a concrete {@link ValServerConfig}.
7990
9023
  *
@@ -8068,31 +9101,39 @@ async function initHandlerOptions(route, opts, config) {
8068
9101
  /**
8069
9102
  * Build the data layer a {@link ValServerConfig} calls for.
8070
9103
  *
8071
- * `auth` decides *whose* credential the http backend sees. Left out, it is the
8072
- * app's own API key — which is what the Studio wants, because there the app has
8073
- * already verified a session cookie and is acting on the user's behalf under its
8074
- * own authority.
9104
+ * The http backend always sees the app's own API key. That is what the Studio
9105
+ * wants — there the app has already verified a session cookie and is acting on
9106
+ * the user's behalf under its own authority — and it is now the only shape:
9107
+ * every caller that reaches here has been authenticated by the app itself, so
9108
+ * there is no request left on which the app is a pipe rather than an authority.
8075
9109
  *
8076
- * A caller acting for a user it has *not* authenticated itself must pass that
8077
- * user's personal access token instead, so the backend is the one that decides
8078
- * what the caller may do. That is the whole of `docs/plans/mcp.md` D.2: the app
8079
- * stops being an authority and goes back to being a pipe. Passing the app's API
8080
- * key on such a request is the D.6 confused deputy, and it is worth being blunt
8081
- * about why it is tempting — it works, and it works for every project the key
8082
- * can reach, including the ones the caller cannot.
9110
+ * This took a parameter for the other case: a caller acting for a user it had
9111
+ * *not* authenticated passed that user's personal access token, and the backend
9112
+ * decided what the caller could do. `ValOpsHttp` still accepts such a token —
9113
+ * the CLI's `debug` command uses the developer's own from `val login` — but no
9114
+ * server request builds one any more, because a request the app cannot
9115
+ * authenticate is now refused instead of relayed.
9116
+ *
9117
+ * What has not changed is why the API key must never stand in for a credential
9118
+ * that was merely *absent*: it works, and it works for every project the key
9119
+ * can reach, including the ones the caller cannot. Callers are refused for a
9120
+ * missing credential well before this point.
8083
9121
  */
8084
- function createValOps(valModules, options, auth) {
9122
+ function createValOps(valModules, options) {
8085
9123
  if (options.mode === "fs") {
8086
9124
  // No credential in fs mode: this reads and writes the developer's own
8087
- // working tree, and there is no backend to authenticate to. A PAT handed in
8088
- // here is not ignored quietly — the caller is told, in createValTools.
9125
+ // working tree, and there is no backend to authenticate to. A credential
9126
+ // that arrives for such a project is not ignored quietly — the caller is
9127
+ // told, by `createValTools` when the project is configured for oauth and by
9128
+ // `initValMcp` when it is not, since with no issuer there is no verified
9129
+ // credential left for the registry to see.
8089
9130
  return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
8090
9131
  formatter: options.formatter,
8091
9132
  config: options.config
8092
9133
  });
8093
9134
  }
8094
9135
  if (options.mode === "http") {
8095
- return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, auth ?? {
9136
+ return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
8096
9137
  apiKey: options.apiKey
8097
9138
  }, valModules, {
8098
9139
  formatter: options.formatter,
@@ -8714,90 +9755,6 @@ const ValServer = (valModules, options, callbacks) => {
8714
9755
  };
8715
9756
  }
8716
9757
  },
8717
- "/session": {
8718
- GET: async req => {
8719
- const cookies = req.cookies;
8720
- if (serverOps instanceof ValOpsFS) {
8721
- return {
8722
- status: 200,
8723
- json: {
8724
- mode: "local",
8725
- enabled: await callbacks.isEnabled()
8726
- }
8727
- };
8728
- }
8729
- if (!options.project) {
8730
- return {
8731
- status: 500,
8732
- json: {
8733
- message: "Project is not set"
8734
- }
8735
- };
8736
- }
8737
- if (!options.valSecret) {
8738
- return {
8739
- status: 500,
8740
- json: {
8741
- message: "Secret is not set"
8742
- }
8743
- };
8744
- }
8745
- return withAuth(options.valSecret, cookies, "session", async data => {
8746
- if (!options.valBuildUrl) {
8747
- return {
8748
- status: 500,
8749
- json: {
8750
- message: "Val is not correctly setup. Build url is missing"
8751
- }
8752
- };
8753
- }
8754
- const url = new URL(`/api/val/${options.project}/auth/session`, options.valBuildUrl);
8755
- const fetchRes = await fetch(url, {
8756
- headers: getAuthHeaders(data.token, "application/json")
8757
- });
8758
- if (fetchRes.status === 200) {
8759
- const json = z.object({
8760
- member_role: z.union([z.literal("owner"), z.literal("developer"), z.literal("editor")]).optional(),
8761
- id: z.string(),
8762
- full_name: z.string().optional(),
8763
- username: z.string().optional(),
8764
- avatar_url: z.string().optional()
8765
- }).safeParse(await fetchRes.json());
8766
- if (json.success) {
8767
- return {
8768
- status: fetchRes.status,
8769
- json: {
8770
- mode: "proxy",
8771
- enabled: await callbacks.isEnabled(),
8772
- ...json.data
8773
- }
8774
- };
8775
- } else {
8776
- const message = getErrorMessageFromUnknownJson(json, "Could not parse session response. Unexpected error (no error message). Status: " + fetchRes.status);
8777
- return {
8778
- status: 500,
8779
- json: {
8780
- message: message,
8781
- ...json
8782
- }
8783
- };
8784
- }
8785
- } else {
8786
- const json = z.object({
8787
- message: z.string()
8788
- }).safeParse(await fetchRes.json());
8789
- const message = getErrorMessageFromUnknownJson(json, "Unknown error");
8790
- return {
8791
- status: fetchRes.status,
8792
- json: {
8793
- message: message,
8794
- ...json
8795
- }
8796
- };
8797
- }
8798
- });
8799
- }
8800
- },
8801
9758
  "/logout": {
8802
9759
  GET: async req => {
8803
9760
  const query = req.query;
@@ -11011,11 +11968,13 @@ const ValServer = (valModules, options, callbacks) => {
11011
11968
  }
11012
11969
  };
11013
11970
  }
11014
- const arrayBuffer = await binaryRes.arrayBuffer();
11015
- const base64 = Buffer.from(arrayBuffer).toString("base64");
11016
- const dataUrl = `data:${file.metadata.mimeType};base64,${base64}`;
11971
+ // Bytes straight through. This used to base64 the buffer,
11972
+ // wrap it in a data: URL, and hand that to a method whose first
11973
+ // act was to unwrap it again - ceremony to satisfy a convention
11974
+ // that no longer exists.
11975
+ const bytes = Buffer.from(await binaryRes.arrayBuffer());
11017
11976
  const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
11018
- const saveRes = await serverOps.saveBase64EncodedBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, dataUrl, type, file.metadata);
11977
+ const saveRes = await serverOps.saveBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, bytes, file.metadata.mimeType, type, file.metadata);
11019
11978
  if (saveRes.error) {
11020
11979
  return {
11021
11980
  status: 500,
@@ -11143,6 +12102,137 @@ const ValServer = (valModules, options, callbacks) => {
11143
12102
  }
11144
12103
  },
11145
12104
  //#region files
12105
+ // #region history
12106
+ "/history/commits": {
12107
+ GET: async req => {
12108
+ const auth = getAuth(req.cookies);
12109
+ if (auth.error) {
12110
+ return {
12111
+ status: 401,
12112
+ json: {
12113
+ message: auth.error
12114
+ }
12115
+ };
12116
+ }
12117
+ const res = await serverOps.listCommits(req.query.branch, {
12118
+ limit: req.query.limit,
12119
+ cursor: req.query.cursor
12120
+ });
12121
+ if (result.isErr(res)) {
12122
+ return historyErrorResponse(res.error);
12123
+ }
12124
+ return {
12125
+ status: 200,
12126
+ json: res.value,
12127
+ // The head moves, so a listing is never reusable.
12128
+ headers: {
12129
+ "Cache-Control": "no-store"
12130
+ }
12131
+ };
12132
+ }
12133
+ },
12134
+ "/history/commit": {
12135
+ GET: async req => {
12136
+ const auth = getAuth(req.cookies);
12137
+ if (auth.error) {
12138
+ return {
12139
+ status: 401,
12140
+ json: {
12141
+ message: auth.error
12142
+ }
12143
+ };
12144
+ }
12145
+ const res = await getHistoricalPatchSet(serverOps, req.query.commit_sha);
12146
+ if (result.isErr(res)) {
12147
+ return historyErrorResponse(res.error);
12148
+ }
12149
+ /*
12150
+ * Immutable ONLY when nothing in the answer was transient.
12151
+ *
12152
+ * What a commit recorded cannot change, so a clean answer is reusable
12153
+ * forever - that is what makes comparing many commits cheap. But a
12154
+ * module marked `unavailable` means a blob fetch failed, and a warning
12155
+ * means an entry read against GitHub did; both can succeed on the next
12156
+ * try. Cached for a year, one flaky read would become "nothing was
12157
+ * recorded for this module" for the rest of the session and beyond.
12158
+ *
12159
+ * `private`, not `public`: this route is behind the session cookie, and
12160
+ * a shared cache holding it would hand one project's module sources to
12161
+ * whoever asked next. The browser's own cache is where the reuse was
12162
+ * wanted anyway.
12163
+ */
12164
+ const settled = res.value.warnings.length === 0 && Object.values(res.value.modules).every(module => module.failures.length === 0);
12165
+ return {
12166
+ status: 200,
12167
+ json: res.value,
12168
+ headers: {
12169
+ "Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
12170
+ }
12171
+ };
12172
+ }
12173
+ },
12174
+ "/history/module": {
12175
+ GET: async req => {
12176
+ const auth = getAuth(req.cookies);
12177
+ if (auth.error) {
12178
+ return {
12179
+ status: 401,
12180
+ json: {
12181
+ message: auth.error
12182
+ }
12183
+ };
12184
+ }
12185
+ const res = await getModuleAtCommit(serverOps, req.query.commit_sha, req.query.module_file_path);
12186
+ if (result.isErr(res)) {
12187
+ return historyErrorResponse(res.error);
12188
+ }
12189
+ // Immutable only when the answer settled — a module reported
12190
+ // unavailable means a blob fetch failed and may succeed next time, and
12191
+ // caching that for a year turns one flaky read into a permanent gap.
12192
+ const settled = res.value === null || res.value.failures.length === 0;
12193
+ return {
12194
+ status: 200,
12195
+ json: {
12196
+ moduleFilePath: req.query.module_file_path,
12197
+ module: res.value
12198
+ },
12199
+ headers: {
12200
+ "Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
12201
+ }
12202
+ };
12203
+ }
12204
+ },
12205
+ "/history/files": {
12206
+ GET: async req => {
12207
+ // No auth, for the same reason /files has none: this is served to an
12208
+ // <img> that the app's own backend may fetch during image
12209
+ // optimisation, with no cookies. What it exposes is a file at a commit
12210
+ // that is already in the repository.
12211
+ const res = await serverOps.getFileAtCommit(req.query.commit_sha, req.query.path, req.query.remote === "true");
12212
+ if (result.isErr(res)) {
12213
+ const response = historyErrorResponse(res.error);
12214
+ // The stream branch of this route's response union has no json, so
12215
+ // narrow to the shapes it does allow.
12216
+ if (response.status === 401 || response.status === 404) {
12217
+ return response;
12218
+ }
12219
+ return {
12220
+ status: 400,
12221
+ json: response.json
12222
+ };
12223
+ }
12224
+ return {
12225
+ status: 200,
12226
+ headers: {
12227
+ "Content-Type": guessMimeTypeFromPath(req.query.path) ?? "application/octet-stream",
12228
+ // A file at a fixed commit cannot change.
12229
+ "Cache-Control": "public, max-age=31536000, immutable"
12230
+ },
12231
+ body: bufferToReadableStream(res.value)
12232
+ };
12233
+ }
12234
+ },
12235
+ // #endregion history
11146
12236
  "/files": {
11147
12237
  GET: async req => {
11148
12238
  const query = req.query;
@@ -11641,6 +12731,45 @@ const ENABLE_COOKIE_VALUE = {
11641
12731
  }
11642
12732
  };
11643
12733
  const chunkSize = 1024 * 1024;
12734
+
12735
+ /**
12736
+ * Turn a HistoryError into the HTTP answer it deserves.
12737
+ *
12738
+ * The `kind` travels in the body alongside the message, because the Studio
12739
+ * decides what to OFFER from it - "cannot restore this field" and "cannot
12740
+ * restore this module at all" are different affordances, and a rendered string
12741
+ * cannot be told apart.
12742
+ */
12743
+ function historyErrorResponse(error) {
12744
+ const message = historyErrorMessage(error);
12745
+ switch (error.kind) {
12746
+ case "commit-not-found":
12747
+ return {
12748
+ status: 404,
12749
+ json: {
12750
+ message
12751
+ }
12752
+ };
12753
+ case "not-supported-in-fs-mode":
12754
+ // Not an error in the request - this deployment simply has no history
12755
+ // service. 400 rather than 500 so it does not read as a bug.
12756
+ return {
12757
+ status: 400,
12758
+ json: {
12759
+ message,
12760
+ kind: error.kind
12761
+ }
12762
+ };
12763
+ default:
12764
+ return {
12765
+ status: 500,
12766
+ json: {
12767
+ message,
12768
+ kind: error.kind
12769
+ }
12770
+ };
12771
+ }
12772
+ }
11644
12773
  function bufferToReadableStream(buffer) {
11645
12774
  const stream = new ReadableStream({
11646
12775
  start(controller) {
@@ -12721,21 +13850,22 @@ async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
12721
13850
  }
12722
13851
 
12723
13852
  /**
12724
- * Null on the PAT path, and the verified profile on the token path.
13853
+ * The verified profile, or null when there was nothing to verify.
12725
13854
  *
12726
- * The PAT case is unchanged and still deliberate: the app cannot resolve a
12727
- * PAT, so any id it wrote here would be an unverified claim dressed up as a
12728
- * checked one — and the request already carries the caller's own token, which
12729
- * is a better answer to "who did this" than anything the app could assert.
12730
- * Attributing that patch is the backend's job.
13855
+ * An author is written only when somebody checked it. On the token path the
13856
+ * host verified a signature over a key it does not hold, so the profile is
13857
+ * checked rather than claimed, and the backend has no token of its own to
13858
+ * attribute from — the call reaches it under the app's API key. If this
13859
+ * stayed null there, every edit made through a signed-in editor's own session
13860
+ * would land with no author at all, which is worse than useless on a CMS
13861
+ * whose review screen is organised by who changed what.
12731
13862
  *
12732
- * The token case is the opposite situation, which is why it gets the opposite
12733
- * answer. The host verified a signature over a key it does not hold, so the
12734
- * profile is checked rather than claimed, and the backend has no token of its
12735
- * own to attribute from — the call reaches it under the app's API key. If this
12736
- * stayed null, every edit made through a signed-in editor's own session would
12737
- * land with no author at all, which is worse than useless on a CMS whose
12738
- * review screen is organised by who changed what.
13863
+ * Null is what local filesystem mode gets, where there is no credential to
13864
+ * resolve, exactly as the Studio does locally. It is also what the removed
13865
+ * personal-access-token path got, and for a reason worth keeping in view: an
13866
+ * id derived from a credential the app cannot resolve is an unverified claim
13867
+ * dressed up as a checked one. Should another unverified credential ever
13868
+ * reach here, null remains its only honest author.
12739
13869
  */
12740
13870
  const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
12741
13871
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -12993,14 +14123,17 @@ function rejectFileOps(patch) {
12993
14123
  */
12994
14124
 
12995
14125
  /**
12996
- * How the caller was established, and it is a union because there are two
12997
- * genuinely different answers — with different consequences downstream.
14126
+ * How the caller was established, and there is one acceptable answer: the host
14127
+ * **checked a signature**.
12998
14128
  *
12999
- * The distinction that matters is **who checked**. A PAT is forwarded to the
13000
- * backend unchecked, because the app cannot resolve one; an access token is
13001
- * verified by the app itself, against a public key it does not hold and
13002
- * therefore cannot forge. The first is a credential being relayed. The second
13003
- * is a signature that has already been checked.
14129
+ * A union of one, deliberately. It carried a second variant — a personal access
14130
+ * token relayed to the backend unchecked, on the reasoning that the app cannot
14131
+ * resolve one and the backend can. The reasoning held; the shape did not. A
14132
+ * credential the host cannot check is one it also cannot refuse, so accepting
14133
+ * one made "a deployed endpoint that authenticates nobody" a supported
14134
+ * configuration, and it let a host serve these tools without ever being told
14135
+ * where callers should authorize. The discriminant stays so that adding a
14136
+ * second *verified* kind stays a one-line change at every call site.
13004
14137
  */
13005
14138
 
13006
14139
  /**
@@ -13035,17 +14168,6 @@ const VAL_SCOPE_READ = "val:read";
13035
14168
  /** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
13036
14169
  const VAL_SCOPE_WRITE = "val:write";
13037
14170
 
13038
- /**
13039
- * How many callers' data layers to keep around in proxy mode.
13040
- *
13041
- * Each entry holds one `ValOpsHttp`, and each of those caches the project's
13042
- * evaluated modules once `initSources` has run — so this bounds memory, not just
13043
- * entry count. Small on purpose: the cost of a miss is re-evaluating the
13044
- * modules on the next call, which is what happened on *every* call before this
13045
- * cache existed.
13046
- */
13047
- const MAX_CACHED_OPS = 8;
13048
-
13049
14171
  /**
13050
14172
  * Val's server-side tool registry.
13051
14173
  *
@@ -13145,17 +14267,25 @@ function createValTools(valModules, options) {
13145
14267
  * Pick the data layer for a call, which in proxy mode means picking whose
13146
14268
  * credential the backend will see.
13147
14269
  *
13148
- * This is the one place authorization is decided, and it decides it by *not*
13149
- * deciding: in proxy mode the caller's own personal access token goes to the
13150
- * backend, which is the only party that can say what that token may do. The app
13151
- * never inspects it, never caches a verdict about it, and never substitutes its
13152
- * own API key for a missing one — see `docs/plans/mcp.md` D.2.
14270
+ * This is the one place authorization is decided, and in proxy mode there is
14271
+ * exactly one credential it will act on: an access token whose signature,
14272
+ * issuer, audience and expiry the host verified against the authorization
14273
+ * server's published key. Anything less is refused here rather than forwarded.
14274
+ *
14275
+ * There used to be a second route — the caller's personal access token, passed
14276
+ * through unread on the reasoning that the backend, not the app, is the
14277
+ * authority on what it may do. That was true, and it was still the wrong shape:
14278
+ * it made an unauthenticated bearer token on a deployed endpoint a supported
14279
+ * configuration, and it meant `initValMcp` had a path where an app served MCP
14280
+ * without ever being told where to authorize. A host that has not verified
14281
+ * anything now gets a refusal that names the missing `oauth` config.
13153
14282
  *
13154
- * The alternative shape, and the reason this function exists at all, is an
13155
- * `authenticate()` that checks the PAT once and then acts under the app's key.
13156
- * That reads as more secure and is strictly less so: the check happens in the
13157
- * app, so every bug in it becomes full access to every project the app's key
13158
- * can reach, and the backend's own permission model stops being consulted (D.6).
14283
+ * What has *not* changed is why a verified token does not become the app's own
14284
+ * API key by some other name. The app authenticates to the backend with its own
14285
+ * key here, and who did what travels as the patch's `authorId` — so the
14286
+ * profile has to be one the host checked cryptographically, never one it was
14287
+ * handed. An `authenticate()` that decided a credential's rights inside the app
14288
+ * would make every bug in it full access to every project that key can reach.
13159
14289
  */
13160
14290
  function createOpsResolver(valModules, options) {
13161
14291
  if (options.mode === "fs") {
@@ -13169,18 +14299,17 @@ function createOpsResolver(valModules, options) {
13169
14299
  // and the difference matters, because fs mode writes straight to disk
13170
14300
  // with no backend permission check at all.
13171
14301
  //
13172
- // The two credentials get different messages because they arrive here
13173
- // for different reasons. A PAT is something the caller chose to send. A
13174
- // verified access token is not: it only exists because this app
13175
- // advertised an authorization server, so the developer seeing this did
13176
- // not do anything wrong — a config file did, and naming it is the
13177
- // difference between a two-minute fix and an afternoon.
14302
+ // A verified access token is not something the caller chose to send: it
14303
+ // only exists because this app advertised an authorization server, so
14304
+ // the developer seeing this did not do anything wrong — a config file
14305
+ // did, and naming it is the difference between a two-minute fix and an
14306
+ // afternoon.
13178
14307
  return {
13179
14308
  status: "error",
13180
14309
  result: {
13181
14310
  status: "error",
13182
14311
  code: "unsupported",
13183
- message: ctx.auth.type === "verified-profile" ? "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." : "This Val project is running in local filesystem mode, where there is nothing to authenticate against. Do not send a credential."
14312
+ 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."
13184
14313
  }
13185
14314
  };
13186
14315
  }
@@ -13191,78 +14320,50 @@ function createOpsResolver(valModules, options) {
13191
14320
  };
13192
14321
  }
13193
14322
 
13194
- // Keyed by a hash of the PAT, so the same caller reuses their own instance and
13195
- // two callers can never share one. Hashing is not a security boundary — the
13196
- // instance holds the token regardless — but it keeps credentials out of the
13197
- // key set, which is the thing that ends up in a heap dump or an error dump.
13198
- const byPatHash = new Map();
13199
14323
  /**
13200
- * One instance for every verified caller, and unlike the PAT map that is
13201
- * correct rather than a shortcut: this instance authenticates with the app's
13202
- * own API key, so there is nothing per-caller in it to keep apart. Who did
13203
- * what travels as the patch's `authorId` instead — see `writePath`.
14324
+ * One instance for every verified caller, and that is correct rather than a
14325
+ * shortcut: this instance authenticates with the app's own API key, so there
14326
+ * is nothing per-caller in it to keep apart. Who did what travels as the
14327
+ * patch's `authorId` instead — see `writePath`.
14328
+ *
14329
+ * One instance is also all proxy mode keeps now. It could not share while a
14330
+ * personal access token reached this function: each token needed its own
14331
+ * `ValOpsHttp` to hold it, each of those cached the project's evaluated
14332
+ * modules, and the bounded cache that kept that memory in check turned an
14333
+ * eviction into a re-evaluation of every module on the next call.
13204
14334
  */
13205
14335
  let sharedOps = null;
13206
14336
  return ctx => {
13207
- if (!ctx.auth) {
14337
+ var _ctx$auth;
14338
+ if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
13208
14339
  return {
13209
14340
  status: "error",
13210
14341
  result: {
13211
14342
  status: "error",
13212
14343
  code: "forbidden",
13213
- 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`."
14344
+ 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."
13214
14345
  }
13215
14346
  };
13216
14347
  }
13217
- if (ctx.auth.type === "verified-profile") {
13218
- if (!options.apiKey) {
13219
- // Proxy mode is inferred from the api key being present, so this is
13220
- // unreachable through `initHandlerOptions`. It stays because the
13221
- // alternative to refusing is building ops with no credential at all.
13222
- return {
13223
- status: "error",
13224
- result: {
13225
- status: "error",
13226
- code: "forbidden",
13227
- message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
13228
- }
13229
- };
13230
- }
13231
- if (!sharedOps) {
13232
- sharedOps = createValOps(valModules, options);
13233
- }
13234
- return {
13235
- status: "ok",
13236
- ops: sharedOps
13237
- };
13238
- }
13239
- const key = createHash("sha256").update(ctx.auth.pat).digest("hex");
13240
- const cached = byPatHash.get(key);
13241
- if (cached) {
13242
- // Re-inserted so eviction drops the least recently used rather than the
13243
- // oldest — a long-running caller should not be evicted by a burst of
13244
- // one-off ones.
13245
- byPatHash.delete(key);
13246
- byPatHash.set(key, cached);
14348
+ if (!options.apiKey) {
14349
+ // Proxy mode is inferred from the api key being present, so this is
14350
+ // unreachable through `initHandlerOptions`. It stays because the
14351
+ // alternative to refusing is building ops with no credential at all.
13247
14352
  return {
13248
- status: "ok",
13249
- ops: cached
14353
+ status: "error",
14354
+ result: {
14355
+ status: "error",
14356
+ code: "forbidden",
14357
+ message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
14358
+ }
13250
14359
  };
13251
14360
  }
13252
- const ops = createValOps(valModules, options, {
13253
- pat: ctx.auth.pat
13254
- });
13255
- byPatHash.set(key, ops);
13256
- while (byPatHash.size > MAX_CACHED_OPS) {
13257
- const oldest = byPatHash.keys().next();
13258
- if (oldest.done) {
13259
- break;
13260
- }
13261
- byPatHash.delete(oldest.value);
14361
+ if (!sharedOps) {
14362
+ sharedOps = createValOps(valModules, options);
13262
14363
  }
13263
14364
  return {
13264
14365
  status: "ok",
13265
- ops
14366
+ ops: sharedOps
13266
14367
  };
13267
14368
  };
13268
14369
  }
@@ -13360,14 +14461,18 @@ function describeZodError(error) {
13360
14461
  * the safe direction: a tool that forgets the hint is treated as a write and
13361
14462
  * demands the wider scope, rather than a write slipping through as a read.
13362
14463
  *
13363
- * Only the verified-token path is checked. A PAT carries no scopes here by
13364
- * design — the backend resolves it and decides — so there is nothing to
13365
- * enforce, and inventing a default would be this app claiming an authority it
13366
- * does not have.
14464
+ * The early return is a call carrying no verified credential, and there is no
14465
+ * scope to check because nothing granted one. In Val's own host that means
14466
+ * local filesystem mode, where a project writing a developer's own working tree
14467
+ * has no wider authority to withhold. A host assembling its own context can
14468
+ * also reach it with an unauthenticated proxy-mode call — refused a few lines
14469
+ * later, by `resolveOps`, for the credential rather than the scope. Every other
14470
+ * caller arrives as a verified profile, carrying the scopes its token was
14471
+ * issued with.
13367
14472
  */
13368
14473
  function refuseInsufficientScope(tool, ctx) {
13369
- var _ctx$auth, _tool$annotations;
13370
- if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
14474
+ var _ctx$auth2, _tool$annotations;
14475
+ if (((_ctx$auth2 = ctx.auth) === null || _ctx$auth2 === void 0 ? void 0 : _ctx$auth2.type) !== "verified-profile") {
13371
14476
  return null;
13372
14477
  }
13373
14478
  // Read is needed by every call, including the writes: a tool that changes
@@ -14896,6 +16001,25 @@ async function handleJsonValuesExtractEntry(ctx) {
14896
16001
  };
14897
16002
  }
14898
16003
 
16004
+ /**
16005
+ * `external:upload` under `val validate --fix`: refuse, and say what to run.
16006
+ *
16007
+ * Every other fix in this registry rewrites something inside the repository —
16008
+ * reversible, visible in a diff, wrong by at most one commit. This one would
16009
+ * write entries into a live external store: not in a diff, not undone by
16010
+ * `git revert`, and against production indistinguishable from an editor's
16011
+ * publish. So a blanket `--fix` must never apply it.
16012
+ *
16013
+ * `fixableErrorMessage` rather than a plain error, because the error IS fixable
16014
+ * — just not by this command.
16015
+ */
16016
+ async function handleExternalUpload() {
16017
+ return {
16018
+ success: true,
16019
+ fixableErrorMessage: "This entry is written inline but its record is .external(). " + "Run 'val external upload' to move it into the store — " + "'val validate --fix' will not write to a live store."
16020
+ };
16021
+ }
16022
+
14899
16023
  // Fix handler registry. `keyof:check-keys` and `router:check-route` are
14900
16024
  // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
14901
16025
  // this registry, so they're excluded from the key set.
@@ -14918,7 +16042,8 @@ const currentFixHandlers = {
14918
16042
  "files:check-unique-folder": handleUniqueFolderCheck,
14919
16043
  "images:check-all-files": handleCheckAllFiles,
14920
16044
  "files:check-all-files": handleCheckAllFiles,
14921
- "jsonValues:extract-entry": handleJsonValuesExtractEntry
16045
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry,
16046
+ "external:upload": handleExternalUpload
14922
16047
  };
14923
16048
  const deprecatedFixHandlers = {
14924
16049
  "image:replace-metadata": handleFileMetadata
@@ -15567,4 +16692,4 @@ function readCapturedReport(snapshotDir) {
15567
16692
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
15568
16693
  }
15569
16694
 
15570
- export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, Service, VAL_SCOPE_READ, VAL_SCOPE_WRITE, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, authorIdFromVerifiedSubject, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, createValTools, currentFixHandlers, decodeJwtWithoutVerifying, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
16695
+ export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, Service, VAL_SCOPE_READ, VAL_SCOPE_WRITE, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, authorIdFromVerifiedSubject, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, createValTools, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err$1 as err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, isExternalResult, loadValModules, ok$1 as ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };