@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.
@@ -599,11 +599,13 @@ const patchValFile = async (id, rootDir, patch$1, sourceFileHandler) => {
599
599
  // );
600
600
  // sourceFileHandler.moveFile(tempFilePath, "." + filePath);
601
601
  // TODO: ensure that directory exists
602
- if (content.startsWith("data:/image/svg+xml")) {
603
- sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("utf8"), "utf8");
604
- } else {
605
- sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
606
- }
602
+ // NOTE: there used to be a branch here testing
603
+ // `content.startsWith("data:/image/svg+xml")` - with a stray slash after
604
+ // `data:`, so it could never match a real data URL and SVGs took the
605
+ // "binary" path anyway. Removed rather than fixed: the branch existed to
606
+ // write SVG as utf8, and writing the decoded bytes is correct for every
607
+ // type including SVG.
608
+ sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
607
609
  }
608
610
  sourceFileHandler.writeSourceFile(newSourceFile.value);
609
611
  // console.timeEnd("patchValFile" + timeId);
@@ -1952,6 +1954,183 @@ class Service {
1952
1954
  }
1953
1955
  }
1954
1956
 
1957
+ /**
1958
+ * The type surface of an external record's adapter.
1959
+ *
1960
+ * Nothing here runs: phase 0 is the contract, and the registry that executes it
1961
+ * arrives with the read endpoints. What the contract has to get right now is the
1962
+ * shape, because every adapter written against it is a compatibility promise.
1963
+ *
1964
+ * Four kinds of method, and each is required or not for one reason:
1965
+ *
1966
+ * - **Read** — `keys`, `get`. Cannot be built out of anything else, so always
1967
+ * required.
1968
+ * - **Write** — `put`, `delete`. Likewise, and they move together: required on a
1969
+ * writable record, forbidden on a `.readonly()` one.
1970
+ * - **Derived** — `count`, `search`. Computable from the read methods, so
1971
+ * omitting one costs performance, never capability. `false` declines the
1972
+ * fallback; that is the only thing `false` ever means here.
1973
+ * - **Media** — `putFile`, `getFile`. A pair, and required by the item SCHEMA
1974
+ * rather than by this type (see `hasMediaSchema`).
1975
+ */
1976
+
1977
+ // #region result
1978
+
1979
+ /**
1980
+ * Brands the result envelope so a bare value can be accepted alongside it.
1981
+ *
1982
+ * A symbol rather than a property name because `get` returns
1983
+ * `Record<key, Item>` — a record that can perfectly well contain a key called
1984
+ * `kind`. The envelope never crosses the wire (it travels in-process between
1985
+ * adapter and Val), so there is no serialization cost to it.
1986
+ */
1987
+ const EXTERNAL_RESULT = Symbol.for("@valbuild/server/ExternalResult");
1988
+
1989
+ /**
1990
+ * What every adapter method may return: the value on its own, or the envelope.
1991
+ *
1992
+ * A bare value means "ok, nothing to report", which keeps the common path one
1993
+ * line. Errors can be thrown instead of returned — the envelope is for a failure
1994
+ * worth classifying, or a success worth annotating.
1995
+ */
1996
+
1997
+ /**
1998
+ * Wrap a value as a successful result, optionally with warnings.
1999
+ *
2000
+ * `NoInfer` is load-bearing, not decoration. Without it `T` is inferred FROM the
2001
+ * argument, and an object literal that infers its own type is not contextually
2002
+ * typed by the adapter's contract — so `title` inside `ok({ a: { title } })` is a
2003
+ * fresh property that happens to be named `title`, with no declaration link back
2004
+ * to `title: s.string()` in the schema. "Find all references" on the schema field
2005
+ * then stops at the page that reads it and never reaches the adapter that
2006
+ * produces it. `NoInfer` blocks that inference, leaving the contextual return
2007
+ * type as the only source for `T`, which is what restores the link (and, as a
2008
+ * bonus, makes a typo report the offending property rather than the whole
2009
+ * return value).
2010
+ *
2011
+ * `externalNavigation.test.ts` guards this; nothing else would notice.
2012
+ *
2013
+ * The cost: `ok(...)` in a position with NO contextual type — a helper without a
2014
+ * return type annotation — infers `unknown` instead of the argument's type, and
2015
+ * fails where the helper is used rather than where it is written. Annotate the
2016
+ * helper's return type, or inline it.
2017
+ */
2018
+ function ok$1(value, warnings) {
2019
+ return warnings && warnings.length > 0 ? {
2020
+ [EXTERNAL_RESULT]: true,
2021
+ kind: "ok",
2022
+ value,
2023
+ warnings
2024
+ } : {
2025
+ [EXTERNAL_RESULT]: true,
2026
+ kind: "ok",
2027
+ value
2028
+ };
2029
+ }
2030
+ function err$1(issue) {
2031
+ return {
2032
+ [EXTERNAL_RESULT]: true,
2033
+ kind: "err",
2034
+ error: issue
2035
+ };
2036
+ }
2037
+ function isExternalResult(value) {
2038
+ return typeof value === "object" && value !== null && value[EXTERNAL_RESULT] === true;
2039
+ }
2040
+
2041
+ // #endregion
2042
+
2043
+ // #region context
2044
+
2045
+ /**
2046
+ * What every adapter method is told about the call it is serving.
2047
+ *
2048
+ * `tx` is present only when the adapter declared an `around`; a store with no
2049
+ * transaction seam has nothing to put there and nothing to ignore.
2050
+ */
2051
+
2052
+ // #endregion
2053
+
2054
+ // #region paging, sorting, searching
2055
+
2056
+ /**
2057
+ * How a page should be ordered.
2058
+ *
2059
+ * Records are unordered today and sorting is coming; the parameter lands now
2060
+ * because adding one to `keys` later would break every adapter that exists by
2061
+ * then. Val passes `undefined` until sorting ships, and an adapter may ignore it.
2062
+ *
2063
+ * Two rules for whoever implements it:
2064
+ *
2065
+ * - **A cursor is only valid for the sort that issued it.** Key-ordered paging
2066
+ * is `where key > cursor`; sorted paging is `where (field, key) > (…, …)`.
2067
+ * Val pairs the cursor with a hash of the sort and restarts rather than
2068
+ * replaying a mismatched one.
2069
+ * - **The key is always the last sort term.** Ordering by a non-unique field
2070
+ * without a tiebreaker lets rows shift between pages, so page 2 can skip or
2071
+ * repeat an entry while both pages look fine on their own.
2072
+ */
2073
+
2074
+ // #endregion
2075
+
2076
+ // #region media
2077
+
2078
+ // #endregion
2079
+
2080
+ // #region the adapter
2081
+
2082
+ /** Read methods. Always required. */
2083
+
2084
+ /**
2085
+ * Write methods. Required together, and forbidden together on `.readonly()`.
2086
+ *
2087
+ * `put` must be an UPSERT keyed by the entry key and `delete` must tolerate an
2088
+ * absent key, because a publish may be replayed: retry re-runs the whole scope
2089
+ * where there is a transaction, and the individual call where there is not.
2090
+ */
2091
+
2092
+ /**
2093
+ * Named so the compiler prints the reason. A bare `never` would report only
2094
+ * "not assignable to type 'undefined'", which tells nobody anything.
2095
+ */
2096
+
2097
+ /** Derived and media methods, and the sort declaration. */
2098
+
2099
+ /** The item type an external module's entries hold, loosened as JSON allows. */
2100
+
2101
+ /**
2102
+ * The adapter a given external module needs.
2103
+ *
2104
+ * Writes are required or forbidden by the module's own `.readonly()`, read off
2105
+ * the source marker's phantom.
2106
+ */
2107
+
2108
+ /** What `entry()` returns: a module and its adapter, checked against each other. */
2109
+
2110
+ /**
2111
+ * Declare the adapters for this project's external records.
2112
+ *
2113
+ * `Tx` is given explicitly: `around` offers the compiler no inference site,
2114
+ * since `run` is a callback you CALL rather than one whose signature you write.
2115
+ * One type argument, in one place, and every inline adapter then gets `tx`,
2116
+ * `cursor`, `limit` and the row shape correctly typed.
2117
+ *
2118
+ * @example
2119
+ * const { entry, modules } = defineExternal<typeof sql>({
2120
+ * around: (run) => sql.begin(run),
2121
+ * });
2122
+ *
2123
+ * export default modules({
2124
+ * posts: entry(postsVal, { keys, get, put, delete: del, search: false }),
2125
+ * });
2126
+ *
2127
+ * @example a store with no transaction
2128
+ * const { entry, modules } = defineExternal();
2129
+ */
2130
+ function defineExternal(definition) {
2131
+ throw new Error("defineExternal is not implemented yet: phase 0 lands the contract, the registry that executes it arrives with the read endpoints.");
2132
+ }
2133
+
1955
2134
  const JwtPayloadSchema = z.z.object({
1956
2135
  sub: z.z.string(),
1957
2136
  exp: z.z.number(),
@@ -3736,6 +3915,47 @@ class ValOps {
3736
3915
  };
3737
3916
  }
3738
3917
  }));
3918
+
3919
+ /*
3920
+ * What each changed module IS after this commit, and the schema it is under.
3921
+ *
3922
+ * The data rather than the file's text, because that is the half git cannot
3923
+ * give back: a `.val.ts` in git is code, and turning code back into data
3924
+ * means parsing it, which is best-effort and rots across TypeScript,
3925
+ * runtime and Val versions. The schema comes along because a value on its
3926
+ * own cannot be RENDERED - showing a module as it was at a commit whose
3927
+ * schema has since changed needs the schema of that commit, and nothing in
3928
+ * the current checkout has it.
3929
+ *
3930
+ * Taken from `getSources(analysis)` rather than re-derived here so the data
3931
+ * stored is the same data the Studio shows, produced by the one
3932
+ * implementation of "apply these ops".
3933
+ */
3934
+ const moduleVersions = {};
3935
+ const {
3936
+ sources: sourcesAfter
3937
+ } = await this.getSources(patchAnalysis);
3938
+ for (const path of Object.keys(patchesByModule)) {
3939
+ let serialized;
3940
+ try {
3941
+ var _schemas$path3;
3942
+ serialized = (_schemas$path3 = schemas[path]) === null || _schemas$path3 === void 0 ? void 0 : _schemas$path3["executeSerialize"]();
3943
+ } catch {
3944
+ // Same guard as above: one unserializable schema must not cost every
3945
+ // other module its history.
3946
+ serialized = undefined;
3947
+ }
3948
+ if (!serialized) {
3949
+ continue;
3950
+ }
3951
+ const source = sourcesAfter[path];
3952
+ moduleVersions[path] = {
3953
+ // `undefined` here means the module is gone, which is a state history
3954
+ // has to be able to show. `null` is how that travels over the wire.
3955
+ source: source === undefined ? null : source,
3956
+ schema: serialized
3957
+ };
3958
+ }
3739
3959
  const res = {
3740
3960
  hasErrors,
3741
3961
  sourceFilePatchErrors,
@@ -3748,7 +3968,8 @@ class ValOps {
3748
3968
  patchedBinaryFilesDescriptors,
3749
3969
  appliedPatches,
3750
3970
  skippedPatches,
3751
- triedPatches
3971
+ triedPatches,
3972
+ moduleVersions
3752
3973
  };
3753
3974
  return res;
3754
3975
  }
