@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.
@@ -8,14 +8,14 @@ import path__default from 'path';
8
8
  import fs, { promises } from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module, createRequire } from 'node:module';
11
- import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
11
+ import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
13
  import crypto$1 from 'crypto';
14
14
  import z$1, { z } from 'zod';
15
15
  import sizeOf from 'image-size';
16
16
  import { fromError } from 'zod-validation-error';
17
17
  import os from 'os';
18
- import { randomUUID, createHash } from 'node:crypto';
18
+ import { randomUUID } from 'node:crypto';
19
19
  import { transform } from 'sucrase';
20
20
  import http from 'http';
21
21
  import https from 'https';
@@ -565,11 +565,13 @@ const patchValFile = async (id, rootDir, patch, sourceFileHandler) => {
565
565
  // );
566
566
  // sourceFileHandler.moveFile(tempFilePath, "." + filePath);
567
567
  // TODO: ensure that directory exists
568
- if (content.startsWith("data:/image/svg+xml")) {
569
- sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("utf8"), "utf8");
570
- } else {
571
- sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
572
- }
568
+ // NOTE: there used to be a branch here testing
569
+ // `content.startsWith("data:/image/svg+xml")` - with a stray slash after
570
+ // `data:`, so it could never match a real data URL and SVGs took the
571
+ // "binary" path anyway. Removed rather than fixed: the branch existed to
572
+ // write SVG as utf8, and writing the decoded bytes is correct for every
573
+ // type including SVG.
574
+ sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
573
575
  }
574
576
  sourceFileHandler.writeSourceFile(newSourceFile.value);
575
577
  // console.timeEnd("patchValFile" + timeId);
@@ -1665,6 +1667,21 @@ class Service {
1665
1667
  getModuleFilePaths() {
1666
1668
  return Object.keys(this.extracted.sources);
1667
1669
  }
1670
+
1671
+ /**
1672
+ * Everything that is wrong with how the project's modules are DECLARED, as
1673
+ * opposed to what is in them.
1674
+ *
1675
+ * A module that failed to load is here, and so is a rule that spans the whole
1676
+ * set — "one settings module, at the root" cannot be checked while looking at
1677
+ * a single module, so `extractValModules` appends it with the offending path.
1678
+ * `get` only surfaces these when the module is missing entirely, which a
1679
+ * misplaced settings module is not: `val validate` reads them from here and
1680
+ * reports them against the file.
1681
+ */
1682
+ getModuleErrors() {
1683
+ return this.extracted.moduleErrors;
1684
+ }
1668
1685
  serializedSchemaOf(moduleFilePath) {
1669
1686
  return this.extracted.serializedSchemas[moduleFilePath];
1670
1687
  }
@@ -1903,6 +1920,183 @@ class Service {
1903
1920
  }
1904
1921
  }
1905
1922
 
