@valbuild/server 0.120.4 → 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);
@@ -1699,6 +1701,21 @@ class Service {
1699
1701
  getModuleFilePaths() {
1700
1702
  return Object.keys(this.extracted.sources);
1701
1703
  }
1704
+
1705
+ /**
1706
+ * Everything that is wrong with how the project's modules are DECLARED, as
1707
+ * opposed to what is in them.
1708
+ *
1709
+ * A module that failed to load is here, and so is a rule that spans the whole
1710
+ * set — "one settings module, at the root" cannot be checked while looking at
1711
+ * a single module, so `extractValModules` appends it with the offending path.
1712
+ * `get` only surfaces these when the module is missing entirely, which a
1713
+ * misplaced settings module is not: `val validate` reads them from here and
1714
+ * reports them against the file.
1715
+ */
1716
+ getModuleErrors() {
1717
+ return this.extracted.moduleErrors;
1718
+ }
1702
1719
  serializedSchemaOf(moduleFilePath) {
1703
1720
  return this.extracted.serializedSchemas[moduleFilePath];
1704
1721
  }
@@ -1937,6 +1954,183 @@ class Service {
1937
1954
  }
1938
1955
  }
1939
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
+
1940
2134
  const JwtPayloadSchema = z.z.object({
1941
2135
  sub: z.z.string(),
1942
2136
  exp: z.z.number(),
@@ -2396,7 +2590,8 @@ class ValOps {
2396
2590
  * in-flight client patches the server has not seen) must pass
2397
2591
  * `applyPatches: false` or the same edits would be applied twice.
2398
2592
  */
2399
- async getJsonEntry(moduleFilePath, entryKey, opts) {
2593
+ async getJsonEntry(moduleFilePath, entryKey, /** Passed straight through — see {@link getJsonEntries}. */
2594
+ opts) {
2400
2595
  const res = await this.getJsonEntries(moduleFilePath, {
2401
2596
  keys: [entryKey]
2402
2597
  }, opts);
@@ -2490,7 +2685,7 @@ class ValOps {
2490
2685
  message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
2491
2686
  };
2492
2687
  }
2493
- modulePatches = patchOps.patches.filter(p => p.path === moduleFilePath && !p.appliedAt).map(p => ({
2688
+ modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds).map(p => ({
2494
2689
  patchId: p.patchId,
2495
2690
  patch: p.patch
2496
2691
  }));
@@ -3720,6 +3915,47 @@ class ValOps {
3720
3915
  };
3721
3916
  }
3722
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
+ }
3723
3959
  const res = {
3724
3960
  hasErrors,
3725
3961
  sourceFilePatchErrors,
@@ -3732,7 +3968,8 @@ class ValOps {
3732
3968
  patchedBinaryFilesDescriptors,
3733
3969
  appliedPatches,
3734
3970
  skippedPatches,
3735
- triedPatches
3971
+ triedPatches,
3972
+ moduleVersions
3736
3973
  };
3737
3974
  return res;
3738
3975
  }
@@ -3750,8 +3987,21 @@ class ValOps {
3750
3987
  }
3751
3988
 
3752
3989
  // #region createPatch
3753
- async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
3754
- const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
3990
+ async createPatch(path, patch, patchId, parentRef, sessionId, authorId,
3991
+ /**
3992
+ * Which patch group this patch joins, recorded in the SAME request.
3993
+ *
3994
+ * Atomic on purpose. The content API runs every refusal before its insert,
3995
+ * so an invalid closure is a 400 with nothing written. Recording membership
3996
+ * in a second call would let a patch exist outside its author's group if
3997
+ * that call failed — and a patch outside your own group is one you cannot
3998
+ * publish until a repair puts it back.
3999
+ *
4000
+ * Optional: `fs` mode has no groups, and a client that predates them sends
4001
+ * nothing.
4002
+ */
4003
+ patchGroup) {
4004
+ const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
3755
4005
  if (fp.result.isErr(saveRes)) {
3756
4006
  console.error(`Could not save source patch at path: '${path}'. Error: ${saveRes.error.errorType === "other" ? saveRes.error.message : saveRes.error.errorType}`);
3757
4007
  if (saveRes.error.errorType === "patch-head-conflict") {
@@ -3766,11 +4016,76 @@ class ValOps {
3766
4016
  }
3767
4017
  return fp.result.ok({
3768
4018
  patchId,
3769
- createdAt: new Date().toISOString()
4019
+ createdAt: new Date().toISOString(),
4020
+ // Spread rather than assigned: absent has to stay distinguishable from a
4021
+ // group whose id is undefined, all the way to the client.
4022
+ ...(saveRes.value.patchGroupId !== undefined ? {
4023
+ patchGroupId: saveRes.value.patchGroupId
4024
+ } : {})
3770
4025
  });
3771
4026
  }
3772
4027
 
3773
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
3774
4089
  }
3775
4090
  function isOnlyFileCheckValidationError(validationError) {
3776
4091
  var _validationError$fixe9;
@@ -3789,6 +4104,21 @@ function isOnlyFileCheckValidationError(validationError) {
3789
4104
  function isFileSource(value) {
3790
4105
  return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
3791
4106
  }
4107
+
4108
+ /**
4109
+ * The patch group a newly created patch joins.
4110
+ *
4111
+ * `withPatchIds` is the CLOSURE the client computed — the patches that share
4112
+ * a patch set with this one and must move with it. It is not derived here and
4113
+ * must not be: the closure needs the content schema, and the service that
4114
+ * stores groups does not have it. One implementation of that rule, on the side
4115
+ * that can actually compute it.
4116
+ *
4117
+ * Membership rows are stamped with `coreVersion` on the content side, the same
4118
+ * stamp the patch row itself carries, so which client wrote a row stays legible
4119
+ * after the fact.
4120
+ */
4121
+
3792
4122
  function formatPatchSourceError(error) {
3793
4123
  if ("message" in error) {
3794
4124
  return error.message;
@@ -3799,6 +4129,33 @@ function formatPatchSourceError(error) {
3799
4129
  return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
3800
4130
  }
3801
4131
  }
4132
+ /**
4133
+ * The patches a json entry render should apply, out of the whole chain.
4134
+ *
4135
+ * Three rules, and the second is the one that was missing. A draft page renders
4136
+ * `jsonValues` entries beside module content, and only the modules were scoped
4137
+ * — so one screen showed the caller's own view for its modules and base plus
4138
+ * EVERY pending patch on the branch for the entries beside them, including
4139
+ * another author's half-finished edit rendered as though it were live.
4140
+ *
4141
+ * 1. this module's, since the chain is branch-wide;
4142
+ * 2. this caller's, when they asked to be scoped. `undefined` is "everything",
4143
+ * which is what every unscoped caller gets and must keep getting;
4144
+ * 3. not already applied — a fact about this path rather than about scoping,
4145
+ * and true with or without a scope.
4146
+ *
4147
+ * Filtered here rather than by asking `fetchPatches` for a list, and that is
4148
+ * load-bearing: both implementations read an empty `patchIds` as "no filter"
4149
+ * and return the whole chain. That is the right default for a caller that
4150
+ * cannot mean "none", and the most dangerous possible reading of a group that
4151
+ * is genuinely empty — it would render every unpublished patch on the branch
4152
+ * instead of base. It costs no round trip either: the whole chain is what the
4153
+ * unscoped path fetches anyway.
4154
+ */
4155
+ function scopedModulePatches(patches, moduleFilePath, patchIds) {
4156
+ const scope = patchIds && new Set(patchIds);
4157
+ return patches.filter(patch => patch.path === moduleFilePath && !patch.appliedAt && (scope === undefined || scope.has(patch.patchId)));
4158
+ }
3802
4159
  function getFieldsForType(type) {
3803
4160
  if (type === "file") {
3804
4161
  return ["mimeType"];
@@ -5829,7 +6186,17 @@ class ValOpsFS extends ValOps {
5829
6186
  };
5830
6187
  }
5831
6188
  }
5832
- async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
6189
+ async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId,
6190
+ /*
6191
+ * Named and ignored, rather than omitted from the signature.
6192
+ *
6193
+ * `fs` mode has no shared store and exactly one author, so there is nothing
6194
+ * for a group to separate — every pending patch is already this person's.
6195
+ * Declaring it makes that a decision a reader can see: TypeScript lets an
6196
+ * implementation take fewer parameters than the abstract, so leaving it off
6197
+ * would drop group membership silently and look identical to handling it.
6198
+ */
6199
+ _patchGroup) {
5833
6200
  const patchesDir = this.getPatchesDir();
5834
6201
  const record = {
5835
6202
  patch,
@@ -6324,6 +6691,46 @@ class ValOpsFS extends ValOps {
6324
6691
  getPatchLockFile() {
6325
6692
  return path__namespace["default"].join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
6326
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
6327
6734
  }
6328
6735
  class FSOpsHost {
6329
6736
  constructor() {}
@@ -6586,7 +6993,14 @@ const FilesResponse = z.z.object({
6586
6993
  })])).optional()
6587
6994
  });
6588
6995
  const SavePatchResponse = z.z.object({
6589
- patchId: PatchId
6996
+ patchId: PatchId,
6997
+ /**
6998
+ * Which group the content API put this patch in.
6999
+ *
7000
+ * Optional: a content API that predates patch groups does not send one, and
7001
+ * absence has to keep meaning "no groups here" rather than failing the save.
7002
+ */
7003
+ patchGroupId: z.z.string().optional()
6590
7004
  });
6591
7005
  const DeletePatchesResponse = z.z.object({
6592
7006
  deleted: z.z.array(PatchId),
@@ -6604,10 +7018,126 @@ const CommitResponse = z.z.object({
6604
7018
  commit: CommitSha,
6605
7019
  branch: z.z.string()
6606
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
+
7114
+ /*
7115
+ * The shared schema, not a copy of it.
7116
+ *
7117
+ * This re-declared `PatchGroup` field for field while the file already imported
7118
+ * `PatchGroupT` from the same module — so a field added on one side and not the
7119
+ * other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
7120
+ * it, with no type error anywhere.
7121
+ */
7122
+ const PatchGroupsResponse = z.z.object({
7123
+ patchGroups: z.z.array(internal.PatchGroup)
7124
+ });
7125
+ const PatchGroupMutationResponse = z.z.object({
7126
+ patchGroupId: z.z.string(),
7127
+ patchIds: z.z.array(PatchId)
7128
+ });
6607
7129
  const NonceResponse = z.z.object({
6608
7130
  nonce: z.z.string(),
6609
7131
  url: z.z.string()
6610
7132
  });
7133
+
7134
+ /**
7135
+ * How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
7136
+ *
7137
+ * Sized to cover one server render, not to be a cache: several `fetchVal` calls
7138
+ * in one request share an answer, and the next request asks again.
7139
+ */
7140
+ const PATCH_GROUPS_CACHE_MS = 1000;
6611
7141
  class ValOpsHttp extends ValOps {
6612
7142
  constructor(contentUrl, project, commitSha,
6613
7143
  // TODO: CommitSha
@@ -6767,8 +7297,26 @@ class ValOpsHttp extends ValOps {
6767
7297
  }
6768
7298
  }
6769
7299
  const patches = [];
7300
+ /*
7301
+ * Which of them have SHIPPED, alongside which of them exist.
7302
+ *
7303
+ * A published patch stays in the chain with `appliedAt` set until the next
7304
+ * deployment moves the base, so "in the chain" and "has shipped" are
7305
+ * different questions — and the chain ids alone answer only the first. A
7306
+ * client that already holds a record never re-fetches it, so it never
7307
+ * learns the second: another author's publish left that patch in your scope
7308
+ * as pending, your prefix gate read a hole in front of it, and Publish
7309
+ * refused for a reason that had stopped being true.
7310
+ *
7311
+ * Sent as ids rather than folded into `patches`, so a client that ignores
7312
+ * it behaves exactly as before.
7313
+ */
7314
+ const appliedPatches = [];
6770
7315
  for (const patchData of allPatchData.patches) {
6771
7316
  patches.push(patchData.patchId);
7317
+ if (patchData.appliedAt) {
7318
+ appliedPatches.push(patchData.patchId);
7319
+ }
6772
7320
  }
6773
7321
  const webSocketNonceRes = await this.getWebSocketNonce(params.profileId);
6774
7322
  if (webSocketNonceRes.status === "error") {
@@ -6791,6 +7339,16 @@ class ValOpsHttp extends ValOps {
6791
7339
  commits: allPatchData.commits || [],
6792
7340
  deployments: allPatchData.deployments || [],
6793
7341
  patches,
7342
+ appliedPatches,
7343
+ /*
7344
+ * The PUBLISH head, which is not `commitSha`.
7345
+ *
7346
+ * `commitSha` is the commit this deployment is serving and does not move
7347
+ * when somebody publishes — only when the new build lands. This does, so
7348
+ * it is what a client carries back to `/save` to say which world it
7349
+ * decided against.
7350
+ */
7351
+ headCommitSha: internal.newestCommitSha(allPatchData.commits) ?? undefined,
6794
7352
  commitSha: this.commitSha
6795
7353
  };
6796
7354
  }
@@ -6871,6 +7429,26 @@ class ValOpsHttp extends ValOps {
6871
7429
  }
6872
7430
  const allPatches = [];
6873
7431
  const allErrors = [];
7432
+ /*
7433
+ * The commits, which are a fact about the whole BRANCH, not about a chunk.
7434
+ *
7435
+ * This loop used to return only `patches` and `errors`, so a filtered fetch
7436
+ * silently answered with no commits at all. That is not cosmetic:
7437
+ * the publish-head guard in `ValServer` reads `newestCommitSha(commits)`,
7438
+ * got `undefined` for every publish (a publish always names patch ids, so
7439
+ * it always takes this branch), and skipped the check entirely. Two clients
7440
+ * could publish against the same head with neither told.
7441
+ *
7442
+ * Taken from the first chunk that carries them, which is sound for the
7443
+ * reason the dedupe below exists: each chunk's response describes the whole
7444
+ * chain regardless of which ids it asked about.
7445
+ *
7446
+ * `deployments` is not carried, because it is declared only on
7447
+ * `OrderedPatchesMetadata` and this is generic over both shapes. Nothing
7448
+ * reads it from a filtered fetch today; if something starts to, it needs
7449
+ * the same treatment and a home on `OrderedPatches` first.
7450
+ */
7451
+ let commits;
6874
7452
  if (patchIds === undefined || patchIds.length === 0) {
6875
7453
  return this.fetchPatchesInternal({
6876
7454
  patchIds: patchIds,
@@ -6888,6 +7466,9 @@ class ValOpsHttp extends ValOps {
6888
7466
  if (res.errors) {
6889
7467
  allErrors.push(...res.errors);
6890
7468
  }
7469
+ if (commits === undefined && res.commits !== undefined) {
7470
+ commits = res.commits;
7471
+ }
6891
7472
  }
6892
7473
  // Chunking is a query-string-length workaround, NOT a filter: the content
6893
7474
  // api returns every applicable patch per request regardless of which
@@ -6914,7 +7495,13 @@ class ValOpsHttp extends ValOps {
6914
7495
  });
6915
7496
  return {
6916
7497
  patches,
6917
- errors: Object.keys(allErrors).length > 0 ? allErrors : undefined
7498
+ errors: Object.keys(allErrors).length > 0 ? allErrors : undefined,
7499
+ // Spread rather than set to `undefined`, so a caller that distinguishes
7500
+ // "absent" from "empty" — `newestCommitSha` does not, but the annotation
7501
+ // readers do — sees the same shape the unchunked path gives it.
7502
+ ...(commits !== undefined ? {
7503
+ commits
7504
+ } : {})
6918
7505
  };
6919
7506
  }
6920
7507
  async fetchPatchesInternal(filters) {
@@ -7022,59 +7609,364 @@ class ValOpsHttp extends ValOps {
7022
7609
  };
7023
7610
  }
7024
7611
  }
7025
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
7026
- const baseSha = await this.getBaseSha();
7027
- return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
7028
- method: "POST",
7029
- headers: {
7030
- ...this.authHeaders,
7031
- "Content-Type": "application/json"
7032
- },
7033
- body: JSON.stringify({
7034
- path,
7035
- patch,
7036
- authorId,
7037
- sessionId,
7038
- patchId,
7039
- parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
7040
- baseSha,
7041
- commit: this.commitSha,
7042
- branch: this.branch,
7043
- coreVersion: core.Internal.VERSION.core
7044
- })
7045
- }).then(async res => {
7046
- var _res$headers$get2;
7047
- if (res.ok) {
7048
- const parsed = SavePatchResponse.safeParse(await res.json());
7049
- if (parsed.success) {
7050
- return fp.result.ok({
7051
- patchId: parsed.data.patchId
7052
- });
7053
- }
7054
- return fp.result.err({
7055
- errorType: "other",
7056
- message: `Could not parse save patch response. Error: ${zodValidationError.fromError(parsed.error)}`
7057
- });
7058
- }
7059
- if (res.status === 409) {
7060
- return fp.result.err({
7061
- errorType: "patch-head-conflict",
7062
- message: "Conflict: " + (await res.text())
7063
- });
7064
- }
7065
- if ((_res$headers$get2 = res.headers.get("Content-Type")) !== null && _res$headers$get2 !== void 0 && _res$headers$get2.includes("application/json")) {
7066
- const json = await res.json();
7067
- const message = internal.getErrorMessageFromUnknownJson(json, "Unknown error");
7068
- return fp.result.err({
7069
- errorType: "other",
7070
- message
7071
- });
7072
- }
7073
- return fp.result.err({
7074
- errorType: "other",
7075
- message: "Could not save patch. HTTP error: " + res.status + " " + res.statusText
7076
- });
7077
- }).catch(e => {
7612
+
7613
+ // #region patch groups
7614
+ /**
7615
+ * Add patches to a patch group.
7616
+ *
7617
+ * The set arrives closed by the client — `withPatchIds` is the prefix closure
7618
+ * over the patch sets the staged patches belong to. We forward it and do not
7619
+ * second-guess it: deriving the closure needs the content schema, which this
7620
+ * process does have but content.val.build does not, and having two
7621
+ * implementations of the rule would be worse than having one.
7622
+ *
7623
+ * Membership rows are stamped with `coreVersion` on the content side — the
7624
+ * same stamp the patch row carries — so which client wrote a row stays
7625
+ * legible after the fact.
7626
+ */
7627
+ async stagePatches(patchGroupId, /** What the user asked to stage. */
7628
+ patchIds,
7629
+ /**
7630
+ * What has to come with it, because the staged patches are written on top
7631
+ * of it.
7632
+ *
7633
+ * The content API stores each membership row as `explicit` or `dependency`
7634
+ * and treats what it is not told about as a dependency. Folding the two
7635
+ * halves into `patchIds` therefore files the patch somebody clicked as one
7636
+ * the closure dragged in — the exact opposite of what happened, and the
7637
+ * only record anywhere of what the author chose.
7638
+ */
7639
+ withPatchIds,
7640
+ /**
7641
+ * WHO is asking, so the content API can refuse a group that is not theirs.
7642
+ *
7643
+ * Every call from this class carries the app's API key, which says which
7644
+ * PROJECT is calling and nothing about which editor. Without this the
7645
+ * content API cannot tell one of a project's editors from another, so the
7646
+ * only check on stage and unstage is the one in `ValServer` — and anything
7647
+ * reaching the content API by another route (an API key, a PAT) has none at
7648
+ * all.
7649
+ *
7650
+ * `null` where there is no session. The content API refuses rather than
7651
+ * treating that as a match: a group written by an api key has a null author
7652
+ * too, and `null === null` must not read as ownership.
7653
+ */
7654
+ authorId) {
7655
+ return this.mutatePatchGroup("POST",
7656
+ // Encoded: patchGroupId arrives in a request body, so an unencoded value
7657
+ // like "../../commit" would reach a different endpoint carrying this
7658
+ // project's auth headers.
7659
+ `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
7660
+ patchIds,
7661
+ withPatchIds,
7662
+ coreVersion: core.Internal.VERSION.core
7663
+ }, authorId);
7664
+ }
7665
+
7666
+ /**
7667
+ * Remove patches from a patch group.
7668
+ *
7669
+ * The set arrives closed FORWARDS by the client: unstaging a patch also
7670
+ * unstages everything built on top of it within its patch sets, and that is
7671
+ * what `withPatchIds` carries.
7672
+ */
7673
+ async unstagePatches(patchGroupId, /** What the user asked to unstage. */
7674
+ patchIds, /** What has to go with it: everything built on top of it. */
7675
+ withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
7676
+ authorId) {
7677
+ return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
7678
+ patchIds,
7679
+ withPatchIds
7680
+ }, authorId);
7681
+ }
7682
+
7683
+ /**
7684
+ * Every patch group on this branch, with what each holds.
7685
+ *
7686
+ * Read rather than mutated, and used to answer "which pending patches is THIS
7687
+ * person allowed to see". A draft render that skips this shows base + every
7688
+ * pending patch on the branch, including work other people have not
7689
+ * published — which is what independent publish exists to prevent.
7690
+ *
7691
+ * A failure is an empty list rather than a throw, and the caller decides what
7692
+ * that means. For a draft render the honest fallback is "show nothing
7693
+ * pending" rather than "show everything": being shown your own committed
7694
+ * content when the group lookup is down is a worse experience than being
7695
+ * shown somebody else's unpublished draft is a bug.
7696
+ */
7697
+ /**
7698
+ * The last group lookup, and when it was made.
7699
+ *
7700
+ * A draft render calls `getPatchGroups` once per `fetchVal`, in series with
7701
+ * the whole-chain fetch, and a page that calls `fetchVal` several times pays
7702
+ * the round trip several times. Groups are per branch and change rarely, so a
7703
+ * short window removes the multiplier without letting a stage go unseen for
7704
+ * meaningfully longer than one render.
7705
+ *
7706
+ * Deliberately short. This is a read whose staleness decides whose draft
7707
+ * content someone sees, so it is a per-request de-duplication rather than a
7708
+ * cache: a second render a second later asks again.
7709
+ */
7710
+ patchGroupsCache = null;
7711
+ async getPatchGroups(options) {
7712
+ const now = Date.now();
7713
+ if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
7714
+ return this.patchGroupsCache.res;
7715
+ }
7716
+ const res = await this.fetchPatchGroups();
7717
+ /*
7718
+ * ANSWERS are cached; failures are not.
7719
+ *
7720
+ * A transient failure held for a second is replayed to every caller in it,
7721
+ * and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
7722
+ * that refuses the stage, a scoped draft render falls back to base and
7723
+ * drops every pending patch on the page, and `GET /patches` omits the
7724
+ * annotation. One flaky request became a second of all three. The point of
7725
+ * this cache is to collapse the several `fetchVal` calls in one render into
7726
+ * one round trip, and an error is exactly the case worth retrying inside
7727
+ * that window rather than the case worth remembering.
7728
+ *
7729
+ * `unsupported` is cached with `ok` deliberately: it is a real answer about
7730
+ * the deployment — this content API predates patch groups — and it will not
7731
+ * change between two renders.
7732
+ */
7733
+ if (res.status === "ok" || res.status === "unsupported") {
7734
+ this.patchGroupsCache = {
7735
+ at: now,
7736
+ res
7737
+ };
7738
+ }
7739
+ return res;
7740
+ }
7741
+ async fetchPatchGroups() {
7742
+ try {
7743
+ /*
7744
+ * `branch` is REQUIRED by the endpoint, which answers 400 without it.
7745
+ *
7746
+ * Same two params every other read here sends (`fetchPatchesInternal`,
7747
+ * `saveSourceFilePatch`): groups are per branch, so a request without one
7748
+ * is not merely under-specified, it is rejected.
7749
+ */
7750
+ const params = new URLSearchParams([["branch", this.branch]]);
7751
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
7752
+ headers: this.authHeaders
7753
+ });
7754
+ if (res.status === 404) {
7755
+ /*
7756
+ * The endpoint is not there, which is a content API that PREDATES patch
7757
+ * groups — not a failure.
7758
+ *
7759
+ * The distinction decides what a draft render shows, and collapsing it
7760
+ * into "error" is not a small mistake: a caller that reads an error as
7761
+ * "could not ask" renders BASE, so every existing http deployment would
7762
+ * silently drop all pending content from every draft preview. "There
7763
+ * are no groups here" has to mean unscoped, which is exactly the
7764
+ * behaviour those projects have today.
7765
+ */
7766
+ return {
7767
+ status: "unsupported"
7768
+ };
7769
+ }
7770
+ if (!res.ok) {
7771
+ return {
7772
+ status: "error",
7773
+ message: res.status === 401 ? "Could not read patch groups: unauthorized. Verify that the val api keys are correct." : `Could not read patch groups. HTTP error: ${res.status} ${res.statusText}`
7774
+ };
7775
+ }
7776
+ const parsed = PatchGroupsResponse.safeParse(await res.json());
7777
+ if (!parsed.success) {
7778
+ return {
7779
+ status: "error",
7780
+ message: `Could not parse patch groups response. Error: ${zodValidationError.fromError(parsed.error)}`
7781
+ };
7782
+ }
7783
+ return {
7784
+ status: "ok",
7785
+ patchGroups: parsed.data.patchGroups
7786
+ };
7787
+ } catch (err) {
7788
+ return {
7789
+ status: "error",
7790
+ message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
7791
+ };
7792
+ }
7793
+ }
7794
+ async mutatePatchGroup(method, path, body,
7795
+ /**
7796
+ * WHO is asking. Sent as `x-val-profile-id`, which is what the content API
7797
+ * reads to decide whether this group is the caller's.
7798
+ *
7799
+ * `this.authHeaders` is the app's API key, and that names the PROJECT, not
7800
+ * the person — so without this the content API cannot resolve a profile
7801
+ * and refuses every stage and unstage with
7802
+ * "Cannot resolve the caller's profile". The group endpoints are the only
7803
+ * ones here that need it, because they are the only ones whose answer
7804
+ * depends on which of a project's editors is calling.
7805
+ *
7806
+ * Omitted when there is no session rather than sent empty: the content API
7807
+ * treats an unidentified caller as a refusal, which is what we want, and an
7808
+ * empty header would be a different and less obvious way to say it.
7809
+ *
7810
+ * A PAT already identifies a person, so `authHeaders` carries the identity
7811
+ * on its own there and this adds nothing.
7812
+ */
7813
+ authorId) {
7814
+ try {
7815
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
7816
+ method,
7817
+ headers: {
7818
+ ...this.authHeaders,
7819
+ ...(authorId !== null ? {
7820
+ "x-val-profile-id": authorId
7821
+ } : {}),
7822
+ "Content-Type": "application/json"
7823
+ },
7824
+ body: JSON.stringify(body)
7825
+ });
7826
+ if (res.ok) {
7827
+ const parsed = PatchGroupMutationResponse.safeParse(await res.json());
7828
+ if (parsed.success) {
7829
+ return {
7830
+ patchIds: parsed.data.patchIds
7831
+ };
7832
+ }
7833
+ return {
7834
+ status: 500,
7835
+ patchIds: [],
7836
+ error: {
7837
+ message: `Could not parse patch group response. Error: ${zodValidationError.fromError(parsed.error)}`
7838
+ }
7839
+ };
7840
+ }
7841
+ // 403 (not your group) and 409 (already published) are meaningful to the
7842
+ // client, so they are passed through rather than flattened to a 500.
7843
+ if (res.status === 403 || res.status === 409) {
7844
+ // `home` answers these with a JSON body, so `res.text()` put the literal
7845
+ // `{"message":"..."}` in front of the user. Every other branch in this
7846
+ // class unwraps it; this one now does too.
7847
+ return {
7848
+ status: res.status,
7849
+ patchIds: [],
7850
+ error: {
7851
+ message: internal.getErrorMessageFromUnknownJson(await res.json().catch(() => undefined), `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`)
7852
+ }
7853
+ };
7854
+ }
7855
+ // A 401 here is the app's own credentials failing, not the user's, so it gets
7856
+ // the same wording as every other call in this class rather than an opaque
7857
+ // 500 that sends the user looking at their own session.
7858
+ if (res.status === 401) {
7859
+ return {
7860
+ status: 500,
7861
+ patchIds: [],
7862
+ error: {
7863
+ message: "Although your user is authorized, the application has authorization issues. Contact the developers on your team and ask them to verify the api keys."
7864
+ }
7865
+ };
7866
+ }
7867
+ return {
7868
+ status: 500,
7869
+ patchIds: [],
7870
+ error: {
7871
+ message: `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`
7872
+ }
7873
+ };
7874
+ } catch (err) {
7875
+ return {
7876
+ status: 500,
7877
+ patchIds: [],
7878
+ error: {
7879
+ message: `Could not update patch group (connection error?): ${err instanceof Error ? err.message : JSON.stringify(err)}`
7880
+ }
7881
+ };
7882
+ }
7883
+ }
7884
+ // #endregion
7885
+
7886
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
7887
+ const baseSha = await this.getBaseSha();
7888
+ return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
7889
+ method: "POST",
7890
+ headers: {
7891
+ ...this.authHeaders,
7892
+ "Content-Type": "application/json"
7893
+ },
7894
+ body: JSON.stringify({
7895
+ path,
7896
+ patch,
7897
+ authorId,
7898
+ sessionId,
7899
+ patchId,
7900
+ parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
7901
+ baseSha,
7902
+ commit: this.commitSha,
7903
+ branch: this.branch,
7904
+ coreVersion: core.Internal.VERSION.core,
7905
+ /*
7906
+ * Group membership in the SAME request as the patch.
7907
+ *
7908
+ * The content API runs every refusal before its insert, so an invalid
7909
+ * closure is a 400 with nothing written. Spread rather than sent as
7910
+ * nulls: a client with no group omits the fields entirely, which is
7911
+ * what an older content API expects to see.
7912
+ */
7913
+ ...(patchGroup ? {
7914
+ // Only when the caller named one. Omitted, the content API
7915
+ // resolves this author's open group and creates it if absent,
7916
+ // which is what every write wants.
7917
+ ...(patchGroup.patchGroupId !== undefined ? {
7918
+ patchGroupId: patchGroup.patchGroupId
7919
+ } : {}),
7920
+ withPatchIds: patchGroup.withPatchIds
7921
+ } : {})
7922
+ })
7923
+ }).then(async res => {
7924
+ var _res$headers$get2;
7925
+ if (res.ok) {
7926
+ const parsed = SavePatchResponse.safeParse(await res.json());
7927
+ if (parsed.success) {
7928
+ return fp.result.ok({
7929
+ patchId: parsed.data.patchId,
7930
+ /*
7931
+ * Passed back to the client, which cannot learn it any other way.
7932
+ *
7933
+ * A write names no group — the content API resolves this author's
7934
+ * open group and CREATES it if absent — so on a fresh branch the
7935
+ * group comes into existence here and nowhere else. The chain
7936
+ * annotation is only re-read when a fetch has missing ids to ask
7937
+ * for, and a patch this client made is never missing, so without
7938
+ * this the tab that bootstrapped the group would never learn its
7939
+ * id and every stage would be a no-op.
7940
+ */
7941
+ ...(parsed.data.patchGroupId !== undefined ? {
7942
+ patchGroupId: parsed.data.patchGroupId
7943
+ } : {})
7944
+ });
7945
+ }
7946
+ return fp.result.err({
7947
+ errorType: "other",
7948
+ message: `Could not parse save patch response. Error: ${zodValidationError.fromError(parsed.error)}`
7949
+ });
7950
+ }
7951
+ if (res.status === 409) {
7952
+ return fp.result.err({
7953
+ errorType: "patch-head-conflict",
7954
+ message: "Conflict: " + (await res.text())
7955
+ });
7956
+ }
7957
+ if ((_res$headers$get2 = res.headers.get("Content-Type")) !== null && _res$headers$get2 !== void 0 && _res$headers$get2.includes("application/json")) {
7958
+ const json = await res.json();
7959
+ const message = internal.getErrorMessageFromUnknownJson(json, "Unknown error");
7960
+ return fp.result.err({
7961
+ errorType: "other",
7962
+ message
7963
+ });
7964
+ }
7965
+ return fp.result.err({
7966
+ errorType: "other",
7967
+ message: "Could not save patch. HTTP error: " + res.status + " " + res.statusText
7968
+ });
7969
+ }).catch(e => {
7078
7970
  return fp.result.err({
7079
7971
  errorType: "other",
7080
7972
  message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
@@ -7238,7 +8130,12 @@ class ValOpsHttp extends ValOps {
7238
8130
  if (!file) {
7239
8131
  return null;
7240
8132
  }
7241
- 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");
7242
8139
  }
7243
8140
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, remote) {
7244
8141
  const params = new URLSearchParams();
@@ -7298,7 +8195,17 @@ class ValOpsHttp extends ValOps {
7298
8195
  }]
7299
8196
  };
7300
8197
  }
7301
- async deletePatches(patchIds) {
8198
+ async deletePatches(patchIds,
8199
+ /**
8200
+ * Patches that are NOT deleted but must lose their group membership.
8201
+ *
8202
+ * Deleting a patch out of the middle of a patch set leaves every group
8203
+ * still holding the rest with a non-prefix intersection — the patches after
8204
+ * the hole were written against a view that had it. The content API cannot
8205
+ * work out which those are (it has no schema), so the client sends the
8206
+ * forward closure and it drops those memberships without deleting anything.
8207
+ */
8208
+ unstagePatchIds) {
7302
8209
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
7303
8210
  method: "DELETE",
7304
8211
  headers: {
@@ -7306,7 +8213,10 @@ class ValOpsHttp extends ValOps {
7306
8213
  "Content-Type": "application/json"
7307
8214
  },
7308
8215
  body: JSON.stringify({
7309
- patchIds
8216
+ patchIds,
8217
+ ...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
8218
+ unstagePatchIds
8219
+ } : {})
7310
8220
  })
7311
8221
  }).then(async res => {
7312
8222
  if (res.ok) {
@@ -7345,7 +8255,22 @@ class ValOpsHttp extends ValOps {
7345
8255
  };
7346
8256
  });
7347
8257
  }
7348
- async commit(prepared, message, committer, filesDirectory, newBranch) {
8258
+ async commit(prepared, message, committer, filesDirectory, newBranch,
8259
+ /**
8260
+ * The patch group this commit EMPTIES, if it empties one.
8261
+ *
8262
+ * The content API closes the group it is given — and closes it WITHOUT
8263
+ * checking that the commit shipped all of it, so a caller that names a
8264
+ * group still holding work takes those patches out of every group and
8265
+ * leaves their author unable to publish them. The client therefore sends it
8266
+ * only when the publish accounts for everything the group still holds.
8267
+ *
8268
+ * Omitting it is not neutral: the commit still empties the group (the
8269
+ * content API drops applied ids from every group), but `published_at` is
8270
+ * never set, so the id is reused across publishes instead of a new group
8271
+ * per publish and the "already published" refusal can never fire.
8272
+ */
8273
+ patchGroupId) {
7349
8274
  try {
7350
8275
  var _res$headers$get3;
7351
8276
  const existingBranch = this.branch;
@@ -7353,12 +8278,45 @@ class ValOpsHttp extends ValOps {
7353
8278
  method: "POST",
7354
8279
  headers: {
7355
8280
  ...this.authHeaders,
8281
+ /*
8282
+ * WHO is publishing — the same `x-val-profile-id` stage and unstage
8283
+ * send, and for the same reason: `this.authHeaders` is the app's API
8284
+ * key, which names the PROJECT and not the person.
8285
+ *
8286
+ * Closing a group is an ownership decision, so the content API
8287
+ * refuses a commit that names a `patchGroupId` it cannot attribute:
8288
+ * without this header `profileId` is `undefined` there and every
8289
+ * group-closing publish is 403 "Cannot resolve the caller's profile".
8290
+ * That is the NORMAL full publish, not an edge — `publish` names the
8291
+ * group whenever the commit empties it.
8292
+ *
8293
+ * Sent on every commit rather than only when a group is named. The
8294
+ * committer is a person either way, `committer` in the body already
8295
+ * says so, and a header that appears only on some commits is one more
8296
+ * conditional for a reader of either repo to reconstruct. It changes
8297
+ * nothing else: the content API reads it as an identity claim beside
8298
+ * the app's key and derives no scope from it.
8299
+ */
8300
+ "x-val-profile-id": committer,
7356
8301
  "Content-Type": "application/json"
7357
8302
  },
7358
8303
  body: JSON.stringify({
7359
8304
  patchedSourceFiles: prepared.patchedSourceFiles,
7360
8305
  patchedBinaryFilesDescriptors: prepared.patchedBinaryFilesDescriptors,
7361
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,
7362
8320
  commit: this.commitSha,
7363
8321
  root: this.root,
7364
8322
  filesDirectory,
@@ -7366,7 +8324,10 @@ class ValOpsHttp extends ValOps {
7366
8324
  committer,
7367
8325
  message,
7368
8326
  existingBranch,
7369
- newBranch
8327
+ newBranch,
8328
+ ...(patchGroupId !== undefined ? {
8329
+ patchGroupId
8330
+ } : {})
7370
8331
  })
7371
8332
  });
7372
8333
  if (res.ok) {
@@ -7403,19 +8364,620 @@ class ValOpsHttp extends ValOps {
7403
8364
  }
7404
8365
  };
7405
8366
  }
7406
- return {
7407
- error: {
7408
- message: "Could not commit. HTTP error: " + res.status + " " + res.statusText
7409
- }
8367
+ return {
8368
+ error: {
8369
+ message: "Could not commit. HTTP error: " + res.status + " " + res.statusText
8370
+ }
8371
+ };
8372
+ } catch (err) {
8373
+ return {
8374
+ error: {
8375
+ message: `Could not commit (connection error?): ${err instanceof Error ? err.message : (err === null || err === void 0 ? void 0 : err.toString()) || "unknown error"}`
8376
+ }
8377
+ };
8378
+ }
8379
+ }
8380
+
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);
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
8453
+ });
8454
+ if ((options === null || options === void 0 ? void 0 : options.limit) !== undefined) {
8455
+ params.set("limit", String(options.limit));
8456
+ }
8457
+ if ((options === null || options === void 0 ? void 0 : options.cursor) !== undefined) {
8458
+ params.set("cursor", options.cursor);
8459
+ }
8460
+ const res = await this.getHistory(`/commits?${params}`, ListCommitsResponse, branch);
8461
+ if (fp.result.isErr(res)) {
8462
+ return res;
8463
+ }
8464
+ return fp.result.ok({
8465
+ commits: res.value.commits,
8466
+ nextCursor: res.value.nextCursor
8467
+ });
8468
+ }
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
7410
8859
  };
7411
- } catch (err) {
7412
- return {
7413
- error: {
7414
- message: `Could not commit (connection error?): ${err instanceof Error ? err.message : (err === null || err === void 0 ? void 0 : err.toString()) || "unknown error"}`
7415
- }
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
7416
8886
  };
8887
+ continue;
7417
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
+ });
7418
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
+ });
7419
8981
  }
7420
8982
 
7421
8983
  const host = process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
@@ -7573,31 +9135,39 @@ async function initHandlerOptions(route, opts, config) {
7573
9135
  /**
7574
9136
  * Build the data layer a {@link ValServerConfig} calls for.
7575
9137
  *
7576
- * `auth` decides *whose* credential the http backend sees. Left out, it is the
7577
- * app's own API key — which is what the Studio wants, because there the app has
7578
- * already verified a session cookie and is acting on the user's behalf under its
7579
- * own authority.
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.
9143
+ *
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.
7580
9150
  *
7581
- * A caller acting for a user it has *not* authenticated itself must pass that
7582
- * user's personal access token instead, so the backend is the one that decides
7583
- * what the caller may do. That is the whole of `docs/plans/mcp.md` D.2: the app
7584
- * stops being an authority and goes back to being a pipe. Passing the app's API
7585
- * key on such a request is the D.6 confused deputy, and it is worth being blunt
7586
- * about why it is tempting — it works, and it works for every project the key
7587
- * can reach, including the ones the caller cannot.
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.
7588
9155
  */
7589
- function createValOps(valModules, options, auth) {
9156
+ function createValOps(valModules, options) {
7590
9157
  if (options.mode === "fs") {
7591
9158
  // No credential in fs mode: this reads and writes the developer's own
7592
- // working tree, and there is no backend to authenticate to. A PAT handed in
7593
- // here is not ignored quietly — the caller is told, in createValTools.
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.
7594
9164
  return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
7595
9165
  formatter: options.formatter,
7596
9166
  config: options.config
7597
9167
  });
7598
9168
  }
7599
9169
  if (options.mode === "http") {
7600
- return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, auth ?? {
9170
+ return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
7601
9171
  apiKey: options.apiKey
7602
9172
  }, valModules, {
7603
9173
  formatter: options.formatter,
@@ -8219,90 +9789,6 @@ const ValServer = (valModules, options, callbacks) => {
8219
9789
  };
8220
9790
  }
8221
9791
  },
8222
- "/session": {
8223
- GET: async req => {
8224
- const cookies = req.cookies;
8225
- if (serverOps instanceof ValOpsFS) {
8226
- return {
8227
- status: 200,
8228
- json: {
8229
- mode: "local",
8230
- enabled: await callbacks.isEnabled()
8231
- }
8232
- };
8233
- }
8234
- if (!options.project) {
8235
- return {
8236
- status: 500,
8237
- json: {
8238
- message: "Project is not set"
8239
- }
8240
- };
8241
- }
8242
- if (!options.valSecret) {
8243
- return {
8244
- status: 500,
8245
- json: {
8246
- message: "Secret is not set"
8247
- }
8248
- };
8249
- }
8250
- return withAuth(options.valSecret, cookies, "session", async data => {
8251
- if (!options.valBuildUrl) {
8252
- return {
8253
- status: 500,
8254
- json: {
8255
- message: "Val is not correctly setup. Build url is missing"
8256
- }
8257
- };
8258
- }
8259
- const url = new URL(`/api/val/${options.project}/auth/session`, options.valBuildUrl);
8260
- const fetchRes = await fetch(url, {
8261
- headers: getAuthHeaders(data.token, "application/json")
8262
- });
8263
- if (fetchRes.status === 200) {
8264
- const json = z.z.object({
8265
- member_role: z.z.union([z.z.literal("owner"), z.z.literal("developer"), z.z.literal("editor")]).optional(),
8266
- id: z.z.string(),
8267
- full_name: z.z.string().optional(),
8268
- username: z.z.string().optional(),
8269
- avatar_url: z.z.string().optional()
8270
- }).safeParse(await fetchRes.json());
8271
- if (json.success) {
8272
- return {
8273
- status: fetchRes.status,
8274
- json: {
8275
- mode: "proxy",
8276
- enabled: await callbacks.isEnabled(),
8277
- ...json.data
8278
- }
8279
- };
8280
- } else {
8281
- const message = internal.getErrorMessageFromUnknownJson(json, "Could not parse session response. Unexpected error (no error message). Status: " + fetchRes.status);
8282
- return {
8283
- status: 500,
8284
- json: {
8285
- message: message,
8286
- ...json
8287
- }
8288
- };
8289
- }
8290
- } else {
8291
- const json = z.z.object({
8292
- message: z.z.string()
8293
- }).safeParse(await fetchRes.json());
8294
- const message = internal.getErrorMessageFromUnknownJson(json, "Unknown error");
8295
- return {
8296
- status: fetchRes.status,
8297
- json: {
8298
- message: message,
8299
- ...json
8300
- }
8301
- };
8302
- }
8303
- });
8304
- }
8305
- },
8306
9792
  "/logout": {
8307
9793
  GET: async req => {
8308
9794
  const query = req.query;
@@ -8611,10 +10097,190 @@ const ValServer = (valModules, options, callbacks) => {
8611
10097
  };
8612
10098
  }
8613
10099
  },
10100
+ //#region patch groups
10101
+ // Staging and unstaging patches. Both take an already-closed set of patch ids:
10102
+ // the prefix closure (staging) or the forward closure (unstaging) is computed
10103
+ // on the client, which is the only side that has the schema needed to derive
10104
+ // patch sets. See `docs/independent-publish/PLAN.md`.
10105
+ //
10106
+ // In FS mode there is no shared store and exactly one author, so group
10107
+ // membership lives in the client and these handlers simply acknowledge. The
10108
+ // client already sends an explicit patch id list to `/save`, so a locally-held
10109
+ // group is enough to publish a subset correctly.
10110
+ "/patch-groups/~/patches": {
10111
+ PUT: async req => {
10112
+ const auth = getAuth(req.cookies);
10113
+ if (auth.error) {
10114
+ return {
10115
+ status: 401,
10116
+ json: {
10117
+ message: auth.error
10118
+ }
10119
+ };
10120
+ }
10121
+ const {
10122
+ patchGroupId,
10123
+ patchIds
10124
+ } = req.body;
10125
+ const withPatchIds = req.body.withPatchIds ?? [];
10126
+ if (serverOps instanceof ValOpsFS) {
10127
+ return {
10128
+ status: 200,
10129
+ json: {
10130
+ patchGroupId,
10131
+ patchIds: [...patchIds, ...withPatchIds]
10132
+ }
10133
+ };
10134
+ }
10135
+ if (!("id" in auth) || !auth.id) {
10136
+ return {
10137
+ status: 401,
10138
+ json: {
10139
+ message: "Unauthorized"
10140
+ }
10141
+ };
10142
+ }
10143
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
10144
+ if (refusal !== null) {
10145
+ return {
10146
+ status: refusal.status,
10147
+ json: {
10148
+ message: refusal.message
10149
+ }
10150
+ };
10151
+ }
10152
+ const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
10153
+ // Forwarded so the content API can refuse independently. This server
10154
+ // has already refused a group that is not the caller's; sending the
10155
+ // author means the check also holds for anything reaching the content
10156
+ // API without coming through here.
10157
+ auth.id);
10158
+ if (res.error) {
10159
+ return {
10160
+ status: res.status,
10161
+ json: {
10162
+ message: res.error.message
10163
+ }
10164
+ };
10165
+ }
10166
+ return {
10167
+ status: 200,
10168
+ json: {
10169
+ patchGroupId,
10170
+ patchIds: res.patchIds
10171
+ }
10172
+ };
10173
+ },
10174
+ DELETE: async req => {
10175
+ const auth = getAuth(req.cookies);
10176
+ if (auth.error) {
10177
+ return {
10178
+ status: 401,
10179
+ json: {
10180
+ message: auth.error
10181
+ }
10182
+ };
10183
+ }
10184
+ const {
10185
+ patchGroupId,
10186
+ patchIds
10187
+ } = req.body;
10188
+ const withPatchIds = req.body.withPatchIds ?? [];
10189
+ if (serverOps instanceof ValOpsFS) {
10190
+ return {
10191
+ status: 200,
10192
+ json: {
10193
+ patchGroupId,
10194
+ patchIds: [...patchIds, ...withPatchIds]
10195
+ }
10196
+ };
10197
+ }
10198
+ if (!("id" in auth) || !auth.id) {
10199
+ return {
10200
+ status: 401,
10201
+ json: {
10202
+ message: "Unauthorized"
10203
+ }
10204
+ };
10205
+ }
10206
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
10207
+ if (refusal !== null) {
10208
+ return {
10209
+ status: refusal.status,
10210
+ json: {
10211
+ message: refusal.message
10212
+ }
10213
+ };
10214
+ }
10215
+ const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
10216
+ if (res.error) {
10217
+ return {
10218
+ status: res.status,
10219
+ json: {
10220
+ message: res.error.message
10221
+ }
10222
+ };
10223
+ }
10224
+ return {
10225
+ status: 200,
10226
+ json: {
10227
+ patchGroupId,
10228
+ patchIds: res.patchIds
10229
+ }
10230
+ };
10231
+ }
10232
+ },
8614
10233
  //#region patches
8615
10234
  "/patches": {
8616
10235
  PUT: async req => {
8617
10236
  const cookies = req.cookies;
10237
+
10238
+ /**
10239
+ * Group membership travels WITH the patch, in one request.
10240
+ *
10241
+ * Atomic on purpose: the content API runs every refusal before its
10242
+ * insert, so an invalid closure is a 400 with nothing written. Recording
10243
+ * membership in a second call would let a patch exist outside its
10244
+ * author's group whenever that call failed — and a patch outside your
10245
+ * own group is one you cannot publish until a repair puts it back.
10246
+ *
10247
+ */
10248
+ /*
10249
+ * A membership is present if EITHER field is.
10250
+ *
10251
+ * The common case names no group: the content API resolves the author's
10252
+ * open group, creating it if absent, so the client never has to hold an
10253
+ * id across publishes. It still sends the closure, which is the part it
10254
+ * alone can compute.
10255
+ *
10256
+ * `patchGroupId` is nullable, so an explicit `null` means "no group
10257
+ * named" exactly as omitting it does — it is passed through as
10258
+ * `undefined` rather than becoming a membership keyed by null.
10259
+ */
10260
+ const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
10261
+ const requestedWith = req.body.withPatchIds;
10262
+ const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
10263
+ ...(requestedPatchGroupId !== undefined ? {
10264
+ patchGroupId: requestedPatchGroupId
10265
+ } : {}),
10266
+ withPatchIds: requestedWith ?? []
10267
+ } : undefined;
10268
+ if (patchGroup !== undefined && serverOps instanceof ValOpsFS) {
10269
+ /*
10270
+ * `fs` has no shared store and one author, so there is no group to
10271
+ * join. Refused rather than acknowledged: answering 200 would tell the
10272
+ * client its membership was recorded when it was dropped, and the
10273
+ * client would then believe a publish is scoped when it is not.
10274
+ */
10275
+ return {
10276
+ status: 400,
10277
+ json: {
10278
+ type: "patch-error",
10279
+ message: "Patch groups are not available in fs mode. Omit the patch group fields.",
10280
+ errors: {}
10281
+ }
10282
+ };
10283
+ }
8618
10284
  const auth = getAuth(cookies);
8619
10285
  if (auth.error) {
8620
10286
  return {
@@ -8637,8 +10303,19 @@ const ValServer = (valModules, options, callbacks) => {
8637
10303
  const sessionId = req.body.sessionId ?? null;
8638
10304
  const authorId = "id" in auth ? auth.id : null;
8639
10305
  const newPatchIds = [];
10306
+ /*
10307
+ * The group the content API put these patches in.
10308
+ *
10309
+ * Every patch in one request has the same author and the same
10310
+ * membership, so the last answer is the answer — they all land in the
10311
+ * same group. Reported back because the client cannot learn it any
10312
+ * other way: it names no group (the content API resolves the author's
10313
+ * open one, creating it if absent), and the chain annotation is only
10314
+ * re-read when a fetch has missing ids to ask for.
10315
+ */
10316
+ let patchGroupIdFromStore;
8640
10317
  for (const patch of patches) {
8641
- const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
10318
+ const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
8642
10319
  if (fp.result.isErr(createPatchRes)) {
8643
10320
  if (createPatchRes.error.errorType === "patch-head-conflict") {
8644
10321
  return {
@@ -8670,13 +10347,22 @@ const ValServer = (valModules, options, callbacks) => {
8670
10347
  patchId: createPatchRes.value.patchId
8671
10348
  };
8672
10349
  newPatchIds.push(createPatchRes.value.patchId);
10350
+ if (createPatchRes.value.patchGroupId !== undefined) {
10351
+ patchGroupIdFromStore = createPatchRes.value.patchGroupId;
10352
+ }
8673
10353
  }
8674
10354
  }
8675
10355
  return {
8676
10356
  status: 200,
8677
10357
  json: {
8678
10358
  newPatchIds,
8679
- parentRef
10359
+ parentRef,
10360
+ // Absent rather than null where there are no groups: `fs` mode and
10361
+ // a content API that predates them both answer without one, and the
10362
+ // client reads absence as "staging is not available here".
10363
+ ...(patchGroupIdFromStore !== undefined ? {
10364
+ patchGroupId: patchGroupIdFromStore
10365
+ } : {})
8680
10366
  }
8681
10367
  };
8682
10368
  },
@@ -8738,15 +10424,84 @@ const ValServer = (valModules, options, callbacks) => {
8738
10424
  }
8739
10425
  // TODO: we should sort by parentRef instead:
8740
10426
  patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
10427
+ /**
10428
+ * Patch groups, ANNOTATED onto the chain rather than filtering it.
10429
+ *
10430
+ * The client computes a new patch's parent as the last id in this
10431
+ * response, so a filtered chain would make every client name a parent
10432
+ * that is not the real head and `POST /patches` would answer 409
10433
+ * forever. Annotate, never filter.
10434
+ *
10435
+ * Absent — not empty — where there are no groups: `fs` mode, a content
10436
+ * API that predates them, or a failed lookup. The client reads absence
10437
+ * as "this deployment has no groups" and leaves staging off, which is
10438
+ * the behaviour every project has today. An empty array would say
10439
+ * "groups exist and hold nothing", which would turn the staging UI on
10440
+ * with everything held.
10441
+ */
10442
+ let patchGroups;
10443
+ if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
10444
+ /*
10445
+ * FRESH, because this answer makes a CLOSING decision.
10446
+ *
10447
+ * The client adopts this annotation as its group membership and
10448
+ * `emptiesOwnPatchGroup` then decides from it whether a publish may
10449
+ * name the group — and the content API closes what it is named
10450
+ * without checking. A one-second-old list is enough to get that
10451
+ * wrong: the same author writing in a second tab joins the open
10452
+ * group, the websocket pushes the chain immediately, so this tab's
10453
+ * fetch for the missing patch lands well inside the cache window and
10454
+ * reads a membership that is one patch short. It then publishes,
10455
+ * names the group, and closes it with the other tab's work still in
10456
+ * it — which leaves that tab wedged on 409 until a reload.
10457
+ *
10458
+ * `resolveOwnPatchScope` keeps the cache deliberately: that read
10459
+ * decides what a draft render SHOWS, it is repeated once per
10460
+ * `fetchVal` in a single render, and being a second stale there costs
10461
+ * a patch appearing late rather than a group closing early.
10462
+ */
10463
+ const groupsRes = await serverOps.getPatchGroups({
10464
+ fresh: true
10465
+ });
10466
+ if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
10467
+ patchGroups = groupsRes.patchGroups;
10468
+ } else if (groupsRes.status === "error") {
10469
+ // Not fatal: the chain is what this endpoint is for, and staging
10470
+ // simply stays off for this read rather than the whole review
10471
+ // screen failing to load.
10472
+ console.error("Val: could not read patch groups", groupsRes.message);
10473
+ }
10474
+ }
10475
+ const groupIdsByPatchId = new Map();
10476
+ for (const group of patchGroups ?? []) {
10477
+ for (const patchId of group.patchIds) {
10478
+ const existing = groupIdsByPatchId.get(patchId);
10479
+ if (existing) {
10480
+ existing.push(group.patchGroupId);
10481
+ } else {
10482
+ groupIdsByPatchId.set(patchId, [group.patchGroupId]);
10483
+ }
10484
+ }
10485
+ }
8741
10486
  return {
8742
10487
  status: 200,
8743
10488
  json: {
8744
- patches: patches,
10489
+ patches: patches.map(patch => {
10490
+ const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
10491
+ return patchGroupIds ? {
10492
+ ...patch,
10493
+ patchGroupIds
10494
+ } : patch;
10495
+ }),
10496
+ ...(patchGroups ? {
10497
+ patchGroups
10498
+ } : {}),
8745
10499
  baseSha: await serverOps.getBaseSha()
8746
10500
  }
8747
10501
  };
8748
10502
  },
8749
10503
  DELETE: async req => {
10504
+ var _req$body;
8750
10505
  const query = req.query;
8751
10506
  const cookies = req.cookies;
8752
10507
  const auth = getAuth(cookies);
@@ -8766,8 +10521,26 @@ const ValServer = (valModules, options, callbacks) => {
8766
10521
  }
8767
10522
  };
8768
10523
  }
8769
- const ids = query.id;
8770
- const deleteRes = await serverOps.deletePatches(ids);
10524
+ const ids = query.id;
10525
+ /*
10526
+ * Which OTHER patches lose their group membership because these are
10527
+ * going. Only the client can COMPUTE it — that needs the patch sets,
10528
+ * which need the schema — but it is bounded here rather than trusted,
10529
+ * because the content API strips those memberships from every group
10530
+ * without an ownership check. See `boundUnstageClosure`.
10531
+ *
10532
+ * Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
10533
+ * client does not send it there.
10534
+ */
10535
+ let unstagePatchIds;
10536
+ const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
10537
+ if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
10538
+ const chain = await serverOps.fetchPatches({
10539
+ excludePatchOps: true
10540
+ });
10541
+ unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
10542
+ }
10543
+ const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
8771
10544
  if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
8772
10545
  console.error("Val: Failed to delete patches", deleteRes.errors);
8773
10546
  return {
@@ -8881,6 +10654,22 @@ const ValServer = (valModules, options, callbacks) => {
8881
10654
  } = req.query;
8882
10655
  // Defaults to true, mirroring /sources/~. The Studio opts out.
8883
10656
  const applyPatches = req.query.apply_patches !== false;
10657
+ /*
10658
+ * Whose pending work this render may see.
10659
+ *
10660
+ * The same resolution `/sources/~` runs, through the same function: a
10661
+ * draft page renders module content and `jsonValues` entries together,
10662
+ * and this route used to apply every pending patch on the branch while
10663
+ * the modules beside it were scoped — so one screen showed the caller's
10664
+ * view and everybody's unpublished work at once.
10665
+ */
10666
+ const {
10667
+ ownPatchIds
10668
+ } = await resolveOwnPatchScope(serverOps, {
10669
+ explicitPatchIds: undefined,
10670
+ ownGroupsOnly: req.query.own_patch_groups_only === true,
10671
+ authorId: "id" in auth && auth.id || undefined
10672
+ });
8884
10673
  const isWindow = offset !== undefined || limit !== undefined;
8885
10674
  const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
8886
10675
  if (shapes !== 1) {
@@ -8901,7 +10690,8 @@ const ValServer = (valModules, options, callbacks) => {
8901
10690
  }
8902
10691
  if (key !== undefined) {
8903
10692
  const res = await serverOps.getJsonEntry(moduleFilePath, key, {
8904
- applyPatches
10693
+ applyPatches,
10694
+ patchIds: ownPatchIds
8905
10695
  });
8906
10696
  if (res.status === "unauthorized") {
8907
10697
  return {
@@ -8942,7 +10732,8 @@ const ValServer = (valModules, options, callbacks) => {
8942
10732
  offset: offset,
8943
10733
  limit: limit
8944
10734
  }, {
8945
- applyPatches
10735
+ applyPatches,
10736
+ patchIds: ownPatchIds
8946
10737
  });
8947
10738
  if (res.status === "unauthorized") {
8948
10739
  return {
@@ -9027,10 +10818,87 @@ const ValServer = (valModules, options, callbacks) => {
9027
10818
  patches: []
9028
10819
  };
9029
10820
  if (query.exclude_patches !== true) {
9030
- patchOps = await serverOps.fetchPatches({
9031
- patchIds: undefined,
9032
- excludePatchOps: false
10821
+ /**
10822
+ * The caller's own groups, resolved from their session.
10823
+ *
10824
+ * A draft render cannot name its own group ids — it has no client
10825
+ * state — so it asks for "mine" and the server works out which. Only
10826
+ * when `patch_id` is absent: an explicit list is a caller that already
10827
+ * knows what it wants.
10828
+ *
10829
+ * A group lookup that FAILS renders base rather than everything. Being
10830
+ * shown only committed content while the content API is unreachable is
10831
+ * a degraded preview; being shown another author's unpublished draft
10832
+ * because a lookup failed is the bug this feature exists to prevent,
10833
+ * and it would be silent.
10834
+ */
10835
+ const {
10836
+ ownPatchIds,
10837
+ scopeAlsoIncludesApplied
10838
+ } = await resolveOwnPatchScope(serverOps, {
10839
+ explicitPatchIds: query.patch_id,
10840
+ ownGroupsOnly: query.own_patch_groups_only === true,
10841
+ authorId: "id" in auth && auth.id || undefined
9033
10842
  });
10843
+ const requestedPatchIds = query.patch_id ?? ownPatchIds;
10844
+ if (scopeAlsoIncludesApplied) {
10845
+ /*
10846
+ * Scoped to this caller's groups, PLUS everything already
10847
+ * committed.
10848
+ *
10849
+ * Filtered here rather than through `patchIds`, because the set is
10850
+ * not knowable before the fetch: `appliedAt` lives on the patch,
10851
+ * not on the group. One request either way — the whole chain is
10852
+ * what the unscoped path fetches too — so this costs a filter, not
10853
+ * a round trip.
10854
+ *
10855
+ * This also subsumes the empty-group case below: a caller holding
10856
+ * nothing, on a branch with nothing applied, filters down to no
10857
+ * patches, which is base.
10858
+ */
10859
+ const all = await serverOps.fetchPatches({
10860
+ patchIds: undefined,
10861
+ excludePatchOps: false
10862
+ });
10863
+ patchOps = {
10864
+ ...all,
10865
+ patches: scopedPatches(all.patches, ownPatchIds)
10866
+ };
10867
+ } else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
10868
+ /*
10869
+ * A group that holds nothing renders base, and is handled HERE.
10870
+ *
10871
+ * `fetchPatches` cannot express it: both implementations read an
10872
+ * empty `patchIds` as "no filter" and return the whole chain
10873
+ * (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
10874
+ * `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
10875
+ * That is the right default for every caller that has ever passed a
10876
+ * list, since none of them can mean "none" — but it is the most
10877
+ * dangerous possible reading of an EXPLICITLY empty group, which
10878
+ * would render every unpublished patch on the branch instead of
10879
+ * base.
10880
+ *
10881
+ * Answered before the call rather than by changing that shared
10882
+ * default, which seven other call sites rely on.
10883
+ */
10884
+ patchOps = {
10885
+ patches: []
10886
+ };
10887
+ } else {
10888
+ patchOps = await serverOps.fetchPatches({
10889
+ /*
10890
+ * The caller's patch group, when it named one.
10891
+ *
10892
+ * `undefined` means every pending patch, which is what every
10893
+ * existing caller gets and has to keep getting. A draft-mode
10894
+ * render that names its group gets base + that group instead, so
10895
+ * a server-rendered preview shows the same thing the person
10896
+ * editing is looking at rather than everybody's unpublished work.
10897
+ */
10898
+ patchIds: requestedPatchIds,
10899
+ excludePatchOps: false
10900
+ });
10901
+ }
9034
10902
  }
9035
10903
  // We check authorization here, because it is the first call to the backend
9036
10904
  if (patchOps.error && patchOps.unauthorized) {
@@ -9287,6 +11155,43 @@ const ValServer = (valModules, options, callbacks) => {
9287
11155
  * store wholesale.
9288
11156
  */
9289
11157
  const consumed = patches.patches.map(patch => patch.patchId);
11158
+ /*
11159
+ * Has somebody else published since this was decided?
11160
+ *
11161
+ * The client names the newest commit it knew about; anything newer here
11162
+ * means the review screen it acted on described a world that has moved.
11163
+ * The answer is "look again" rather than "your commit was rejected".
11164
+ *
11165
+ * Git's own not-fast-forward guard cannot see this: the chain is
11166
+ * fetched and committed fresh at this point, so the parent commit sent
11167
+ * is always the server's current one.
11168
+ *
11169
+ * Checked HERE, before `analyzePatches` and `prepare`, rather than just
11170
+ * before the commit: a publish that is going to be refused should not
11171
+ * first pay to apply every patch in it. And compared against the
11172
+ * commits `fetchPatches` just returned — `applicable/patches` filters
11173
+ * its PATCHES by the requested ids and never its commits, so the second
11174
+ * whole-chain fetch this used to make asked for a list it already had.
11175
+ *
11176
+ * Only when the client sends a head. One that does not publishes
11177
+ * exactly as it did before, and this cannot start refusing publishes for
11178
+ * a field it never sets.
11179
+ */
11180
+ if (serverOps instanceof ValOpsHttp) {
11181
+ const expectedHead = body.expectedHeadCommitSha;
11182
+ if (expectedHead !== undefined) {
11183
+ const serverHead = internal.newestCommitSha(patches.commits);
11184
+ if (serverHead !== null && serverHead !== expectedHead) {
11185
+ return {
11186
+ status: 409,
11187
+ json: {
11188
+ message: "Someone else published while you were reviewing. Nothing was published — open Review again to see what changed.",
11189
+ headMoved: true
11190
+ }
11191
+ };
11192
+ }
11193
+ }
11194
+ }
9290
11195
  const analysis = serverOps.analyzePatches(patches.patches, patches.commits, commit);
9291
11196
  let preparedCommit = await serverOps.prepare({
9292
11197
  ...analysis,
@@ -9435,8 +11340,53 @@ const ValServer = (valModules, options, callbacks) => {
9435
11340
  } else if (serverOps instanceof ValOpsHttp) {
9436
11341
  if (auth.error === undefined && auth.id) {
9437
11342
  var _options$config$files;
11343
+ /*
11344
+ * The group this commit CLOSES has to be the caller's.
11345
+ *
11346
+ * Stage and unstage go through `refuseUnlessOwn`; this route did
11347
+ * not, and the content API's `postCommit` marks the group published
11348
+ * on id alone with no author clause — so the id was trusted twice
11349
+ * and checked nowhere. Any logged-in editor could name a colleague's
11350
+ * open group and close it: their pending patches land in a closed
11351
+ * group and in no open one, so a scoped draft render shows base for
11352
+ * them, and their own tab still believes the group is open, so their
11353
+ * next stage is refused with 409.
11354
+ *
11355
+ * Same hole as the stage/unstage one this branch already closed, one
11356
+ * route over. The content API needs the same guard — this one is the
11357
+ * convenience, that one is what actually holds.
11358
+ */
11359
+ if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
11360
+ const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
11361
+ if (refusal !== null) {
11362
+ return {
11363
+ status: refusal.status,
11364
+ json: {
11365
+ message: refusal.message,
11366
+ /*
11367
+ * Flagged, because a bare 409 here reads as git refusing
11368
+ * the commit — which is retryable, and this is the
11369
+ * opposite. `refuseUnlessOwn` answers 409 for one reason
11370
+ * only: the group has already been published, so its id
11371
+ * will never be writable again and retrying reproduces
11372
+ * this forever. The client forgets the id instead.
11373
+ */
11374
+ ...(refusal.status === 409 ? {
11375
+ patchGroupPublished: true
11376
+ } : {})
11377
+ }
11378
+ };
11379
+ }
11380
+ }
9438
11381
  const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
9439
- const commitRes = await serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val");
11382
+ const commitRes = await serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val", undefined,
11383
+ /*
11384
+ * Forwarded verbatim, and only the client can decide it: the
11385
+ * content API closes the group it is named without checking that
11386
+ * the commit shipped all of it, and whether it did needs the
11387
+ * patch sets, which live in the browser.
11388
+ */
11389
+ body.patchGroupId);
9440
11390
  if (commitRes.error) {
9441
11391
  console.error("Failed to commit", commitRes.error);
9442
11392
  if ("isNotFastForward" in commitRes && commitRes.isNotFastForward) {
@@ -9457,9 +11407,20 @@ const ValServer = (valModules, options, callbacks) => {
9457
11407
  };
9458
11408
  }
9459
11409
  // TODO: serverOps.markApplied(patchIds);
11410
+ /*
11411
+ * The new head, back to the client that made it.
11412
+ *
11413
+ * Nothing else tells it in time: `headCommitSha` moves on a `/stat`
11414
+ * response, so until the next poll the client still believes the
11415
+ * pre-publish head — and its next publish sent that as
11416
+ * `expectedHeadCommitSha`, hit the check above against the commit it
11417
+ * had itself just made, and was told somebody else had published.
11418
+ */
9460
11419
  return {
9461
11420
  status: 200,
9462
- json: {} // TODO:
11421
+ json: {
11422
+ commitSha: commitRes.commit
11423
+ }
9463
11424
  };
9464
11425
  }
9465
11426
  return {
@@ -10041,11 +12002,13 @@ const ValServer = (valModules, options, callbacks) => {
10041
12002
  }
10042
12003
  };
10043
12004
  }
10044
- const arrayBuffer = await binaryRes.arrayBuffer();
10045
- const base64 = Buffer.from(arrayBuffer).toString("base64");
10046
- 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());
10047
12010
  const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
10048
- 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);
10049
12012
  if (saveRes.error) {
10050
12013
  return {
10051
12014
  status: 500,
@@ -10173,6 +12136,137 @@ const ValServer = (valModules, options, callbacks) => {
10173
12136
  }
10174
12137
  },
10175
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
10176
12270
  "/files": {
10177
12271
  GET: async req => {
10178
12272
  const query = req.query;
@@ -10225,6 +12319,242 @@ const ValServer = (valModules, options, callbacks) => {
10225
12319
  }
10226
12320
  };
10227
12321
  };
12322
+ /**
12323
+ * Refuse to touch a group that is not the caller's.
12324
+ *
12325
+ * Exported for `patchGroupOwnership.test.ts`: this is the whole of the
12326
+ * authorization for stage and unstage, and nothing else in the process checks
12327
+ * it, so it is worth testing as a policy rather than only through a route.
12328
+ *
12329
+ * `getAuth` only proves a session EXISTS; it says nothing about whose
12330
+ * group this is. And the content API cannot decide either — every call
12331
+ * from here carries the app's API key, not the editor's identity — so if
12332
+ * this does not check, nothing does.
12333
+ *
12334
+ * `GET /patches?include_patch_groups=true` hands every editor the id and
12335
+ * author of every open group on the branch, so without this any logged-in
12336
+ * editor can unstage another author's patches (their next publish
12337
+ * silently ships less) or stage into their group (it silently ships
12338
+ * more). The 403 declared for this route in `ApiRoutes.ts` was
12339
+ * unreachable.
12340
+ *
12341
+ * Fails CLOSED: if the groups cannot be read, the mutation is refused
12342
+ * rather than allowed unverified.
12343
+ */
12344
+
12345
+ /**
12346
+ * The patches a scoped draft render should apply: the caller's own group, plus
12347
+ * everything already committed.
12348
+ *
12349
+ * Scoping is about PENDING work. A published patch stays in the chain with
12350
+ * `appliedAt` set until the next deployment moves the base, and it is part of
12351
+ * everyone's view in that window — the unscoped path applies it. Dropping it
12352
+ * meant the moment somebody published, their own draft preview reverted the
12353
+ * field they had just shipped, and nobody else saw it either until the deploy
12354
+ * landed; anything written on top in that window is authored against content
12355
+ * already stale on `main`.
12356
+ *
12357
+ * Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
12358
+ * PARTIAL publish leaves the group open with only some of its patches applied.
12359
+ * Those are committed too, and no flag on the group names them.
12360
+ *
12361
+ * `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
12362
+ * caller holding nothing, and on a branch with nothing applied it correctly
12363
+ * filters down to no patches, which renders base.
12364
+ */
12365
+ function scopedPatches(patches, ownPatchIds) {
12366
+ const own = new Set(ownPatchIds ?? []);
12367
+ return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
12368
+ }
12369
+
12370
+ /**
12371
+ * Which of the client's `unstagePatchIds` this server is willing to forward.
12372
+ *
12373
+ * The forward closure of a discard is the client's to compute — it needs the
12374
+ * patch sets, which need the schema — and it was being forwarded verbatim. But
12375
+ * the content API removes those memberships from EVERY group with no ownership
12376
+ * check, so any logged-in editor could strip arbitrary patches out of any other
12377
+ * author's group by attaching them to a delete of one of their own throwaway
12378
+ * patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
12379
+ * reached by a different door: their next publish silently ships less.
12380
+ *
12381
+ * Neither server can compute the true closure, but this one can BOUND it. A
12382
+ * patch can only be invalidated by a delete if it was written after that delete
12383
+ * — its paths were chosen against a view that had it — and if it is in the same
12384
+ * module, since a patch set never spans two. Anything outside those bounds was
12385
+ * not in the closure whatever the client says, so it is dropped rather than
12386
+ * refused: the delete is still correct, and refusing the whole request over an
12387
+ * over-broad extra would turn a discard into an error the user cannot act on.
12388
+ *
12389
+ * Exported for the test. Pure, and given the chain rather than fetching it, so
12390
+ * the ordering it depends on is visible in the test rather than mocked.
12391
+ */
12392
+ function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
12393
+ chain, deleted, requested) {
12394
+ if (requested.length === 0) {
12395
+ return [];
12396
+ }
12397
+ const positionOf = new Map();
12398
+ const moduleOf = new Map();
12399
+ chain.forEach((entry, index) => {
12400
+ positionOf.set(entry.patchId, index);
12401
+ moduleOf.set(entry.patchId, entry.path);
12402
+ });
12403
+ const doomed = new Set(deleted);
12404
+ return requested.filter(patchId => {
12405
+ // A patch being deleted anyway does not need its membership stripped
12406
+ // separately, and naming one is how an over-broad list hides.
12407
+ if (doomed.has(patchId)) return false;
12408
+ const position = positionOf.get(patchId);
12409
+ const moduleFilePath = moduleOf.get(patchId);
12410
+ if (position === undefined || moduleFilePath === undefined) return false;
12411
+ return deleted.some(deletedId => {
12412
+ const deletedPosition = positionOf.get(deletedId);
12413
+ if (deletedPosition === undefined) return false;
12414
+ return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
12415
+ });
12416
+ });
12417
+ }
12418
+
12419
+ /**
12420
+ * Which pending patches this caller may see, when they asked for "only mine".
12421
+ *
12422
+ * Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
12423
+ * both, so two answers to "whose work is this" put one person's half-finished
12424
+ * edit on another person's preview through whichever route was not scoped. That
12425
+ * is exactly what happened — `/json` applied every pending patch on the branch
12426
+ * while the module content beside it was scoped.
12427
+ *
12428
+ * `undefined` means "apply everything", which is what every caller that does
12429
+ * not ask for scoping gets and must keep getting.
12430
+ */
12431
+ async function resolveOwnPatchScope(serverOps, opts) {
12432
+ let ownPatchIds;
12433
+ /** See where this is set: committed work is nobody's to hold back. */
12434
+ let scopeAlsoIncludesApplied = false;
12435
+ if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
12436
+ if (serverOps instanceof ValOpsHttp && opts.authorId) {
12437
+ const groupsRes = await serverOps.getPatchGroups();
12438
+ if (groupsRes.status === "unsupported") {
12439
+ /*
12440
+ * A content API that PREDATES patch groups — the endpoint 404s.
12441
+ *
12442
+ * Unscoped, which is what those deployments do today and must
12443
+ * keep doing. Reading this as a failure and rendering base
12444
+ * would silently drop every pending patch from every draft
12445
+ * preview on every existing http project — the exact opposite
12446
+ * of "keeps working unchanged", and invisible to the reader.
12447
+ */
12448
+ ownPatchIds = undefined;
12449
+ } else if (groupsRes.status === "error") {
12450
+ /*
12451
+ * We could not ask, and this deployment DOES have the endpoint.
12452
+ * Render base rather than everything: a degraded preview is
12453
+ * recoverable, showing another author's unpublished draft is
12454
+ * not, and it would be silent.
12455
+ */
12456
+ ownPatchIds = [];
12457
+ } else if (groupsRes.patchGroups.length === 0) {
12458
+ /*
12459
+ * The branch has no groups AT ALL, so this deployment is not
12460
+ * using them — a content API that predates patch groups, or a
12461
+ * project where nobody has staged anything since they existed.
12462
+ *
12463
+ * Unscoped, which is the behaviour every such project has
12464
+ * today. Collapsing this into "your group is empty" would make
12465
+ * every draft render base and silently drop all pending
12466
+ * content, which is what happened before this branch: nothing
12467
+ * writes a group yet, so EVERY project is in this state right
12468
+ * now.
12469
+ */
12470
+ ownPatchIds = undefined;
12471
+ } else {
12472
+ /*
12473
+ * Groups exist and none are this person's: they have staged
12474
+ * nothing, and base is the honest answer. Distinct from the
12475
+ * case above, which is why the two are not one expression.
12476
+ */
12477
+ ownPatchIds = groupsRes.patchGroups.filter(group => group.publishedAt === null && group.authorId === opts.authorId).flatMap(group => group.patchIds);
12478
+ /*
12479
+ * Scoping applies to PENDING work only. Anything already
12480
+ * committed is part of everyone's view.
12481
+ *
12482
+ * A published patch stays in the chain with `appliedAt` set
12483
+ * until the next deployment moves the base, and the unscoped
12484
+ * path applies it. Filtering to open groups dropped it — so the
12485
+ * moment someone published, their own draft preview reverted
12486
+ * the field they had just shipped, and nobody else saw it
12487
+ * either until the deploy landed. Anything written on top in
12488
+ * that window is authored against content that is already
12489
+ * stale on `main`.
12490
+ *
12491
+ * Unioned by `appliedAt` rather than by pulling in groups with
12492
+ * a `publishedAt`, because a partial publish leaves the group
12493
+ * OPEN with some of its patches applied — those are committed
12494
+ * too, and no group flag names them.
12495
+ */
12496
+ scopeAlsoIncludesApplied = true;
12497
+ }
12498
+ } else {
12499
+ /*
12500
+ * fs mode, or a server with no groups: there is nothing to scope
12501
+ * BY, and every pending patch is this one person's anyway. Left
12502
+ * `undefined` so the existing "apply everything" path runs.
12503
+ */
12504
+ ownPatchIds = undefined;
12505
+ }
12506
+ }
12507
+ return {
12508
+ ownPatchIds,
12509
+ scopeAlsoIncludesApplied
12510
+ };
12511
+ }
12512
+ async function refuseUnlessOwn(ops, patchGroupId, authorId) {
12513
+ /*
12514
+ * Never from the cache. This answers "is this group yours", and a group is at
12515
+ * its youngest exactly when the question is asked — the first write creates
12516
+ * it and the shell flushes its queued stages the moment the save response
12517
+ * names it. A cached list fetched a few hundred milliseconds earlier does not
12518
+ * contain it, and every one of those stages was refused and dropped.
12519
+ */
12520
+ const groupsRes = await ops.getPatchGroups({
12521
+ fresh: true
12522
+ });
12523
+ if (groupsRes.status !== "ok") {
12524
+ return {
12525
+ status: 500,
12526
+ message: "Could not verify that this patch group is yours, so it was not changed."
12527
+ };
12528
+ }
12529
+ const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
12530
+ if (group === undefined || group.authorId === null || group.authorId !== authorId) {
12531
+ /*
12532
+ * "Not found" and "not yours" are the SAME refusal on purpose: the route
12533
+ * schema has no 404, and `GET /patches` already lists every group on the
12534
+ * branch, so distinguishing them hides nothing and only adds a second
12535
+ * message to keep consistent.
12536
+ *
12537
+ * A null author is a group written by an api key or a PAT. Nobody owns it,
12538
+ * so nobody may stage into it — `null === null` must not read as a match.
12539
+ */
12540
+ return {
12541
+ status: 403,
12542
+ message: "You can only change your own patch group"
12543
+ };
12544
+ }
12545
+ if (group.publishedAt !== null) {
12546
+ /*
12547
+ * Already shipped, so it can never be written again. The content API
12548
+ * answers this too; refusing here saves the round trip and keeps the
12549
+ * wording the same as every other refusal on this route.
12550
+ */
12551
+ return {
12552
+ status: 409,
12553
+ message: "Patch group is already published"
12554
+ };
12555
+ }
12556
+ return null;
12557
+ }
10228
12558
  function verifyCallbackReq(stateCookie, queryParams) {
10229
12559
  if (typeof stateCookie !== "string") {
10230
12560
  return {
@@ -10435,6 +12765,45 @@ const ENABLE_COOKIE_VALUE = {
10435
12765
  }
10436
12766
  };
10437
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
+ }
10438
12807
  function bufferToReadableStream(buffer) {
10439
12808
  const stream = new ReadableStream({
10440
12809
  start(controller) {
@@ -10756,7 +13125,7 @@ function createValApiRouter(route, valServerPromise, convert) {
10756
13125
  }
10757
13126
  let bodyRes;
10758
13127
  try {
10759
- bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await req.json()) : {
13128
+ bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await readJsonBody(req)) : {
10760
13129
  success: true,
10761
13130
  data: {}
10762
13131
  };
@@ -10835,6 +13204,33 @@ function formatZodErrorString(error) {
10835
13204
  const errors = zodValidationError.fromError(error).toString();
10836
13205
  return errors.length > 640 ? `${errors.slice(0, 640)}...` : errors;
10837
13206
  }
13207
+
13208
+ /**
13209
+ * The request's JSON body, or `undefined` when it has none.
13210
+ *
13211
+ * `req.json()` THROWS on an empty body, and the router used to call it
13212
+ * unconditionally for any route that declares a body — so the moment
13213
+ * `DELETE /patches` gained an optional body, every caller that sent none got
13214
+ * `400 Could not parse request body`. Declaring the schema `.optional()` did
13215
+ * not help: the throw happens before zod is ever consulted. Six e2e tests went
13216
+ * red on a helper doing exactly what the route still permits.
13217
+ *
13218
+ * Told apart by the request rather than by catching, so a body that IS sent and
13219
+ * is malformed still fails: absent means no content type and nothing to read,
13220
+ * and anything else is parsed and allowed to throw. A route whose schema
13221
+ * requires a body is unaffected — it gets `undefined` and zod refuses it, with
13222
+ * the same 400 as before, now naming the field.
13223
+ */
13224
+ async function readJsonBody(req) {
13225
+ if (req.headers.get("content-length") === "0") {
13226
+ return undefined;
13227
+ }
13228
+ const text = await req.text();
13229
+ if (text.length === 0) {
13230
+ return undefined;
13231
+ }
13232
+ return JSON.parse(text);
13233
+ }
10838
13234
  function zodErrorResult(error, message) {
10839
13235
  return {
10840
13236
  status: 400,
@@ -11488,21 +13884,22 @@ async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
11488
13884
  }
11489
13885
 
11490
13886
  /**
11491
- * 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.
11492
13888
  *
11493
- * The PAT case is unchanged and still deliberate: the app cannot resolve a
11494
- * PAT, so any id it wrote here would be an unverified claim dressed up as a
11495
- * checked one — and the request already carries the caller's own token, which
11496
- * is a better answer to "who did this" than anything the app could assert.
11497
- * 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.
11498
13896
  *
11499
- * The token case is the opposite situation, which is why it gets the opposite
11500
- * answer. The host verified a signature over a key it does not hold, so the
11501
- * profile is checked rather than claimed, and the backend has no token of its
11502
- * own to attribute from — the call reaches it under the app's API key. If this
11503
- * stayed null, every edit made through a signed-in editor's own session would
11504
- * land with no author at all, which is worse than useless on a CMS whose
11505
- * 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.
11506
13903
  */
11507
13904
  const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
11508
13905
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -11760,14 +14157,17 @@ function rejectFileOps(patch) {
11760
14157
  */
11761
14158
 
11762
14159
  /**
11763
- * How the caller was established, and it is a union because there are two
11764
- * 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**.
11765
14162
  *
11766
- * The distinction that matters is **who checked**. A PAT is forwarded to the
11767
- * backend unchecked, because the app cannot resolve one; an access token is
11768
- * verified by the app itself, against a public key it does not hold and
11769
- * therefore cannot forge. The first is a credential being relayed. The second
11770
- * 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.
11771
14171
  */
11772
14172
 
11773
14173
  /**
@@ -11802,17 +14202,6 @@ const VAL_SCOPE_READ = "val:read";
11802
14202
  /** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
11803
14203
  const VAL_SCOPE_WRITE = "val:write";
11804
14204
 
11805
- /**
11806
- * How many callers' data layers to keep around in proxy mode.
11807
- *
11808
- * Each entry holds one `ValOpsHttp`, and each of those caches the project's
11809
- * evaluated modules once `initSources` has run — so this bounds memory, not just
11810
- * entry count. Small on purpose: the cost of a miss is re-evaluating the
11811
- * modules on the next call, which is what happened on *every* call before this
11812
- * cache existed.
11813
- */
11814
- const MAX_CACHED_OPS = 8;
11815
-
11816
14205
  /**
11817
14206
  * Val's server-side tool registry.
11818
14207
  *
@@ -11912,17 +14301,25 @@ function createValTools(valModules, options) {
11912
14301
  * Pick the data layer for a call, which in proxy mode means picking whose
11913
14302
  * credential the backend will see.
11914
14303
  *
11915
- * This is the one place authorization is decided, and it decides it by *not*
11916
- * deciding: in proxy mode the caller's own personal access token goes to the
11917
- * backend, which is the only party that can say what that token may do. The app
11918
- * never inspects it, never caches a verdict about it, and never substitutes its
11919
- * 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.
11920
14316
  *
11921
- * The alternative shape, and the reason this function exists at all, is an
11922
- * `authenticate()` that checks the PAT once and then acts under the app's key.
11923
- * That reads as more secure and is strictly less so: the check happens in the
11924
- * app, so every bug in it becomes full access to every project the app's key
11925
- * 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.
11926
14323
  */
11927
14324
  function createOpsResolver(valModules, options) {
11928
14325
  if (options.mode === "fs") {
@@ -11936,18 +14333,17 @@ function createOpsResolver(valModules, options) {
11936
14333
  // and the difference matters, because fs mode writes straight to disk
11937
14334
  // with no backend permission check at all.
11938
14335
  //
11939
- // The two credentials get different messages because they arrive here
11940
- // for different reasons. A PAT is something the caller chose to send. A
11941
- // verified access token is not: it only exists because this app
11942
- // advertised an authorization server, so the developer seeing this did
11943
- // not do anything wrong — a config file did, and naming it is the
11944
- // 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.
11945
14341
  return {
11946
14342
  status: "error",
11947
14343
  result: {
11948
14344
  status: "error",
11949
14345
  code: "unsupported",
11950
- 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."
11951
14347
  }
11952
14348
  };
11953
14349
  }
@@ -11958,78 +14354,50 @@ function createOpsResolver(valModules, options) {
11958
14354
  };
11959
14355
  }
11960
14356
 
11961
- // Keyed by a hash of the PAT, so the same caller reuses their own instance and
11962
- // two callers can never share one. Hashing is not a security boundary — the
11963
- // instance holds the token regardless — but it keeps credentials out of the
11964
- // key set, which is the thing that ends up in a heap dump or an error dump.
11965
- const byPatHash = new Map();
11966
14357
  /**
11967
- * One instance for every verified caller, and unlike the PAT map that is
11968
- * correct rather than a shortcut: this instance authenticates with the app's
11969
- * own API key, so there is nothing per-caller in it to keep apart. Who did
11970
- * 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.
11971
14368
  */
11972
14369
  let sharedOps = null;
11973
14370
  return ctx => {
11974
- 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") {
11975
14373
  return {
11976
14374
  status: "error",
11977
14375
  result: {
11978
14376
  status: "error",
11979
14377
  code: "forbidden",
11980
- 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."
11981
14379
  }
11982
14380
  };
11983
14381
  }
11984
- if (ctx.auth.type === "verified-profile") {
11985
- if (!options.apiKey) {
11986
- // Proxy mode is inferred from the api key being present, so this is
11987
- // unreachable through `initHandlerOptions`. It stays because the
11988
- // alternative to refusing is building ops with no credential at all.
11989
- return {
11990
- status: "error",
11991
- result: {
11992
- status: "error",
11993
- code: "forbidden",
11994
- message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
11995
- }
11996
- };
11997
- }
11998
- if (!sharedOps) {
11999
- sharedOps = createValOps(valModules, options);
12000
- }
12001
- return {
12002
- status: "ok",
12003
- ops: sharedOps
12004
- };
12005
- }
12006
- const key = node_crypto.createHash("sha256").update(ctx.auth.pat).digest("hex");
12007
- const cached = byPatHash.get(key);
12008
- if (cached) {
12009
- // Re-inserted so eviction drops the least recently used rather than the
12010
- // oldest — a long-running caller should not be evicted by a burst of
12011
- // one-off ones.
12012
- byPatHash.delete(key);
12013
- 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.
12014
14386
  return {
12015
- status: "ok",
12016
- 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
+ }
12017
14393
  };
12018
14394
  }
12019
- const ops = createValOps(valModules, options, {
12020
- pat: ctx.auth.pat
12021
- });
12022
- byPatHash.set(key, ops);
12023
- while (byPatHash.size > MAX_CACHED_OPS) {
12024
- const oldest = byPatHash.keys().next();
12025
- if (oldest.done) {
12026
- break;
12027
- }
12028
- byPatHash.delete(oldest.value);
14395
+ if (!sharedOps) {
14396
+ sharedOps = createValOps(valModules, options);
12029
14397
  }
12030
14398
  return {
12031
14399
  status: "ok",
12032
- ops
14400
+ ops: sharedOps
12033
14401
  };
12034
14402
  };
12035
14403
  }
@@ -12127,14 +14495,18 @@ function describeZodError(error) {
12127
14495
  * the safe direction: a tool that forgets the hint is treated as a write and
12128
14496
  * demands the wider scope, rather than a write slipping through as a read.
12129
14497
  *
12130
- * Only the verified-token path is checked. A PAT carries no scopes here by
12131
- * design — the backend resolves it and decides — so there is nothing to
12132
- * enforce, and inventing a default would be this app claiming an authority it
12133
- * 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.
12134
14506
  */
12135
14507
  function refuseInsufficientScope(tool, ctx) {
12136
- var _ctx$auth, _tool$annotations;
12137
- 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") {
12138
14510
  return null;
12139
14511
  }
12140
14512
  // Read is needed by every call, including the writes: a tool that changes
@@ -13663,6 +16035,25 @@ async function handleJsonValuesExtractEntry(ctx) {
13663
16035
  };
13664
16036
  }
13665
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
+
13666
16057
  // Fix handler registry. `keyof:check-keys` and `router:check-route` are
13667
16058
  // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
13668
16059
  // this registry, so they're excluded from the key set.
@@ -13685,7 +16076,8 @@ const currentFixHandlers = {
13685
16076
  "files:check-unique-folder": handleUniqueFolderCheck,
13686
16077
  "images:check-all-files": handleCheckAllFiles,
13687
16078
  "files:check-all-files": handleCheckAllFiles,
13688
- "jsonValues:extract-entry": handleJsonValuesExtractEntry
16079
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry,
16080
+ "external:upload": handleExternalUpload
13689
16081
  };
13690
16082
  const deprecatedFixHandlers = {
13691
16083
  "image:replace-metadata": handleFileMetadata
@@ -14341,6 +16733,7 @@ Object.defineProperty(exports, 'hasRemoteFileSchema', {
14341
16733
  exports.DEFAULT_LOGIN_EXPIRES_IN_SECONDS = DEFAULT_LOGIN_EXPIRES_IN_SECONDS;
14342
16734
  exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
14343
16735
  exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
16736
+ exports.EXTERNAL_RESULT = EXTERNAL_RESULT;
14344
16737
  exports.Service = Service;
14345
16738
  exports.VAL_SCOPE_READ = VAL_SCOPE_READ;
14346
16739
  exports.VAL_SCOPE_WRITE = VAL_SCOPE_WRITE;
@@ -14368,9 +16761,11 @@ exports.createValServer = createValServer;
14368
16761
  exports.createValTools = createValTools;
14369
16762
  exports.currentFixHandlers = currentFixHandlers;
14370
16763
  exports.decodeJwtWithoutVerifying = decodeJwtWithoutVerifying;
16764
+ exports.defineExternal = defineExternal;
14371
16765
  exports.describePatchStoreProblems = describePatchStoreProblems;
14372
16766
  exports.downloadFileFromRemote = downloadFileFromRemote;
14373
16767
  exports.encodeJwt = encodeJwt;
16768
+ exports.err = err$1;
14374
16769
  exports.evalValConfigFile = evalValConfigFile;
14375
16770
  exports.extractFileMetadata = extractFileMetadata;
14376
16771
  exports.extractImageMetadata = extractImageMetadata;
@@ -14390,6 +16785,7 @@ exports.getPersonalAccessTokenPath = getPersonalAccessTokenPath;
14390
16785
  exports.getSettings = getSettings;
14391
16786
  exports.getValidationErrorFileRef = getValidationErrorFileRef;
14392
16787
  exports.handleCheckAllFiles = handleCheckAllFiles;
16788
+ exports.handleExternalUpload = handleExternalUpload;
14393
16789
  exports.handleFileMetadata = handleFileMetadata;
14394
16790
  exports.handleJsonValuesExtractEntry = handleJsonValuesExtractEntry;
14395
16791
  exports.handleRemoteFileCheck = handleRemoteFileCheck;
@@ -14398,7 +16794,9 @@ exports.handleRemoteFileUpload = handleRemoteFileUpload;
14398
16794
  exports.handleRemoteGalleryFileUpload = handleRemoteGalleryFileUpload;
14399
16795
  exports.handleUniqueFolderCheck = handleUniqueFolderCheck;
14400
16796
  exports.initHandlerOptions = initHandlerOptions;
16797
+ exports.isExternalResult = isExternalResult;
14401
16798
  exports.loadValModules = loadValModules;
16799
+ exports.ok = ok$1;
14402
16800
  exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
14403
16801
  exports.patchSourceFile = patchSourceFile;
14404
16802
  exports.persistPersonalAccessToken = persistPersonalAccessToken;