@@ -3805,6 +4026,66 @@ class ValOps {
3805
4026
  }
3806
4027
 
3807
4028
  // #region abstract ops
4029
+
4030
+ /**
4031
+ * Save a patch's binary file from a `data:...;base64,...` URL.
4032
+ *
4033
+ * The wire form: `FileReader.readAsDataURL` is what the browser produces, and
4034
+ * published `@valbuild/server` versions send it. Code that already HAS bytes
4035
+ * should call {@link saveBinaryFileFromPatch} instead of wrapping them in a
4036
+ * data URL just to have this unwrap them again.
4037
+ *
4038
+ * A `null` `data` records a DELETION, which is why this cannot simply be
4039
+ * replaced by the byte-taking sibling: there is nothing to hand it.
4040
+ */
4041
+
4042
+ /**
4043
+ * The same, for a caller that already has the bytes.
4044
+ *
4045
+ * Default implementation wraps them back into a data URL so every backend
4046
+ * gets this for free; a backend that can take bytes straight through should
4047
+ * override it.
4048
+ */
4049
+ async saveBinaryFileFromPatch(filePath, parentRef, patchId, bytes, mimeType, type, metadata) {
4050
+ return this.saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, `data:${mimeType};base64,${bytes.toString("base64")}`, type, metadata);
4051
+ }
4052
+
4053
+ // #region history
4054
+ //
4055
+ // Reading the past, as opposed to reading the present with pending patches
4056
+ // applied. Every one of these is Result-typed against `HistoryError`, because
4057
+ // the ways this can fail - an unreadable record, a source that no longer
4058
+ // parses, an op that will not replay, a schema that has moved on - are the
4059
+ // interesting part rather than an edge case, and a caller deciding whether to
4060
+ // offer a RESTORE has to know which one it hit.
4061
+ //
4062
+ // Only implemented where there is a service holding the history:
4063
+ // `ValOpsHttp`. `ValOpsFS` answers `not-supported-in-fs-mode`, because local
4064
+ // dev has git for this and no commit records of its own.
4065
+
4066
+ /** One page of a branch's commits, newest first. See history/listCommits. */
4067
+
4068
+ /** The patches that produced one commit, with their ops. */
4069
+
4070
+ /**
4071
+ * How each `.val.ts` the commit changed looked BEFORE it, keyed by module
4072
+ * file path. Empty for a commit made before this was recorded - which the
4073
+ * caller reports as `source-unavailable` rather than as an empty module.
4074
+ */
4075
+ /**
4076
+ * Each module a commit changed: its data, and the schema it was under.
4077
+ *
4078
+ * `asOf` widens it from "what this commit changed" to "the whole project as
4079
+ * this commit left it", which is what reverting everything to a point in time
4080
+ * needs; `moduleFilePath` narrows it to one module, for navigating the
4081
+ * history pane off the changed set.
4082
+ */
4083
+
4084
+ /** Which files the commit touched, and how. Names them; does not fetch them. */
4085
+
4086
+ /** One file's bytes as they were at one commit. */
4087
+
4088
+ // #endregion history
3808
4089
  }
3809
4090
  function isOnlyFileCheckValidationError(validationError) {
3810
4091
  var _validationError$fixe9;
@@ -6410,6 +6691,46 @@ class ValOpsFS extends ValOps {
6410
6691
  getPatchLockFile() {
6411
6692
  return path__namespace["default"].join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
6412
6693
  }
6694
+
6695
+ // #region history
6696
+ //
6697
+ // Not available locally, and deliberately not faked from git.
6698
+ //
6699
+ // History is a record of what VAL did: which patches produced a commit, who
6700
+ // wrote them, and what each module looked like immediately before. Git has
6701
+ // the files but not that - it cannot say which of a commit's changes were one
6702
+ // editor's patch set, so a "restore this change" built on it would be
6703
+ // guesswork wearing the same UI.
6704
+ //
6705
+ // A single, honest error rather than five different ones: the caller's
6706
+ // question is "is history available here", and the answer is no.
6707
+
6708
+ async listCommits() {
6709
+ return fp.result.err({
6710
+ kind: "not-supported-in-fs-mode"
6711
+ });
6712
+ }
6713
+ async getCommitPatches() {
6714
+ return fp.result.err({
6715
+ kind: "not-supported-in-fs-mode"
6716
+ });
6717
+ }
6718
+ async getCommitModules() {
6719
+ return fp.result.err({
6720
+ kind: "not-supported-in-fs-mode"
6721
+ });
6722
+ }
6723
+ async getCommitAffectedFiles() {
6724
+ return fp.result.err({
6725
+ kind: "not-supported-in-fs-mode"
6726
+ });
6727
+ }
6728
+ async getFileAtCommit() {
6729
+ return fp.result.err({
6730
+ kind: "not-supported-in-fs-mode"
6731
+ });
6732
+ }
6733
+ // #endregion history
6413
6734
  }
6414
6735
  class FSOpsHost {
6415
6736
  constructor() {}
@@ -6697,6 +7018,99 @@ const CommitResponse = z.z.object({
6697
7018
  commit: CommitSha,
6698
7019
  branch: z.z.string()
6699
7020
  });
7021
+ // #region history wire schemas
7022
+ //
7023
+ // Validated on arrival rather than trusted: these come from a service that
7024
+ // versions separately, and a silently mis-shaped commit record reads as "this
7025
+ // commit changed nothing", which is indistinguishable from a real answer.
7026
+ const HistoricalCommitResponse = z.z.object({
7027
+ commitSha: z.z.string(),
7028
+ parentCommitSha: z.z.string(),
7029
+ clientCommitSha: z.z.string(),
7030
+ branch: z.z.string(),
7031
+ createdBranch: z.z.string().nullable(),
7032
+ creator: z.z.string().nullable(),
7033
+ message: z.z.string().nullable(),
7034
+ createdAt: z.z.string(),
7035
+ seqNum: z.z.string(),
7036
+ patchCount: z.z.number(),
7037
+ hasArchive: z.z.boolean()
7038
+ });
7039
+ const ListCommitsResponse = z.z.object({
7040
+ commits: z.z.array(HistoricalCommitResponse),
7041
+ nextCursor: z.z.string().nullable()
7042
+ });
7043
+ const CommitPatchesResponse = z.z.object({
7044
+ commitSha: z.z.string(),
7045
+ commit: z.z.object({
7046
+ commitSha: z.z.string(),
7047
+ parentCommitSha: z.z.string(),
7048
+ clientCommitSha: z.z.string(),
7049
+ branch: z.z.string(),
7050
+ createdBranch: z.z.string().nullable(),
7051
+ creator: z.z.string().nullable(),
7052
+ message: z.z.string().nullable(),
7053
+ createdAt: z.z.string(),
7054
+ seqNum: z.z.string(),
7055
+ hasArchive: z.z.boolean()
7056
+ }),
7057
+ patches: z.z.array(z.z.object({
7058
+ patchId: z.z.string(),
7059
+ path: z.z.string(),
7060
+ patch: z.z.unknown(),
7061
+ authorId: z.z.string().nullable(),
7062
+ createdAt: z.z.string(),
7063
+ baseSha: z.z.string(),
7064
+ coreVersion: z.z.string()
7065
+ }))
7066
+ });
7067
+
7068
+ /**
7069
+ * `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
7070
+ *
7071
+ * `schema` stays `unknown` here on purpose. The content service stores it
7072
+ * opaquely and cannot vouch for it, so validating it at the transport boundary
7073
+ * would turn "a schema written by a different version of Val" into a failed
7074
+ * REQUEST rather than one module that cannot be shown. It is checked in
7075
+ * `getHistoricalPatchSet`, per module, where a failure degrades that module and
7076
+ * leaves the commit readable.
7077
+ */
7078
+ const CommitModulesResponse = z.z.object({
7079
+ commitSha: z.z.string(),
7080
+ parentCommitSha: z.z.string(),
7081
+ /**
7082
+ * Whether an `asOf` read covered the whole project.
7083
+ *
7084
+ * Optional so an older content server still parses. False means modules last
7085
+ * edited before history started being recorded are missing from the answer -
7086
+ * which a whole-project revert has to say out loud rather than silently skip.
7087
+ */
7088
+ complete: z.z.boolean().optional(),
7089
+ modules: z.z.array(z.z.object({
7090
+ moduleFilePath: z.z.string(),
7091
+ commitSha: z.z.string(),
7092
+ sourceSha: z.z.string().nullable(),
7093
+ schemaSha: z.z.string(),
7094
+ // Validated, because a Source IS just JSON and this side knows that much.
7095
+ source: internal.JSONValue.nullable(),
7096
+ schema: z.z.unknown(),
7097
+ unavailable: z.z.boolean()
7098
+ }))
7099
+ });
7100
+ const CommitAffectedFilesResponse = z.z.object({
7101
+ commitSha: z.z.string(),
7102
+ files: z.z.array(z.z.union([z.z.object({
7103
+ kind: z.z.union([z.z.literal("module-source"), z.z.literal("json-entry"), z.z.literal("binary")]),
7104
+ gitPath: z.z.string(),
7105
+ change: z.z.union([z.z.literal("added"), z.z.literal("modified"), z.z.literal("deleted")])
7106
+ }), z.z.object({
7107
+ kind: z.z.literal("remote-binary"),
7108
+ ref: z.z.string(),
7109
+ change: z.z.union([z.z.literal("added"), z.z.literal("modified"), z.z.literal("deleted")])
7110
+ })]))
7111
+ });
7112
+ // #endregion history wire schemas
7113
+
6700
7114
  /*
6701
7115
  * The shared schema, not a copy of it.
6702
7116
  *
@@ -7716,7 +8130,12 @@ class ValOpsHttp extends ValOps {
7716
8130
  if (!file) {
7717
8131
  return null;
7718
8132
  }
7719
- return bufferFromDataUrl(file.value) ?? null;
8133
+ // Plain base64, the same as the `repo` branch of getBinaryFile above.
8134
+ //
8135
+ // `value` used to be a data: URL here and plain base64 there - two
8136
+ // encodings in one field, told apart only by which branch produced them.
8137
+ // The content service answers base64 for both now.
8138
+ return Buffer.from(file.value, "base64");
7720
8139
  }
7721
8140
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, remote) {
7722
8141
  const params = new URLSearchParams();
@@ -7885,6 +8304,19 @@ class ValOpsHttp extends ValOps {
7885
8304
  patchedSourceFiles: prepared.patchedSourceFiles,
7886
8305
  patchedBinaryFilesDescriptors: prepared.patchedBinaryFilesDescriptors,
7887
8306
  appliedPatches: prepared.appliedPatches,
8307
+ /*
8308
+ * What each changed module IS after this commit, as DATA, with the
8309
+ * schema it is under.
8310
+ *
8311
+ * The half git cannot give back. Git keeps the `.val.ts`, but that is
8312
+ * code: turning it back into data means parsing it, which is
8313
+ * best-effort and rots across TypeScript, runtime and Val versions -
8314
+ * so a commit that reads today can quietly stop reading later. And
8315
+ * git has no copy at all of the SCHEMA a commit was written under,
8316
+ * which is what showing a module as it was needs once the schema has
8317
+ * moved on.
8318
+ */
8319
+ modules: prepared.moduleVersions,
7888
8320
  commit: this.commitSha,
7889
8321
  root: this.root,
7890
8322
  filesDirectory,
@@ -7945,80 +8377,681 @@ class ValOpsHttp extends ValOps {
7945
8377
  };
7946
8378
  }
7947
8379
  }