1923
+ /**
1924
+ * The type surface of an external record's adapter.
1925
+ *
1926
+ * Nothing here runs: phase 0 is the contract, and the registry that executes it
1927
+ * arrives with the read endpoints. What the contract has to get right now is the
1928
+ * shape, because every adapter written against it is a compatibility promise.
1929
+ *
1930
+ * Four kinds of method, and each is required or not for one reason:
1931
+ *
1932
+ * - **Read** — `keys`, `get`. Cannot be built out of anything else, so always
1933
+ * required.
1934
+ * - **Write** — `put`, `delete`. Likewise, and they move together: required on a
1935
+ * writable record, forbidden on a `.readonly()` one.
1936
+ * - **Derived** — `count`, `search`. Computable from the read methods, so
1937
+ * omitting one costs performance, never capability. `false` declines the
1938
+ * fallback; that is the only thing `false` ever means here.
1939
+ * - **Media** — `putFile`, `getFile`. A pair, and required by the item SCHEMA
1940
+ * rather than by this type (see `hasMediaSchema`).
1941
+ */
1942
+
1943
+ // #region result
1944
+
1945
+ /**
1946
+ * Brands the result envelope so a bare value can be accepted alongside it.
1947
+ *
1948
+ * A symbol rather than a property name because `get` returns
1949
+ * `Record<key, Item>` — a record that can perfectly well contain a key called
1950
+ * `kind`. The envelope never crosses the wire (it travels in-process between
1951
+ * adapter and Val), so there is no serialization cost to it.
1952
+ */
1953
+ const EXTERNAL_RESULT = Symbol.for("@valbuild/server/ExternalResult");
1954
+
1955
+ /**
1956
+ * What every adapter method may return: the value on its own, or the envelope.
1957
+ *
1958
+ * A bare value means "ok, nothing to report", which keeps the common path one
1959
+ * line. Errors can be thrown instead of returned — the envelope is for a failure
1960
+ * worth classifying, or a success worth annotating.
1961
+ */
1962
+
1963
+ /**
1964
+ * Wrap a value as a successful result, optionally with warnings.
1965
+ *
1966
+ * `NoInfer` is load-bearing, not decoration. Without it `T` is inferred FROM the
1967
+ * argument, and an object literal that infers its own type is not contextually
1968
+ * typed by the adapter's contract — so `title` inside `ok({ a: { title } })` is a
1969
+ * fresh property that happens to be named `title`, with no declaration link back
1970
+ * to `title: s.string()` in the schema. "Find all references" on the schema field
1971
+ * then stops at the page that reads it and never reaches the adapter that
1972
+ * produces it. `NoInfer` blocks that inference, leaving the contextual return
1973
+ * type as the only source for `T`, which is what restores the link (and, as a
1974
+ * bonus, makes a typo report the offending property rather than the whole
1975
+ * return value).
1976
+ *
1977
+ * `externalNavigation.test.ts` guards this; nothing else would notice.
1978
+ *
1979
+ * The cost: `ok(...)` in a position with NO contextual type — a helper without a
1980
+ * return type annotation — infers `unknown` instead of the argument's type, and
1981
+ * fails where the helper is used rather than where it is written. Annotate the
1982
+ * helper's return type, or inline it.
1983
+ */
1984
+ function ok$1(value, warnings) {
1985
+ return warnings && warnings.length > 0 ? {
1986
+ [EXTERNAL_RESULT]: true,
1987
+ kind: "ok",
1988
+ value,
1989
+ warnings
1990
+ } : {
1991
+ [EXTERNAL_RESULT]: true,
1992
+ kind: "ok",
1993
+ value
1994
+ };
1995
+ }
1996
+ function err$1(issue) {
1997
+ return {
1998
+ [EXTERNAL_RESULT]: true,
1999
+ kind: "err",
2000
+ error: issue
2001
+ };
2002
+ }
2003
+ function isExternalResult(value) {
2004
+ return typeof value === "object" && value !== null && value[EXTERNAL_RESULT] === true;
2005
+ }
2006
+
2007
+ // #endregion
2008
+
2009
+ // #region context
2010
+
2011
+ /**
2012
+ * What every adapter method is told about the call it is serving.
2013
+ *
2014
+ * `tx` is present only when the adapter declared an `around`; a store with no
2015
+ * transaction seam has nothing to put there and nothing to ignore.
2016
+ */
2017
+
2018
+ // #endregion
2019
+
2020
+ // #region paging, sorting, searching
2021
+
2022
+ /**
2023
+ * How a page should be ordered.
2024
+ *
2025
+ * Records are unordered today and sorting is coming; the parameter lands now
2026
+ * because adding one to `keys` later would break every adapter that exists by
2027
+ * then. Val passes `undefined` until sorting ships, and an adapter may ignore it.
2028
+ *
2029
+ * Two rules for whoever implements it:
2030
+ *
2031
+ * - **A cursor is only valid for the sort that issued it.** Key-ordered paging
2032
+ * is `where key > cursor`; sorted paging is `where (field, key) > (…, …)`.
2033
+ * Val pairs the cursor with a hash of the sort and restarts rather than
2034
+ * replaying a mismatched one.
2035
+ * - **The key is always the last sort term.** Ordering by a non-unique field
2036
+ * without a tiebreaker lets rows shift between pages, so page 2 can skip or
2037
+ * repeat an entry while both pages look fine on their own.
2038
+ */
2039
+
2040
+ // #endregion
2041
+
2042
+ // #region media
2043
+
2044
+ // #endregion
2045
+
2046
+ // #region the adapter
2047
+
2048
+ /** Read methods. Always required. */
2049
+
2050
+ /**
2051
+ * Write methods. Required together, and forbidden together on `.readonly()`.
2052
+ *
2053
+ * `put` must be an UPSERT keyed by the entry key and `delete` must tolerate an
2054
+ * absent key, because a publish may be replayed: retry re-runs the whole scope
2055
+ * where there is a transaction, and the individual call where there is not.
2056
+ */
2057
+
2058
+ /**
2059
+ * Named so the compiler prints the reason. A bare `never` would report only
2060
+ * "not assignable to type 'undefined'", which tells nobody anything.
2061
+ */
2062
+
2063
+ /** Derived and media methods, and the sort declaration. */
2064
+
2065
+ /** The item type an external module's entries hold, loosened as JSON allows. */
2066
+
2067
+ /**
2068
+ * The adapter a given external module needs.
2069
+ *
2070
+ * Writes are required or forbidden by the module's own `.readonly()`, read off
2071
+ * the source marker's phantom.
2072
+ */
2073
+
2074
+ /** What `entry()` returns: a module and its adapter, checked against each other. */
2075
+
2076
+ /**
2077
+ * Declare the adapters for this project's external records.
2078
+ *
2079
+ * `Tx` is given explicitly: `around` offers the compiler no inference site,
2080
+ * since `run` is a callback you CALL rather than one whose signature you write.
2081
+ * One type argument, in one place, and every inline adapter then gets `tx`,
2082
+ * `cursor`, `limit` and the row shape correctly typed.
2083
+ *
2084
+ * @example
2085
+ * const { entry, modules } = defineExternal<typeof sql>({
2086
+ * around: (run) => sql.begin(run),
2087
+ * });
2088
+ *
2089
+ * export default modules({
2090
+ * posts: entry(postsVal, { keys, get, put, delete: del, search: false }),
2091
+ * });
2092
+ *
2093
+ * @example a store with no transaction
2094
+ * const { entry, modules } = defineExternal();
2095
+ */
2096
+ function defineExternal(definition) {
2097
+ throw new Error("defineExternal is not implemented yet: phase 0 lands the contract, the registry that executes it arrives with the read endpoints.");
2098
+ }
2099
+
1906
2100
  const JwtPayloadSchema = z.object({
1907
2101
  sub: z.string(),
1908
2102
  exp: z.number(),
@@ -2362,7 +2556,8 @@ class ValOps {
2362
2556
  * in-flight client patches the server has not seen) must pass
2363
2557
  * `applyPatches: false` or the same edits would be applied twice.
2364
2558
  */
2365
- async getJsonEntry(moduleFilePath, entryKey, opts) {
2559
+ async getJsonEntry(moduleFilePath, entryKey, /** Passed straight through — see {@link getJsonEntries}. */
2560
+ opts) {
2366
2561
  const res = await this.getJsonEntries(moduleFilePath, {
2367
2562
  keys: [entryKey]
2368
2563
  }, opts);
@@ -2456,7 +2651,7 @@ class ValOps {
2456
2651
  message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
2457
2652
  };
2458
2653
  }
2459
- modulePatches = patchOps.patches.filter(p => p.path === moduleFilePath && !p.appliedAt).map(p => ({
2654
+ modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds).map(p => ({
2460
2655
  patchId: p.patchId,
2461
2656
  patch: p.patch
2462
2657
  }));
@@ -3686,6 +3881,47 @@ class ValOps {
3686
3881
  };
3687
3882
  }
3688
3883
  }));
3884
+
3885
+ /*
3886
+ * What each changed module IS after this commit, and the schema it is under.
3887
+ *
3888
+ * The data rather than the file's text, because that is the half git cannot
3889
+ * give back: a `.val.ts` in git is code, and turning code back into data
3890
+ * means parsing it, which is best-effort and rots across TypeScript,
3891
+ * runtime and Val versions. The schema comes along because a value on its
3892
+ * own cannot be RENDERED - showing a module as it was at a commit whose
3893
+ * schema has since changed needs the schema of that commit, and nothing in
3894
+ * the current checkout has it.
3895
+ *
3896
+ * Taken from `getSources(analysis)` rather than re-derived here so the data
3897
+ * stored is the same data the Studio shows, produced by the one
3898
+ * implementation of "apply these ops".
3899
+ */
3900
+ const moduleVersions = {};
3901
+ const {
3902
+ sources: sourcesAfter
3903
+ } = await this.getSources(patchAnalysis);
3904
+ for (const path of Object.keys(patchesByModule)) {
3905
+ let serialized;
3906
+ try {
3907
+ var _schemas$path3;
3908
+ serialized = (_schemas$path3 = schemas[path]) === null || _schemas$path3 === void 0 ? void 0 : _schemas$path3["executeSerialize"]();
3909
+ } catch {
3910
+ // Same guard as above: one unserializable schema must not cost every
3911
+ // other module its history.
3912
+ serialized = undefined;
3913
+ }
3914
+ if (!serialized) {
3915
+ continue;
3916
+ }
3917
+ const source = sourcesAfter[path];
3918
+ moduleVersions[path] = {
3919
+ // `undefined` here means the module is gone, which is a state history
3920
+ // has to be able to show. `null` is how that travels over the wire.
3921
+ source: source === undefined ? null : source,
3922
+ schema: serialized
3923
+ };
3924
+ }
3689
3925
  const res = {
3690
3926
  hasErrors,
3691
3927
  sourceFilePatchErrors,
@@ -3698,7 +3934,8 @@ class ValOps {
3698
3934
  patchedBinaryFilesDescriptors,
3699
3935
  appliedPatches,
3700
3936
  skippedPatches,
3701
- triedPatches
3937
+ triedPatches,
3938
+ moduleVersions
3702
3939
  };
3703
3940
  return res;
3704
3941
  }
@@ -3716,8 +3953,21 @@ class ValOps {
3716
3953
  }
3717
3954
 
3718
3955
  // #region createPatch
3719
- async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
3720
- const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
3956
+ async createPatch(path, patch, patchId, parentRef, sessionId, authorId,
3957
+ /**
3958
+ * Which patch group this patch joins, recorded in the SAME request.
3959
+ *
3960
+ * Atomic on purpose. The content API runs every refusal before its insert,
3961
+ * so an invalid closure is a 400 with nothing written. Recording membership
3962
+ * in a second call would let a patch exist outside its author's group if
3963
+ * that call failed — and a patch outside your own group is one you cannot
3964
+ * publish until a repair puts it back.
3965
+ *
3966
+ * Optional: `fs` mode has no groups, and a client that predates them sends
3967
+ * nothing.
3968
+ */
3969
+ patchGroup) {
3970
+ const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
3721
3971
  if (result.isErr(saveRes)) {
3722
3972
  console.error(`Could not save source patch at path: '${path}'. Error: ${saveRes.error.errorType === "other" ? saveRes.error.message : saveRes.error.errorType}`);
3723
3973
  if (saveRes.error.errorType === "patch-head-conflict") {
@@ -3732,11 +3982,76 @@ class ValOps {
3732
3982
  }
3733
3983
  return result.ok({
3734
3984
  patchId,
3735
- createdAt: new Date().toISOString()
3985
+ createdAt: new Date().toISOString(),
3986
+ // Spread rather than assigned: absent has to stay distinguishable from a
3987
+ // group whose id is undefined, all the way to the client.
3988
+ ...(saveRes.value.patchGroupId !== undefined ? {
3989
+ patchGroupId: saveRes.value.patchGroupId
3990
+ } : {})
3736
3991
  });
3737
3992
  }
3738
3993
 
3739
3994
  // #region abstract ops
3995
+
3996
+ /**
3997
+ * Save a patch's binary file from a `data:...;base64,...` URL.
3998
+ *
3999
+ * The wire form: `FileReader.readAsDataURL` is what the browser produces, and
4000
+ * published `@valbuild/server` versions send it. Code that already HAS bytes
4001
+ * should call {@link saveBinaryFileFromPatch} instead of wrapping them in a
4002
+ * data URL just to have this unwrap them again.
4003
+ *
4004
+ * A `null` `data` records a DELETION, which is why this cannot simply be
4005
+ * replaced by the byte-taking sibling: there is nothing to hand it.
4006
+ */
4007
+
4008
+ /**
4009
+ * The same, for a caller that already has the bytes.
4010
+ *
4011
+ * Default implementation wraps them back into a data URL so every backend
4012
+ * gets this for free; a backend that can take bytes straight through should
4013
+ * override it.
4014
+ */
4015
+ async saveBinaryFileFromPatch(filePath, parentRef, patchId, bytes, mimeType, type, metadata) {
4016
+ return this.saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, `data:${mimeType};base64,${bytes.toString("base64")}`, type, metadata);
4017
+ }
4018
+
4019
+ // #region history
4020
+ //
4021
+ // Reading the past, as opposed to reading the present with pending patches
4022
+ // applied. Every one of these is Result-typed against `HistoryError`, because
4023
+ // the ways this can fail - an unreadable record, a source that no longer
4024
+ // parses, an op that will not replay, a schema that has moved on - are the
4025
+ // interesting part rather than an edge case, and a caller deciding whether to
4026
+ // offer a RESTORE has to know which one it hit.
4027
+ //
4028
+ // Only implemented where there is a service holding the history:
4029
+ // `ValOpsHttp`. `ValOpsFS` answers `not-supported-in-fs-mode`, because local
4030
+ // dev has git for this and no commit records of its own.
4031
+
4032
+ /** One page of a branch's commits, newest first. See history/listCommits. */
4033
+
4034
+ /** The patches that produced one commit, with their ops. */
4035
+
4036
+ /**
4037
+ * How each `.val.ts` the commit changed looked BEFORE it, keyed by module
4038
+ * file path. Empty for a commit made before this was recorded - which the
4039
+ * caller reports as `source-unavailable` rather than as an empty module.
4040
+ */
4041
+ /**
4042
+ * Each module a commit changed: its data, and the schema it was under.
4043
+ *
4044
+ * `asOf` widens it from "what this commit changed" to "the whole project as
4045
+ * this commit left it", which is what reverting everything to a point in time
4046
+ * needs; `moduleFilePath` narrows it to one module, for navigating the
4047
+ * history pane off the changed set.
4048
+ */
4049
+
4050
+ /** Which files the commit touched, and how. Names them; does not fetch them. */
4051
+
4052
+ /** One file's bytes as they were at one commit. */
4053
+
4054
+ // #endregion history
3740
4055
  }
3741
4056
  function isOnlyFileCheckValidationError(validationError) {
3742
4057
  var _validationError$fixe9;
@@ -3755,6 +4070,21 @@ function isOnlyFileCheckValidationError(validationError) {
3755
4070
  function isFileSource(value) {
3756
4071
  return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
3757
4072
  }
4073
+
4074
+ /**
4075
+ * The patch group a newly created patch joins.
4076
+ *
4077
+ * `withPatchIds` is the CLOSURE the client computed — the patches that share
4078
+ * a patch set with this one and must move with it. It is not derived here and
4079
+ * must not be: the closure needs the content schema, and the service that
4080
+ * stores groups does not have it. One implementation of that rule, on the side
4081
+ * that can actually compute it.
4082
+ *
4083
+ * Membership rows are stamped with `coreVersion` on the content side, the same
4084
+ * stamp the patch row itself carries, so which client wrote a row stays legible
4085
+ * after the fact.
4086
+ */
4087
+
3758
4088
  function formatPatchSourceError(error) {
3759
4089
  if ("message" in error) {
3760
4090
  return error.message;
@@ -3765,6 +4095,33 @@ function formatPatchSourceError(error) {
3765
4095
  return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
3766
4096
  }
3767
4097
  }
4098
+ /**
4099
+ * The patches a json entry render should apply, out of the whole chain.
4100
+ *
4101
+ * Three rules, and the second is the one that was missing. A draft page renders
4102
+ * `jsonValues` entries beside module content, and only the modules were scoped
4103
+ * — so one screen showed the caller's own view for its modules and base plus
4104
+ * EVERY pending patch on the branch for the entries beside them, including
4105
+ * another author's half-finished edit rendered as though it were live.
4106
+ *
4107
+ * 1. this module's, since the chain is branch-wide;
4108
+ * 2. this caller's, when they asked to be scoped. `undefined` is "everything",
4109
+ * which is what every unscoped caller gets and must keep getting;
4110
+ * 3. not already applied — a fact about this path rather than about scoping,
4111
+ * and true with or without a scope.
4112
+ *
4113
+ * Filtered here rather than by asking `fetchPatches` for a list, and that is
4114
+ * load-bearing: both implementations read an empty `patchIds` as "no filter"
4115
+ * and return the whole chain. That is the right default for a caller that
4116
+ * cannot mean "none", and the most dangerous possible reading of a group that
4117
+ * is genuinely empty — it would render every unpublished patch on the branch
4118
+ * instead of base. It costs no round trip either: the whole chain is what the
4119
+ * unscoped path fetches anyway.
4120
+ */
4121
+ function scopedModulePatches(patches, moduleFilePath, patchIds) {
4122
+ const scope = patchIds && new Set(patchIds);
4123
+ return patches.filter(patch => patch.path === moduleFilePath && !patch.appliedAt && (scope === undefined || scope.has(patch.patchId)));
4124
+ }
3768
4125
  function getFieldsForType(type) {
3769
4126
  if (type === "file") {
3770
4127
  return ["mimeType"];
@@ -5795,7 +6152,17 @@ class ValOpsFS extends ValOps {
5795
6152
  };
5796
6153
  }
5797
6154
  }
5798
- async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
6155
+ async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId,
6156
+ /*
6157
+ * Named and ignored, rather than omitted from the signature.
6158
+ *
6159
+ * `fs` mode has no shared store and exactly one author, so there is nothing
6160
+ * for a group to separate — every pending patch is already this person's.
6161
+ * Declaring it makes that a decision a reader can see: TypeScript lets an
6162
+ * implementation take fewer parameters than the abstract, so leaving it off
6163
+ * would drop group membership silently and look identical to handling it.
6164
+ */
6165
+ _patchGroup) {
5799
6166
  const patchesDir = this.getPatchesDir();
5800
6167
  const record = {
5801
6168
  patch,
@@ -6290,6 +6657,46 @@ class ValOpsFS extends ValOps {
6290
6657
  getPatchLockFile() {
6291
6658
  return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
6292
6659
  }
6660
+
6661
+ // #region history
6662
+ //
6663
+ // Not available locally, and deliberately not faked from git.
6664
+ //
6665
+ // History is a record of what VAL did: which patches produced a commit, who
6666
+ // wrote them, and what each module looked like immediately before. Git has
6667
+ // the files but not that - it cannot say which of a commit's changes were one
6668
+ // editor's patch set, so a "restore this change" built on it would be
6669
+ // guesswork wearing the same UI.
6670
+ //
6671
+ // A single, honest error rather than five different ones: the caller's
6672
+ // question is "is history available here", and the answer is no.
6673
+
6674
+ async listCommits() {
6675
+ return result.err({
6676
+ kind: "not-supported-in-fs-mode"
6677
+ });
6678
+ }
6679
+ async getCommitPatches() {
6680
+ return result.err({
6681
+ kind: "not-supported-in-fs-mode"
6682
+ });
6683
+ }
6684
+ async getCommitModules() {
6685
+ return result.err({
6686
+ kind: "not-supported-in-fs-mode"
6687
+ });
6688
+ }
6689
+ async getCommitAffectedFiles() {
6690
+ return result.err({
6691
+ kind: "not-supported-in-fs-mode"
6692
+ });
6693
+ }
6694
+ async getFileAtCommit() {
6695
+ return result.err({
6696
+ kind: "not-supported-in-fs-mode"
6697
+ });
6698
+ }
6699
+ // #endregion history
6293
6700
  }
6294
6701
  class FSOpsHost {
6295
6702
  constructor() {}
@@ -6552,7 +6959,14 @@ const FilesResponse = z.object({
6552
6959
  })])).optional()
6553
6960
  });
6554
6961
  const SavePatchResponse = z.object({
6555
- patchId: PatchId
6962
+ patchId: PatchId,
6963
+ /**
6964
+ * Which group the content API put this patch in.
6965
+ *
6966
+ * Optional: a content API that predates patch groups does not send one, and
6967
+ * absence has to keep meaning "no groups here" rather than failing the save.
6968
+ */
6969
+ patchGroupId: z.string().optional()
6556
6970
  });
6557
6971
  const DeletePatchesResponse = z.object({
6558
6972
  deleted: z.array(PatchId),
@@ -6570,10 +6984,126 @@ const CommitResponse = z.object({
6570
6984
  commit: CommitSha,
6571
6985
  branch: z.string()
6572
6986
  });
6987
+ // #region history wire schemas
6988
+ //
6989
+ // Validated on arrival rather than trusted: these come from a service that
6990
+ // versions separately, and a silently mis-shaped commit record reads as "this
6991
+ // commit changed nothing", which is indistinguishable from a real answer.
6992
+ const HistoricalCommitResponse = z.object({
6993
+ commitSha: z.string(),
6994
+ parentCommitSha: z.string(),
6995
+ clientCommitSha: z.string(),
6996
+ branch: z.string(),
6997
+ createdBranch: z.string().nullable(),
6998
+ creator: z.string().nullable(),
6999
+ message: z.string().nullable(),
7000
+ createdAt: z.string(),
7001
+ seqNum: z.string(),
7002
+ patchCount: z.number(),
7003
+ hasArchive: z.boolean()
7004
+ });
7005
+ const ListCommitsResponse = z.object({
7006
+ commits: z.array(HistoricalCommitResponse),
7007
+ nextCursor: z.string().nullable()
7008
+ });
7009
+ const CommitPatchesResponse = z.object({
7010
+ commitSha: z.string(),
7011
+ commit: z.object({
7012
+ commitSha: z.string(),
7013
+ parentCommitSha: z.string(),
7014
+ clientCommitSha: z.string(),
7015
+ branch: z.string(),
7016
+ createdBranch: z.string().nullable(),
7017
+ creator: z.string().nullable(),
7018
+ message: z.string().nullable(),
7019
+ createdAt: z.string(),
7020
+ seqNum: z.string(),
7021
+ hasArchive: z.boolean()
7022
+ }),
7023
+ patches: z.array(z.object({
7024
+ patchId: z.string(),
7025
+ path: z.string(),
7026
+ patch: z.unknown(),
7027
+ authorId: z.string().nullable(),
7028
+ createdAt: z.string(),
7029
+ baseSha: z.string(),
7030
+ coreVersion: z.string()
7031
+ }))
7032
+ });
7033
+
7034
+ /**
7035
+ * `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
7036
+ *
7037
+ * `schema` stays `unknown` here on purpose. The content service stores it
7038
+ * opaquely and cannot vouch for it, so validating it at the transport boundary
7039
+ * would turn "a schema written by a different version of Val" into a failed
7040
+ * REQUEST rather than one module that cannot be shown. It is checked in
7041
+ * `getHistoricalPatchSet`, per module, where a failure degrades that module and
7042
+ * leaves the commit readable.
7043
+ */
7044
+ const CommitModulesResponse = z.object({
7045
+ commitSha: z.string(),
7046
+ parentCommitSha: z.string(),
7047
+ /**
7048
+ * Whether an `asOf` read covered the whole project.
7049
+ *
7050
+ * Optional so an older content server still parses. False means modules last
7051
+ * edited before history started being recorded are missing from the answer -
7052
+ * which a whole-project revert has to say out loud rather than silently skip.
7053
+ */
7054
+ complete: z.boolean().optional(),
7055
+ modules: z.array(z.object({
7056
+ moduleFilePath: z.string(),
7057
+ commitSha: z.string(),
7058
+ sourceSha: z.string().nullable(),
7059
+ schemaSha: z.string(),
7060
+ // Validated, because a Source IS just JSON and this side knows that much.
7061
+ source: JSONValue.nullable(),
7062
+ schema: z.unknown(),
7063
+ unavailable: z.boolean()
7064
+ }))
7065
+ });
7066
+ const CommitAffectedFilesResponse = z.object({
7067
+ commitSha: z.string(),
7068
+ files: z.array(z.union([z.object({
7069
+ kind: z.union([z.literal("module-source"), z.literal("json-entry"), z.literal("binary")]),
7070
+ gitPath: z.string(),
7071
+ change: z.union([z.literal("added"), z.literal("modified"), z.literal("deleted")])
7072
+ }), z.object({
7073
+ kind: z.literal("remote-binary"),
7074
+ ref: z.string(),
7075
+ change: z.union([z.literal("added"), z.literal("modified"), z.literal("deleted")])
7076
+ })]))
7077
+ });
7078
+ // #endregion history wire schemas
7079
+
7080
+ /*
7081
+ * The shared schema, not a copy of it.
7082
+ *
7083
+ * This re-declared `PatchGroup` field for field while the file already imported
7084
+ * `PatchGroupT` from the same module — so a field added on one side and not the
7085
+ * other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
7086
+ * it, with no type error anywhere.
7087
+ */
7088
+ const PatchGroupsResponse = z.object({
7089
+ patchGroups: z.array(PatchGroup)
7090
+ });
7091
+ const PatchGroupMutationResponse = z.object({
7092
+ patchGroupId: z.string(),
7093
+ patchIds: z.array(PatchId)
7094
+ });
6573
7095
  const NonceResponse = z.object({
6574
7096
  nonce: z.string(),
6575
7097
  url: z.string()
6576
7098
  });
7099
+
7100
+ /**
7101
+ * How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
7102
+ *
7103
+ * Sized to cover one server render, not to be a cache: several `fetchVal` calls
7104
+ * in one request share an answer, and the next request asks again.
7105
+ */
7106
+ const PATCH_GROUPS_CACHE_MS = 1000;
6577
7107
  class ValOpsHttp extends ValOps {
6578
7108
  constructor(contentUrl, project, commitSha,
6579
7109
  // TODO: CommitSha
@@ -6733,8 +7263,26 @@ class ValOpsHttp extends ValOps {
6733
7263
  }
6734
7264
  }
6735
7265
  const patches = [];
7266
+ /*
7267
+ * Which of them have SHIPPED, alongside which of them exist.
7268
+ *
7269
+ * A published patch stays in the chain with `appliedAt` set until the next
7270
+ * deployment moves the base, so "in the chain" and "has shipped" are
7271
+ * different questions — and the chain ids alone answer only the first. A
7272
+ * client that already holds a record never re-fetches it, so it never
7273
+ * learns the second: another author's publish left that patch in your scope
7274
+ * as pending, your prefix gate read a hole in front of it, and Publish
7275
+ * refused for a reason that had stopped being true.
7276
+ *
7277
+ * Sent as ids rather than folded into `patches`, so a client that ignores
7278
+ * it behaves exactly as before.
7279
+ */
7280
+ const appliedPatches = [];
6736
7281
  for (const patchData of allPatchData.patches) {
6737
7282
  patches.push(patchData.patchId);
7283
+ if (patchData.appliedAt) {
7284
+ appliedPatches.push(patchData.patchId);
7285
+ }
6738
7286
  }
6739
7287
  const webSocketNonceRes = await this.getWebSocketNonce(params.profileId);
6740
7288
  if (webSocketNonceRes.status === "error") {
@@ -6757,6 +7305,16 @@ class ValOpsHttp extends ValOps {
6757
7305
  commits: allPatchData.commits || [],
6758
7306
  deployments: allPatchData.deployments || [],
6759
7307
  patches,
7308
+ appliedPatches,
7309
+ /*
7310
+ * The PUBLISH head, which is not `commitSha`.
7311
+ *
7312
+ * `commitSha` is the commit this deployment is serving and does not move
7313
+ * when somebody publishes — only when the new build lands. This does, so
7314
+ * it is what a client carries back to `/save` to say which world it
7315
+ * decided against.
7316
+ */
7317
+ headCommitSha: newestCommitSha(allPatchData.commits) ?? undefined,
6760
7318
  commitSha: this.commitSha
6761
7319
  };
6762
7320
  }
@@ -6837,6 +7395,26 @@ class ValOpsHttp extends ValOps {
6837
7395
  }
6838
7396
  const allPatches = [];
6839
7397
  const allErrors = [];
7398
+ /*
7399
+ * The commits, which are a fact about the whole BRANCH, not about a chunk.
7400
+ *
7401
+ * This loop used to return only `patches` and `errors`, so a filtered fetch
7402
+ * silently answered with no commits at all. That is not cosmetic:
7403
+ * the publish-head guard in `ValServer` reads `newestCommitSha(commits)`,
7404
+ * got `undefined` for every publish (a publish always names patch ids, so
7405
+ * it always takes this branch), and skipped the check entirely. Two clients
7406
+ * could publish against the same head with neither told.
7407
+ *
7408
+ * Taken from the first chunk that carries them, which is sound for the
7409
+ * reason the dedupe below exists: each chunk's response describes the whole
7410
+ * chain regardless of which ids it asked about.
7411
+ *
7412
+ * `deployments` is not carried, because it is declared only on
7413
+ * `OrderedPatchesMetadata` and this is generic over both shapes. Nothing
7414
+ * reads it from a filtered fetch today; if something starts to, it needs
7415
+ * the same treatment and a home on `OrderedPatches` first.
7416
+ */
7417
+ let commits;
6840
7418
  if (patchIds === undefined || patchIds.length === 0) {
6841
7419
  return this.fetchPatchesInternal({
6842
7420
  patchIds: patchIds,
@@ -6854,6 +7432,9 @@ class ValOpsHttp extends ValOps {
6854
7432
  if (res.errors) {
6855
7433
  allErrors.push(...res.errors);
6856
7434
  }
7435
+ if (commits === undefined && res.commits !== undefined) {
7436
+ commits = res.commits;
7437
+ }
6857
7438
  }
6858
7439
  // Chunking is a query-string-length workaround, NOT a filter: the content
6859
7440
  // api returns every applicable patch per request regardless of which
@@ -6880,7 +7461,13 @@ class ValOpsHttp extends ValOps {
6880
7461
  });
6881
7462
  return {
6882
7463
  patches,
6883
- errors: Object.keys(allErrors).length > 0 ? allErrors : undefined
7464
+ errors: Object.keys(allErrors).length > 0 ? allErrors : undefined,
7465
+ // Spread rather than set to `undefined`, so a caller that distinguishes
7466
+ // "absent" from "empty" — `newestCommitSha` does not, but the annotation
7467
+ // readers do — sees the same shape the unchunked path gives it.
7468
+ ...(commits !== undefined ? {
7469
+ commits
7470
+ } : {})
6884
7471
  };
6885
7472
  }
6886
7473
  async fetchPatchesInternal(filters) {
@@ -6988,59 +7575,364 @@ class ValOpsHttp extends ValOps {
6988
7575
  };
6989
7576
  }
6990
7577
  }
6991
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
6992
- const baseSha = await this.getBaseSha();
6993
- return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
6994
- method: "POST",
6995
- headers: {
6996
- ...this.authHeaders,
6997
- "Content-Type": "application/json"
6998
- },
6999
- body: JSON.stringify({
7000
- path,
7001
- patch,
7002
- authorId,
7003
- sessionId,
7004
- patchId,
7005
- parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
7006
- baseSha,
7007
- commit: this.commitSha,
7008
- branch: this.branch,
7009
- coreVersion: Internal.VERSION.core
7010
- })
7011
- }).then(async res => {
7012
- var _res$headers$get2;
7013
- if (res.ok) {
7014
- const parsed = SavePatchResponse.safeParse(await res.json());
7015
- if (parsed.success) {
7016
- return result.ok({
7017
- patchId: parsed.data.patchId
7018
- });
7019
- }
7020
- return result.err({
7021
- errorType: "other",
7022
- message: `Could not parse save patch response. Error: ${fromError(parsed.error)}`
7023
- });
7024
- }
7025
- if (res.status === 409) {
7026
- return result.err({
7027
- errorType: "patch-head-conflict",
7028
- message: "Conflict: " + (await res.text())
7029
- });
7030
- }
7031
- if ((_res$headers$get2 = res.headers.get("Content-Type")) !== null && _res$headers$get2 !== void 0 && _res$headers$get2.includes("application/json")) {
7032
- const json = await res.json();
7033
- const message = getErrorMessageFromUnknownJson(json, "Unknown error");
7034
- return result.err({
7035
- errorType: "other",
7036
- message
7037
- });
7038
- }
7039
- return result.err({
7040
- errorType: "other",
7041
- message: "Could not save patch. HTTP error: " + res.status + " " + res.statusText
7042
- });
7043
- }).catch(e => {
7578
+
7579
+ // #region patch groups
7580
+ /**
7581
+ * Add patches to a patch group.
7582
+ *
7583
+ * The set arrives closed by the client — `withPatchIds` is the prefix closure
7584
+ * over the patch sets the staged patches belong to. We forward it and do not
7585
+ * second-guess it: deriving the closure needs the content schema, which this
7586
+ * process does have but content.val.build does not, and having two
7587
+ * implementations of the rule would be worse than having one.
7588
+ *
7589
+ * Membership rows are stamped with `coreVersion` on the content side — the
7590
+ * same stamp the patch row carries — so which client wrote a row stays
7591
+ * legible after the fact.
7592
+ */
7593
+ async stagePatches(patchGroupId, /** What the user asked to stage. */
7594
+ patchIds,
7595
+ /**
7596
+ * What has to come with it, because the staged patches are written on top
7597
+ * of it.
7598
+ *
7599
+ * The content API stores each membership row as `explicit` or `dependency`
7600
+ * and treats what it is not told about as a dependency. Folding the two
7601
+ * halves into `patchIds` therefore files the patch somebody clicked as one
7602
+ * the closure dragged in — the exact opposite of what happened, and the
7603
+ * only record anywhere of what the author chose.
7604
+ */
7605
+ withPatchIds,
7606
+ /**
7607
+ * WHO is asking, so the content API can refuse a group that is not theirs.
7608
+ *
7609
+ * Every call from this class carries the app's API key, which says which
7610
+ * PROJECT is calling and nothing about which editor. Without this the
7611
+ * content API cannot tell one of a project's editors from another, so the
7612
+ * only check on stage and unstage is the one in `ValServer` — and anything
7613
+ * reaching the content API by another route (an API key, a PAT) has none at
7614
+ * all.
7615
+ *
7616
+ * `null` where there is no session. The content API refuses rather than
7617
+ * treating that as a match: a group written by an api key has a null author
7618
+ * too, and `null === null` must not read as ownership.
7619
+ */
7620
+ authorId) {
7621
+ return this.mutatePatchGroup("POST",
7622
+ // Encoded: patchGroupId arrives in a request body, so an unencoded value
7623
+ // like "../../commit" would reach a different endpoint carrying this
7624
+ // project's auth headers.
7625
+ `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
7626
+ patchIds,
7627
+ withPatchIds,
7628
+ coreVersion: Internal.VERSION.core
7629
+ }, authorId);
7630
+ }
7631
+
7632
+ /**
7633
+ * Remove patches from a patch group.
7634
+ *
7635
+ * The set arrives closed FORWARDS by the client: unstaging a patch also
7636
+ * unstages everything built on top of it within its patch sets, and that is
7637
+ * what `withPatchIds` carries.
7638
+ */
7639
+ async unstagePatches(patchGroupId, /** What the user asked to unstage. */
7640
+ patchIds, /** What has to go with it: everything built on top of it. */
7641
+ withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
7642
+ authorId) {
7643
+ return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
7644
+ patchIds,
7645
+ withPatchIds
7646
+ }, authorId);
7647
+ }
7648
+
7649
+ /**
7650
+ * Every patch group on this branch, with what each holds.
7651
+ *
7652
+ * Read rather than mutated, and used to answer "which pending patches is THIS
7653
+ * person allowed to see". A draft render that skips this shows base + every
7654
+ * pending patch on the branch, including work other people have not
7655
+ * published — which is what independent publish exists to prevent.
7656
+ *
7657
+ * A failure is an empty list rather than a throw, and the caller decides what
7658
+ * that means. For a draft render the honest fallback is "show nothing
7659
+ * pending" rather than "show everything": being shown your own committed
7660
+ * content when the group lookup is down is a worse experience than being
7661
+ * shown somebody else's unpublished draft is a bug.
7662
+ */
7663
+ /**
7664
+ * The last group lookup, and when it was made.
7665
+ *
7666
+ * A draft render calls `getPatchGroups` once per `fetchVal`, in series with
7667
+ * the whole-chain fetch, and a page that calls `fetchVal` several times pays
7668
+ * the round trip several times. Groups are per branch and change rarely, so a
7669
+ * short window removes the multiplier without letting a stage go unseen for
7670
+ * meaningfully longer than one render.
7671
+ *
7672
+ * Deliberately short. This is a read whose staleness decides whose draft
7673
+ * content someone sees, so it is a per-request de-duplication rather than a
7674
+ * cache: a second render a second later asks again.
7675
+ */
7676
+ patchGroupsCache = null;
7677
+ async getPatchGroups(options) {
7678
+ const now = Date.now();
7679
+ if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
7680
+ return this.patchGroupsCache.res;
7681
+ }
7682
+ const res = await this.fetchPatchGroups();
7683
+ /*
7684
+ * ANSWERS are cached; failures are not.
7685
+ *
7686
+ * A transient failure held for a second is replayed to every caller in it,
7687
+ * and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
7688
+ * that refuses the stage, a scoped draft render falls back to base and
7689
+ * drops every pending patch on the page, and `GET /patches` omits the
7690
+ * annotation. One flaky request became a second of all three. The point of
7691
+ * this cache is to collapse the several `fetchVal` calls in one render into
7692
+ * one round trip, and an error is exactly the case worth retrying inside
7693
+ * that window rather than the case worth remembering.
7694
+ *
7695
+ * `unsupported` is cached with `ok` deliberately: it is a real answer about
7696
+ * the deployment — this content API predates patch groups — and it will not
7697
+ * change between two renders.
7698
+ */
7699
+ if (res.status === "ok" || res.status === "unsupported") {
7700
+ this.patchGroupsCache = {
7701
+ at: now,
7702
+ res
7703
+ };
7704
+ }
7705
+ return res;
7706
+ }
7707
+ async fetchPatchGroups() {
7708
+ try {
7709
+ /*
7710
+ * `branch` is REQUIRED by the endpoint, which answers 400 without it.
7711
+ *
7712
+ * Same two params every other read here sends (`fetchPatchesInternal`,
7713
+ * `saveSourceFilePatch`): groups are per branch, so a request without one
7714
+ * is not merely under-specified, it is rejected.
7715
+ */
7716
+ const params = new URLSearchParams([["branch", this.branch]]);
7717
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
7718
+ headers: this.authHeaders
7719
+ });
7720
+ if (res.status === 404) {
7721
+ /*
7722
+ * The endpoint is not there, which is a content API that PREDATES patch
7723
+ * groups — not a failure.
7724
+ *
7725
+ * The distinction decides what a draft render shows, and collapsing it
7726
+ * into "error" is not a small mistake: a caller that reads an error as
7727
+ * "could not ask" renders BASE, so every existing http deployment would
7728
+ * silently drop all pending content from every draft preview. "There
7729
+ * are no groups here" has to mean unscoped, which is exactly the
7730
+ * behaviour those projects have today.
7731
+ */
7732
+ return {
7733
+ status: "unsupported"
7734
+ };
7735
+ }
7736
+ if (!res.ok) {
7737
+ return {
7738
+ status: "error",
7739
+ 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}`
7740
+ };
7741
+ }
7742
+ const parsed = PatchGroupsResponse.safeParse(await res.json());
7743
+ if (!parsed.success) {
7744
+ return {
7745
+ status: "error",
7746
+ message: `Could not parse patch groups response. Error: ${fromError(parsed.error)}`
7747
+ };
7748
+ }
7749
+ return {
7750
+ status: "ok",
7751
+ patchGroups: parsed.data.patchGroups
7752
+ };
7753
+ } catch (err) {
7754
+ return {
7755
+ status: "error",
7756
+ message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
7757
+ };
7758
+ }
7759
+ }
7760
+ async mutatePatchGroup(method, path, body,
7761
+ /**
7762
+ * WHO is asking. Sent as `x-val-profile-id`, which is what the content API
7763
+ * reads to decide whether this group is the caller's.
7764
+ *
7765
+ * `this.authHeaders` is the app's API key, and that names the PROJECT, not
7766
+ * the person — so without this the content API cannot resolve a profile
7767
+ * and refuses every stage and unstage with
7768
+ * "Cannot resolve the caller's profile". The group endpoints are the only
7769
+ * ones here that need it, because they are the only ones whose answer
7770
+ * depends on which of a project's editors is calling.
7771
+ *
7772
+ * Omitted when there is no session rather than sent empty: the content API
7773
+ * treats an unidentified caller as a refusal, which is what we want, and an
7774
+ * empty header would be a different and less obvious way to say it.
7775
+ *
7776
+ * A PAT already identifies a person, so `authHeaders` carries the identity
7777
+ * on its own there and this adds nothing.
7778
+ */
7779
+ authorId) {
7780
+ try {
7781
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
7782
+ method,
7783
+ headers: {
7784
+ ...this.authHeaders,
7785
+ ...(authorId !== null ? {
7786
+ "x-val-profile-id": authorId
7787
+ } : {}),
7788
+ "Content-Type": "application/json"
7789
+ },
7790
+ body: JSON.stringify(body)
7791
+ });
7792
+ if (res.ok) {
7793
+ const parsed = PatchGroupMutationResponse.safeParse(await res.json());
7794
+ if (parsed.success) {
7795
+ return {
7796
+ patchIds: parsed.data.patchIds
7797
+ };
7798
+ }
7799
+ return {
7800
+ status: 500,
7801
+ patchIds: [],
7802
+ error: {
7803
+ message: `Could not parse patch group response. Error: ${fromError(parsed.error)}`
7804
+ }
7805
+ };
7806
+ }
7807
+ // 403 (not your group) and 409 (already published) are meaningful to the
7808
+ // client, so they are passed through rather than flattened to a 500.
7809
+ if (res.status === 403 || res.status === 409) {
7810
+ // `home` answers these with a JSON body, so `res.text()` put the literal
7811
+ // `{"message":"..."}` in front of the user. Every other branch in this
7812
+ // class unwraps it; this one now does too.
7813
+ return {
7814
+ status: res.status,
7815
+ patchIds: [],
7816
+ error: {
7817
+ message: getErrorMessageFromUnknownJson(await res.json().catch(() => undefined), `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`)
7818
+ }
7819
+ };
7820
+ }
7821
+ // A 401 here is the app's own credentials failing, not the user's, so it gets
7822
+ // the same wording as every other call in this class rather than an opaque
7823
+ // 500 that sends the user looking at their own session.
7824
+ if (res.status === 401) {
7825
+ return {
7826
+ status: 500,
7827
+ patchIds: [],
7828
+ error: {
7829
+ 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."
7830
+ }
7831
+ };
7832
+ }
7833
+ return {
7834
+ status: 500,
7835
+ patchIds: [],
7836
+ error: {
7837
+ message: `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`
7838
+ }
7839
+ };
7840
+ } catch (err) {
7841
+ return {
7842
+ status: 500,
7843
+ patchIds: [],
7844
+ error: {
7845
+ message: `Could not update patch group (connection error?): ${err instanceof Error ? err.message : JSON.stringify(err)}`
7846
+ }
7847
+ };
7848
+ }
7849
+ }
7850
+ // #endregion
7851
+
7852
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
7853
+ const baseSha = await this.getBaseSha();
7854
+ return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
7855
+ method: "POST",
7856
+ headers: {
7857
+ ...this.authHeaders,
7858
+ "Content-Type": "application/json"
7859
+ },
7860
+ body: JSON.stringify({
7861
+ path,
7862
+ patch,
7863
+ authorId,
7864
+ sessionId,
7865
+ patchId,
7866
+ parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
7867
+ baseSha,
7868
+ commit: this.commitSha,
7869
+ branch: this.branch,
7870
+ coreVersion: Internal.VERSION.core,
7871
+ /*
7872
+ * Group membership in the SAME request as the patch.
7873
+ *
7874
+ * The content API runs every refusal before its insert, so an invalid
7875
+ * closure is a 400 with nothing written. Spread rather than sent as
7876
+ * nulls: a client with no group omits the fields entirely, which is
7877
+ * what an older content API expects to see.
7878
+ */
7879
+ ...(patchGroup ? {
7880
+ // Only when the caller named one. Omitted, the content API
7881
+ // resolves this author's open group and creates it if absent,
7882
+ // which is what every write wants.
7883
+ ...(patchGroup.patchGroupId !== undefined ? {
7884
+ patchGroupId: patchGroup.patchGroupId
7885
+ } : {}),
7886
+ withPatchIds: patchGroup.withPatchIds
7887
+ } : {})
7888
+ })
7889
+ }).then(async res => {
7890
+ var _res$headers$get2;
7891
+ if (res.ok) {
7892
+ const parsed = SavePatchResponse.safeParse(await res.json());
7893
+ if (parsed.success) {
7894
+ return result.ok({
7895
+ patchId: parsed.data.patchId,
7896
+ /*
7897
+ * Passed back to the client, which cannot learn it any other way.
7898
+ *
7899
+ * A write names no group — the content API resolves this author's
7900
+ * open group and CREATES it if absent — so on a fresh branch the
7901
+ * group comes into existence here and nowhere else. The chain
7902
+ * annotation is only re-read when a fetch has missing ids to ask
7903
+ * for, and a patch this client made is never missing, so without
7904
+ * this the tab that bootstrapped the group would never learn its
7905
+ * id and every stage would be a no-op.
7906
+ */
7907
+ ...(parsed.data.patchGroupId !== undefined ? {
7908
+ patchGroupId: parsed.data.patchGroupId
7909
+ } : {})
7910
+ });
7911
+ }
7912
+ return result.err({
7913
+ errorType: "other",
7914
+ message: `Could not parse save patch response. Error: ${fromError(parsed.error)}`
7915
+ });
7916
+ }
7917
+ if (res.status === 409) {
7918
+ return result.err({
7919
+ errorType: "patch-head-conflict",
7920
+ message: "Conflict: " + (await res.text())
7921
+ });
7922
+ }
7923
+ if ((_res$headers$get2 = res.headers.get("Content-Type")) !== null && _res$headers$get2 !== void 0 && _res$headers$get2.includes("application/json")) {
7924
+ const json = await res.json();
7925
+ const message = getErrorMessageFromUnknownJson(json, "Unknown error");
7926
+ return result.err({
7927
+ errorType: "other",
7928
+ message
7929
+ });
7930
+ }
7931
+ return result.err({
7932
+ errorType: "other",
7933
+ message: "Could not save patch. HTTP error: " + res.status + " " + res.statusText
7934
+ });
7935
+ }).catch(e => {
7044
7936
  return result.err({
7045
7937
  errorType: "other",
7046
7938
  message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
@@ -7204,7 +8096,12 @@ class ValOpsHttp extends ValOps {
7204
8096
  if (!file) {
7205
8097
  return null;
7206
8098
  }
7207
- return bufferFromDataUrl(file.value) ?? null;
8099
+ // Plain base64, the same as the `repo` branch of getBinaryFile above.
8100
+ //
8101
+ // `value` used to be a data: URL here and plain base64 there - two
8102
+ // encodings in one field, told apart only by which branch produced them.
8103
+ // The content service answers base64 for both now.
8104
+ return Buffer.from(file.value, "base64");
7208
8105
  }
7209
8106
  async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, remote) {
7210
8107
  const params = new URLSearchParams();
@@ -7264,7 +8161,17 @@ class ValOpsHttp extends ValOps {
7264
8161
  }]
7265
8162
  };
7266
8163
  }
7267
- async deletePatches(patchIds) {
8164
+ async deletePatches(patchIds,
8165
+ /**
8166
+ * Patches that are NOT deleted but must lose their group membership.
8167
+ *
8168
+ * Deleting a patch out of the middle of a patch set leaves every group
8169
+ * still holding the rest with a non-prefix intersection — the patches after
8170
+ * the hole were written against a view that had it. The content API cannot
8171
+ * work out which those are (it has no schema), so the client sends the
8172
+ * forward closure and it drops those memberships without deleting anything.
8173
+ */
8174
+ unstagePatchIds) {
7268
8175
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
7269
8176
  method: "DELETE",
7270
8177
  headers: {
@@ -7272,7 +8179,10 @@ class ValOpsHttp extends ValOps {
7272
8179
  "Content-Type": "application/json"
7273
8180
  },
7274
8181
  body: JSON.stringify({
7275
- patchIds
8182
+ patchIds,
8183
+ ...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
8184
+ unstagePatchIds
8185
+ } : {})
7276
8186
  })
7277
8187
  }).then(async res => {
7278
8188
  if (res.ok) {
@@ -7311,7 +8221,22 @@ class ValOpsHttp extends ValOps {
7311
8221
  };
7312
8222
  });
7313
8223
  }
7314
- async commit(prepared, message, committer, filesDirectory, newBranch) {
8224
+ async commit(prepared, message, committer, filesDirectory, newBranch,
8225
+ /**
8226
+ * The patch group this commit EMPTIES, if it empties one.
8227
+ *
8228
+ * The content API closes the group it is given — and closes it WITHOUT
8229
+ * checking that the commit shipped all of it, so a caller that names a
8230
+ * group still holding work takes those patches out of every group and
8231
+ * leaves their author unable to publish them. The client therefore sends it
8232
+ * only when the publish accounts for everything the group still holds.
8233
+ *
8234
+ * Omitting it is not neutral: the commit still empties the group (the
8235
+ * content API drops applied ids from every group), but `published_at` is
8236
+ * never set, so the id is reused across publishes instead of a new group
8237
+ * per publish and the "already published" refusal can never fire.
8238
+ */
8239
+ patchGroupId) {
7315
8240
  try {
7316
8241
  var _res$headers$get3;
7317
8242
  const existingBranch = this.branch;
@@ -7319,12 +8244,45 @@ class ValOpsHttp extends ValOps {
7319
8244
  method: "POST",
7320
8245
  headers: {
7321
8246
  ...this.authHeaders,
8247
+ /*
8248
+ * WHO is publishing — the same `x-val-profile-id` stage and unstage
8249
+ * send, and for the same reason: `this.authHeaders` is the app's API
8250
+ * key, which names the PROJECT and not the person.
8251
+ *
8252
+ * Closing a group is an ownership decision, so the content API
8253
+ * refuses a commit that names a `patchGroupId` it cannot attribute:
8254
+ * without this header `profileId` is `undefined` there and every
8255
+ * group-closing publish is 403 "Cannot resolve the caller's profile".
8256
+ * That is the NORMAL full publish, not an edge — `publish` names the
8257
+ * group whenever the commit empties it.
8258
+ *
8259
+ * Sent on every commit rather than only when a group is named. The
8260
+ * committer is a person either way, `committer` in the body already
8261
+ * says so, and a header that appears only on some commits is one more
8262
+ * conditional for a reader of either repo to reconstruct. It changes
8263
+ * nothing else: the content API reads it as an identity claim beside
8264
+ * the app's key and derives no scope from it.
8265
+ */
8266
+ "x-val-profile-id": committer,
7322
8267
  "Content-Type": "application/json"
7323
8268
  },
7324
8269
  body: JSON.stringify({
7325
8270
  patchedSourceFiles: prepared.patchedSourceFiles,
7326
8271
  patchedBinaryFilesDescriptors: prepared.patchedBinaryFilesDescriptors,
7327
8272
  appliedPatches: prepared.appliedPatches,
8273
+ /*
8274
+ * What each changed module IS after this commit, as DATA, with the
8275
+ * schema it is under.
8276
+ *
8277
+ * The half git cannot give back. Git keeps the `.val.ts`, but that is
8278
+ * code: turning it back into data means parsing it, which is
8279
+ * best-effort and rots across TypeScript, runtime and Val versions -
8280
+ * so a commit that reads today can quietly stop reading later. And
8281
+ * git has no copy at all of the SCHEMA a commit was written under,
8282
+ * which is what showing a module as it was needs once the schema has
8283
+ * moved on.
8284
+ */
8285
+ modules: prepared.moduleVersions,
7328
8286
  commit: this.commitSha,
7329
8287
  root: this.root,
7330
8288
  filesDirectory,
@@ -7332,7 +8290,10 @@ class ValOpsHttp extends ValOps {
7332
8290
  committer,
7333
8291
  message,
7334
8292
  existingBranch,
7335
- newBranch
8293
+ newBranch,
8294
+ ...(patchGroupId !== undefined ? {
8295
+ patchGroupId
8296
+ } : {})
7336
8297
  })
7337
8298
  });
7338
8299
  if (res.ok) {
@@ -7369,19 +8330,620 @@ class ValOpsHttp extends ValOps {
7369
8330
  }
7370
8331
  };
7371
8332
  }
7372
- return {
7373
- error: {
7374
- message: "Could not commit. HTTP error: " + res.status + " " + res.statusText
7375
- }
8333
+ return {
8334
+ error: {
8335
+ message: "Could not commit. HTTP error: " + res.status + " " + res.statusText
8336
+ }
8337
+ };
8338
+ } catch (err) {
8339
+ return {
8340
+ error: {
8341
+ message: `Could not commit (connection error?): ${err instanceof Error ? err.message : (err === null || err === void 0 ? void 0 : err.toString()) || "unknown error"}`
8342
+ }
8343
+ };
8344
+ }
8345
+ }
8346
+
8347
+ // #region history
8348
+
8349
+ /**
8350
+ * One GET against the content service, parsed and Result-typed.
8351
+ *
8352
+ * Every history read has the same three failure modes - could not reach the
8353
+ * service, the commit is not there, the answer was not what was expected -
8354
+ * and each of them means something different to a caller deciding whether to
8355
+ * offer a restore. Doing it once here is what keeps that consistent across
8356
+ * the five endpoints.
8357
+ */
8358
+ async getHistory(path, schema, commitShaForErrors) {
8359
+ let res;
8360
+ try {
8361
+ res = await fetch(`${this.contentUrl}/v1/${this.project}${path}`, {
8362
+ headers: {
8363
+ ...this.authHeaders,
8364
+ "Content-Type": "application/json"
8365
+ }
8366
+ });
8367
+ } catch (err) {
8368
+ return result.err({
8369
+ kind: "transport",
8370
+ message: err instanceof Error ? err.message : String(err)
8371
+ });
8372
+ }
8373
+ if (res.status === 404) {
8374
+ return result.err({
8375
+ kind: "commit-not-found",
8376
+ commitSha: commitShaForErrors
8377
+ });
8378
+ }
8379
+ if (!res.ok) {
8380
+ var _res$headers$get4;
8381
+ let message = `${res.status} ${res.statusText}`;
8382
+ if ((_res$headers$get4 = res.headers.get("Content-Type")) !== null && _res$headers$get4 !== void 0 && _res$headers$get4.includes("application/json")) {
8383
+ message = getErrorMessageFromUnknownJson(await res.json(), message);
8384
+ }
8385
+ // A 5xx from the service reading a record it says it has is a real
8386
+ // failure of that record, not a network problem - keep them apart.
8387
+ return result.err({
8388
+ kind: "archive-unreadable",
8389
+ commitSha: commitShaForErrors,
8390
+ message
8391
+ });
8392
+ }
8393
+ // A 200 is not a promise of JSON: a proxy or gateway in front of the
8394
+ // service answers HTML, and an unguarded `.json()` would reject straight
8395
+ // out of this Result-typed API and 500 the route.
8396
+ let body;
8397
+ try {
8398
+ body = await res.json();
8399
+ } catch (err) {
8400
+ return result.err({
8401
+ kind: "archive-unreadable",
8402
+ commitSha: commitShaForErrors,
8403
+ message: `response was not JSON: ${err instanceof Error ? err.message : String(err)}`
8404
+ });
8405
+ }
8406
+ const parsed = schema.safeParse(body);
8407
+ if (!parsed.success) {
8408
+ return result.err({
8409
+ kind: "archive-unreadable",
8410
+ commitSha: commitShaForErrors,
8411
+ message: `unexpected response shape: ${fromError(parsed.error)}`
8412
+ });
8413
+ }
8414
+ return result.ok(parsed.data);
8415
+ }
8416
+ async listCommits(branch, options) {
8417
+ const params = new URLSearchParams({
8418
+ branch
8419
+ });
8420
+ if ((options === null || options === void 0 ? void 0 : options.limit) !== undefined) {
8421
+ params.set("limit", String(options.limit));
8422
+ }
8423
+ if ((options === null || options === void 0 ? void 0 : options.cursor) !== undefined) {
8424
+ params.set("cursor", options.cursor);
8425
+ }
8426
+ const res = await this.getHistory(`/commits?${params}`, ListCommitsResponse, branch);
8427
+ if (result.isErr(res)) {
8428
+ return res;
8429
+ }
8430
+ return result.ok({
8431
+ commits: res.value.commits,
8432
+ nextCursor: res.value.nextCursor
8433
+ });
8434
+ }
8435
+ async getCommitPatches(commitSha) {
8436
+ // One request. The endpoint returns the commit's own summary alongside its
8437
+ // patches, because it already has the row - this used to page the commit
8438
+ // LISTING until the sha turned up, which cost up to twenty requests to open
8439
+ // an old commit and could not find one on another branch at all.
8440
+ const patchesRes = await this.getHistory(`/commits/${commitSha}/patches`, CommitPatchesResponse, commitSha);
8441
+ if (result.isErr(patchesRes)) {
8442
+ return patchesRes;
8443
+ }
8444
+ return result.ok({
8445
+ commit: {
8446
+ ...patchesRes.value.commit,
8447
+ patchCount: patchesRes.value.patches.length
8448
+ },
8449
+ patches: patchesRes.value.patches.map(patch => ({
8450
+ patchId: patch.patchId,
8451
+ moduleFilePath: patch.path,
8452
+ patch: patch.patch,
8453
+ authorId: patch.authorId,
8454
+ createdAt: patch.createdAt,
8455
+ baseSha: patch.baseSha,
8456
+ coreVersion: patch.coreVersion
8457
+ }))
8458
+ });
8459
+ }
8460
+ async getCommitModules(commitSha, options) {
8461
+ const query = new URLSearchParams();
8462
+ if (options !== null && options !== void 0 && options.asOf) {
8463
+ query.set("as_of", "1");
8464
+ }
8465
+ if (options !== null && options !== void 0 && options.moduleFilePath) {
8466
+ query.set("path", options.moduleFilePath);
8467
+ }
8468
+ const search = query.toString();
8469
+ const res = await this.getHistory(`/commits/${commitSha}/modules${search ? `?${search}` : ""}`, CommitModulesResponse, commitSha);
8470
+ if (result.isErr(res)) {
8471
+ return res;
8472
+ }
8473
+ return result.ok({
8474
+ modules: res.value.modules.map(module => ({
8475
+ ...module,
8476
+ moduleFilePath: module.moduleFilePath
8477
+ })),
8478
+ // Absent from an older content server, which only ever answered "what
8479
+ // this commit changed" - and that answer is always whole.
8480
+ complete: res.value.complete ?? !(options !== null && options !== void 0 && options.asOf)
8481
+ });
8482
+ }
8483
+ async getCommitAffectedFiles(commitSha) {
8484
+ const res = await this.getHistory(`/commits/${commitSha}/affected-files`, CommitAffectedFilesResponse, commitSha);
8485
+ if (result.isErr(res)) {
8486
+ return res;
8487
+ }
8488
+ return result.ok(res.value.files);
8489
+ }
8490
+ async getFileAtCommit(commitSha, filePath, remote) {
8491
+ const params = new URLSearchParams({
8492
+ path: filePath
8493
+ });
8494
+ if (remote) {
8495
+ params.set("remote", "true");
8496
+ }
8497
+ let res;
8498
+ try {
8499
+ res = await fetch(`${this.contentUrl}/v1/${this.project}/commits/${commitSha}/file?${params}`, {
8500
+ headers: {
8501
+ ...this.authHeaders
8502
+ }
8503
+ });
8504
+ } catch (err) {
8505
+ return result.err({
8506
+ kind: "transport",
8507
+ message: err instanceof Error ? err.message : String(err)
8508
+ });
8509
+ }
8510
+ if (!res.ok) {
8511
+ // A missing file is about the FILE, not the commit: the commit may be
8512
+ // perfectly readable and this one blob gone. Saying "commit not found"
8513
+ // here would send a caller looking in the wrong place.
8514
+ return result.err({
8515
+ kind: "file-unavailable",
8516
+ gitPath: filePath,
8517
+ message: `${res.status} ${res.statusText}`
8518
+ });
8519
+ }
8520
+ return result.ok(Buffer.from(await res.arrayBuffer()));
8521
+ }
8522
+ // #endregion history
8523
+ }
8524
+
8525
+ /**
8526
+ * Everything that can go wrong reading, replaying or restoring history.
8527
+ *
8528
+ * There is a lot of it, which is the point of making it one closed union rather
8529
+ * than strings: reading a commit means reaching a content host, finding the
8530
+ * commit, reading what was recorded for each of its modules, and asking whether
8531
+ * this version of Val can make sense of the schema it was stored under. Each of
8532
+ * those fails differently, and a caller deciding what to SHOW needs to know
8533
+ * which.
8534
+ *
8535
+ * Every member has a producer. The union once carried five more - a module
8536
+ * removed from the project, an op that would not replay, a value that no longer
8537
+ * fits today's schema, a field the schema no longer defines, and ops from a
8538
+ * core version known to replay wrongly. All five belonged to reconstructing a
8539
+ * commit by replaying patches against a parsed source; storing the data ended
8540
+ * that, and a member nothing can produce is a state the Studio writes handling
8541
+ * for and never sees.
8542
+ *
8543
+ * ## The rule
8544
+ *
8545
+ * Whole-commit failures are the `err` channel. Per-module and per-patch
8546
+ * failures ride along inside the `ok` payload.
8547
+ *
8548
+ * A commit that cannot be found or read at all has nothing to show. But one
8549
+ * unparseable module out of ten does not make the other nine unreadable, and
8550
+ * collapsing the whole view because of it would hide exactly the information
8551
+ * someone needs to see - that this module is the broken one.
8552
+ */
8553
+
8554
+ function historyErrorMessage(error) {
8555
+ switch (error.kind) {
8556
+ case "commit-not-found":
8557
+ return `No commit ${error.commitSha} created by Val in this project`;
8558
+ case "archive-unreadable":
8559
+ return `Could not read the stored record of commit ${error.commitSha}: ${error.message}`;
8560
+ case "source-unavailable":
8561
+ return `No stored source for ${error.moduleFilePath} at this commit (it predates history being recorded)`;
8562
+ case "schema-unreadable":
8563
+ return `The schema stored for ${error.moduleFilePath} at this commit is not one this version of Val can read: ${error.message}`;
8564
+ case "file-unavailable":
8565
+ return `Could not read ${error.gitPath} at this commit: ${error.message}`;
8566
+ case "not-supported-in-fs-mode":
8567
+ return "History is only available for projects connected to Val's content service";
8568
+ case "transport":
8569
+ return `Could not reach the content service: ${error.message}`;
8570
+ }
8571
+ }
8572
+
8573
+ /**
8574
+ * Everything the content service knows about one commit, in one step.
8575
+ *
8576
+ * Three endpoints, requested together rather than in sequence: they are
8577
+ * independent, they all resolve from the same stored record, and the round
8578
+ * trips are what a user waits through when opening a commit.
8579
+ *
8580
+ * The commit itself comes from the patches call, which is the one that fails
8581
+ * usefully when the commit does not exist.
8582
+ */
8583
+ async function fetchCommitRecord(ops, commitSha) {
8584
+ const [patchesRes, modulesRes, filesRes] = await Promise.all([ops.getCommitPatches(commitSha), ops.getCommitModules(commitSha), ops.getCommitAffectedFiles(commitSha)]);
8585
+ if (result.isErr(patchesRes)) {
8586
+ return patchesRes;
8587
+ }
8588
+ if (result.isErr(modulesRes)) {
8589
+ return modulesRes;
8590
+ }
8591
+ if (result.isErr(filesRes)) {
8592
+ return filesRes;
8593
+ }
8594
+ return result.ok({
8595
+ commit: patchesRes.value.commit,
8596
+ patches: patchesRes.value.patches,
8597
+ modules: modulesRes.value.modules,
8598
+ affectedFiles: filesRes.value
8599
+ });
8600
+ }
8601
+
8602
+ /**
8603
+ * The `*.val.json` entries a commit touched, read from git at that commit.
8604
+ *
8605
+ * Not stored in the commit record, unlike the `.val.ts` sources: an entry is a
8606
+ * plain JSON file that git has at every commit, so a second copy could only
8607
+ * drift from the first. A `.val.ts` is different - it is what a restore
8608
+ * replays patches against, and reading it from git would make every look at
8609
+ * history depend on the repository still being there.
8610
+ *
8611
+ * Reads at the commit itself rather than its parent: an entry's content AT a
8612
+ * commit is what that commit produced, which is the "after" side. The "before"
8613
+ * side is the same file at the parent commit, and a caller that wants it asks
8614
+ * for the parent.
8615
+ */
8616
+ async function resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles) {
8617
+ const entryPaths = affectedFiles.filter(file => file.kind === "json-entry")
8618
+ // A deleted entry has no content at this commit; asking for it would be a
8619
+ // guaranteed 404 reported as a failure, which is noise rather than news.
8620
+ .filter(file => file.change !== "deleted").map(file => file.gitPath);
8621
+ if (entryPaths.length === 0) {
8622
+ return result.ok({
8623
+ entries: {},
8624
+ failures: []
8625
+ });
8626
+ }
8627
+ const entries = {};
8628
+ const failures = [];
8629
+ const fetched = await Promise.all(entryPaths.map(async gitPath => ({
8630
+ gitPath,
8631
+ res: await ops.getFileAtCommit(commitSha, gitPath, false)
8632
+ })));
8633
+ for (const {
8634
+ gitPath,
8635
+ res
8636
+ } of fetched) {
8637
+ if (result.isErr(res)) {
8638
+ failures.push({
8639
+ kind: "file-unavailable",
8640
+ gitPath,
8641
+ message: res.error.kind === "file-unavailable" ? res.error.message : `could not read entry: ${res.error.kind}`
8642
+ });
8643
+ continue;
8644
+ }
8645
+ try {
8646
+ entries[gitPath] = JSON.parse(res.value.toString("utf-8"));
8647
+ } catch (err) {
8648
+ failures.push({
8649
+ kind: "file-unavailable",
8650
+ gitPath,
8651
+ message: `not valid JSON at this commit: ${err instanceof Error ? err.message : String(err)}`
8652
+ });
8653
+ }
8654
+ }
8655
+ return result.ok({
8656
+ entries,
8657
+ failures
8658
+ });
8659
+ }
8660
+
8661
+ /**
8662
+ * Turn a commit's binary files into descriptors, WITHOUT fetching any of them.
8663
+ *
8664
+ * Pure, and that is the whole design. Showing that a commit changed six images
8665
+ * needs six rows, not six downloads; only an `<img>` that actually mounts pays
8666
+ * for its bytes. `url` points at the app's own history file route, which is
8667
+ * immutable for a given commit, so the browser's HTTP cache handles flipping
8668
+ * between commits with no JS cache at all.
8669
+ */
8670
+ function describeBinaryFilesAtCommit(commitSha, affectedFiles, /** Where the app serves `/api/val` from, e.g. "/api/val". */
8671
+ apiBasePath) {
8672
+ const refs = [];
8673
+ for (const file of affectedFiles) {
8674
+ if (file.kind === "remote-binary") {
8675
+ refs.push({
8676
+ gitPath: file.ref,
8677
+ change: file.change,
8678
+ remote: true,
8679
+ url: historyFileUrl(apiBasePath, commitSha, file.ref, true)
8680
+ });
8681
+ continue;
8682
+ }
8683
+ if (file.kind !== "binary") {
8684
+ continue;
8685
+ }
8686
+ refs.push({
8687
+ gitPath: file.gitPath,
8688
+ change: file.change,
8689
+ remote: false,
8690
+ url: historyFileUrl(apiBasePath, commitSha, file.gitPath, false)
8691
+ });
8692
+ }
8693
+ return refs;
8694
+ }
8695
+ function historyFileUrl(apiBasePath, commitSha, filePath, remote) {
8696
+ const params = new URLSearchParams({
8697
+ commit_sha: commitSha,
8698
+ path: filePath
8699
+ });
8700
+ if (remote) {
8701
+ params.set("remote", "true");
8702
+ }
8703
+ return `${apiBasePath}/history/files?${params.toString()}`;
8704
+ }
8705
+
8706
+ /**
8707
+ * The paths a commit's ops touched, from the ops themselves.
8708
+ *
8709
+ * The alternative was reconstructing the module's previous state and diffing it
8710
+ * against the new one - which needed the pre-commit `.val.ts`, a static parse
8711
+ * of it, and a replay of every op, three fragile steps to rediscover something
8712
+ * the ops already say outright. An op carries the path it applies to. That is
8713
+ * the answer.
8714
+ *
8715
+ * Deduplicated and returned in first-touched order, because a module edited
8716
+ * eight times in one publish should list a field once, where it first changed.
8717
+ */
8718
+ function changedPathsOf(moduleFilePath, patches) {
8719
+ const seen = new Set();
8720
+ const paths = [];
8721
+ for (const {
8722
+ patch
8723
+ } of patches) {
8724
+ for (const op of patch) {
8725
+ // A `file` op names a file, not a place in the source; the `replace` that
8726
+ // points the field at it is in the same patch and is the real change.
8727
+ if (op.op === "file") {
8728
+ continue;
8729
+ }
8730
+ /*
8731
+ * The canonical patch-path-to-source-path conversion, not a hand-rolled
8732
+ * join.
8733
+ *
8734
+ * `createValPathOfItem` JSON-quotes the ONE key it is handed, so joining
8735
+ * the segments with "." first produced `?p="teddy.name"` — a single
8736
+ * segment with a dot in its name — rather than `?p="teddy"."name"`. Only
8737
+ * top-level fields came out right, a record key containing a dot was
8738
+ * split in the other direction, and an op at the module root became
8739
+ * `?p=""`.
8740
+ *
8741
+ * `patchPathToModulePath` is what the rest of the Studio produces and
8742
+ * parses, integer segments left unquoted (`"items".2."label"`).
8743
+ */
8744
+ const sourcePath = op.path.length === 0 ? moduleFilePath : Internal.joinModuleFilePathAndModulePath(moduleFilePath, Internal.patchPathToModulePath(op.path));
8745
+ if (!seen.has(sourcePath)) {
8746
+ seen.add(sourcePath);
8747
+ paths.push(sourcePath);
8748
+ }
8749
+ }
8750
+ }
8751
+ return paths;
8752
+ }
8753
+
8754
+ /**
8755
+ * Reconstruct one commit: how each module looked before it, and after it.
8756
+ *
8757
+ * Deliberately says NOTHING about the current source or the current schema. For
8758
+ * a given commit sha this can never change, which is what lets its result be
8759
+ * cached forever and what makes flipping between commits cheap. The comparison
8760
+ * against today is `compareWithCurrent`, and it is deliberately the cheap half.
8761
+ *
8762
+ * Nothing is parsed and nothing is replayed: the module's data was recorded at
8763
+ * the commit, so reading it back is a read. What IS done here is validating the
8764
+ * stored schema against this version of Val, because that is the one thing the
8765
+ * content service could not check for us.
8766
+ *
8767
+ * Failures are collected per module, not thrown - one module this Val cannot
8768
+ * read leaves the other nine readable, and knowing WHICH one is the thing
8769
+ * someone opening history actually needs. Only a commit that cannot be read at
8770
+ * all is an `err`.
8771
+ */
8772
+ async function getHistoricalPatchSet(ops, commitSha, options) {
8773
+ const recordRes = await fetchCommitRecord(ops, commitSha);
8774
+ if (result.isErr(recordRes)) {
8775
+ return recordRes;
8776
+ }
8777
+ const {
8778
+ commit,
8779
+ patches,
8780
+ modules: stored,
8781
+ affectedFiles
8782
+ } = recordRes.value;
8783
+ const warnings = [];
8784
+
8785
+ // Group the commit's patches by module. Not to replay them - the stored
8786
+ // source already IS the result - but because the ops name the paths this
8787
+ // commit touched, which is what the view highlights.
8788
+ const patchesByModule = new Map();
8789
+ for (const patch of patches) {
8790
+ const existing = patchesByModule.get(patch.moduleFilePath) ?? [];
8791
+ existing.push({
8792
+ patchId: patch.patchId,
8793
+ coreVersion: patch.coreVersion,
8794
+ // The wire type is `unknown` because the content service does not know
8795
+ // Val's patch type; it has been validated on the way out of the archive.
8796
+ patch: patch.patch
8797
+ });
8798
+ patchesByModule.set(patch.moduleFilePath, existing);
8799
+ }
8800
+ const storedByPath = new Map(stored.map(module => [module.moduleFilePath, module]));
8801
+
8802
+ // Every module this commit touched: one it patched, and one we hold a stored
8803
+ // version of. Usually the same set - but a commit made by a Val too old to
8804
+ // send its modules has the first and not the second, and that has to read as
8805
+ // `source-unavailable` rather than as "no modules changed".
8806
+ const touched = new Set([...patchesByModule.keys(), ...storedByPath.keys()]);
8807
+ const modules = {};
8808
+ for (const moduleFilePath of touched) {
8809
+ const failures = [];
8810
+ const modulePatches = patchesByModule.get(moduleFilePath) ?? [];
8811
+ const patchIds = modulePatches.map(patch => patch.patchId);
8812
+ const changedPaths = changedPathsOf(moduleFilePath, modulePatches);
8813
+ const version = storedByPath.get(moduleFilePath);
8814
+ if (version === undefined || version.unavailable) {
8815
+ failures.push({
8816
+ kind: "source-unavailable",
8817
+ moduleFilePath
8818
+ });
8819
+ modules[moduleFilePath] = {
8820
+ source: null,
8821
+ schema: null,
8822
+ patchIds,
8823
+ changedPaths,
8824
+ failures
7376
8825
  };
7377
- } catch (err) {
7378
- return {
7379
- error: {
7380
- message: `Could not commit (connection error?): ${err instanceof Error ? err.message : (err === null || err === void 0 ? void 0 : err.toString()) || "unknown error"}`
7381
- }
8826
+ continue;
8827
+ }
8828
+
8829
+ /*
8830
+ * The schema is validated here, and nowhere earlier.
8831
+ *
8832
+ * It was stored as written and served back opaquely, so this is the first
8833
+ * place that knows what a schema is supposed to look like. A failure is
8834
+ * NOT damage and not anyone's mistake - Val's schema format is allowed to
8835
+ * move, and an older project opened in a newer Val (or the reverse) lands
8836
+ * exactly here. The module reads as one this version cannot show, and the
8837
+ * rest of the commit is unaffected.
8838
+ */
8839
+ const schemaRes = SerializedSchema.safeParse(version.schema);
8840
+ if (!schemaRes.success) {
8841
+ failures.push({
8842
+ kind: "schema-unreadable",
8843
+ moduleFilePath,
8844
+ message: fromError(schemaRes.error).toString()
8845
+ });
8846
+ modules[moduleFilePath] = {
8847
+ source: version.source,
8848
+ schema: null,
8849
+ patchIds,
8850
+ changedPaths,
8851
+ failures
7382
8852
  };
8853
+ continue;
7383
8854
  }
8855
+ modules[moduleFilePath] = {
8856
+ source: version.source,
8857
+ schema: schemaRes.data,
8858
+ patchIds,
8859
+ changedPaths,
8860
+ failures
8861
+ };
8862
+ }
8863
+ const entriesRes = await resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles);
8864
+ let jsonEntries = {};
8865
+ if (result.isErr(entriesRes)) {
8866
+ // Entry contents are supporting detail, not the commit. Losing them should
8867
+ // narrow what can be shown, not hide the commit entirely.
8868
+ warnings.push(entriesRes.error);
8869
+ } else {
8870
+ jsonEntries = entriesRes.value.entries;
8871
+ warnings.push(...entriesRes.value.failures);
8872
+ }
8873
+ return result.ok({
8874
+ commit,
8875
+ modules,
8876
+ patches,
8877
+ jsonEntries,
8878
+ binaryFiles: describeBinaryFilesAtCommit(commitSha, affectedFiles, (options === null || options === void 0 ? void 0 : options.apiBasePath) ?? "/api/val"),
8879
+ warnings
8880
+ });
8881
+ }
8882
+
8883
+ /**
8884
+ * One module, as of a commit — including a commit that never touched it.
8885
+ *
8886
+ * `getHistoricalPatchSet` answers "what did this commit change", which is what
8887
+ * a commit IS. This answers "how did this module look at that point", which is
8888
+ * what someone comparing two panes is asking as soon as they navigate off the
8889
+ * changed set — and without it the history pane can only show a handful of
8890
+ * modules per commit.
8891
+ *
8892
+ * `null` means history has no record at or before that commit: the module was
8893
+ * last edited before recording started, or never. That is deliberately NOT the
8894
+ * same as a module the commit deleted, which is a recorded fact with a source
8895
+ * of `null`, and not the same as an error.
8896
+ */
8897
+ async function getModuleAtCommit(ops, commitSha, moduleFilePath) {
8898
+ const res = await ops.getCommitModules(commitSha, {
8899
+ asOf: true,
8900
+ moduleFilePath
8901
+ });
8902
+ if (result.isErr(res)) {
8903
+ return res;
8904
+ }
8905
+ const version = res.value.modules.find(module => module.moduleFilePath === moduleFilePath);
8906
+ if (version === undefined) {
8907
+ return result.ok(null);
8908
+ }
8909
+ if (version.unavailable) {
8910
+ return result.ok({
8911
+ source: null,
8912
+ schema: null,
8913
+ patchIds: [],
8914
+ changedPaths: [],
8915
+ failures: [{
8916
+ kind: "source-unavailable",
8917
+ moduleFilePath
8918
+ }]
8919
+ });
8920
+ }
8921
+ // Validated here, not at the transport boundary, for the same reason as in
8922
+ // `getHistoricalPatchSet`: a schema this Val cannot read is one module it
8923
+ // cannot show, not a failed request.
8924
+ const schemaRes = SerializedSchema.safeParse(version.schema);
8925
+ if (!schemaRes.success) {
8926
+ return result.ok({
8927
+ source: version.source,
8928
+ schema: null,
8929
+ patchIds: [],
8930
+ changedPaths: [],
8931
+ failures: [{
8932
+ kind: "schema-unreadable",
8933
+ moduleFilePath,
8934
+ message: fromError(schemaRes.error).toString()
8935
+ }]
8936
+ });
7384
8937
  }
8938
+ return result.ok({
8939
+ source: version.source,
8940
+ schema: schemaRes.data,
8941
+ // Empty, and correct: this module may well have been changed by an EARLIER
8942
+ // commit than the one asked about, and those are that commit's patches.
8943
+ patchIds: [],
8944
+ changedPaths: [],
8945
+ failures: []
8946
+ });
7385
8947
  }