7948
- }
7949
8380
 
7950
- const host = process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
7951
- const SettingsSchema = z.z.object({
7952
- publicProjectId: z.z.string(),
7953
- remoteFileBuckets: z.z.array(z.z.object({
7954
- bucket: z.z.string()
7955
- }))
7956
- });
7957
- async function getSettings(projectName, auth) {
7958
- try {
7959
- const response = await fetch(`${host}/v1/${projectName}/settings`, {
7960
- headers: "pat" in auth ? {
7961
- "x-val-pat": auth.pat,
7962
- "Content-Type": "application/json"
7963
- } : {
7964
- Authorization: `Bearer ${auth.apiKey}`,
7965
- "Content-Type": "application/json"
8381
+ // #region history
8382
+
8383
+ /**
8384
+ * One GET against the content service, parsed and Result-typed.
8385
+ *
8386
+ * Every history read has the same three failure modes - could not reach the
8387
+ * service, the commit is not there, the answer was not what was expected -
8388
+ * and each of them means something different to a caller deciding whether to
8389
+ * offer a restore. Doing it once here is what keeps that consistent across
8390
+ * the five endpoints.
8391
+ */
8392
+ async getHistory(path, schema, commitShaForErrors) {
8393
+ let res;
8394
+ try {
8395
+ res = await fetch(`${this.contentUrl}/v1/${this.project}${path}`, {
8396
+ headers: {
8397
+ ...this.authHeaders,
8398
+ "Content-Type": "application/json"
8399
+ }
8400
+ });
8401
+ } catch (err) {
8402
+ return fp.result.err({
8403
+ kind: "transport",
8404
+ message: err instanceof Error ? err.message : String(err)
8405
+ });
8406
+ }
8407
+ if (res.status === 404) {
8408
+ return fp.result.err({
8409
+ kind: "commit-not-found",
8410
+ commitSha: commitShaForErrors
8411
+ });
8412
+ }
8413
+ if (!res.ok) {
8414
+ var _res$headers$get4;
8415
+ let message = `${res.status} ${res.statusText}`;
8416
+ if ((_res$headers$get4 = res.headers.get("Content-Type")) !== null && _res$headers$get4 !== void 0 && _res$headers$get4.includes("application/json")) {
8417
+ message = internal.getErrorMessageFromUnknownJson(await res.json(), message);
7966
8418
  }
8419
+ // A 5xx from the service reading a record it says it has is a real
8420
+ // failure of that record, not a network problem - keep them apart.
8421
+ return fp.result.err({
8422
+ kind: "archive-unreadable",
8423
+ commitSha: commitShaForErrors,
8424
+ message
8425
+ });
8426
+ }
8427
+ // A 200 is not a promise of JSON: a proxy or gateway in front of the
8428
+ // service answers HTML, and an unguarded `.json()` would reject straight
8429
+ // out of this Result-typed API and 500 the route.
8430
+ let body;
8431
+ try {
8432
+ body = await res.json();
8433
+ } catch (err) {
8434
+ return fp.result.err({
8435
+ kind: "archive-unreadable",
8436
+ commitSha: commitShaForErrors,
8437
+ message: `response was not JSON: ${err instanceof Error ? err.message : String(err)}`
8438
+ });
8439
+ }
8440
+ const parsed = schema.safeParse(body);
8441
+ if (!parsed.success) {
8442
+ return fp.result.err({
8443
+ kind: "archive-unreadable",
8444
+ commitSha: commitShaForErrors,
8445
+ message: `unexpected response shape: ${zodValidationError.fromError(parsed.error)}`
8446
+ });
8447
+ }
8448
+ return fp.result.ok(parsed.data);
8449
+ }
8450
+ async listCommits(branch, options) {
8451
+ const params = new URLSearchParams({
8452
+ branch
7967
8453
  });
7968
- if (response.status === 404) {
7969
- return {
7970
- success: false,
7971
- message: `Project '${projectName}' not found: verify that the name of the project is correct and that you have access to it.`
7972
- };
8454
+ if ((options === null || options === void 0 ? void 0 : options.limit) !== undefined) {
8455
+ params.set("limit", String(options.limit));
7973
8456
  }
7974
- if (response.status !== 200) {
7975
- return {
7976
- success: false,
7977
- message: `Failed to get project id: ${response.statusText}`
7978
- };
8457
+ if ((options === null || options === void 0 ? void 0 : options.cursor) !== undefined) {
8458
+ params.set("cursor", options.cursor);
7979
8459
  }
7980
- const json = await response.json();
7981
- const parseRes = SettingsSchema.safeParse(json);
7982
- if (!parseRes.success) {
7983
- return {
7984
- success: false,
7985
- message: `Failed to parse settings data: ${parseRes.error.message}`
7986
- };
8460
+ const res = await this.getHistory(`/commits?${params}`, ListCommitsResponse, branch);
8461
+ if (fp.result.isErr(res)) {
8462
+ return res;
7987
8463
  }
7988
- return {
7989
- success: true,
7990
- data: parseRes.data
7991
- };
7992
- } catch {
7993
- return {
7994
- success: false,
7995
- message: `Failed to get project id. Check network connection and try again.`
7996
- };
8464
+ return fp.result.ok({
8465
+ commits: res.value.commits,
8466
+ nextCursor: res.value.nextCursor
8467
+ });
7997
8468
  }
7998
- }
7999
-
8000
- /**
8001
- * Resolving how Val is configured, and building the data layer from it.
8002
- *
8003
- * Both live here rather than inside `createValApiRouter` because the MCP tool
8004
- * registry needs exactly the same answers: which mode we are in, which
8005
- * credential to use, and which `ValOps` implementation that implies. Two copies
8006
- * of this would drift, and the failure would be quiet — a registry that decides
8007
- * it is in fs mode while the Studio decides it is in proxy mode reads different
8008
- * content from the same project.
8009
- *
8010
- * The type-only import from `./ValRouter` is deliberate: it keeps `ValApiOptions`
8011
- * where its documentation lives without creating a runtime cycle.
8012
- *
8013
- * The credential-bearing URL check at the bottom of this file lives here for the
8014
- * same reason: it is a check on the URLs `initHandlerOptions` resolves, and its
8015
- * only caller is that function. Leaving it in `./ValRouter` would have meant
8016
- * importing it back from there, which is the runtime cycle the paragraph above
8017
- * exists to avoid.
8018
- */
8019
-
8020
- const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
8021
-
8469
+ async getCommitPatches(commitSha) {
8470
+ // One request. The endpoint returns the commit's own summary alongside its
8471
+ // patches, because it already has the row - this used to page the commit
8472
+ // LISTING until the sha turned up, which cost up to twenty requests to open
8473
+ // an old commit and could not find one on another branch at all.
8474
+ const patchesRes = await this.getHistory(`/commits/${commitSha}/patches`, CommitPatchesResponse, commitSha);
8475
+ if (fp.result.isErr(patchesRes)) {
8476
+ return patchesRes;
8477
+ }
8478
+ return fp.result.ok({
8479
+ commit: {
8480
+ ...patchesRes.value.commit,
8481
+ patchCount: patchesRes.value.patches.length
8482
+ },
8483
+ patches: patchesRes.value.patches.map(patch => ({
8484
+ patchId: patch.patchId,
8485
+ moduleFilePath: patch.path,
8486
+ patch: patch.patch,
8487
+ authorId: patch.authorId,
8488
+ createdAt: patch.createdAt,
8489
+ baseSha: patch.baseSha,
8490
+ coreVersion: patch.coreVersion
8491
+ }))
8492
+ });
8493
+ }
8494
+ async getCommitModules(commitSha, options) {
8495
+ const query = new URLSearchParams();
8496
+ if (options !== null && options !== void 0 && options.asOf) {
8497
+ query.set("as_of", "1");
8498
+ }
8499
+ if (options !== null && options !== void 0 && options.moduleFilePath) {
8500
+ query.set("path", options.moduleFilePath);
8501
+ }
8502
+ const search = query.toString();
8503
+ const res = await this.getHistory(`/commits/${commitSha}/modules${search ? `?${search}` : ""}`, CommitModulesResponse, commitSha);
8504
+ if (fp.result.isErr(res)) {
8505
+ return res;
8506
+ }
8507
+ return fp.result.ok({
8508
+ modules: res.value.modules.map(module => ({
8509
+ ...module,
8510
+ moduleFilePath: module.moduleFilePath
8511
+ })),
8512
+ // Absent from an older content server, which only ever answered "what
8513
+ // this commit changed" - and that answer is always whole.
8514
+ complete: res.value.complete ?? !(options !== null && options !== void 0 && options.asOf)
8515
+ });
8516
+ }
8517
+ async getCommitAffectedFiles(commitSha) {
8518
+ const res = await this.getHistory(`/commits/${commitSha}/affected-files`, CommitAffectedFilesResponse, commitSha);
8519
+ if (fp.result.isErr(res)) {
8520
+ return res;
8521
+ }
8522
+ return fp.result.ok(res.value.files);
8523
+ }
8524
+ async getFileAtCommit(commitSha, filePath, remote) {
8525
+ const params = new URLSearchParams({
8526
+ path: filePath
8527
+ });
8528
+ if (remote) {
8529
+ params.set("remote", "true");
8530
+ }
8531
+ let res;
8532
+ try {
8533
+ res = await fetch(`${this.contentUrl}/v1/${this.project}/commits/${commitSha}/file?${params}`, {
8534
+ headers: {
8535
+ ...this.authHeaders
8536
+ }
8537
+ });
8538
+ } catch (err) {
8539
+ return fp.result.err({
8540
+ kind: "transport",
8541
+ message: err instanceof Error ? err.message : String(err)
8542
+ });
8543
+ }
8544
+ if (!res.ok) {
8545
+ // A missing file is about the FILE, not the commit: the commit may be
8546
+ // perfectly readable and this one blob gone. Saying "commit not found"
8547
+ // here would send a caller looking in the wrong place.
8548
+ return fp.result.err({
8549
+ kind: "file-unavailable",
8550
+ gitPath: filePath,
8551
+ message: `${res.status} ${res.statusText}`
8552
+ });
8553
+ }
8554
+ return fp.result.ok(Buffer.from(await res.arrayBuffer()));
8555
+ }
8556
+ // #endregion history
8557
+ }
8558
+
8559
+ /**
8560
+ * Everything that can go wrong reading, replaying or restoring history.
8561
+ *
8562
+ * There is a lot of it, which is the point of making it one closed union rather
8563
+ * than strings: reading a commit means reaching a content host, finding the
8564
+ * commit, reading what was recorded for each of its modules, and asking whether
8565
+ * this version of Val can make sense of the schema it was stored under. Each of
8566
+ * those fails differently, and a caller deciding what to SHOW needs to know
8567
+ * which.
8568
+ *
8569
+ * Every member has a producer. The union once carried five more - a module
8570
+ * removed from the project, an op that would not replay, a value that no longer
8571
+ * fits today's schema, a field the schema no longer defines, and ops from a
8572
+ * core version known to replay wrongly. All five belonged to reconstructing a
8573
+ * commit by replaying patches against a parsed source; storing the data ended
8574
+ * that, and a member nothing can produce is a state the Studio writes handling
8575
+ * for and never sees.
8576
+ *
8577
+ * ## The rule
8578
+ *
8579
+ * Whole-commit failures are the `err` channel. Per-module and per-patch
8580
+ * failures ride along inside the `ok` payload.
8581
+ *
8582
+ * A commit that cannot be found or read at all has nothing to show. But one
8583
+ * unparseable module out of ten does not make the other nine unreadable, and
8584
+ * collapsing the whole view because of it would hide exactly the information
8585
+ * someone needs to see - that this module is the broken one.
8586
+ */
8587
+
8588
+ function historyErrorMessage(error) {
8589
+ switch (error.kind) {
8590
+ case "commit-not-found":
8591
+ return `No commit ${error.commitSha} created by Val in this project`;
8592
+ case "archive-unreadable":
8593
+ return `Could not read the stored record of commit ${error.commitSha}: ${error.message}`;
8594
+ case "source-unavailable":
8595
+ return `No stored source for ${error.moduleFilePath} at this commit (it predates history being recorded)`;
8596
+ case "schema-unreadable":
8597
+ return `The schema stored for ${error.moduleFilePath} at this commit is not one this version of Val can read: ${error.message}`;
8598
+ case "file-unavailable":
8599
+ return `Could not read ${error.gitPath} at this commit: ${error.message}`;
8600
+ case "not-supported-in-fs-mode":
8601
+ return "History is only available for projects connected to Val's content service";
8602
+ case "transport":
8603
+ return `Could not reach the content service: ${error.message}`;
8604
+ }
8605
+ }
8606
+
8607
+ /**
8608
+ * Everything the content service knows about one commit, in one step.
8609
+ *
8610
+ * Three endpoints, requested together rather than in sequence: they are
8611
+ * independent, they all resolve from the same stored record, and the round
8612
+ * trips are what a user waits through when opening a commit.
8613
+ *
8614
+ * The commit itself comes from the patches call, which is the one that fails
8615
+ * usefully when the commit does not exist.
8616
+ */
8617
+ async function fetchCommitRecord(ops, commitSha) {
8618
+ const [patchesRes, modulesRes, filesRes] = await Promise.all([ops.getCommitPatches(commitSha), ops.getCommitModules(commitSha), ops.getCommitAffectedFiles(commitSha)]);
8619
+ if (fp.result.isErr(patchesRes)) {
8620
+ return patchesRes;
8621
+ }
8622
+ if (fp.result.isErr(modulesRes)) {
8623
+ return modulesRes;
8624
+ }
8625
+ if (fp.result.isErr(filesRes)) {
8626
+ return filesRes;
8627
+ }
8628
+ return fp.result.ok({
8629
+ commit: patchesRes.value.commit,
8630
+ patches: patchesRes.value.patches,
8631
+ modules: modulesRes.value.modules,
8632
+ affectedFiles: filesRes.value
8633
+ });
8634
+ }
8635
+
8636
+ /**
8637
+ * The `*.val.json` entries a commit touched, read from git at that commit.
8638
+ *
8639
+ * Not stored in the commit record, unlike the `.val.ts` sources: an entry is a
8640
+ * plain JSON file that git has at every commit, so a second copy could only
8641
+ * drift from the first. A `.val.ts` is different - it is what a restore
8642
+ * replays patches against, and reading it from git would make every look at
8643
+ * history depend on the repository still being there.
8644
+ *
8645
+ * Reads at the commit itself rather than its parent: an entry's content AT a
8646
+ * commit is what that commit produced, which is the "after" side. The "before"
8647
+ * side is the same file at the parent commit, and a caller that wants it asks
8648
+ * for the parent.
8649
+ */
8650
+ async function resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles) {
8651
+ const entryPaths = affectedFiles.filter(file => file.kind === "json-entry")
8652
+ // A deleted entry has no content at this commit; asking for it would be a
8653
+ // guaranteed 404 reported as a failure, which is noise rather than news.
8654
+ .filter(file => file.change !== "deleted").map(file => file.gitPath);
8655
+ if (entryPaths.length === 0) {
8656
+ return fp.result.ok({
8657
+ entries: {},
8658
+ failures: []
8659
+ });
8660
+ }
8661
+ const entries = {};
8662
+ const failures = [];
8663
+ const fetched = await Promise.all(entryPaths.map(async gitPath => ({
8664
+ gitPath,
8665
+ res: await ops.getFileAtCommit(commitSha, gitPath, false)
8666
+ })));
8667
+ for (const {
8668
+ gitPath,
8669
+ res
8670
+ } of fetched) {
8671
+ if (fp.result.isErr(res)) {
8672
+ failures.push({
8673
+ kind: "file-unavailable",
8674
+ gitPath,
8675
+ message: res.error.kind === "file-unavailable" ? res.error.message : `could not read entry: ${res.error.kind}`
8676
+ });
8677
+ continue;
8678
+ }
8679
+ try {
8680
+ entries[gitPath] = JSON.parse(res.value.toString("utf-8"));
8681
+ } catch (err) {
8682
+ failures.push({
8683
+ kind: "file-unavailable",
8684
+ gitPath,
8685
+ message: `not valid JSON at this commit: ${err instanceof Error ? err.message : String(err)}`
8686
+ });
8687
+ }
8688
+ }
8689
+ return fp.result.ok({
8690
+ entries,
8691
+ failures
8692
+ });
8693
+ }
8694
+
8695
+ /**
8696
+ * Turn a commit's binary files into descriptors, WITHOUT fetching any of them.
8697
+ *
8698
+ * Pure, and that is the whole design. Showing that a commit changed six images
8699
+ * needs six rows, not six downloads; only an `<img>` that actually mounts pays
8700
+ * for its bytes. `url` points at the app's own history file route, which is
8701
+ * immutable for a given commit, so the browser's HTTP cache handles flipping
8702
+ * between commits with no JS cache at all.
8703
+ */
8704
+ function describeBinaryFilesAtCommit(commitSha, affectedFiles, /** Where the app serves `/api/val` from, e.g. "/api/val". */
8705
+ apiBasePath) {
8706
+ const refs = [];
8707
+ for (const file of affectedFiles) {
8708
+ if (file.kind === "remote-binary") {
8709
+ refs.push({
8710
+ gitPath: file.ref,
8711
+ change: file.change,
8712
+ remote: true,
8713
+ url: historyFileUrl(apiBasePath, commitSha, file.ref, true)
8714
+ });
8715
+ continue;
8716
+ }
8717
+ if (file.kind !== "binary") {
8718
+ continue;
8719
+ }
8720
+ refs.push({
8721
+ gitPath: file.gitPath,
8722
+ change: file.change,
8723
+ remote: false,
8724
+ url: historyFileUrl(apiBasePath, commitSha, file.gitPath, false)
8725
+ });
8726
+ }
8727
+ return refs;
8728
+ }
8729
+ function historyFileUrl(apiBasePath, commitSha, filePath, remote) {
8730
+ const params = new URLSearchParams({
8731
+ commit_sha: commitSha,
8732
+ path: filePath
8733
+ });
8734
+ if (remote) {
8735
+ params.set("remote", "true");
8736
+ }
8737
+ return `${apiBasePath}/history/files?${params.toString()}`;
8738
+ }
8739
+
8740
+ /**
8741
+ * The paths a commit's ops touched, from the ops themselves.
8742
+ *
8743
+ * The alternative was reconstructing the module's previous state and diffing it
8744
+ * against the new one - which needed the pre-commit `.val.ts`, a static parse
8745
+ * of it, and a replay of every op, three fragile steps to rediscover something
8746
+ * the ops already say outright. An op carries the path it applies to. That is
8747
+ * the answer.
8748
+ *
8749
+ * Deduplicated and returned in first-touched order, because a module edited
8750
+ * eight times in one publish should list a field once, where it first changed.
8751
+ */
8752
+ function changedPathsOf(moduleFilePath, patches) {
8753
+ const seen = new Set();
8754
+ const paths = [];
8755
+ for (const {
8756
+ patch
8757
+ } of patches) {
8758
+ for (const op of patch) {
8759
+ // A `file` op names a file, not a place in the source; the `replace` that
8760
+ // points the field at it is in the same patch and is the real change.
8761
+ if (op.op === "file") {
8762
+ continue;
8763
+ }
8764
+ /*
8765
+ * The canonical patch-path-to-source-path conversion, not a hand-rolled
8766
+ * join.
8767
+ *
8768
+ * `createValPathOfItem` JSON-quotes the ONE key it is handed, so joining
8769
+ * the segments with "." first produced `?p="teddy.name"` — a single
8770
+ * segment with a dot in its name — rather than `?p="teddy"."name"`. Only
8771
+ * top-level fields came out right, a record key containing a dot was
8772
+ * split in the other direction, and an op at the module root became
8773
+ * `?p=""`.
8774
+ *
8775
+ * `patchPathToModulePath` is what the rest of the Studio produces and
8776
+ * parses, integer segments left unquoted (`"items".2."label"`).
8777
+ */
8778
+ const sourcePath = op.path.length === 0 ? moduleFilePath : core.Internal.joinModuleFilePathAndModulePath(moduleFilePath, core.Internal.patchPathToModulePath(op.path));
8779
+ if (!seen.has(sourcePath)) {
8780
+ seen.add(sourcePath);
8781
+ paths.push(sourcePath);
8782
+ }
8783
+ }
8784
+ }
8785
+ return paths;
8786
+ }
8787
+
8788
+ /**
8789
+ * Reconstruct one commit: how each module looked before it, and after it.
8790
+ *
8791
+ * Deliberately says NOTHING about the current source or the current schema. For
8792
+ * a given commit sha this can never change, which is what lets its result be
8793
+ * cached forever and what makes flipping between commits cheap. The comparison
8794
+ * against today is `compareWithCurrent`, and it is deliberately the cheap half.
8795
+ *
8796
+ * Nothing is parsed and nothing is replayed: the module's data was recorded at
8797
+ * the commit, so reading it back is a read. What IS done here is validating the
8798
+ * stored schema against this version of Val, because that is the one thing the
8799
+ * content service could not check for us.
8800
+ *
8801
+ * Failures are collected per module, not thrown - one module this Val cannot
8802
+ * read leaves the other nine readable, and knowing WHICH one is the thing
8803
+ * someone opening history actually needs. Only a commit that cannot be read at
8804
+ * all is an `err`.
8805
+ */
8806
+ async function getHistoricalPatchSet(ops, commitSha, options) {
8807
+ const recordRes = await fetchCommitRecord(ops, commitSha);
8808
+ if (fp.result.isErr(recordRes)) {
8809
+ return recordRes;
8810
+ }
8811
+ const {
8812
+ commit,
8813
+ patches,
8814
+ modules: stored,
8815
+ affectedFiles
8816
+ } = recordRes.value;
8817
+ const warnings = [];
8818
+
8819
+ // Group the commit's patches by module. Not to replay them - the stored
8820
+ // source already IS the result - but because the ops name the paths this
8821
+ // commit touched, which is what the view highlights.
8822
+ const patchesByModule = new Map();
8823
+ for (const patch of patches) {
8824
+ const existing = patchesByModule.get(patch.moduleFilePath) ?? [];
8825
+ existing.push({
8826
+ patchId: patch.patchId,
8827
+ coreVersion: patch.coreVersion,
8828
+ // The wire type is `unknown` because the content service does not know
8829
+ // Val's patch type; it has been validated on the way out of the archive.
8830
+ patch: patch.patch
8831
+ });
8832
+ patchesByModule.set(patch.moduleFilePath, existing);
8833
+ }
8834
+ const storedByPath = new Map(stored.map(module => [module.moduleFilePath, module]));
8835
+
8836
+ // Every module this commit touched: one it patched, and one we hold a stored
8837
+ // version of. Usually the same set - but a commit made by a Val too old to
8838
+ // send its modules has the first and not the second, and that has to read as
8839
+ // `source-unavailable` rather than as "no modules changed".
8840
+ const touched = new Set([...patchesByModule.keys(), ...storedByPath.keys()]);
8841
+ const modules = {};
8842
+ for (const moduleFilePath of touched) {
8843
+ const failures = [];
8844
+ const modulePatches = patchesByModule.get(moduleFilePath) ?? [];
8845
+ const patchIds = modulePatches.map(patch => patch.patchId);
8846
+ const changedPaths = changedPathsOf(moduleFilePath, modulePatches);
8847
+ const version = storedByPath.get(moduleFilePath);
8848
+ if (version === undefined || version.unavailable) {
8849
+ failures.push({
8850
+ kind: "source-unavailable",
8851
+ moduleFilePath
8852
+ });
8853
+ modules[moduleFilePath] = {
8854
+ source: null,
8855
+ schema: null,
8856
+ patchIds,
8857
+ changedPaths,
8858
+ failures
8859
+ };
8860
+ continue;
8861
+ }
8862
+
8863
+ /*
8864
+ * The schema is validated here, and nowhere earlier.
8865
+ *
8866
+ * It was stored as written and served back opaquely, so this is the first
8867
+ * place that knows what a schema is supposed to look like. A failure is
8868
+ * NOT damage and not anyone's mistake - Val's schema format is allowed to
8869
+ * move, and an older project opened in a newer Val (or the reverse) lands
8870
+ * exactly here. The module reads as one this version cannot show, and the
8871
+ * rest of the commit is unaffected.
8872
+ */
8873
+ const schemaRes = internal.SerializedSchema.safeParse(version.schema);
8874
+ if (!schemaRes.success) {
8875
+ failures.push({
8876
+ kind: "schema-unreadable",
8877
+ moduleFilePath,
8878
+ message: zodValidationError.fromError(schemaRes.error).toString()
8879
+ });
8880
+ modules[moduleFilePath] = {
8881
+ source: version.source,
8882
+ schema: null,
8883
+ patchIds,
8884
+ changedPaths,
8885
+ failures
8886
+ };
8887
+ continue;
8888
+ }
8889
+ modules[moduleFilePath] = {
8890
+ source: version.source,
8891
+ schema: schemaRes.data,
8892
+ patchIds,
8893
+ changedPaths,
8894
+ failures
8895
+ };
8896
+ }
8897
+ const entriesRes = await resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles);
8898
+ let jsonEntries = {};
8899
+ if (fp.result.isErr(entriesRes)) {
8900
+ // Entry contents are supporting detail, not the commit. Losing them should
8901
+ // narrow what can be shown, not hide the commit entirely.
8902
+ warnings.push(entriesRes.error);
8903
+ } else {
8904
+ jsonEntries = entriesRes.value.entries;
8905
+ warnings.push(...entriesRes.value.failures);
8906
+ }
8907
+ return fp.result.ok({
8908
+ commit,
8909
+ modules,
8910
+ patches,
8911
+ jsonEntries,
8912
+ binaryFiles: describeBinaryFilesAtCommit(commitSha, affectedFiles, (options === null || options === void 0 ? void 0 : options.apiBasePath) ?? "/api/val"),
8913
+ warnings
8914
+ });
8915
+ }
8916
+
8917
+ /**
8918
+ * One module, as of a commit — including a commit that never touched it.
8919
+ *
8920
+ * `getHistoricalPatchSet` answers "what did this commit change", which is what
8921
+ * a commit IS. This answers "how did this module look at that point", which is
8922
+ * what someone comparing two panes is asking as soon as they navigate off the
8923
+ * changed set — and without it the history pane can only show a handful of
8924
+ * modules per commit.
8925
+ *
8926
+ * `null` means history has no record at or before that commit: the module was
8927
+ * last edited before recording started, or never. That is deliberately NOT the
8928
+ * same as a module the commit deleted, which is a recorded fact with a source
8929
+ * of `null`, and not the same as an error.
8930
+ */
8931
+ async function getModuleAtCommit(ops, commitSha, moduleFilePath) {
8932
+ const res = await ops.getCommitModules(commitSha, {
8933
+ asOf: true,
8934
+ moduleFilePath
8935
+ });
8936
+ if (fp.result.isErr(res)) {
8937
+ return res;
8938
+ }
8939
+ const version = res.value.modules.find(module => module.moduleFilePath === moduleFilePath);
8940
+ if (version === undefined) {
8941
+ return fp.result.ok(null);
8942
+ }
8943
+ if (version.unavailable) {
8944
+ return fp.result.ok({
8945
+ source: null,
8946
+ schema: null,
8947
+ patchIds: [],
8948
+ changedPaths: [],
8949
+ failures: [{
8950
+ kind: "source-unavailable",
8951
+ moduleFilePath
8952
+ }]
8953
+ });
8954
+ }
8955
+ // Validated here, not at the transport boundary, for the same reason as in
8956
+ // `getHistoricalPatchSet`: a schema this Val cannot read is one module it
8957
+ // cannot show, not a failed request.
8958
+ const schemaRes = internal.SerializedSchema.safeParse(version.schema);
8959
+ if (!schemaRes.success) {
8960
+ return fp.result.ok({
8961
+ source: version.source,
8962
+ schema: null,
8963
+ patchIds: [],
8964
+ changedPaths: [],
8965
+ failures: [{
8966
+ kind: "schema-unreadable",
8967
+ moduleFilePath,
8968
+ message: zodValidationError.fromError(schemaRes.error).toString()
8969
+ }]
8970
+ });
8971
+ }
8972
+ return fp.result.ok({
8973
+ source: version.source,
8974
+ schema: schemaRes.data,
8975
+ // Empty, and correct: this module may well have been changed by an EARLIER
8976
+ // commit than the one asked about, and those are that commit's patches.
8977
+ patchIds: [],
8978
+ changedPaths: [],
8979
+ failures: []
8980
+ });
8981
+ }
8982
+
8983
+ const host = process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
8984
+ const SettingsSchema = z.z.object({
8985
+ publicProjectId: z.z.string(),
8986
+ remoteFileBuckets: z.z.array(z.z.object({
8987
+ bucket: z.z.string()
8988
+ }))
8989
+ });
8990
+ async function getSettings(projectName, auth) {
8991
+ try {
8992
+ const response = await fetch(`${host}/v1/${projectName}/settings`, {
8993
+ headers: "pat" in auth ? {
8994
+ "x-val-pat": auth.pat,
8995
+ "Content-Type": "application/json"
8996
+ } : {
8997
+ Authorization: `Bearer ${auth.apiKey}`,
8998
+ "Content-Type": "application/json"
8999
+ }
9000
+ });
9001
+ if (response.status === 404) {
9002
+ return {
9003
+ success: false,
9004
+ message: `Project '${projectName}' not found: verify that the name of the project is correct and that you have access to it.`
9005
+ };
9006
+ }
9007
+ if (response.status !== 200) {
9008
+ return {
9009
+ success: false,
9010
+ message: `Failed to get project id: ${response.statusText}`
9011
+ };
9012
+ }
9013
+ const json = await response.json();
9014
+ const parseRes = SettingsSchema.safeParse(json);
9015
+ if (!parseRes.success) {
9016
+ return {
9017
+ success: false,
9018
+ message: `Failed to parse settings data: ${parseRes.error.message}`
9019
+ };
9020
+ }
9021
+ return {
9022
+ success: true,
9023
+ data: parseRes.data
9024
+ };
9025
+ } catch {
9026
+ return {
9027
+ success: false,
9028
+ message: `Failed to get project id. Check network connection and try again.`
9029
+ };
9030
+ }
9031
+ }
9032
+
9033
+ /**
9034
+ * Resolving how Val is configured, and building the data layer from it.
9035
+ *
9036
+ * Both live here rather than inside `createValApiRouter` because the MCP tool
9037
+ * registry needs exactly the same answers: which mode we are in, which
9038
+ * credential to use, and which `ValOps` implementation that implies. Two copies
9039
+ * of this would drift, and the failure would be quiet — a registry that decides
9040
+ * it is in fs mode while the Studio decides it is in proxy mode reads different
9041
+ * content from the same project.
9042
+ *
9043
+ * The type-only import from `./ValRouter` is deliberate: it keeps `ValApiOptions`
9044
+ * where its documentation lives without creating a runtime cycle.
9045
+ *
9046
+ * The credential-bearing URL check at the bottom of this file lives here for the
9047
+ * same reason: it is a check on the URLs `initHandlerOptions` resolves, and its
9048
+ * only caller is that function. Leaving it in `./ValRouter` would have meant
9049
+ * importing it back from there, which is the runtime cycle the paragraph above
9050
+ * exists to avoid.
9051
+ */
9052
+
9053
+ const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
9054
+
8022
9055
  /**
8023
9056
  * Resolve options plus environment into a concrete {@link ValServerConfig}.
8024
9057
  *
@@ -8102,31 +9135,39 @@ async function initHandlerOptions(route, opts, config) {
8102
9135
  /**
8103
9136
  * Build the data layer a {@link ValServerConfig} calls for.
8104
9137
  *
8105
- * `auth` decides *whose* credential the http backend sees. Left out, it is the
8106
- * app's own API key — which is what the Studio wants, because there the app has
8107
- * already verified a session cookie and is acting on the user's behalf under its
8108
- * own authority.
9138
+ * The http backend always sees the app's own API key. That is what the Studio
9139
+ * wants — there the app has already verified a session cookie and is acting on
9140
+ * the user's behalf under its own authority — and it is now the only shape:
9141
+ * every caller that reaches here has been authenticated by the app itself, so
9142
+ * there is no request left on which the app is a pipe rather than an authority.
8109
9143
  *
8110
- * A caller acting for a user it has *not* authenticated itself must pass that
8111
- * user's personal access token instead, so the backend is the one that decides
8112
- * what the caller may do. That is the whole of `docs/plans/mcp.md` D.2: the app
8113
- * stops being an authority and goes back to being a pipe. Passing the app's API
8114
- * key on such a request is the D.6 confused deputy, and it is worth being blunt
8115
- * about why it is tempting — it works, and it works for every project the key
8116
- * can reach, including the ones the caller cannot.
9144
+ * This took a parameter for the other case: a caller acting for a user it had
9145
+ * *not* authenticated passed that user's personal access token, and the backend
9146
+ * decided what the caller could do. `ValOpsHttp` still accepts such a token —
9147
+ * the CLI's `debug` command uses the developer's own from `val login` — but no
9148
+ * server request builds one any more, because a request the app cannot
9149
+ * authenticate is now refused instead of relayed.
9150
+ *
9151
+ * What has not changed is why the API key must never stand in for a credential
9152
+ * that was merely *absent*: it works, and it works for every project the key
9153
+ * can reach, including the ones the caller cannot. Callers are refused for a
9154
+ * missing credential well before this point.
8117
9155
  */
8118
- function createValOps(valModules, options, auth) {
9156
+ function createValOps(valModules, options) {
8119
9157
  if (options.mode === "fs") {
8120
9158
  // No credential in fs mode: this reads and writes the developer's own
8121
- // working tree, and there is no backend to authenticate to. A PAT handed in
8122
- // here is not ignored quietly — the caller is told, in createValTools.
9159
+ // working tree, and there is no backend to authenticate to. A credential
9160
+ // that arrives for such a project is not ignored quietly — the caller is
9161
+ // told, by `createValTools` when the project is configured for oauth and by
9162
+ // `initValMcp` when it is not, since with no issuer there is no verified
9163
+ // credential left for the registry to see.
8123
9164
  return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
8124
9165
  formatter: options.formatter,
8125
9166
  config: options.config
8126
9167
  });
8127
9168
  }
8128
9169
  if (options.mode === "http") {
8129
- return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, auth ?? {
9170
+ return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
8130
9171
  apiKey: options.apiKey
8131
9172
  }, valModules, {
8132
9173
  formatter: options.formatter,
@@ -8748,90 +9789,6 @@ const ValServer = (valModules, options, callbacks) => {
8748
9789
  };
8749
9790
  }
8750
9791
  },
8751
- "/session": {
8752
- GET: async req => {
8753
- const cookies = req.cookies;
8754
- if (serverOps instanceof ValOpsFS) {
8755
- return {
8756
- status: 200,
8757
- json: {
8758
- mode: "local",
8759
- enabled: await callbacks.isEnabled()
8760
- }
8761
- };
8762
- }
8763
- if (!options.project) {
8764
- return {
8765
- status: 500,
8766
- json: {
8767
- message: "Project is not set"
8768
- }
8769
- };
8770
- }
8771
- if (!options.valSecret) {
8772
- return {
8773
- status: 500,
8774
- json: {
8775
- message: "Secret is not set"
8776
- }
8777
- };
8778
- }
8779
- return withAuth(options.valSecret, cookies, "session", async data => {
8780
- if (!options.valBuildUrl) {
8781
- return {
8782
- status: 500,
8783
- json: {
8784
- message: "Val is not correctly setup. Build url is missing"
8785
- }
8786
- };
8787
- }
8788
- const url = new URL(`/api/val/${options.project}/auth/session`, options.valBuildUrl);
8789
- const fetchRes = await fetch(url, {
8790
- headers: getAuthHeaders(data.token, "application/json")
8791
- });
8792
- if (fetchRes.status === 200) {
8793
- const json = z.z.object({
8794
- member_role: z.z.union([z.z.literal("owner"), z.z.literal("developer"), z.z.literal("editor")]).optional(),
8795
- id: z.z.string(),
8796
- full_name: z.z.string().optional(),
8797
- username: z.z.string().optional(),
8798
- avatar_url: z.z.string().optional()
8799
- }).safeParse(await fetchRes.json());
8800
- if (json.success) {
8801
- return {
8802
- status: fetchRes.status,
8803
- json: {
8804
- mode: "proxy",
8805
- enabled: await callbacks.isEnabled(),
8806
- ...json.data
8807
- }
8808
- };
8809
- } else {
8810
- const message = internal.getErrorMessageFromUnknownJson(json, "Could not parse session response. Unexpected error (no error message). Status: " + fetchRes.status);
8811
- return {
8812
- status: 500,
8813
- json: {
8814
- message: message,
8815
- ...json
8816
- }
8817
- };
8818
- }
8819
- } else {
8820
- const json = z.z.object({
8821
- message: z.z.string()
8822
- }).safeParse(await fetchRes.json());
8823
- const message = internal.getErrorMessageFromUnknownJson(json, "Unknown error");
8824
- return {
8825
- status: fetchRes.status,
8826
- json: {
8827
- message: message,
8828
- ...json
8829
- }
8830
- };
8831
- }
8832
- });
8833
- }
8834
- },
8835
9792
  "/logout": {
8836
9793
  GET: async req => {
8837
9794
  const query = req.query;
@@ -11045,11 +12002,13 @@ const ValServer = (valModules, options, callbacks) => {
11045
12002
  }
11046
12003
  };
11047
12004
  }
11048
- const arrayBuffer = await binaryRes.arrayBuffer();
11049
- const base64 = Buffer.from(arrayBuffer).toString("base64");
11050
- const dataUrl = `data:${file.metadata.mimeType};base64,${base64}`;
12005
+ // Bytes straight through. This used to base64 the buffer,
12006
+ // wrap it in a data: URL, and hand that to a method whose first
12007
+ // act was to unwrap it again - ceremony to satisfy a convention
12008
+ // that no longer exists.
12009
+ const bytes = Buffer.from(await binaryRes.arrayBuffer());
11051
12010
  const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
11052
- const saveRes = await serverOps.saveBase64EncodedBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, dataUrl, type, file.metadata);
12011
+ const saveRes = await serverOps.saveBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, bytes, file.metadata.mimeType, type, file.metadata);
11053
12012
  if (saveRes.error) {
11054
12013
  return {
11055
12014
  status: 500,
@@ -11177,6 +12136,137 @@ const ValServer = (valModules, options, callbacks) => {
11177
12136
  }
11178
12137
  },
11179
12138
  //#region files
12139
+ // #region history
12140
+ "/history/commits": {
12141
+ GET: async req => {
12142
+ const auth = getAuth(req.cookies);
12143
+ if (auth.error) {
12144
+ return {
12145
+ status: 401,
12146
+ json: {
12147
+ message: auth.error
12148
+ }
12149
+ };
12150
+ }
12151
+ const res = await serverOps.listCommits(req.query.branch, {
12152
+ limit: req.query.limit,
12153
+ cursor: req.query.cursor
12154
+ });
12155
+ if (fp.result.isErr(res)) {
12156
+ return historyErrorResponse(res.error);
12157
+ }
12158
+ return {
12159
+ status: 200,
12160
+ json: res.value,
12161
+ // The head moves, so a listing is never reusable.
12162
+ headers: {
12163
+ "Cache-Control": "no-store"
12164
+ }
12165
+ };
12166
+ }
12167
+ },
12168
+ "/history/commit": {
12169
+ GET: async req => {
12170
+ const auth = getAuth(req.cookies);
12171
+ if (auth.error) {
12172
+ return {
12173
+ status: 401,
12174
+ json: {
12175
+ message: auth.error
12176
+ }
12177
+ };
12178
+ }
12179
+ const res = await getHistoricalPatchSet(serverOps, req.query.commit_sha);
12180
+ if (fp.result.isErr(res)) {
12181
+ return historyErrorResponse(res.error);
12182
+ }
12183
+ /*
12184
+ * Immutable ONLY when nothing in the answer was transient.
12185
+ *
12186
+ * What a commit recorded cannot change, so a clean answer is reusable
12187
+ * forever - that is what makes comparing many commits cheap. But a
12188
+ * module marked `unavailable` means a blob fetch failed, and a warning
12189
+ * means an entry read against GitHub did; both can succeed on the next
12190
+ * try. Cached for a year, one flaky read would become "nothing was
12191
+ * recorded for this module" for the rest of the session and beyond.
12192
+ *
12193
+ * `private`, not `public`: this route is behind the session cookie, and
12194
+ * a shared cache holding it would hand one project's module sources to
12195
+ * whoever asked next. The browser's own cache is where the reuse was
12196
+ * wanted anyway.
12197
+ */
12198
+ const settled = res.value.warnings.length === 0 && Object.values(res.value.modules).every(module => module.failures.length === 0);
12199
+ return {
12200
+ status: 200,
12201
+ json: res.value,
12202
+ headers: {
12203
+ "Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
12204
+ }
12205
+ };
12206
+ }
12207
+ },
12208
+ "/history/module": {
12209
+ GET: async req => {
12210
+ const auth = getAuth(req.cookies);
12211
+ if (auth.error) {
12212
+ return {
12213
+ status: 401,
12214
+ json: {
12215
+ message: auth.error
12216
+ }
12217
+ };
12218
+ }
12219
+ const res = await getModuleAtCommit(serverOps, req.query.commit_sha, req.query.module_file_path);
12220
+ if (fp.result.isErr(res)) {
12221
+ return historyErrorResponse(res.error);
12222
+ }
12223
+ // Immutable only when the answer settled — a module reported
12224
+ // unavailable means a blob fetch failed and may succeed next time, and
12225
+ // caching that for a year turns one flaky read into a permanent gap.
12226
+ const settled = res.value === null || res.value.failures.length === 0;
12227
+ return {
12228
+ status: 200,
12229
+ json: {
12230
+ moduleFilePath: req.query.module_file_path,
12231
+ module: res.value
12232
+ },
12233
+ headers: {
12234
+ "Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
12235
+ }
12236
+ };
12237
+ }
12238
+ },
12239
+ "/history/files": {
12240
+ GET: async req => {
12241
+ // No auth, for the same reason /files has none: this is served to an
12242
+ // <img> that the app's own backend may fetch during image
12243
+ // optimisation, with no cookies. What it exposes is a file at a commit
12244
+ // that is already in the repository.
12245
+ const res = await serverOps.getFileAtCommit(req.query.commit_sha, req.query.path, req.query.remote === "true");
12246
+ if (fp.result.isErr(res)) {
12247
+ const response = historyErrorResponse(res.error);
12248
+ // The stream branch of this route's response union has no json, so
12249
+ // narrow to the shapes it does allow.
12250
+ if (response.status === 401 || response.status === 404) {
12251
+ return response;
12252
+ }
12253
+ return {
12254
+ status: 400,
12255
+ json: response.json
12256
+ };
12257
+ }
12258
+ return {
12259
+ status: 200,
12260
+ headers: {
12261
+ "Content-Type": guessMimeTypeFromPath(req.query.path) ?? "application/octet-stream",
12262
+ // A file at a fixed commit cannot change.
12263
+ "Cache-Control": "public, max-age=31536000, immutable"
12264
+ },
12265
+ body: bufferToReadableStream(res.value)
12266
+ };
12267
+ }
12268
+ },
12269
+ // #endregion history
11180
12270
  "/files": {
11181
12271
  GET: async req => {
11182
12272
  const query = req.query;
@@ -11675,6 +12765,45 @@ const ENABLE_COOKIE_VALUE = {
11675
12765
  }
11676
12766
  };
11677
12767
  const chunkSize = 1024 * 1024;
12768
+
12769
+ /**
12770
+ * Turn a HistoryError into the HTTP answer it deserves.
12771
+ *
12772
+ * The `kind` travels in the body alongside the message, because the Studio
12773
+ * decides what to OFFER from it - "cannot restore this field" and "cannot
12774
+ * restore this module at all" are different affordances, and a rendered string
12775
+ * cannot be told apart.
12776
+ */
12777
+ function historyErrorResponse(error) {
12778
+ const message = historyErrorMessage(error);
12779
+ switch (error.kind) {
12780
+ case "commit-not-found":
12781
+ return {
12782
+ status: 404,
12783
+ json: {
12784
+ message
12785
+ }
12786
+ };
12787
+ case "not-supported-in-fs-mode":
12788
+ // Not an error in the request - this deployment simply has no history
12789
+ // service. 400 rather than 500 so it does not read as a bug.
12790
+ return {
12791
+ status: 400,
12792
+ json: {
12793
+ message,
12794
+ kind: error.kind
12795
+ }
12796
+ };
12797
+ default:
12798
+ return {
12799
+ status: 500,
12800
+ json: {
12801
+ message,
12802
+ kind: error.kind
12803
+ }
12804
+ };
12805
+ }
12806
+ }
11678
12807
  function bufferToReadableStream(buffer) {
11679
12808
  const stream = new ReadableStream({
11680
12809
  start(controller) {
@@ -12755,21 +13884,22 @@ async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
12755
13884
  }
12756
13885
 
12757
13886
  /**
12758
- * Null on the PAT path, and the verified profile on the token path.
13887
+ * The verified profile, or null when there was nothing to verify.
12759
13888
  *
12760
- * The PAT case is unchanged and still deliberate: the app cannot resolve a
12761
- * PAT, so any id it wrote here would be an unverified claim dressed up as a
12762
- * checked one — and the request already carries the caller's own token, which
12763
- * is a better answer to "who did this" than anything the app could assert.
12764
- * Attributing that patch is the backend's job.
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.
12765
13896
  *
12766
- * The token case is the opposite situation, which is why it gets the opposite
12767
- * answer. The host verified a signature over a key it does not hold, so the
12768
- * profile is checked rather than claimed, and the backend has no token of its
12769
- * own to attribute from — the call reaches it under the app's API key. If this
12770
- * stayed null, every edit made through a signed-in editor's own session would
12771
- * land with no author at all, which is worse than useless on a CMS whose
12772
- * review screen is organised by who changed what.
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.
12773
13903
  */
12774
13904
  const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
12775
13905
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -13027,14 +14157,17 @@ function rejectFileOps(patch) {
13027
14157
  */
13028
14158
 
13029
14159
  /**
13030
- * How the caller was established, and it is a union because there are two
13031
- * genuinely different answers — with different consequences downstream.
14160
+ * How the caller was established, and there is one acceptable answer: the host
14161
+ * **checked a signature**.
13032
14162
  *
13033
- * The distinction that matters is **who checked**. A PAT is forwarded to the
13034
- * backend unchecked, because the app cannot resolve one; an access token is
13035
- * verified by the app itself, against a public key it does not hold and
13036
- * therefore cannot forge. The first is a credential being relayed. The second
13037
- * is a signature that has already been checked.
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.
13038
14171
  */
13039
14172
 
13040
14173
  /**
@@ -13069,17 +14202,6 @@ const VAL_SCOPE_READ = "val:read";
13069
14202
  /** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
13070
14203
  const VAL_SCOPE_WRITE = "val:write";
13071
14204
 
13072
- /**
13073
- * How many callers' data layers to keep around in proxy mode.
13074
- *
13075
- * Each entry holds one `ValOpsHttp`, and each of those caches the project's
13076
- * evaluated modules once `initSources` has run — so this bounds memory, not just
13077
- * entry count. Small on purpose: the cost of a miss is re-evaluating the
13078
- * modules on the next call, which is what happened on *every* call before this
13079
- * cache existed.
13080
- */
13081
- const MAX_CACHED_OPS = 8;
13082
-
13083
14205
  /**
13084
14206
  * Val's server-side tool registry.
13085
14207
  *
@@ -13179,17 +14301,25 @@ function createValTools(valModules, options) {
13179
14301
  * Pick the data layer for a call, which in proxy mode means picking whose
13180
14302
  * credential the backend will see.
13181
14303
  *
13182
- * This is the one place authorization is decided, and it decides it by *not*
13183
- * deciding: in proxy mode the caller's own personal access token goes to the
13184
- * backend, which is the only party that can say what that token may do. The app
13185
- * never inspects it, never caches a verdict about it, and never substitutes its
13186
- * own API key for a missing one — see `docs/plans/mcp.md` D.2.
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.
13187
14316
  *
13188
- * The alternative shape, and the reason this function exists at all, is an
13189
- * `authenticate()` that checks the PAT once and then acts under the app's key.
13190
- * That reads as more secure and is strictly less so: the check happens in the
13191
- * app, so every bug in it becomes full access to every project the app's key
13192
- * can reach, and the backend's own permission model stops being consulted (D.6).
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.
13193
14323
  */
13194
14324
  function createOpsResolver(valModules, options) {
13195
14325
  if (options.mode === "fs") {
@@ -13203,18 +14333,17 @@ function createOpsResolver(valModules, options) {
13203
14333
  // and the difference matters, because fs mode writes straight to disk
13204
14334
  // with no backend permission check at all.
13205
14335
  //
13206
- // The two credentials get different messages because they arrive here
13207
- // for different reasons. A PAT is something the caller chose to send. A
13208
- // verified access token is not: it only exists because this app
13209
- // advertised an authorization server, so the developer seeing this did
13210
- // not do anything wrong — a config file did, and naming it is the
13211
- // difference between a two-minute fix and an afternoon.
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.
13212
14341
  return {
13213
14342
  status: "error",
13214
14343
  result: {
13215
14344
  status: "error",
13216
14345
  code: "unsupported",
13217
- 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."
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."
13218
14347
  }
13219
14348
  };
13220
14349
  }
@@ -13225,78 +14354,50 @@ function createOpsResolver(valModules, options) {
13225
14354
  };
13226
14355
  }
13227
14356
 
13228
- // Keyed by a hash of the PAT, so the same caller reuses their own instance and
13229
- // two callers can never share one. Hashing is not a security boundary — the
13230
- // instance holds the token regardless — but it keeps credentials out of the
13231
- // key set, which is the thing that ends up in a heap dump or an error dump.
13232
- const byPatHash = new Map();
13233
14357
  /**
13234
- * One instance for every verified caller, and unlike the PAT map that is
13235
- * correct rather than a shortcut: this instance authenticates with the app's
13236
- * own API key, so there is nothing per-caller in it to keep apart. Who did
13237
- * what travels as the patch's `authorId` instead — see `writePath`.
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.
13238
14368
  */
13239
14369
  let sharedOps = null;
13240
14370
  return ctx => {
13241
- if (!ctx.auth) {
14371
+ var _ctx$auth;
14372
+ if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
13242
14373
  return {
13243
14374
  status: "error",
13244
14375
  result: {
13245
14376
  status: "error",
13246
14377
  code: "forbidden",
13247
- 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`."
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."
13248
14379
  }
13249
14380
  };
13250
14381
  }
13251
- if (ctx.auth.type === "verified-profile") {
13252
- if (!options.apiKey) {
13253
- // Proxy mode is inferred from the api key being present, so this is
13254
- // unreachable through `initHandlerOptions`. It stays because the
13255
- // alternative to refusing is building ops with no credential at all.
13256
- return {
13257
- status: "error",
13258
- result: {
13259
- status: "error",
13260
- code: "forbidden",
13261
- message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
13262
- }
13263
- };
13264
- }
13265
- if (!sharedOps) {
13266
- sharedOps = createValOps(valModules, options);
13267
- }
13268
- return {
13269
- status: "ok",
13270
- ops: sharedOps
13271
- };
13272
- }
13273
- const key = node_crypto.createHash("sha256").update(ctx.auth.pat).digest("hex");
13274
- const cached = byPatHash.get(key);
13275
- if (cached) {
13276
- // Re-inserted so eviction drops the least recently used rather than the
13277
- // oldest — a long-running caller should not be evicted by a burst of
13278
- // one-off ones.
13279
- byPatHash.delete(key);
13280
- byPatHash.set(key, cached);
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.
13281
14386
  return {
13282
- status: "ok",
13283
- ops: cached
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
+ }
13284
14393
  };
13285
14394
  }
13286
- const ops = createValOps(valModules, options, {
13287
- pat: ctx.auth.pat
13288
- });
13289
- byPatHash.set(key, ops);
13290
- while (byPatHash.size > MAX_CACHED_OPS) {
13291
- const oldest = byPatHash.keys().next();
13292
- if (oldest.done) {
13293
- break;
13294
- }
13295
- byPatHash.delete(oldest.value);
14395
+ if (!sharedOps) {
14396
+ sharedOps = createValOps(valModules, options);
13296
14397
  }
13297
14398
  return {
13298
14399
  status: "ok",
13299
- ops
14400
+ ops: sharedOps
13300
14401
  };
13301
14402
  };
13302
14403
  }
@@ -13394,14 +14495,18 @@ function describeZodError(error) {
13394
14495
  * the safe direction: a tool that forgets the hint is treated as a write and
13395
14496
  * demands the wider scope, rather than a write slipping through as a read.
13396
14497
  *
13397
- * Only the verified-token path is checked. A PAT carries no scopes here by
13398
- * design — the backend resolves it and decides — so there is nothing to
13399
- * enforce, and inventing a default would be this app claiming an authority it
13400
- * does not have.
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.
13401
14506
  */
13402
14507
  function refuseInsufficientScope(tool, ctx) {
13403
- var _ctx$auth, _tool$annotations;
13404
- if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
14508
+ var _ctx$auth2, _tool$annotations;
14509
+ if (((_ctx$auth2 = ctx.auth) === null || _ctx$auth2 === void 0 ? void 0 : _ctx$auth2.type) !== "verified-profile") {
13405
14510
  return null;
13406
14511
  }
13407
14512
  // Read is needed by every call, including the writes: a tool that changes
@@ -14930,6 +16035,25 @@ async function handleJsonValuesExtractEntry(ctx) {
14930
16035
  };
14931
16036
  }
14932
16037
 
16038
+ /**
16039
+ * `external:upload` under `val validate --fix`: refuse, and say what to run.
16040
+ *
16041
+ * Every other fix in this registry rewrites something inside the repository —
16042
+ * reversible, visible in a diff, wrong by at most one commit. This one would
16043
+ * write entries into a live external store: not in a diff, not undone by
16044
+ * `git revert`, and against production indistinguishable from an editor's
16045
+ * publish. So a blanket `--fix` must never apply it.
16046
+ *
16047
+ * `fixableErrorMessage` rather than a plain error, because the error IS fixable
16048
+ * — just not by this command.
16049
+ */
16050
+ async function handleExternalUpload() {
16051
+ return {
16052
+ success: true,
16053
+ 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."
16054
+ };
16055
+ }
16056
+
14933
16057
  // Fix handler registry. `keyof:check-keys` and `router:check-route` are
14934
16058
  // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
14935
16059
  // this registry, so they're excluded from the key set.
@@ -14952,7 +16076,8 @@ const currentFixHandlers = {
14952
16076
  "files:check-unique-folder": handleUniqueFolderCheck,
14953
16077
  "images:check-all-files": handleCheckAllFiles,
14954
16078
  "files:check-all-files": handleCheckAllFiles,
14955
- "jsonValues:extract-entry": handleJsonValuesExtractEntry
16079
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry,
16080
+ "external:upload": handleExternalUpload
14956
16081
  };
14957
16082
  const deprecatedFixHandlers = {
14958
16083
  "image:replace-metadata": handleFileMetadata
@@ -15608,6 +16733,7 @@ Object.defineProperty(exports, 'hasRemoteFileSchema', {
15608
16733
  exports.DEFAULT_LOGIN_EXPIRES_IN_SECONDS = DEFAULT_LOGIN_EXPIRES_IN_SECONDS;
15609
16734
  exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
15610
16735
  exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
16736
+ exports.EXTERNAL_RESULT = EXTERNAL_RESULT;
15611
16737
  exports.Service = Service;
15612
16738
  exports.VAL_SCOPE_READ = VAL_SCOPE_READ;
15613
16739
  exports.VAL_SCOPE_WRITE = VAL_SCOPE_WRITE;
@@ -15635,9 +16761,11 @@ exports.createValServer = createValServer;
15635
16761
  exports.createValTools = createValTools;
15636
16762
  exports.currentFixHandlers = currentFixHandlers;
15637
16763
  exports.decodeJwtWithoutVerifying = decodeJwtWithoutVerifying;
16764
+ exports.defineExternal = defineExternal;
15638
16765
  exports.describePatchStoreProblems = describePatchStoreProblems;
15639
16766
  exports.downloadFileFromRemote = downloadFileFromRemote;
15640
16767
  exports.encodeJwt = encodeJwt;
16768
+ exports.err = err$1;
15641
16769
  exports.evalValConfigFile = evalValConfigFile;
15642
16770
  exports.extractFileMetadata = extractFileMetadata;
15643
16771
  exports.extractImageMetadata = extractImageMetadata;
@@ -15657,6 +16785,7 @@ exports.getPersonalAccessTokenPath = getPersonalAccessTokenPath;
15657
16785
  exports.getSettings = getSettings;
15658
16786
  exports.getValidationErrorFileRef = getValidationErrorFileRef;
15659
16787
  exports.handleCheckAllFiles = handleCheckAllFiles;
16788
+ exports.handleExternalUpload = handleExternalUpload;
15660
16789
  exports.handleFileMetadata = handleFileMetadata;
15661
16790
  exports.handleJsonValuesExtractEntry = handleJsonValuesExtractEntry;
15662
16791
  exports.handleRemoteFileCheck = handleRemoteFileCheck;
@@ -15665,7 +16794,9 @@ exports.handleRemoteFileUpload = handleRemoteFileUpload;
15665
16794
  exports.handleRemoteGalleryFileUpload = handleRemoteGalleryFileUpload;
15666
16795
  exports.handleUniqueFolderCheck = handleUniqueFolderCheck;
15667
16796
  exports.initHandlerOptions = initHandlerOptions;
16797
+ exports.isExternalResult = isExternalResult;
15668
16798
  exports.loadValModules = loadValModules;
16799
+ exports.ok = ok$1;
15669
16800
  exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
15670
16801
  exports.patchSourceFile = patchSourceFile;
15671
16802
  exports.persistPersonalAccessToken = persistPersonalAccessToken;