7386
8948
 
7387
8949
  const host = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
@@ -7539,31 +9101,39 @@ async function initHandlerOptions(route, opts, config) {
7539
9101
  /**
7540
9102
  * Build the data layer a {@link ValServerConfig} calls for.
7541
9103
  *
7542
- * `auth` decides *whose* credential the http backend sees. Left out, it is the
7543
- * app's own API key — which is what the Studio wants, because there the app has
7544
- * already verified a session cookie and is acting on the user's behalf under its
7545
- * own authority.
9104
+ * The http backend always sees the app's own API key. That is what the Studio
9105
+ * wants — there the app has already verified a session cookie and is acting on
9106
+ * the user's behalf under its own authority — and it is now the only shape:
9107
+ * every caller that reaches here has been authenticated by the app itself, so
9108
+ * there is no request left on which the app is a pipe rather than an authority.
9109
+ *
9110
+ * This took a parameter for the other case: a caller acting for a user it had
9111
+ * *not* authenticated passed that user's personal access token, and the backend
9112
+ * decided what the caller could do. `ValOpsHttp` still accepts such a token —
9113
+ * the CLI's `debug` command uses the developer's own from `val login` — but no
9114
+ * server request builds one any more, because a request the app cannot
9115
+ * authenticate is now refused instead of relayed.
7546
9116
  *
7547
- * A caller acting for a user it has *not* authenticated itself must pass that
7548
- * user's personal access token instead, so the backend is the one that decides
7549
- * what the caller may do. That is the whole of `docs/plans/mcp.md` D.2: the app
7550
- * stops being an authority and goes back to being a pipe. Passing the app's API
7551
- * key on such a request is the D.6 confused deputy, and it is worth being blunt
7552
- * about why it is tempting — it works, and it works for every project the key
7553
- * can reach, including the ones the caller cannot.
9117
+ * What has not changed is why the API key must never stand in for a credential
9118
+ * that was merely *absent*: it works, and it works for every project the key
9119
+ * can reach, including the ones the caller cannot. Callers are refused for a
9120
+ * missing credential well before this point.
7554
9121
  */
7555
- function createValOps(valModules, options, auth) {
9122
+ function createValOps(valModules, options) {
7556
9123
  if (options.mode === "fs") {
7557
9124
  // No credential in fs mode: this reads and writes the developer's own
7558
- // working tree, and there is no backend to authenticate to. A PAT handed in
7559
- // here is not ignored quietly — the caller is told, in createValTools.
9125
+ // working tree, and there is no backend to authenticate to. A credential
9126
+ // that arrives for such a project is not ignored quietly — the caller is
9127
+ // told, by `createValTools` when the project is configured for oauth and by
9128
+ // `initValMcp` when it is not, since with no issuer there is no verified
9129
+ // credential left for the registry to see.
7560
9130
  return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
7561
9131
  formatter: options.formatter,
7562
9132
  config: options.config
7563
9133
  });
7564
9134
  }
7565
9135
  if (options.mode === "http") {
7566
- return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, auth ?? {
9136
+ return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
7567
9137
  apiKey: options.apiKey
7568
9138
  }, valModules, {
7569
9139
  formatter: options.formatter,
@@ -8185,90 +9755,6 @@ const ValServer = (valModules, options, callbacks) => {
8185
9755
  };
8186
9756
  }
8187
9757
  },
8188
- "/session": {
8189
- GET: async req => {
8190
- const cookies = req.cookies;
8191
- if (serverOps instanceof ValOpsFS) {
8192
- return {
8193
- status: 200,
8194
- json: {
8195
- mode: "local",
8196
- enabled: await callbacks.isEnabled()
8197
- }
8198
- };
8199
- }
8200
- if (!options.project) {
8201
- return {
8202
- status: 500,
8203
- json: {
8204
- message: "Project is not set"
8205
- }
8206
- };
8207
- }
8208
- if (!options.valSecret) {
8209
- return {
8210
- status: 500,
8211
- json: {
8212
- message: "Secret is not set"
8213
- }
8214
- };
8215
- }
8216
- return withAuth(options.valSecret, cookies, "session", async data => {
8217
- if (!options.valBuildUrl) {
8218
- return {
8219
- status: 500,
8220
- json: {
8221
- message: "Val is not correctly setup. Build url is missing"
8222
- }
8223
- };
8224
- }
8225
- const url = new URL(`/api/val/${options.project}/auth/session`, options.valBuildUrl);
8226
- const fetchRes = await fetch(url, {
8227
- headers: getAuthHeaders(data.token, "application/json")
8228
- });
8229
- if (fetchRes.status === 200) {
8230
- const json = z.object({
8231
- member_role: z.union([z.literal("owner"), z.literal("developer"), z.literal("editor")]).optional(),
8232
- id: z.string(),
8233
- full_name: z.string().optional(),
8234
- username: z.string().optional(),
8235
- avatar_url: z.string().optional()
8236
- }).safeParse(await fetchRes.json());
8237
- if (json.success) {
8238
- return {
8239
- status: fetchRes.status,
8240
- json: {
8241
- mode: "proxy",
8242
- enabled: await callbacks.isEnabled(),
8243
- ...json.data
8244
- }
8245
- };
8246
- } else {
8247
- const message = getErrorMessageFromUnknownJson(json, "Could not parse session response. Unexpected error (no error message). Status: " + fetchRes.status);
8248
- return {
8249
- status: 500,
8250
- json: {
8251
- message: message,
8252
- ...json
8253
- }
8254
- };
8255
- }
8256
- } else {
8257
- const json = z.object({
8258
- message: z.string()
8259
- }).safeParse(await fetchRes.json());
8260
- const message = getErrorMessageFromUnknownJson(json, "Unknown error");
8261
- return {
8262
- status: fetchRes.status,
8263
- json: {
8264
- message: message,
8265
- ...json
8266
- }
8267
- };
8268
- }
8269
- });
8270
- }
8271
- },
8272
9758
  "/logout": {
8273
9759
  GET: async req => {
8274
9760
  const query = req.query;
@@ -8577,10 +10063,190 @@ const ValServer = (valModules, options, callbacks) => {
8577
10063
  };
8578
10064
  }
8579
10065
  },
10066
+ //#region patch groups
10067
+ // Staging and unstaging patches. Both take an already-closed set of patch ids:
10068
+ // the prefix closure (staging) or the forward closure (unstaging) is computed
10069
+ // on the client, which is the only side that has the schema needed to derive
10070
+ // patch sets. See `docs/independent-publish/PLAN.md`.
10071
+ //
10072
+ // In FS mode there is no shared store and exactly one author, so group
10073
+ // membership lives in the client and these handlers simply acknowledge. The
10074
+ // client already sends an explicit patch id list to `/save`, so a locally-held
10075
+ // group is enough to publish a subset correctly.
10076
+ "/patch-groups/~/patches": {
10077
+ PUT: async req => {
10078
+ const auth = getAuth(req.cookies);
10079
+ if (auth.error) {
10080
+ return {
10081
+ status: 401,
10082
+ json: {
10083
+ message: auth.error
10084
+ }
10085
+ };
10086
+ }
10087
+ const {
10088
+ patchGroupId,
10089
+ patchIds
10090
+ } = req.body;
10091
+ const withPatchIds = req.body.withPatchIds ?? [];
10092
+ if (serverOps instanceof ValOpsFS) {
10093
+ return {
10094
+ status: 200,
10095
+ json: {
10096
+ patchGroupId,
10097
+ patchIds: [...patchIds, ...withPatchIds]
10098
+ }
10099
+ };
10100
+ }
10101
+ if (!("id" in auth) || !auth.id) {
10102
+ return {
10103
+ status: 401,
10104
+ json: {
10105
+ message: "Unauthorized"
10106
+ }
10107
+ };
10108
+ }
10109
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
10110
+ if (refusal !== null) {
10111
+ return {
10112
+ status: refusal.status,
10113
+ json: {
10114
+ message: refusal.message
10115
+ }
10116
+ };
10117
+ }
10118
+ const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
10119
+ // Forwarded so the content API can refuse independently. This server
10120
+ // has already refused a group that is not the caller's; sending the
10121
+ // author means the check also holds for anything reaching the content
10122
+ // API without coming through here.
10123
+ auth.id);
10124
+ if (res.error) {
10125
+ return {
10126
+ status: res.status,
10127
+ json: {
10128
+ message: res.error.message
10129
+ }
10130
+ };
10131
+ }
10132
+ return {
10133
+ status: 200,
10134
+ json: {
10135
+ patchGroupId,
10136
+ patchIds: res.patchIds
10137
+ }
10138
+ };
10139
+ },
10140
+ DELETE: async req => {
10141
+ const auth = getAuth(req.cookies);
10142
+ if (auth.error) {
10143
+ return {
10144
+ status: 401,
10145
+ json: {
10146
+ message: auth.error
10147
+ }
10148
+ };
10149
+ }
10150
+ const {
10151
+ patchGroupId,
10152
+ patchIds
10153
+ } = req.body;
10154
+ const withPatchIds = req.body.withPatchIds ?? [];
10155
+ if (serverOps instanceof ValOpsFS) {
10156
+ return {
10157
+ status: 200,
10158
+ json: {
10159
+ patchGroupId,
10160
+ patchIds: [...patchIds, ...withPatchIds]
10161
+ }
10162
+ };
10163
+ }
10164
+ if (!("id" in auth) || !auth.id) {
10165
+ return {
10166
+ status: 401,
10167
+ json: {
10168
+ message: "Unauthorized"
10169
+ }
10170
+ };
10171
+ }
10172
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
10173
+ if (refusal !== null) {
10174
+ return {
10175
+ status: refusal.status,
10176
+ json: {
10177
+ message: refusal.message
10178
+ }
10179
+ };
10180
+ }
10181
+ const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
10182
+ if (res.error) {
10183
+ return {
10184
+ status: res.status,
10185
+ json: {
10186
+ message: res.error.message
10187
+ }
10188
+ };
10189
+ }
10190
+ return {
10191
+ status: 200,
10192
+ json: {
10193
+ patchGroupId,
10194
+ patchIds: res.patchIds
10195
+ }
10196
+ };
10197
+ }
10198
+ },
8580
10199
  //#region patches
8581
10200
  "/patches": {
8582
10201
  PUT: async req => {
8583
10202
  const cookies = req.cookies;
10203
+
10204
+ /**
10205
+ * Group membership travels WITH the patch, in one request.
10206
+ *
10207
+ * Atomic on purpose: the content API runs every refusal before its
10208
+ * insert, so an invalid closure is a 400 with nothing written. Recording
10209
+ * membership in a second call would let a patch exist outside its
10210
+ * author's group whenever that call failed — and a patch outside your
10211
+ * own group is one you cannot publish until a repair puts it back.
10212
+ *
10213
+ */
10214
+ /*
10215
+ * A membership is present if EITHER field is.
10216
+ *
10217
+ * The common case names no group: the content API resolves the author's
10218
+ * open group, creating it if absent, so the client never has to hold an
10219
+ * id across publishes. It still sends the closure, which is the part it
10220
+ * alone can compute.
10221
+ *
10222
+ * `patchGroupId` is nullable, so an explicit `null` means "no group
10223
+ * named" exactly as omitting it does — it is passed through as
10224
+ * `undefined` rather than becoming a membership keyed by null.
10225
+ */
10226
+ const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
10227
+ const requestedWith = req.body.withPatchIds;
10228
+ const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
10229
+ ...(requestedPatchGroupId !== undefined ? {
10230
+ patchGroupId: requestedPatchGroupId
10231
+ } : {}),
10232
+ withPatchIds: requestedWith ?? []
10233
+ } : undefined;
10234
+ if (patchGroup !== undefined && serverOps instanceof ValOpsFS) {
10235
+ /*
10236
+ * `fs` has no shared store and one author, so there is no group to
10237
+ * join. Refused rather than acknowledged: answering 200 would tell the
10238
+ * client its membership was recorded when it was dropped, and the
10239
+ * client would then believe a publish is scoped when it is not.
10240
+ */
10241
+ return {
10242
+ status: 400,
10243
+ json: {
10244
+ type: "patch-error",
10245
+ message: "Patch groups are not available in fs mode. Omit the patch group fields.",
10246
+ errors: {}
10247
+ }
10248
+ };
10249
+ }
8584
10250
  const auth = getAuth(cookies);
8585
10251
  if (auth.error) {
8586
10252
  return {
@@ -8603,8 +10269,19 @@ const ValServer = (valModules, options, callbacks) => {
8603
10269
  const sessionId = req.body.sessionId ?? null;
8604
10270
  const authorId = "id" in auth ? auth.id : null;
8605
10271
  const newPatchIds = [];
10272
+ /*
10273
+ * The group the content API put these patches in.
10274
+ *
10275
+ * Every patch in one request has the same author and the same
10276
+ * membership, so the last answer is the answer — they all land in the
10277
+ * same group. Reported back because the client cannot learn it any
10278
+ * other way: it names no group (the content API resolves the author's
10279
+ * open one, creating it if absent), and the chain annotation is only
10280
+ * re-read when a fetch has missing ids to ask for.
10281
+ */
10282
+ let patchGroupIdFromStore;
8606
10283
  for (const patch of patches) {
8607
- const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
10284
+ const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
8608
10285
  if (result.isErr(createPatchRes)) {
8609
10286
  if (createPatchRes.error.errorType === "patch-head-conflict") {
8610
10287
  return {
@@ -8636,13 +10313,22 @@ const ValServer = (valModules, options, callbacks) => {
8636
10313
  patchId: createPatchRes.value.patchId
8637
10314
  };
8638
10315
  newPatchIds.push(createPatchRes.value.patchId);
10316
+ if (createPatchRes.value.patchGroupId !== undefined) {
10317
+ patchGroupIdFromStore = createPatchRes.value.patchGroupId;
10318
+ }
8639
10319
  }
8640
10320
  }
8641
10321
  return {
8642
10322
  status: 200,
8643
10323
  json: {
8644
10324
  newPatchIds,
8645
- parentRef
10325
+ parentRef,
10326
+ // Absent rather than null where there are no groups: `fs` mode and
10327
+ // a content API that predates them both answer without one, and the
10328
+ // client reads absence as "staging is not available here".
10329
+ ...(patchGroupIdFromStore !== undefined ? {
10330
+ patchGroupId: patchGroupIdFromStore
10331
+ } : {})
8646
10332
  }
8647
10333
  };
8648
10334
  },
@@ -8704,15 +10390,84 @@ const ValServer = (valModules, options, callbacks) => {
8704
10390
  }
8705
10391
  // TODO: we should sort by parentRef instead:
8706
10392
  patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
10393
+ /**
10394
+ * Patch groups, ANNOTATED onto the chain rather than filtering it.
10395
+ *
10396
+ * The client computes a new patch's parent as the last id in this
10397
+ * response, so a filtered chain would make every client name a parent
10398
+ * that is not the real head and `POST /patches` would answer 409
10399
+ * forever. Annotate, never filter.
10400
+ *
10401
+ * Absent — not empty — where there are no groups: `fs` mode, a content
10402
+ * API that predates them, or a failed lookup. The client reads absence
10403
+ * as "this deployment has no groups" and leaves staging off, which is
10404
+ * the behaviour every project has today. An empty array would say
10405
+ * "groups exist and hold nothing", which would turn the staging UI on
10406
+ * with everything held.
10407
+ */
10408
+ let patchGroups;
10409
+ if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
10410
+ /*
10411
+ * FRESH, because this answer makes a CLOSING decision.
10412
+ *
10413
+ * The client adopts this annotation as its group membership and
10414
+ * `emptiesOwnPatchGroup` then decides from it whether a publish may
10415
+ * name the group — and the content API closes what it is named
10416
+ * without checking. A one-second-old list is enough to get that
10417
+ * wrong: the same author writing in a second tab joins the open
10418
+ * group, the websocket pushes the chain immediately, so this tab's
10419
+ * fetch for the missing patch lands well inside the cache window and
10420
+ * reads a membership that is one patch short. It then publishes,
10421
+ * names the group, and closes it with the other tab's work still in
10422
+ * it — which leaves that tab wedged on 409 until a reload.
10423
+ *
10424
+ * `resolveOwnPatchScope` keeps the cache deliberately: that read
10425
+ * decides what a draft render SHOWS, it is repeated once per
10426
+ * `fetchVal` in a single render, and being a second stale there costs
10427
+ * a patch appearing late rather than a group closing early.
10428
+ */
10429
+ const groupsRes = await serverOps.getPatchGroups({
10430
+ fresh: true
10431
+ });
10432
+ if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
10433
+ patchGroups = groupsRes.patchGroups;
10434
+ } else if (groupsRes.status === "error") {
10435
+ // Not fatal: the chain is what this endpoint is for, and staging
10436
+ // simply stays off for this read rather than the whole review
10437
+ // screen failing to load.
10438
+ console.error("Val: could not read patch groups", groupsRes.message);
10439
+ }
10440
+ }
10441
+ const groupIdsByPatchId = new Map();
10442
+ for (const group of patchGroups ?? []) {
10443
+ for (const patchId of group.patchIds) {
10444
+ const existing = groupIdsByPatchId.get(patchId);
10445
+ if (existing) {
10446
+ existing.push(group.patchGroupId);
10447
+ } else {
10448
+ groupIdsByPatchId.set(patchId, [group.patchGroupId]);
10449
+ }
10450
+ }
10451
+ }
8707
10452
  return {
8708
10453
  status: 200,
8709
10454
  json: {
8710
- patches: patches,
10455
+ patches: patches.map(patch => {
10456
+ const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
10457
+ return patchGroupIds ? {
10458
+ ...patch,
10459
+ patchGroupIds
10460
+ } : patch;
10461
+ }),
10462
+ ...(patchGroups ? {
10463
+ patchGroups
10464
+ } : {}),
8711
10465
  baseSha: await serverOps.getBaseSha()
8712
10466
  }
8713
10467
  };
8714
10468
  },
8715
10469
  DELETE: async req => {
10470
+ var _req$body;
8716
10471
  const query = req.query;
8717
10472
  const cookies = req.cookies;
8718
10473
  const auth = getAuth(cookies);
@@ -8732,8 +10487,26 @@ const ValServer = (valModules, options, callbacks) => {
8732
10487
  }
8733
10488
  };
8734
10489
  }
8735
- const ids = query.id;
8736
- const deleteRes = await serverOps.deletePatches(ids);
10490
+ const ids = query.id;
10491
+ /*
10492
+ * Which OTHER patches lose their group membership because these are
10493
+ * going. Only the client can COMPUTE it — that needs the patch sets,
10494
+ * which need the schema — but it is bounded here rather than trusted,
10495
+ * because the content API strips those memberships from every group
10496
+ * without an ownership check. See `boundUnstageClosure`.
10497
+ *
10498
+ * Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
10499
+ * client does not send it there.
10500
+ */
10501
+ let unstagePatchIds;
10502
+ const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
10503
+ if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
10504
+ const chain = await serverOps.fetchPatches({
10505
+ excludePatchOps: true
10506
+ });
10507
+ unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
10508
+ }
10509
+ const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
8737
10510
  if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
8738
10511
  console.error("Val: Failed to delete patches", deleteRes.errors);
8739
10512
  return {
@@ -8847,6 +10620,22 @@ const ValServer = (valModules, options, callbacks) => {
8847
10620
  } = req.query;
8848
10621
  // Defaults to true, mirroring /sources/~. The Studio opts out.
8849
10622
  const applyPatches = req.query.apply_patches !== false;
10623
+ /*
10624
+ * Whose pending work this render may see.
10625
+ *
10626
+ * The same resolution `/sources/~` runs, through the same function: a
10627
+ * draft page renders module content and `jsonValues` entries together,
10628
+ * and this route used to apply every pending patch on the branch while
10629
+ * the modules beside it were scoped — so one screen showed the caller's
10630
+ * view and everybody's unpublished work at once.
10631
+ */
10632
+ const {
10633
+ ownPatchIds
10634
+ } = await resolveOwnPatchScope(serverOps, {
10635
+ explicitPatchIds: undefined,
10636
+ ownGroupsOnly: req.query.own_patch_groups_only === true,
10637
+ authorId: "id" in auth && auth.id || undefined
10638
+ });
8850
10639
  const isWindow = offset !== undefined || limit !== undefined;
8851
10640
  const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
8852
10641
  if (shapes !== 1) {
@@ -8867,7 +10656,8 @@ const ValServer = (valModules, options, callbacks) => {
8867
10656
  }
8868
10657
  if (key !== undefined) {
8869
10658
  const res = await serverOps.getJsonEntry(moduleFilePath, key, {
8870
- applyPatches
10659
+ applyPatches,
10660
+ patchIds: ownPatchIds
8871
10661
  });
8872
10662
  if (res.status === "unauthorized") {
8873
10663
  return {
@@ -8908,7 +10698,8 @@ const ValServer = (valModules, options, callbacks) => {
8908
10698
  offset: offset,
8909
10699
  limit: limit
8910
10700
  }, {
8911
- applyPatches
10701
+ applyPatches,
10702
+ patchIds: ownPatchIds
8912
10703
  });
8913
10704
  if (res.status === "unauthorized") {
8914
10705
  return {
@@ -8993,10 +10784,87 @@ const ValServer = (valModules, options, callbacks) => {
8993
10784
  patches: []
8994
10785
  };
8995
10786
  if (query.exclude_patches !== true) {
8996
- patchOps = await serverOps.fetchPatches({
8997
- patchIds: undefined,
8998
- excludePatchOps: false
10787
+ /**
10788
+ * The caller's own groups, resolved from their session.
10789
+ *
10790
+ * A draft render cannot name its own group ids — it has no client
10791
+ * state — so it asks for "mine" and the server works out which. Only
10792
+ * when `patch_id` is absent: an explicit list is a caller that already
10793
+ * knows what it wants.
10794
+ *
10795
+ * A group lookup that FAILS renders base rather than everything. Being
10796
+ * shown only committed content while the content API is unreachable is
10797
+ * a degraded preview; being shown another author's unpublished draft
10798
+ * because a lookup failed is the bug this feature exists to prevent,
10799
+ * and it would be silent.
10800
+ */
10801
+ const {
10802
+ ownPatchIds,
10803
+ scopeAlsoIncludesApplied
10804
+ } = await resolveOwnPatchScope(serverOps, {
10805
+ explicitPatchIds: query.patch_id,
10806
+ ownGroupsOnly: query.own_patch_groups_only === true,
10807
+ authorId: "id" in auth && auth.id || undefined
8999
10808
  });
10809
+ const requestedPatchIds = query.patch_id ?? ownPatchIds;
10810
+ if (scopeAlsoIncludesApplied) {
10811
+ /*
10812
+ * Scoped to this caller's groups, PLUS everything already
10813
+ * committed.
10814
+ *
10815
+ * Filtered here rather than through `patchIds`, because the set is
10816
+ * not knowable before the fetch: `appliedAt` lives on the patch,
10817
+ * not on the group. One request either way — the whole chain is
10818
+ * what the unscoped path fetches too — so this costs a filter, not
10819
+ * a round trip.
10820
+ *
10821
+ * This also subsumes the empty-group case below: a caller holding
10822
+ * nothing, on a branch with nothing applied, filters down to no
10823
+ * patches, which is base.
10824
+ */
10825
+ const all = await serverOps.fetchPatches({
10826
+ patchIds: undefined,
10827
+ excludePatchOps: false
10828
+ });
10829
+ patchOps = {
10830
+ ...all,
10831
+ patches: scopedPatches(all.patches, ownPatchIds)
10832
+ };
10833
+ } else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
10834
+ /*
10835
+ * A group that holds nothing renders base, and is handled HERE.
10836
+ *
10837
+ * `fetchPatches` cannot express it: both implementations read an
10838
+ * empty `patchIds` as "no filter" and return the whole chain
10839
+ * (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
10840
+ * `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
10841
+ * That is the right default for every caller that has ever passed a
10842
+ * list, since none of them can mean "none" — but it is the most
10843
+ * dangerous possible reading of an EXPLICITLY empty group, which
10844
+ * would render every unpublished patch on the branch instead of
10845
+ * base.
10846
+ *
10847
+ * Answered before the call rather than by changing that shared
10848
+ * default, which seven other call sites rely on.
10849
+ */
10850
+ patchOps = {
10851
+ patches: []
10852
+ };
10853
+ } else {
10854
+ patchOps = await serverOps.fetchPatches({
10855
+ /*
10856
+ * The caller's patch group, when it named one.
10857
+ *
10858
+ * `undefined` means every pending patch, which is what every
10859
+ * existing caller gets and has to keep getting. A draft-mode
10860
+ * render that names its group gets base + that group instead, so
10861
+ * a server-rendered preview shows the same thing the person
10862
+ * editing is looking at rather than everybody's unpublished work.
10863
+ */
10864
+ patchIds: requestedPatchIds,
10865
+ excludePatchOps: false
10866
+ });
10867
+ }
9000
10868
  }
9001
10869
  // We check authorization here, because it is the first call to the backend
9002
10870
  if (patchOps.error && patchOps.unauthorized) {
@@ -9253,6 +11121,43 @@ const ValServer = (valModules, options, callbacks) => {
9253
11121
  * store wholesale.
9254
11122
  */
9255
11123
  const consumed = patches.patches.map(patch => patch.patchId);
11124
+ /*
11125
+ * Has somebody else published since this was decided?
11126
+ *
11127
+ * The client names the newest commit it knew about; anything newer here
11128
+ * means the review screen it acted on described a world that has moved.
11129
+ * The answer is "look again" rather than "your commit was rejected".
11130
+ *
11131
+ * Git's own not-fast-forward guard cannot see this: the chain is
11132
+ * fetched and committed fresh at this point, so the parent commit sent
11133
+ * is always the server's current one.
11134
+ *
11135
+ * Checked HERE, before `analyzePatches` and `prepare`, rather than just
11136
+ * before the commit: a publish that is going to be refused should not
11137
+ * first pay to apply every patch in it. And compared against the
11138
+ * commits `fetchPatches` just returned — `applicable/patches` filters
11139
+ * its PATCHES by the requested ids and never its commits, so the second
11140
+ * whole-chain fetch this used to make asked for a list it already had.
11141
+ *
11142
+ * Only when the client sends a head. One that does not publishes
11143
+ * exactly as it did before, and this cannot start refusing publishes for
11144
+ * a field it never sets.
11145
+ */
11146
+ if (serverOps instanceof ValOpsHttp) {
11147
+ const expectedHead = body.expectedHeadCommitSha;
11148
+ if (expectedHead !== undefined) {
11149
+ const serverHead = newestCommitSha(patches.commits);
11150
+ if (serverHead !== null && serverHead !== expectedHead) {
11151
+ return {
11152
+ status: 409,
11153
+ json: {
11154
+ message: "Someone else published while you were reviewing. Nothing was published — open Review again to see what changed.",
11155
+ headMoved: true
11156
+ }
11157
+ };
11158
+ }
11159
+ }
11160
+ }
9256
11161
  const analysis = serverOps.analyzePatches(patches.patches, patches.commits, commit);
9257
11162
  let preparedCommit = await serverOps.prepare({
9258
11163
  ...analysis,
@@ -9401,8 +11306,53 @@ const ValServer = (valModules, options, callbacks) => {
9401
11306
  } else if (serverOps instanceof ValOpsHttp) {
9402
11307
  if (auth.error === undefined && auth.id) {
9403
11308
  var _options$config$files;
11309
+ /*
11310
+ * The group this commit CLOSES has to be the caller's.
11311
+ *
11312
+ * Stage and unstage go through `refuseUnlessOwn`; this route did
11313
+ * not, and the content API's `postCommit` marks the group published
11314
+ * on id alone with no author clause — so the id was trusted twice
11315
+ * and checked nowhere. Any logged-in editor could name a colleague's
11316
+ * open group and close it: their pending patches land in a closed
11317
+ * group and in no open one, so a scoped draft render shows base for
11318
+ * them, and their own tab still believes the group is open, so their
11319
+ * next stage is refused with 409.
11320
+ *
11321
+ * Same hole as the stage/unstage one this branch already closed, one
11322
+ * route over. The content API needs the same guard — this one is the
11323
+ * convenience, that one is what actually holds.
11324
+ */
11325
+ if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
11326
+ const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
11327
+ if (refusal !== null) {
11328
+ return {
11329
+ status: refusal.status,
11330
+ json: {
11331
+ message: refusal.message,
11332
+ /*
11333
+ * Flagged, because a bare 409 here reads as git refusing
11334
+ * the commit — which is retryable, and this is the
11335
+ * opposite. `refuseUnlessOwn` answers 409 for one reason
11336
+ * only: the group has already been published, so its id
11337
+ * will never be writable again and retrying reproduces
11338
+ * this forever. The client forgets the id instead.
11339
+ */
11340
+ ...(refusal.status === 409 ? {
11341
+ patchGroupPublished: true
11342
+ } : {})
11343
+ }
11344
+ };
11345
+ }
11346
+ }
9404
11347
  const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
9405
- 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");
11348
+ 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,
11349
+ /*
11350
+ * Forwarded verbatim, and only the client can decide it: the
11351
+ * content API closes the group it is named without checking that
11352
+ * the commit shipped all of it, and whether it did needs the
11353
+ * patch sets, which live in the browser.
11354
+ */
11355
+ body.patchGroupId);
9406
11356
  if (commitRes.error) {
9407
11357
  console.error("Failed to commit", commitRes.error);
9408
11358
  if ("isNotFastForward" in commitRes && commitRes.isNotFastForward) {
@@ -9423,9 +11373,20 @@ const ValServer = (valModules, options, callbacks) => {
9423
11373
  };
9424
11374
  }
9425
11375
  // TODO: serverOps.markApplied(patchIds);
11376
+ /*
11377
+ * The new head, back to the client that made it.
11378
+ *
11379
+ * Nothing else tells it in time: `headCommitSha` moves on a `/stat`
11380
+ * response, so until the next poll the client still believes the
11381
+ * pre-publish head — and its next publish sent that as
11382
+ * `expectedHeadCommitSha`, hit the check above against the commit it
11383
+ * had itself just made, and was told somebody else had published.
11384
+ */
9426
11385
  return {
9427
11386
  status: 200,
9428
- json: {} // TODO:
11387
+ json: {
11388
+ commitSha: commitRes.commit
11389
+ }
9429
11390
  };
9430
11391
  }
9431
11392
  return {
@@ -10007,11 +11968,13 @@ const ValServer = (valModules, options, callbacks) => {
10007
11968
  }
10008
11969
  };
10009
11970
  }
10010
- const arrayBuffer = await binaryRes.arrayBuffer();
10011
- const base64 = Buffer.from(arrayBuffer).toString("base64");
10012
- const dataUrl = `data:${file.metadata.mimeType};base64,${base64}`;
11971
+ // Bytes straight through. This used to base64 the buffer,
11972
+ // wrap it in a data: URL, and hand that to a method whose first
11973
+ // act was to unwrap it again - ceremony to satisfy a convention
11974
+ // that no longer exists.
11975
+ const bytes = Buffer.from(await binaryRes.arrayBuffer());
10013
11976
  const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
10014
- const saveRes = await serverOps.saveBase64EncodedBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, dataUrl, type, file.metadata);
11977
+ const saveRes = await serverOps.saveBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, bytes, file.metadata.mimeType, type, file.metadata);
10015
11978
  if (saveRes.error) {
10016
11979
  return {
10017
11980
  status: 500,
@@ -10139,6 +12102,137 @@ const ValServer = (valModules, options, callbacks) => {
10139
12102
  }
10140
12103
  },
10141
12104
  //#region files
12105
+ // #region history
12106
+ "/history/commits": {
12107
+ GET: async req => {
12108
+ const auth = getAuth(req.cookies);
12109
+ if (auth.error) {
12110
+ return {
12111
+ status: 401,
12112
+ json: {
12113
+ message: auth.error
12114
+ }
12115
+ };
12116
+ }
12117
+ const res = await serverOps.listCommits(req.query.branch, {
12118
+ limit: req.query.limit,
12119
+ cursor: req.query.cursor
12120
+ });
12121
+ if (result.isErr(res)) {
12122
+ return historyErrorResponse(res.error);
12123
+ }
12124
+ return {
12125
+ status: 200,
12126
+ json: res.value,
12127
+ // The head moves, so a listing is never reusable.
12128
+ headers: {
12129
+ "Cache-Control": "no-store"
12130
+ }
12131
+ };
12132
+ }
12133
+ },
12134
+ "/history/commit": {
12135
+ GET: async req => {
12136
+ const auth = getAuth(req.cookies);
12137
+ if (auth.error) {
12138
+ return {
12139
+ status: 401,
12140
+ json: {
12141
+ message: auth.error
12142
+ }
12143
+ };
12144
+ }
12145
+ const res = await getHistoricalPatchSet(serverOps, req.query.commit_sha);
12146
+ if (result.isErr(res)) {
12147
+ return historyErrorResponse(res.error);
12148
+ }
12149
+ /*
12150
+ * Immutable ONLY when nothing in the answer was transient.
12151
+ *
12152
+ * What a commit recorded cannot change, so a clean answer is reusable
12153
+ * forever - that is what makes comparing many commits cheap. But a
12154
+ * module marked `unavailable` means a blob fetch failed, and a warning
12155
+ * means an entry read against GitHub did; both can succeed on the next
12156
+ * try. Cached for a year, one flaky read would become "nothing was
12157
+ * recorded for this module" for the rest of the session and beyond.
12158
+ *
12159
+ * `private`, not `public`: this route is behind the session cookie, and
12160
+ * a shared cache holding it would hand one project's module sources to
12161
+ * whoever asked next. The browser's own cache is where the reuse was
12162
+ * wanted anyway.
12163
+ */
12164
+ const settled = res.value.warnings.length === 0 && Object.values(res.value.modules).every(module => module.failures.length === 0);
12165
+ return {
12166
+ status: 200,
12167
+ json: res.value,
12168
+ headers: {
12169
+ "Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
12170
+ }
12171
+ };
12172
+ }
12173
+ },
12174
+ "/history/module": {
12175
+ GET: async req => {
12176
+ const auth = getAuth(req.cookies);
12177
+ if (auth.error) {
12178
+ return {
12179
+ status: 401,
12180
+ json: {
12181
+ message: auth.error
12182
+ }
12183
+ };
12184
+ }
12185
+ const res = await getModuleAtCommit(serverOps, req.query.commit_sha, req.query.module_file_path);
12186
+ if (result.isErr(res)) {
12187
+ return historyErrorResponse(res.error);
12188
+ }
12189
+ // Immutable only when the answer settled — a module reported
12190
+ // unavailable means a blob fetch failed and may succeed next time, and
12191
+ // caching that for a year turns one flaky read into a permanent gap.
12192
+ const settled = res.value === null || res.value.failures.length === 0;
12193
+ return {
12194
+ status: 200,
12195
+ json: {
12196
+ moduleFilePath: req.query.module_file_path,
12197
+ module: res.value
12198
+ },
12199
+ headers: {
12200
+ "Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
12201
+ }
12202
+ };
12203
+ }
12204
+ },
12205
+ "/history/files": {
12206
+ GET: async req => {
12207
+ // No auth, for the same reason /files has none: this is served to an
12208
+ // <img> that the app's own backend may fetch during image
12209
+ // optimisation, with no cookies. What it exposes is a file at a commit
12210
+ // that is already in the repository.
12211
+ const res = await serverOps.getFileAtCommit(req.query.commit_sha, req.query.path, req.query.remote === "true");
12212
+ if (result.isErr(res)) {
12213
+ const response = historyErrorResponse(res.error);
12214
+ // The stream branch of this route's response union has no json, so
12215
+ // narrow to the shapes it does allow.
12216
+ if (response.status === 401 || response.status === 404) {
12217
+ return response;
12218
+ }
12219
+ return {
12220
+ status: 400,
12221
+ json: response.json
12222
+ };
12223
+ }
12224
+ return {
12225
+ status: 200,
12226
+ headers: {
12227
+ "Content-Type": guessMimeTypeFromPath(req.query.path) ?? "application/octet-stream",
12228
+ // A file at a fixed commit cannot change.
12229
+ "Cache-Control": "public, max-age=31536000, immutable"
12230
+ },
12231
+ body: bufferToReadableStream(res.value)
12232
+ };
12233
+ }
12234
+ },
12235
+ // #endregion history
10142
12236
  "/files": {
10143
12237
  GET: async req => {
10144
12238
  const query = req.query;
@@ -10191,6 +12285,242 @@ const ValServer = (valModules, options, callbacks) => {
10191
12285
  }
10192
12286
  };
10193
12287
  };
12288
+ /**
12289
+ * Refuse to touch a group that is not the caller's.
12290
+ *
12291
+ * Exported for `patchGroupOwnership.test.ts`: this is the whole of the
12292
+ * authorization for stage and unstage, and nothing else in the process checks
12293
+ * it, so it is worth testing as a policy rather than only through a route.
12294
+ *
12295
+ * `getAuth` only proves a session EXISTS; it says nothing about whose
12296
+ * group this is. And the content API cannot decide either — every call
12297
+ * from here carries the app's API key, not the editor's identity — so if
12298
+ * this does not check, nothing does.
12299
+ *
12300
+ * `GET /patches?include_patch_groups=true` hands every editor the id and
12301
+ * author of every open group on the branch, so without this any logged-in
12302
+ * editor can unstage another author's patches (their next publish
12303
+ * silently ships less) or stage into their group (it silently ships
12304
+ * more). The 403 declared for this route in `ApiRoutes.ts` was
12305
+ * unreachable.
12306
+ *
12307
+ * Fails CLOSED: if the groups cannot be read, the mutation is refused
12308
+ * rather than allowed unverified.
12309
+ */
12310
+
12311
+ /**
12312
+ * The patches a scoped draft render should apply: the caller's own group, plus
12313
+ * everything already committed.
12314
+ *
12315
+ * Scoping is about PENDING work. A published patch stays in the chain with
12316
+ * `appliedAt` set until the next deployment moves the base, and it is part of
12317
+ * everyone's view in that window — the unscoped path applies it. Dropping it
12318
+ * meant the moment somebody published, their own draft preview reverted the
12319
+ * field they had just shipped, and nobody else saw it either until the deploy
12320
+ * landed; anything written on top in that window is authored against content
12321
+ * already stale on `main`.
12322
+ *
12323
+ * Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
12324
+ * PARTIAL publish leaves the group open with only some of its patches applied.
12325
+ * Those are committed too, and no flag on the group names them.
12326
+ *
12327
+ * `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
12328
+ * caller holding nothing, and on a branch with nothing applied it correctly
12329
+ * filters down to no patches, which renders base.
12330
+ */
12331
+ function scopedPatches(patches, ownPatchIds) {
12332
+ const own = new Set(ownPatchIds ?? []);
12333
+ return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
12334
+ }
12335
+
12336
+ /**
12337
+ * Which of the client's `unstagePatchIds` this server is willing to forward.
12338
+ *
12339
+ * The forward closure of a discard is the client's to compute — it needs the
12340
+ * patch sets, which need the schema — and it was being forwarded verbatim. But
12341
+ * the content API removes those memberships from EVERY group with no ownership
12342
+ * check, so any logged-in editor could strip arbitrary patches out of any other
12343
+ * author's group by attaching them to a delete of one of their own throwaway
12344
+ * patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
12345
+ * reached by a different door: their next publish silently ships less.
12346
+ *
12347
+ * Neither server can compute the true closure, but this one can BOUND it. A
12348
+ * patch can only be invalidated by a delete if it was written after that delete
12349
+ * — its paths were chosen against a view that had it — and if it is in the same
12350
+ * module, since a patch set never spans two. Anything outside those bounds was
12351
+ * not in the closure whatever the client says, so it is dropped rather than
12352
+ * refused: the delete is still correct, and refusing the whole request over an
12353
+ * over-broad extra would turn a discard into an error the user cannot act on.
12354
+ *
12355
+ * Exported for the test. Pure, and given the chain rather than fetching it, so
12356
+ * the ordering it depends on is visible in the test rather than mocked.
12357
+ */
12358
+ function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
12359
+ chain, deleted, requested) {
12360
+ if (requested.length === 0) {
12361
+ return [];
12362
+ }
12363
+ const positionOf = new Map();
12364
+ const moduleOf = new Map();
12365
+ chain.forEach((entry, index) => {
12366
+ positionOf.set(entry.patchId, index);
12367
+ moduleOf.set(entry.patchId, entry.path);
12368
+ });
12369
+ const doomed = new Set(deleted);
12370
+ return requested.filter(patchId => {
12371
+ // A patch being deleted anyway does not need its membership stripped
12372
+ // separately, and naming one is how an over-broad list hides.
12373
+ if (doomed.has(patchId)) return false;
12374
+ const position = positionOf.get(patchId);
12375
+ const moduleFilePath = moduleOf.get(patchId);
12376
+ if (position === undefined || moduleFilePath === undefined) return false;
12377
+ return deleted.some(deletedId => {
12378
+ const deletedPosition = positionOf.get(deletedId);
12379
+ if (deletedPosition === undefined) return false;
12380
+ return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
12381
+ });
12382
+ });
12383
+ }
12384
+
12385
+ /**
12386
+ * Which pending patches this caller may see, when they asked for "only mine".
12387
+ *
12388
+ * Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
12389
+ * both, so two answers to "whose work is this" put one person's half-finished
12390
+ * edit on another person's preview through whichever route was not scoped. That
12391
+ * is exactly what happened — `/json` applied every pending patch on the branch
12392
+ * while the module content beside it was scoped.
12393
+ *
12394
+ * `undefined` means "apply everything", which is what every caller that does
12395
+ * not ask for scoping gets and must keep getting.
12396
+ */
12397
+ async function resolveOwnPatchScope(serverOps, opts) {
12398
+ let ownPatchIds;
12399
+ /** See where this is set: committed work is nobody's to hold back. */
12400
+ let scopeAlsoIncludesApplied = false;
12401
+ if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
12402
+ if (serverOps instanceof ValOpsHttp && opts.authorId) {
12403
+ const groupsRes = await serverOps.getPatchGroups();
12404
+ if (groupsRes.status === "unsupported") {
12405
+ /*
12406
+ * A content API that PREDATES patch groups — the endpoint 404s.
12407
+ *
12408
+ * Unscoped, which is what those deployments do today and must
12409
+ * keep doing. Reading this as a failure and rendering base
12410
+ * would silently drop every pending patch from every draft
12411
+ * preview on every existing http project — the exact opposite
12412
+ * of "keeps working unchanged", and invisible to the reader.
12413
+ */
12414
+ ownPatchIds = undefined;
12415
+ } else if (groupsRes.status === "error") {
12416
+ /*
12417
+ * We could not ask, and this deployment DOES have the endpoint.
12418
+ * Render base rather than everything: a degraded preview is
12419
+ * recoverable, showing another author's unpublished draft is
12420
+ * not, and it would be silent.
12421
+ */
12422
+ ownPatchIds = [];
12423
+ } else if (groupsRes.patchGroups.length === 0) {
12424
+ /*
12425
+ * The branch has no groups AT ALL, so this deployment is not
12426
+ * using them — a content API that predates patch groups, or a
12427
+ * project where nobody has staged anything since they existed.
12428
+ *
12429
+ * Unscoped, which is the behaviour every such project has
12430
+ * today. Collapsing this into "your group is empty" would make
12431
+ * every draft render base and silently drop all pending
12432
+ * content, which is what happened before this branch: nothing
12433
+ * writes a group yet, so EVERY project is in this state right
12434
+ * now.
12435
+ */
12436
+ ownPatchIds = undefined;
12437
+ } else {
12438
+ /*
12439
+ * Groups exist and none are this person's: they have staged
12440
+ * nothing, and base is the honest answer. Distinct from the
12441
+ * case above, which is why the two are not one expression.
12442
+ */
12443
+ ownPatchIds = groupsRes.patchGroups.filter(group => group.publishedAt === null && group.authorId === opts.authorId).flatMap(group => group.patchIds);
12444
+ /*
12445
+ * Scoping applies to PENDING work only. Anything already
12446
+ * committed is part of everyone's view.
12447
+ *
12448
+ * A published patch stays in the chain with `appliedAt` set
12449
+ * until the next deployment moves the base, and the unscoped
12450
+ * path applies it. Filtering to open groups dropped it — so the
12451
+ * moment someone published, their own draft preview reverted
12452
+ * the field they had just shipped, and nobody else saw it
12453
+ * either until the deploy landed. Anything written on top in
12454
+ * that window is authored against content that is already
12455
+ * stale on `main`.
12456
+ *
12457
+ * Unioned by `appliedAt` rather than by pulling in groups with
12458
+ * a `publishedAt`, because a partial publish leaves the group
12459
+ * OPEN with some of its patches applied — those are committed
12460
+ * too, and no group flag names them.
12461
+ */
12462
+ scopeAlsoIncludesApplied = true;
12463
+ }
12464
+ } else {
12465
+ /*
12466
+ * fs mode, or a server with no groups: there is nothing to scope
12467
+ * BY, and every pending patch is this one person's anyway. Left
12468
+ * `undefined` so the existing "apply everything" path runs.
12469
+ */
12470
+ ownPatchIds = undefined;
12471
+ }
12472
+ }
12473
+ return {
12474
+ ownPatchIds,
12475
+ scopeAlsoIncludesApplied
12476
+ };
12477
+ }
12478
+ async function refuseUnlessOwn(ops, patchGroupId, authorId) {
12479
+ /*
12480
+ * Never from the cache. This answers "is this group yours", and a group is at
12481
+ * its youngest exactly when the question is asked — the first write creates
12482
+ * it and the shell flushes its queued stages the moment the save response
12483
+ * names it. A cached list fetched a few hundred milliseconds earlier does not
12484
+ * contain it, and every one of those stages was refused and dropped.
12485
+ */
12486
+ const groupsRes = await ops.getPatchGroups({
12487
+ fresh: true
12488
+ });
12489
+ if (groupsRes.status !== "ok") {
12490
+ return {
12491
+ status: 500,
12492
+ message: "Could not verify that this patch group is yours, so it was not changed."
12493
+ };
12494
+ }
12495
+ const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
12496
+ if (group === undefined || group.authorId === null || group.authorId !== authorId) {
12497
+ /*
12498
+ * "Not found" and "not yours" are the SAME refusal on purpose: the route
12499
+ * schema has no 404, and `GET /patches` already lists every group on the
12500
+ * branch, so distinguishing them hides nothing and only adds a second
12501
+ * message to keep consistent.
12502
+ *
12503
+ * A null author is a group written by an api key or a PAT. Nobody owns it,
12504
+ * so nobody may stage into it — `null === null` must not read as a match.
12505
+ */
12506
+ return {
12507
+ status: 403,
12508
+ message: "You can only change your own patch group"
12509
+ };
12510
+ }
12511
+ if (group.publishedAt !== null) {
12512
+ /*
12513
+ * Already shipped, so it can never be written again. The content API
12514
+ * answers this too; refusing here saves the round trip and keeps the
12515
+ * wording the same as every other refusal on this route.
12516
+ */
12517
+ return {
12518
+ status: 409,
12519
+ message: "Patch group is already published"
12520
+ };
12521
+ }
12522
+ return null;
12523
+ }
10194
12524
  function verifyCallbackReq(stateCookie, queryParams) {
10195
12525
  if (typeof stateCookie !== "string") {
10196
12526
  return {
@@ -10401,6 +12731,45 @@ const ENABLE_COOKIE_VALUE = {
10401
12731
  }
10402
12732
  };
10403
12733
  const chunkSize = 1024 * 1024;
12734
+
12735
+ /**
12736
+ * Turn a HistoryError into the HTTP answer it deserves.
12737
+ *
12738
+ * The `kind` travels in the body alongside the message, because the Studio
12739
+ * decides what to OFFER from it - "cannot restore this field" and "cannot
12740
+ * restore this module at all" are different affordances, and a rendered string
12741
+ * cannot be told apart.
12742
+ */
12743
+ function historyErrorResponse(error) {
12744
+ const message = historyErrorMessage(error);
12745
+ switch (error.kind) {
12746
+ case "commit-not-found":
12747
+ return {
12748
+ status: 404,
12749
+ json: {
12750
+ message
12751
+ }
12752
+ };
12753
+ case "not-supported-in-fs-mode":
12754
+ // Not an error in the request - this deployment simply has no history
12755
+ // service. 400 rather than 500 so it does not read as a bug.
12756
+ return {
12757
+ status: 400,
12758
+ json: {
12759
+ message,
12760
+ kind: error.kind
12761
+ }
12762
+ };
12763
+ default:
12764
+ return {
12765
+ status: 500,
12766
+ json: {
12767
+ message,
12768
+ kind: error.kind
12769
+ }
12770
+ };
12771
+ }
12772
+ }
10404
12773
  function bufferToReadableStream(buffer) {
10405
12774
  const stream = new ReadableStream({
10406
12775
  start(controller) {
@@ -10722,7 +13091,7 @@ function createValApiRouter(route, valServerPromise, convert) {
10722
13091
  }
10723
13092
  let bodyRes;
10724
13093
  try {
10725
- bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await req.json()) : {
13094
+ bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await readJsonBody(req)) : {
10726
13095
  success: true,
10727
13096
  data: {}
10728
13097
  };
@@ -10801,6 +13170,33 @@ function formatZodErrorString(error) {
10801
13170
  const errors = fromError(error).toString();
10802
13171
  return errors.length > 640 ? `${errors.slice(0, 640)}...` : errors;
10803
13172
  }
13173
+
13174
+ /**
13175
+ * The request's JSON body, or `undefined` when it has none.
13176
+ *
13177
+ * `req.json()` THROWS on an empty body, and the router used to call it
13178
+ * unconditionally for any route that declares a body — so the moment
13179
+ * `DELETE /patches` gained an optional body, every caller that sent none got
13180
+ * `400 Could not parse request body`. Declaring the schema `.optional()` did
13181
+ * not help: the throw happens before zod is ever consulted. Six e2e tests went
13182
+ * red on a helper doing exactly what the route still permits.
13183
+ *
13184
+ * Told apart by the request rather than by catching, so a body that IS sent and
13185
+ * is malformed still fails: absent means no content type and nothing to read,
13186
+ * and anything else is parsed and allowed to throw. A route whose schema
13187
+ * requires a body is unaffected — it gets `undefined` and zod refuses it, with
13188
+ * the same 400 as before, now naming the field.
13189
+ */
13190
+ async function readJsonBody(req) {
13191
+ if (req.headers.get("content-length") === "0") {
13192
+ return undefined;
13193
+ }
13194
+ const text = await req.text();
13195
+ if (text.length === 0) {
13196
+ return undefined;
13197
+ }
13198
+ return JSON.parse(text);
13199
+ }
10804
13200
  function zodErrorResult(error, message) {
10805
13201
  return {
10806
13202
  status: 400,
@@ -11454,21 +13850,22 @@ async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
11454
13850
  }
11455
13851
 
11456
13852
  /**
11457
- * Null on the PAT path, and the verified profile on the token path.
13853
+ * The verified profile, or null when there was nothing to verify.
11458
13854
  *
11459
- * The PAT case is unchanged and still deliberate: the app cannot resolve a
11460
- * PAT, so any id it wrote here would be an unverified claim dressed up as a
11461
- * checked one — and the request already carries the caller's own token, which
11462
- * is a better answer to "who did this" than anything the app could assert.
11463
- * Attributing that patch is the backend's job.
13855
+ * An author is written only when somebody checked it. On the token path the
13856
+ * host verified a signature over a key it does not hold, so the profile is
13857
+ * checked rather than claimed, and the backend has no token of its own to
13858
+ * attribute from — the call reaches it under the app's API key. If this
13859
+ * stayed null there, every edit made through a signed-in editor's own session
13860
+ * would land with no author at all, which is worse than useless on a CMS
13861
+ * whose review screen is organised by who changed what.
11464
13862
  *
11465
- * The token case is the opposite situation, which is why it gets the opposite
11466
- * answer. The host verified a signature over a key it does not hold, so the
11467
- * profile is checked rather than claimed, and the backend has no token of its
11468
- * own to attribute from — the call reaches it under the app's API key. If this
11469
- * stayed null, every edit made through a signed-in editor's own session would
11470
- * land with no author at all, which is worse than useless on a CMS whose
11471
- * review screen is organised by who changed what.
13863
+ * Null is what local filesystem mode gets, where there is no credential to
13864
+ * resolve, exactly as the Studio does locally. It is also what the removed
13865
+ * personal-access-token path got, and for a reason worth keeping in view: an
13866
+ * id derived from a credential the app cannot resolve is an unverified claim
13867
+ * dressed up as a checked one. Should another unverified credential ever
13868
+ * reach here, null remains its only honest author.
11472
13869
  */
11473
13870
  const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
11474
13871
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -11726,14 +14123,17 @@ function rejectFileOps(patch) {
11726
14123
  */
11727
14124
 
11728
14125
  /**
11729
- * How the caller was established, and it is a union because there are two
11730
- * genuinely different answers — with different consequences downstream.
14126
+ * How the caller was established, and there is one acceptable answer: the host
14127
+ * **checked a signature**.
11731
14128
  *
11732
- * The distinction that matters is **who checked**. A PAT is forwarded to the
11733
- * backend unchecked, because the app cannot resolve one; an access token is
11734
- * verified by the app itself, against a public key it does not hold and
11735
- * therefore cannot forge. The first is a credential being relayed. The second
11736
- * is a signature that has already been checked.
14129
+ * A union of one, deliberately. It carried a second variant — a personal access
14130
+ * token relayed to the backend unchecked, on the reasoning that the app cannot
14131
+ * resolve one and the backend can. The reasoning held; the shape did not. A
14132
+ * credential the host cannot check is one it also cannot refuse, so accepting
14133
+ * one made "a deployed endpoint that authenticates nobody" a supported
14134
+ * configuration, and it let a host serve these tools without ever being told
14135
+ * where callers should authorize. The discriminant stays so that adding a
14136
+ * second *verified* kind stays a one-line change at every call site.
11737
14137
  */
11738
14138
 
11739
14139
  /**
@@ -11768,17 +14168,6 @@ const VAL_SCOPE_READ = "val:read";
11768
14168
  /** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
11769
14169
  const VAL_SCOPE_WRITE = "val:write";
11770
14170
 
11771
- /**
11772
- * How many callers' data layers to keep around in proxy mode.
11773
- *
11774
- * Each entry holds one `ValOpsHttp`, and each of those caches the project's
11775
- * evaluated modules once `initSources` has run — so this bounds memory, not just
11776
- * entry count. Small on purpose: the cost of a miss is re-evaluating the
11777
- * modules on the next call, which is what happened on *every* call before this
11778
- * cache existed.
11779
- */
11780
- const MAX_CACHED_OPS = 8;
11781
-
11782
14171
  /**
11783
14172
  * Val's server-side tool registry.
11784
14173
  *
@@ -11878,17 +14267,25 @@ function createValTools(valModules, options) {
11878
14267
  * Pick the data layer for a call, which in proxy mode means picking whose
11879
14268
  * credential the backend will see.
11880
14269
  *
11881
- * This is the one place authorization is decided, and it decides it by *not*
11882
- * deciding: in proxy mode the caller's own personal access token goes to the
11883
- * backend, which is the only party that can say what that token may do. The app
11884
- * never inspects it, never caches a verdict about it, and never substitutes its
11885
- * own API key for a missing one — see `docs/plans/mcp.md` D.2.
14270
+ * This is the one place authorization is decided, and in proxy mode there is
14271
+ * exactly one credential it will act on: an access token whose signature,
14272
+ * issuer, audience and expiry the host verified against the authorization
14273
+ * server's published key. Anything less is refused here rather than forwarded.
14274
+ *
14275
+ * There used to be a second route — the caller's personal access token, passed
14276
+ * through unread on the reasoning that the backend, not the app, is the
14277
+ * authority on what it may do. That was true, and it was still the wrong shape:
14278
+ * it made an unauthenticated bearer token on a deployed endpoint a supported
14279
+ * configuration, and it meant `initValMcp` had a path where an app served MCP
14280
+ * without ever being told where to authorize. A host that has not verified
14281
+ * anything now gets a refusal that names the missing `oauth` config.
11886
14282
  *
11887
- * The alternative shape, and the reason this function exists at all, is an
11888
- * `authenticate()` that checks the PAT once and then acts under the app's key.
11889
- * That reads as more secure and is strictly less so: the check happens in the
11890
- * app, so every bug in it becomes full access to every project the app's key
11891
- * can reach, and the backend's own permission model stops being consulted (D.6).
14283
+ * What has *not* changed is why a verified token does not become the app's own
14284
+ * API key by some other name. The app authenticates to the backend with its own
14285
+ * key here, and who did what travels as the patch's `authorId` — so the
14286
+ * profile has to be one the host checked cryptographically, never one it was
14287
+ * handed. An `authenticate()` that decided a credential's rights inside the app
14288
+ * would make every bug in it full access to every project that key can reach.
11892
14289
  */
11893
14290
  function createOpsResolver(valModules, options) {
11894
14291
  if (options.mode === "fs") {
@@ -11902,18 +14299,17 @@ function createOpsResolver(valModules, options) {
11902
14299
  // and the difference matters, because fs mode writes straight to disk
11903
14300
  // with no backend permission check at all.
11904
14301
  //
11905
- // The two credentials get different messages because they arrive here
11906
- // for different reasons. A PAT is something the caller chose to send. A
11907
- // verified access token is not: it only exists because this app
11908
- // advertised an authorization server, so the developer seeing this did
11909
- // not do anything wrong — a config file did, and naming it is the
11910
- // difference between a two-minute fix and an afternoon.
14302
+ // A verified access token is not something the caller chose to send: it
14303
+ // only exists because this app advertised an authorization server, so
14304
+ // the developer seeing this did not do anything wrong — a config file
14305
+ // did, and naming it is the difference between a two-minute fix and an
14306
+ // afternoon.
11911
14307
  return {
11912
14308
  status: "error",
11913
14309
  result: {
11914
14310
  status: "error",
11915
14311
  code: "unsupported",
11916
- message: ctx.auth.type === "verified-profile" ? "This Val project is running in local filesystem mode, so there is nothing to authenticate against, but it is configured with an `oauth` issuer and is therefore asking clients for an access token it cannot use. Remove the `oauth` config (or `VAL_OAUTH_ISSUER` from your local `.env`) for local development." : "This Val project is running in local filesystem mode, where there is nothing to authenticate against. Do not send a credential."
14312
+ message: "This Val project is running in local filesystem mode, so there is nothing to authenticate against, but it is configured with an `oauth` issuer and is therefore asking clients for an access token it cannot use. Remove the `oauth` config (or `VAL_OAUTH_ISSUER` from your local `.env`) for local development."
11917
14313
  }
11918
14314
  };
11919
14315
  }
@@ -11924,78 +14320,50 @@ function createOpsResolver(valModules, options) {
11924
14320
  };
11925
14321
  }
11926
14322
 
11927
- // Keyed by a hash of the PAT, so the same caller reuses their own instance and
11928
- // two callers can never share one. Hashing is not a security boundary — the
11929
- // instance holds the token regardless — but it keeps credentials out of the
11930
- // key set, which is the thing that ends up in a heap dump or an error dump.
11931
- const byPatHash = new Map();
11932
14323
  /**
11933
- * One instance for every verified caller, and unlike the PAT map that is
11934
- * correct rather than a shortcut: this instance authenticates with the app's
11935
- * own API key, so there is nothing per-caller in it to keep apart. Who did
11936
- * what travels as the patch's `authorId` instead — see `writePath`.
14324
+ * One instance for every verified caller, and that is correct rather than a
14325
+ * shortcut: this instance authenticates with the app's own API key, so there
14326
+ * is nothing per-caller in it to keep apart. Who did what travels as the
14327
+ * patch's `authorId` instead — see `writePath`.
14328
+ *
14329
+ * One instance is also all proxy mode keeps now. It could not share while a
14330
+ * personal access token reached this function: each token needed its own
14331
+ * `ValOpsHttp` to hold it, each of those cached the project's evaluated
14332
+ * modules, and the bounded cache that kept that memory in check turned an
14333
+ * eviction into a re-evaluation of every module on the next call.
11937
14334
  */
11938
14335
  let sharedOps = null;
11939
14336
  return ctx => {
11940
- if (!ctx.auth) {
14337
+ var _ctx$auth;
14338
+ if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
11941
14339
  return {
11942
14340
  status: "error",
11943
14341
  result: {
11944
14342
  status: "error",
11945
14343
  code: "forbidden",
11946
- message: "This Val project talks to the Val content backend, so every call needs a credential: an access token from the Val authorization server, or the caller's own personal access token from `val login`."
14344
+ message: "This Val project talks to the Val content backend, so every call needs an access token from the Val authorization server. If this endpoint is not asking clients to authorize, it has no `oauth` config — give `initValMcp` one, or run the project in local filesystem mode for development."
11947
14345
  }
11948
14346
  };
11949
14347
  }
11950
- if (ctx.auth.type === "verified-profile") {
11951
- if (!options.apiKey) {
11952
- // Proxy mode is inferred from the api key being present, so this is
11953
- // unreachable through `initHandlerOptions`. It stays because the
11954
- // alternative to refusing is building ops with no credential at all.
11955
- return {
11956
- status: "error",
11957
- result: {
11958
- status: "error",
11959
- code: "forbidden",
11960
- message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
11961
- }
11962
- };
11963
- }
11964
- if (!sharedOps) {
11965
- sharedOps = createValOps(valModules, options);
11966
- }
11967
- return {
11968
- status: "ok",
11969
- ops: sharedOps
11970
- };
11971
- }
11972
- const key = createHash("sha256").update(ctx.auth.pat).digest("hex");
11973
- const cached = byPatHash.get(key);
11974
- if (cached) {
11975
- // Re-inserted so eviction drops the least recently used rather than the
11976
- // oldest — a long-running caller should not be evicted by a burst of
11977
- // one-off ones.
11978
- byPatHash.delete(key);
11979
- byPatHash.set(key, cached);
14348
+ if (!options.apiKey) {
14349
+ // Proxy mode is inferred from the api key being present, so this is
14350
+ // unreachable through `initHandlerOptions`. It stays because the
14351
+ // alternative to refusing is building ops with no credential at all.
11980
14352
  return {
11981
- status: "ok",
11982
- ops: cached
14353
+ status: "error",
14354
+ result: {
14355
+ status: "error",
14356
+ code: "forbidden",
14357
+ message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
14358
+ }
11983
14359
  };
11984
14360
  }
11985
- const ops = createValOps(valModules, options, {
11986
- pat: ctx.auth.pat
11987
- });
11988
- byPatHash.set(key, ops);
11989
- while (byPatHash.size > MAX_CACHED_OPS) {
11990
- const oldest = byPatHash.keys().next();
11991
- if (oldest.done) {
11992
- break;
11993
- }
11994
- byPatHash.delete(oldest.value);
14361
+ if (!sharedOps) {
14362
+ sharedOps = createValOps(valModules, options);
11995
14363
  }
11996
14364
  return {
11997
14365
  status: "ok",
11998
- ops
14366
+ ops: sharedOps
11999
14367
  };
12000
14368
  };
12001
14369
  }
@@ -12093,14 +14461,18 @@ function describeZodError(error) {
12093
14461
  * the safe direction: a tool that forgets the hint is treated as a write and
12094
14462
  * demands the wider scope, rather than a write slipping through as a read.
12095
14463
  *
12096
- * Only the verified-token path is checked. A PAT carries no scopes here by
12097
- * design — the backend resolves it and decides — so there is nothing to
12098
- * enforce, and inventing a default would be this app claiming an authority it
12099
- * does not have.
14464
+ * The early return is a call carrying no verified credential, and there is no
14465
+ * scope to check because nothing granted one. In Val's own host that means
14466
+ * local filesystem mode, where a project writing a developer's own working tree
14467
+ * has no wider authority to withhold. A host assembling its own context can
14468
+ * also reach it with an unauthenticated proxy-mode call — refused a few lines
14469
+ * later, by `resolveOps`, for the credential rather than the scope. Every other
14470
+ * caller arrives as a verified profile, carrying the scopes its token was
14471
+ * issued with.
12100
14472
  */
12101
14473
  function refuseInsufficientScope(tool, ctx) {
12102
- var _ctx$auth, _tool$annotations;
12103
- if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
14474
+ var _ctx$auth2, _tool$annotations;
14475
+ if (((_ctx$auth2 = ctx.auth) === null || _ctx$auth2 === void 0 ? void 0 : _ctx$auth2.type) !== "verified-profile") {
12104
14476
  return null;
12105
14477
  }
12106
14478
  // Read is needed by every call, including the writes: a tool that changes
@@ -13629,6 +16001,25 @@ async function handleJsonValuesExtractEntry(ctx) {
13629
16001
  };
13630
16002
  }
13631
16003
 
16004
+ /**
16005
+ * `external:upload` under `val validate --fix`: refuse, and say what to run.
16006
+ *
16007
+ * Every other fix in this registry rewrites something inside the repository —
16008
+ * reversible, visible in a diff, wrong by at most one commit. This one would
16009
+ * write entries into a live external store: not in a diff, not undone by
16010
+ * `git revert`, and against production indistinguishable from an editor's
16011
+ * publish. So a blanket `--fix` must never apply it.
16012
+ *
16013
+ * `fixableErrorMessage` rather than a plain error, because the error IS fixable
16014
+ * — just not by this command.
16015
+ */
16016
+ async function handleExternalUpload() {
16017
+ return {
16018
+ success: true,
16019
+ fixableErrorMessage: "This entry is written inline but its record is .external(). " + "Run 'val external upload' to move it into the store — " + "'val validate --fix' will not write to a live store."
16020
+ };
16021
+ }
16022
+
13632
16023
  // Fix handler registry. `keyof:check-keys` and `router:check-route` are
13633
16024
  // resolved upfront by the shared resolveSchemaSourceFixes — they never reach
13634
16025
  // this registry, so they're excluded from the key set.
@@ -13651,7 +16042,8 @@ const currentFixHandlers = {
13651
16042
  "files:check-unique-folder": handleUniqueFolderCheck,
13652
16043
  "images:check-all-files": handleCheckAllFiles,
13653
16044
  "files:check-all-files": handleCheckAllFiles,
13654
- "jsonValues:extract-entry": handleJsonValuesExtractEntry
16045
+ "jsonValues:extract-entry": handleJsonValuesExtractEntry,
16046
+ "external:upload": handleExternalUpload
13655
16047
  };
13656
16048
  const deprecatedFixHandlers = {
13657
16049
  "image:replace-metadata": handleFileMetadata
@@ -14300,4 +16692,4 @@ function readCapturedReport(snapshotDir) {
14300
16692
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
14301
16693
  }
14302
16694
 
14303
- export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, Service, VAL_SCOPE_READ, VAL_SCOPE_WRITE, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, authorIdFromVerifiedSubject, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, createValTools, currentFixHandlers, decodeJwtWithoutVerifying, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
16695
+ export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, Service, VAL_SCOPE_READ, VAL_SCOPE_WRITE, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, authorIdFromVerifiedSubject, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, createValTools, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err$1 as err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, isExternalResult, loadValModules, ok$1 as ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };