@valbuild/server 0.120.4 → 0.121.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.
@@ -1699,6 +1699,21 @@ class Service {
1699
1699
  getModuleFilePaths() {
1700
1700
  return Object.keys(this.extracted.sources);
1701
1701
  }
1702
+
1703
+ /**
1704
+ * Everything that is wrong with how the project's modules are DECLARED, as
1705
+ * opposed to what is in them.
1706
+ *
1707
+ * A module that failed to load is here, and so is a rule that spans the whole
1708
+ * set — "one settings module, at the root" cannot be checked while looking at
1709
+ * a single module, so `extractValModules` appends it with the offending path.
1710
+ * `get` only surfaces these when the module is missing entirely, which a
1711
+ * misplaced settings module is not: `val validate` reads them from here and
1712
+ * reports them against the file.
1713
+ */
1714
+ getModuleErrors() {
1715
+ return this.extracted.moduleErrors;
1716
+ }
1702
1717
  serializedSchemaOf(moduleFilePath) {
1703
1718
  return this.extracted.serializedSchemas[moduleFilePath];
1704
1719
  }
@@ -2396,7 +2411,8 @@ class ValOps {
2396
2411
  * in-flight client patches the server has not seen) must pass
2397
2412
  * `applyPatches: false` or the same edits would be applied twice.
2398
2413
  */
2399
- async getJsonEntry(moduleFilePath, entryKey, opts) {
2414
+ async getJsonEntry(moduleFilePath, entryKey, /** Passed straight through — see {@link getJsonEntries}. */
2415
+ opts) {
2400
2416
  const res = await this.getJsonEntries(moduleFilePath, {
2401
2417
  keys: [entryKey]
2402
2418
  }, opts);
@@ -2490,7 +2506,7 @@ class ValOps {
2490
2506
  message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
2491
2507
  };
2492
2508
  }
2493
- modulePatches = patchOps.patches.filter(p => p.path === moduleFilePath && !p.appliedAt).map(p => ({
2509
+ modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds).map(p => ({
2494
2510
  patchId: p.patchId,
2495
2511
  patch: p.patch
2496
2512
  }));
@@ -3750,8 +3766,21 @@ class ValOps {
3750
3766
  }
3751
3767
 
3752
3768
  // #region createPatch
3753
- async createPatch(path, patch, patchId, parentRef, sessionId, authorId) {
3754
- const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId);
3769
+ async createPatch(path, patch, patchId, parentRef, sessionId, authorId,
3770
+ /**
3771
+ * Which patch group this patch joins, recorded in the SAME request.
3772
+ *
3773
+ * Atomic on purpose. The content API runs every refusal before its insert,
3774
+ * so an invalid closure is a 400 with nothing written. Recording membership
3775
+ * in a second call would let a patch exist outside its author's group if
3776
+ * that call failed — and a patch outside your own group is one you cannot
3777
+ * publish until a repair puts it back.
3778
+ *
3779
+ * Optional: `fs` mode has no groups, and a client that predates them sends
3780
+ * nothing.
3781
+ */
3782
+ patchGroup) {
3783
+ const saveRes = await this.saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup);
3755
3784
  if (fp.result.isErr(saveRes)) {
3756
3785
  console.error(`Could not save source patch at path: '${path}'. Error: ${saveRes.error.errorType === "other" ? saveRes.error.message : saveRes.error.errorType}`);
3757
3786
  if (saveRes.error.errorType === "patch-head-conflict") {
@@ -3766,7 +3795,12 @@ class ValOps {
3766
3795
  }
3767
3796
  return fp.result.ok({
3768
3797
  patchId,
3769
- createdAt: new Date().toISOString()
3798
+ createdAt: new Date().toISOString(),
3799
+ // Spread rather than assigned: absent has to stay distinguishable from a
3800
+ // group whose id is undefined, all the way to the client.
3801
+ ...(saveRes.value.patchGroupId !== undefined ? {
3802
+ patchGroupId: saveRes.value.patchGroupId
3803
+ } : {})
3770
3804
  });
3771
3805
  }
3772
3806
 
@@ -3789,6 +3823,21 @@ function isOnlyFileCheckValidationError(validationError) {
3789
3823
  function isFileSource(value) {
3790
3824
  return typeof value === "object" && value !== null && "path" in value && typeof value.path === "string";
3791
3825
  }
3826
+
3827
+ /**
3828
+ * The patch group a newly created patch joins.
3829
+ *
3830
+ * `withPatchIds` is the CLOSURE the client computed — the patches that share
3831
+ * a patch set with this one and must move with it. It is not derived here and
3832
+ * must not be: the closure needs the content schema, and the service that
3833
+ * stores groups does not have it. One implementation of that rule, on the side
3834
+ * that can actually compute it.
3835
+ *
3836
+ * Membership rows are stamped with `coreVersion` on the content side, the same
3837
+ * stamp the patch row itself carries, so which client wrote a row stays legible
3838
+ * after the fact.
3839
+ */
3840
+
3792
3841
  function formatPatchSourceError(error) {
3793
3842
  if ("message" in error) {
3794
3843
  return error.message;
@@ -3799,6 +3848,33 @@ function formatPatchSourceError(error) {
3799
3848
  return "Unknown patch source error: " + JSON.stringify(_exhaustiveCheck);
3800
3849
  }
3801
3850
  }
3851
+ /**
3852
+ * The patches a json entry render should apply, out of the whole chain.
3853
+ *
3854
+ * Three rules, and the second is the one that was missing. A draft page renders
3855
+ * `jsonValues` entries beside module content, and only the modules were scoped
3856
+ * — so one screen showed the caller's own view for its modules and base plus
3857
+ * EVERY pending patch on the branch for the entries beside them, including
3858
+ * another author's half-finished edit rendered as though it were live.
3859
+ *
3860
+ * 1. this module's, since the chain is branch-wide;
3861
+ * 2. this caller's, when they asked to be scoped. `undefined` is "everything",
3862
+ * which is what every unscoped caller gets and must keep getting;
3863
+ * 3. not already applied — a fact about this path rather than about scoping,
3864
+ * and true with or without a scope.
3865
+ *
3866
+ * Filtered here rather than by asking `fetchPatches` for a list, and that is
3867
+ * load-bearing: both implementations read an empty `patchIds` as "no filter"
3868
+ * and return the whole chain. That is the right default for a caller that
3869
+ * cannot mean "none", and the most dangerous possible reading of a group that
3870
+ * is genuinely empty — it would render every unpublished patch on the branch
3871
+ * instead of base. It costs no round trip either: the whole chain is what the
3872
+ * unscoped path fetches anyway.
3873
+ */
3874
+ function scopedModulePatches(patches, moduleFilePath, patchIds) {
3875
+ const scope = patchIds && new Set(patchIds);
3876
+ return patches.filter(patch => patch.path === moduleFilePath && !patch.appliedAt && (scope === undefined || scope.has(patch.patchId)));
3877
+ }
3802
3878
  function getFieldsForType(type) {
3803
3879
  if (type === "file") {
3804
3880
  return ["mimeType"];
@@ -5829,7 +5905,17 @@ class ValOpsFS extends ValOps {
5829
5905
  };
5830
5906
  }
5831
5907
  }
5832
- async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId) {
5908
+ async saveSourceFilePatch(path, patch, patchId, _parentRef, authorId, sessionId,
5909
+ /*
5910
+ * Named and ignored, rather than omitted from the signature.
5911
+ *
5912
+ * `fs` mode has no shared store and exactly one author, so there is nothing
5913
+ * for a group to separate — every pending patch is already this person's.
5914
+ * Declaring it makes that a decision a reader can see: TypeScript lets an
5915
+ * implementation take fewer parameters than the abstract, so leaving it off
5916
+ * would drop group membership silently and look identical to handling it.
5917
+ */
5918
+ _patchGroup) {
5833
5919
  const patchesDir = this.getPatchesDir();
5834
5920
  const record = {
5835
5921
  patch,
@@ -6586,7 +6672,14 @@ const FilesResponse = z.z.object({
6586
6672
  })])).optional()
6587
6673
  });
6588
6674
  const SavePatchResponse = z.z.object({
6589
- patchId: PatchId
6675
+ patchId: PatchId,
6676
+ /**
6677
+ * Which group the content API put this patch in.
6678
+ *
6679
+ * Optional: a content API that predates patch groups does not send one, and
6680
+ * absence has to keep meaning "no groups here" rather than failing the save.
6681
+ */
6682
+ patchGroupId: z.z.string().optional()
6590
6683
  });
6591
6684
  const DeletePatchesResponse = z.z.object({
6592
6685
  deleted: z.z.array(PatchId),
@@ -6604,10 +6697,33 @@ const CommitResponse = z.z.object({
6604
6697
  commit: CommitSha,
6605
6698
  branch: z.z.string()
6606
6699
  });
6700
+ /*
6701
+ * The shared schema, not a copy of it.
6702
+ *
6703
+ * This re-declared `PatchGroup` field for field while the file already imported
6704
+ * `PatchGroupT` from the same module — so a field added on one side and not the
6705
+ * other would have `getPatchGroups()` return a `PatchGroupT[]` silently missing
6706
+ * it, with no type error anywhere.
6707
+ */
6708
+ const PatchGroupsResponse = z.z.object({
6709
+ patchGroups: z.z.array(internal.PatchGroup)
6710
+ });
6711
+ const PatchGroupMutationResponse = z.z.object({
6712
+ patchGroupId: z.z.string(),
6713
+ patchIds: z.z.array(PatchId)
6714
+ });
6607
6715
  const NonceResponse = z.z.object({
6608
6716
  nonce: z.z.string(),
6609
6717
  url: z.z.string()
6610
6718
  });
6719
+
6720
+ /**
6721
+ * How long a patch-group lookup is reused. See `ValOpsHttp.patchGroupsCache`.
6722
+ *
6723
+ * Sized to cover one server render, not to be a cache: several `fetchVal` calls
6724
+ * in one request share an answer, and the next request asks again.
6725
+ */
6726
+ const PATCH_GROUPS_CACHE_MS = 1000;
6611
6727
  class ValOpsHttp extends ValOps {
6612
6728
  constructor(contentUrl, project, commitSha,
6613
6729
  // TODO: CommitSha
@@ -6767,8 +6883,26 @@ class ValOpsHttp extends ValOps {
6767
6883
  }
6768
6884
  }
6769
6885
  const patches = [];
6886
+ /*
6887
+ * Which of them have SHIPPED, alongside which of them exist.
6888
+ *
6889
+ * A published patch stays in the chain with `appliedAt` set until the next
6890
+ * deployment moves the base, so "in the chain" and "has shipped" are
6891
+ * different questions — and the chain ids alone answer only the first. A
6892
+ * client that already holds a record never re-fetches it, so it never
6893
+ * learns the second: another author's publish left that patch in your scope
6894
+ * as pending, your prefix gate read a hole in front of it, and Publish
6895
+ * refused for a reason that had stopped being true.
6896
+ *
6897
+ * Sent as ids rather than folded into `patches`, so a client that ignores
6898
+ * it behaves exactly as before.
6899
+ */
6900
+ const appliedPatches = [];
6770
6901
  for (const patchData of allPatchData.patches) {
6771
6902
  patches.push(patchData.patchId);
6903
+ if (patchData.appliedAt) {
6904
+ appliedPatches.push(patchData.patchId);
6905
+ }
6772
6906
  }
6773
6907
  const webSocketNonceRes = await this.getWebSocketNonce(params.profileId);
6774
6908
  if (webSocketNonceRes.status === "error") {
@@ -6791,6 +6925,16 @@ class ValOpsHttp extends ValOps {
6791
6925
  commits: allPatchData.commits || [],
6792
6926
  deployments: allPatchData.deployments || [],
6793
6927
  patches,
6928
+ appliedPatches,
6929
+ /*
6930
+ * The PUBLISH head, which is not `commitSha`.
6931
+ *
6932
+ * `commitSha` is the commit this deployment is serving and does not move
6933
+ * when somebody publishes — only when the new build lands. This does, so
6934
+ * it is what a client carries back to `/save` to say which world it
6935
+ * decided against.
6936
+ */
6937
+ headCommitSha: internal.newestCommitSha(allPatchData.commits) ?? undefined,
6794
6938
  commitSha: this.commitSha
6795
6939
  };
6796
6940
  }
@@ -6871,6 +7015,26 @@ class ValOpsHttp extends ValOps {
6871
7015
  }
6872
7016
  const allPatches = [];
6873
7017
  const allErrors = [];
7018
+ /*
7019
+ * The commits, which are a fact about the whole BRANCH, not about a chunk.
7020
+ *
7021
+ * This loop used to return only `patches` and `errors`, so a filtered fetch
7022
+ * silently answered with no commits at all. That is not cosmetic:
7023
+ * the publish-head guard in `ValServer` reads `newestCommitSha(commits)`,
7024
+ * got `undefined` for every publish (a publish always names patch ids, so
7025
+ * it always takes this branch), and skipped the check entirely. Two clients
7026
+ * could publish against the same head with neither told.
7027
+ *
7028
+ * Taken from the first chunk that carries them, which is sound for the
7029
+ * reason the dedupe below exists: each chunk's response describes the whole
7030
+ * chain regardless of which ids it asked about.
7031
+ *
7032
+ * `deployments` is not carried, because it is declared only on
7033
+ * `OrderedPatchesMetadata` and this is generic over both shapes. Nothing
7034
+ * reads it from a filtered fetch today; if something starts to, it needs
7035
+ * the same treatment and a home on `OrderedPatches` first.
7036
+ */
7037
+ let commits;
6874
7038
  if (patchIds === undefined || patchIds.length === 0) {
6875
7039
  return this.fetchPatchesInternal({
6876
7040
  patchIds: patchIds,
@@ -6888,6 +7052,9 @@ class ValOpsHttp extends ValOps {
6888
7052
  if (res.errors) {
6889
7053
  allErrors.push(...res.errors);
6890
7054
  }
7055
+ if (commits === undefined && res.commits !== undefined) {
7056
+ commits = res.commits;
7057
+ }
6891
7058
  }
6892
7059
  // Chunking is a query-string-length workaround, NOT a filter: the content
6893
7060
  // api returns every applicable patch per request regardless of which
@@ -6914,7 +7081,13 @@ class ValOpsHttp extends ValOps {
6914
7081
  });
6915
7082
  return {
6916
7083
  patches,
6917
- errors: Object.keys(allErrors).length > 0 ? allErrors : undefined
7084
+ errors: Object.keys(allErrors).length > 0 ? allErrors : undefined,
7085
+ // Spread rather than set to `undefined`, so a caller that distinguishes
7086
+ // "absent" from "empty" — `newestCommitSha` does not, but the annotation
7087
+ // readers do — sees the same shape the unchunked path gives it.
7088
+ ...(commits !== undefined ? {
7089
+ commits
7090
+ } : {})
6918
7091
  };
6919
7092
  }
6920
7093
  async fetchPatchesInternal(filters) {
@@ -7022,7 +7195,281 @@ class ValOpsHttp extends ValOps {
7022
7195
  };
7023
7196
  }
7024
7197
  }
7025
- async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId) {
7198
+
7199
+ // #region patch groups
7200
+ /**
7201
+ * Add patches to a patch group.
7202
+ *
7203
+ * The set arrives closed by the client — `withPatchIds` is the prefix closure
7204
+ * over the patch sets the staged patches belong to. We forward it and do not
7205
+ * second-guess it: deriving the closure needs the content schema, which this
7206
+ * process does have but content.val.build does not, and having two
7207
+ * implementations of the rule would be worse than having one.
7208
+ *
7209
+ * Membership rows are stamped with `coreVersion` on the content side — the
7210
+ * same stamp the patch row carries — so which client wrote a row stays
7211
+ * legible after the fact.
7212
+ */
7213
+ async stagePatches(patchGroupId, /** What the user asked to stage. */
7214
+ patchIds,
7215
+ /**
7216
+ * What has to come with it, because the staged patches are written on top
7217
+ * of it.
7218
+ *
7219
+ * The content API stores each membership row as `explicit` or `dependency`
7220
+ * and treats what it is not told about as a dependency. Folding the two
7221
+ * halves into `patchIds` therefore files the patch somebody clicked as one
7222
+ * the closure dragged in — the exact opposite of what happened, and the
7223
+ * only record anywhere of what the author chose.
7224
+ */
7225
+ withPatchIds,
7226
+ /**
7227
+ * WHO is asking, so the content API can refuse a group that is not theirs.
7228
+ *
7229
+ * Every call from this class carries the app's API key, which says which
7230
+ * PROJECT is calling and nothing about which editor. Without this the
7231
+ * content API cannot tell one of a project's editors from another, so the
7232
+ * only check on stage and unstage is the one in `ValServer` — and anything
7233
+ * reaching the content API by another route (an API key, a PAT) has none at
7234
+ * all.
7235
+ *
7236
+ * `null` where there is no session. The content API refuses rather than
7237
+ * treating that as a match: a group written by an api key has a null author
7238
+ * too, and `null === null` must not read as ownership.
7239
+ */
7240
+ authorId) {
7241
+ return this.mutatePatchGroup("POST",
7242
+ // Encoded: patchGroupId arrives in a request body, so an unencoded value
7243
+ // like "../../commit" would reach a different endpoint carrying this
7244
+ // project's auth headers.
7245
+ `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
7246
+ patchIds,
7247
+ withPatchIds,
7248
+ coreVersion: core.Internal.VERSION.core
7249
+ }, authorId);
7250
+ }
7251
+
7252
+ /**
7253
+ * Remove patches from a patch group.
7254
+ *
7255
+ * The set arrives closed FORWARDS by the client: unstaging a patch also
7256
+ * unstages everything built on top of it within its patch sets, and that is
7257
+ * what `withPatchIds` carries.
7258
+ */
7259
+ async unstagePatches(patchGroupId, /** What the user asked to unstage. */
7260
+ patchIds, /** What has to go with it: everything built on top of it. */
7261
+ withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
7262
+ authorId) {
7263
+ return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
7264
+ patchIds,
7265
+ withPatchIds
7266
+ }, authorId);
7267
+ }
7268
+
7269
+ /**
7270
+ * Every patch group on this branch, with what each holds.
7271
+ *
7272
+ * Read rather than mutated, and used to answer "which pending patches is THIS
7273
+ * person allowed to see". A draft render that skips this shows base + every
7274
+ * pending patch on the branch, including work other people have not
7275
+ * published — which is what independent publish exists to prevent.
7276
+ *
7277
+ * A failure is an empty list rather than a throw, and the caller decides what
7278
+ * that means. For a draft render the honest fallback is "show nothing
7279
+ * pending" rather than "show everything": being shown your own committed
7280
+ * content when the group lookup is down is a worse experience than being
7281
+ * shown somebody else's unpublished draft is a bug.
7282
+ */
7283
+ /**
7284
+ * The last group lookup, and when it was made.
7285
+ *
7286
+ * A draft render calls `getPatchGroups` once per `fetchVal`, in series with
7287
+ * the whole-chain fetch, and a page that calls `fetchVal` several times pays
7288
+ * the round trip several times. Groups are per branch and change rarely, so a
7289
+ * short window removes the multiplier without letting a stage go unseen for
7290
+ * meaningfully longer than one render.
7291
+ *
7292
+ * Deliberately short. This is a read whose staleness decides whose draft
7293
+ * content someone sees, so it is a per-request de-duplication rather than a
7294
+ * cache: a second render a second later asks again.
7295
+ */
7296
+ patchGroupsCache = null;
7297
+ async getPatchGroups(options) {
7298
+ const now = Date.now();
7299
+ if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
7300
+ return this.patchGroupsCache.res;
7301
+ }
7302
+ const res = await this.fetchPatchGroups();
7303
+ /*
7304
+ * ANSWERS are cached; failures are not.
7305
+ *
7306
+ * A transient failure held for a second is replayed to every caller in it,
7307
+ * and the callers are not equivalent: `refuseUnlessOwn` turns it into a 500
7308
+ * that refuses the stage, a scoped draft render falls back to base and
7309
+ * drops every pending patch on the page, and `GET /patches` omits the
7310
+ * annotation. One flaky request became a second of all three. The point of
7311
+ * this cache is to collapse the several `fetchVal` calls in one render into
7312
+ * one round trip, and an error is exactly the case worth retrying inside
7313
+ * that window rather than the case worth remembering.
7314
+ *
7315
+ * `unsupported` is cached with `ok` deliberately: it is a real answer about
7316
+ * the deployment — this content API predates patch groups — and it will not
7317
+ * change between two renders.
7318
+ */
7319
+ if (res.status === "ok" || res.status === "unsupported") {
7320
+ this.patchGroupsCache = {
7321
+ at: now,
7322
+ res
7323
+ };
7324
+ }
7325
+ return res;
7326
+ }
7327
+ async fetchPatchGroups() {
7328
+ try {
7329
+ /*
7330
+ * `branch` is REQUIRED by the endpoint, which answers 400 without it.
7331
+ *
7332
+ * Same two params every other read here sends (`fetchPatchesInternal`,
7333
+ * `saveSourceFilePatch`): groups are per branch, so a request without one
7334
+ * is not merely under-specified, it is rejected.
7335
+ */
7336
+ const params = new URLSearchParams([["branch", this.branch]]);
7337
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
7338
+ headers: this.authHeaders
7339
+ });
7340
+ if (res.status === 404) {
7341
+ /*
7342
+ * The endpoint is not there, which is a content API that PREDATES patch
7343
+ * groups — not a failure.
7344
+ *
7345
+ * The distinction decides what a draft render shows, and collapsing it
7346
+ * into "error" is not a small mistake: a caller that reads an error as
7347
+ * "could not ask" renders BASE, so every existing http deployment would
7348
+ * silently drop all pending content from every draft preview. "There
7349
+ * are no groups here" has to mean unscoped, which is exactly the
7350
+ * behaviour those projects have today.
7351
+ */
7352
+ return {
7353
+ status: "unsupported"
7354
+ };
7355
+ }
7356
+ if (!res.ok) {
7357
+ return {
7358
+ status: "error",
7359
+ 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}`
7360
+ };
7361
+ }
7362
+ const parsed = PatchGroupsResponse.safeParse(await res.json());
7363
+ if (!parsed.success) {
7364
+ return {
7365
+ status: "error",
7366
+ message: `Could not parse patch groups response. Error: ${zodValidationError.fromError(parsed.error)}`
7367
+ };
7368
+ }
7369
+ return {
7370
+ status: "ok",
7371
+ patchGroups: parsed.data.patchGroups
7372
+ };
7373
+ } catch (err) {
7374
+ return {
7375
+ status: "error",
7376
+ message: `Could not read patch groups. Error: ${err instanceof Error ? err.message : String(err)}`
7377
+ };
7378
+ }
7379
+ }
7380
+ async mutatePatchGroup(method, path, body,
7381
+ /**
7382
+ * WHO is asking. Sent as `x-val-profile-id`, which is what the content API
7383
+ * reads to decide whether this group is the caller's.
7384
+ *
7385
+ * `this.authHeaders` is the app's API key, and that names the PROJECT, not
7386
+ * the person — so without this the content API cannot resolve a profile
7387
+ * and refuses every stage and unstage with
7388
+ * "Cannot resolve the caller's profile". The group endpoints are the only
7389
+ * ones here that need it, because they are the only ones whose answer
7390
+ * depends on which of a project's editors is calling.
7391
+ *
7392
+ * Omitted when there is no session rather than sent empty: the content API
7393
+ * treats an unidentified caller as a refusal, which is what we want, and an
7394
+ * empty header would be a different and less obvious way to say it.
7395
+ *
7396
+ * A PAT already identifies a person, so `authHeaders` carries the identity
7397
+ * on its own there and this adds nothing.
7398
+ */
7399
+ authorId) {
7400
+ try {
7401
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
7402
+ method,
7403
+ headers: {
7404
+ ...this.authHeaders,
7405
+ ...(authorId !== null ? {
7406
+ "x-val-profile-id": authorId
7407
+ } : {}),
7408
+ "Content-Type": "application/json"
7409
+ },
7410
+ body: JSON.stringify(body)
7411
+ });
7412
+ if (res.ok) {
7413
+ const parsed = PatchGroupMutationResponse.safeParse(await res.json());
7414
+ if (parsed.success) {
7415
+ return {
7416
+ patchIds: parsed.data.patchIds
7417
+ };
7418
+ }
7419
+ return {
7420
+ status: 500,
7421
+ patchIds: [],
7422
+ error: {
7423
+ message: `Could not parse patch group response. Error: ${zodValidationError.fromError(parsed.error)}`
7424
+ }
7425
+ };
7426
+ }
7427
+ // 403 (not your group) and 409 (already published) are meaningful to the
7428
+ // client, so they are passed through rather than flattened to a 500.
7429
+ if (res.status === 403 || res.status === 409) {
7430
+ // `home` answers these with a JSON body, so `res.text()` put the literal
7431
+ // `{"message":"..."}` in front of the user. Every other branch in this
7432
+ // class unwraps it; this one now does too.
7433
+ return {
7434
+ status: res.status,
7435
+ patchIds: [],
7436
+ error: {
7437
+ message: internal.getErrorMessageFromUnknownJson(await res.json().catch(() => undefined), `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`)
7438
+ }
7439
+ };
7440
+ }
7441
+ // A 401 here is the app's own credentials failing, not the user's, so it gets
7442
+ // the same wording as every other call in this class rather than an opaque
7443
+ // 500 that sends the user looking at their own session.
7444
+ if (res.status === 401) {
7445
+ return {
7446
+ status: 500,
7447
+ patchIds: [],
7448
+ error: {
7449
+ 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."
7450
+ }
7451
+ };
7452
+ }
7453
+ return {
7454
+ status: 500,
7455
+ patchIds: [],
7456
+ error: {
7457
+ message: `Could not update patch group. HTTP error: ${res.status} ${res.statusText}`
7458
+ }
7459
+ };
7460
+ } catch (err) {
7461
+ return {
7462
+ status: 500,
7463
+ patchIds: [],
7464
+ error: {
7465
+ message: `Could not update patch group (connection error?): ${err instanceof Error ? err.message : JSON.stringify(err)}`
7466
+ }
7467
+ };
7468
+ }
7469
+ }
7470
+ // #endregion
7471
+
7472
+ async saveSourceFilePatch(path, patch, patchId, parentRef, authorId, sessionId, patchGroup) {
7026
7473
  const baseSha = await this.getBaseSha();
7027
7474
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
7028
7475
  method: "POST",
@@ -7040,7 +7487,24 @@ class ValOpsHttp extends ValOps {
7040
7487
  baseSha,
7041
7488
  commit: this.commitSha,
7042
7489
  branch: this.branch,
7043
- coreVersion: core.Internal.VERSION.core
7490
+ coreVersion: core.Internal.VERSION.core,
7491
+ /*
7492
+ * Group membership in the SAME request as the patch.
7493
+ *
7494
+ * The content API runs every refusal before its insert, so an invalid
7495
+ * closure is a 400 with nothing written. Spread rather than sent as
7496
+ * nulls: a client with no group omits the fields entirely, which is
7497
+ * what an older content API expects to see.
7498
+ */
7499
+ ...(patchGroup ? {
7500
+ // Only when the caller named one. Omitted, the content API
7501
+ // resolves this author's open group and creates it if absent,
7502
+ // which is what every write wants.
7503
+ ...(patchGroup.patchGroupId !== undefined ? {
7504
+ patchGroupId: patchGroup.patchGroupId
7505
+ } : {}),
7506
+ withPatchIds: patchGroup.withPatchIds
7507
+ } : {})
7044
7508
  })
7045
7509
  }).then(async res => {
7046
7510
  var _res$headers$get2;
@@ -7048,7 +7512,21 @@ class ValOpsHttp extends ValOps {
7048
7512
  const parsed = SavePatchResponse.safeParse(await res.json());
7049
7513
  if (parsed.success) {
7050
7514
  return fp.result.ok({
7051
- patchId: parsed.data.patchId
7515
+ patchId: parsed.data.patchId,
7516
+ /*
7517
+ * Passed back to the client, which cannot learn it any other way.
7518
+ *
7519
+ * A write names no group — the content API resolves this author's
7520
+ * open group and CREATES it if absent — so on a fresh branch the
7521
+ * group comes into existence here and nowhere else. The chain
7522
+ * annotation is only re-read when a fetch has missing ids to ask
7523
+ * for, and a patch this client made is never missing, so without
7524
+ * this the tab that bootstrapped the group would never learn its
7525
+ * id and every stage would be a no-op.
7526
+ */
7527
+ ...(parsed.data.patchGroupId !== undefined ? {
7528
+ patchGroupId: parsed.data.patchGroupId
7529
+ } : {})
7052
7530
  });
7053
7531
  }
7054
7532
  return fp.result.err({
@@ -7298,7 +7776,17 @@ class ValOpsHttp extends ValOps {
7298
7776
  }]
7299
7777
  };
7300
7778
  }
7301
- async deletePatches(patchIds) {
7779
+ async deletePatches(patchIds,
7780
+ /**
7781
+ * Patches that are NOT deleted but must lose their group membership.
7782
+ *
7783
+ * Deleting a patch out of the middle of a patch set leaves every group
7784
+ * still holding the rest with a non-prefix intersection — the patches after
7785
+ * the hole were written against a view that had it. The content API cannot
7786
+ * work out which those are (it has no schema), so the client sends the
7787
+ * forward closure and it drops those memberships without deleting anything.
7788
+ */
7789
+ unstagePatchIds) {
7302
7790
  return fetch(`${this.contentUrl}/v1/${this.project}/patches`, {
7303
7791
  method: "DELETE",
7304
7792
  headers: {
@@ -7306,7 +7794,10 @@ class ValOpsHttp extends ValOps {
7306
7794
  "Content-Type": "application/json"
7307
7795
  },
7308
7796
  body: JSON.stringify({
7309
- patchIds
7797
+ patchIds,
7798
+ ...(unstagePatchIds !== undefined && unstagePatchIds.length > 0 ? {
7799
+ unstagePatchIds
7800
+ } : {})
7310
7801
  })
7311
7802
  }).then(async res => {
7312
7803
  if (res.ok) {
@@ -7345,7 +7836,22 @@ class ValOpsHttp extends ValOps {
7345
7836
  };
7346
7837
  });
7347
7838
  }
7348
- async commit(prepared, message, committer, filesDirectory, newBranch) {
7839
+ async commit(prepared, message, committer, filesDirectory, newBranch,
7840
+ /**
7841
+ * The patch group this commit EMPTIES, if it empties one.
7842
+ *
7843
+ * The content API closes the group it is given — and closes it WITHOUT
7844
+ * checking that the commit shipped all of it, so a caller that names a
7845
+ * group still holding work takes those patches out of every group and
7846
+ * leaves their author unable to publish them. The client therefore sends it
7847
+ * only when the publish accounts for everything the group still holds.
7848
+ *
7849
+ * Omitting it is not neutral: the commit still empties the group (the
7850
+ * content API drops applied ids from every group), but `published_at` is
7851
+ * never set, so the id is reused across publishes instead of a new group
7852
+ * per publish and the "already published" refusal can never fire.
7853
+ */
7854
+ patchGroupId) {
7349
7855
  try {
7350
7856
  var _res$headers$get3;
7351
7857
  const existingBranch = this.branch;
@@ -7353,6 +7859,26 @@ class ValOpsHttp extends ValOps {
7353
7859
  method: "POST",
7354
7860
  headers: {
7355
7861
  ...this.authHeaders,
7862
+ /*
7863
+ * WHO is publishing — the same `x-val-profile-id` stage and unstage
7864
+ * send, and for the same reason: `this.authHeaders` is the app's API
7865
+ * key, which names the PROJECT and not the person.
7866
+ *
7867
+ * Closing a group is an ownership decision, so the content API
7868
+ * refuses a commit that names a `patchGroupId` it cannot attribute:
7869
+ * without this header `profileId` is `undefined` there and every
7870
+ * group-closing publish is 403 "Cannot resolve the caller's profile".
7871
+ * That is the NORMAL full publish, not an edge — `publish` names the
7872
+ * group whenever the commit empties it.
7873
+ *
7874
+ * Sent on every commit rather than only when a group is named. The
7875
+ * committer is a person either way, `committer` in the body already
7876
+ * says so, and a header that appears only on some commits is one more
7877
+ * conditional for a reader of either repo to reconstruct. It changes
7878
+ * nothing else: the content API reads it as an identity claim beside
7879
+ * the app's key and derives no scope from it.
7880
+ */
7881
+ "x-val-profile-id": committer,
7356
7882
  "Content-Type": "application/json"
7357
7883
  },
7358
7884
  body: JSON.stringify({
@@ -7366,7 +7892,10 @@ class ValOpsHttp extends ValOps {
7366
7892
  committer,
7367
7893
  message,
7368
7894
  existingBranch,
7369
- newBranch
7895
+ newBranch,
7896
+ ...(patchGroupId !== undefined ? {
7897
+ patchGroupId
7898
+ } : {})
7370
7899
  })
7371
7900
  });
7372
7901
  if (res.ok) {
@@ -8611,10 +9140,190 @@ const ValServer = (valModules, options, callbacks) => {
8611
9140
  };
8612
9141
  }
8613
9142
  },
9143
+ //#region patch groups
9144
+ // Staging and unstaging patches. Both take an already-closed set of patch ids:
9145
+ // the prefix closure (staging) or the forward closure (unstaging) is computed
9146
+ // on the client, which is the only side that has the schema needed to derive
9147
+ // patch sets. See `docs/independent-publish/PLAN.md`.
9148
+ //
9149
+ // In FS mode there is no shared store and exactly one author, so group
9150
+ // membership lives in the client and these handlers simply acknowledge. The
9151
+ // client already sends an explicit patch id list to `/save`, so a locally-held
9152
+ // group is enough to publish a subset correctly.
9153
+ "/patch-groups/~/patches": {
9154
+ PUT: async req => {
9155
+ const auth = getAuth(req.cookies);
9156
+ if (auth.error) {
9157
+ return {
9158
+ status: 401,
9159
+ json: {
9160
+ message: auth.error
9161
+ }
9162
+ };
9163
+ }
9164
+ const {
9165
+ patchGroupId,
9166
+ patchIds
9167
+ } = req.body;
9168
+ const withPatchIds = req.body.withPatchIds ?? [];
9169
+ if (serverOps instanceof ValOpsFS) {
9170
+ return {
9171
+ status: 200,
9172
+ json: {
9173
+ patchGroupId,
9174
+ patchIds: [...patchIds, ...withPatchIds]
9175
+ }
9176
+ };
9177
+ }
9178
+ if (!("id" in auth) || !auth.id) {
9179
+ return {
9180
+ status: 401,
9181
+ json: {
9182
+ message: "Unauthorized"
9183
+ }
9184
+ };
9185
+ }
9186
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
9187
+ if (refusal !== null) {
9188
+ return {
9189
+ status: refusal.status,
9190
+ json: {
9191
+ message: refusal.message
9192
+ }
9193
+ };
9194
+ }
9195
+ const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
9196
+ // Forwarded so the content API can refuse independently. This server
9197
+ // has already refused a group that is not the caller's; sending the
9198
+ // author means the check also holds for anything reaching the content
9199
+ // API without coming through here.
9200
+ auth.id);
9201
+ if (res.error) {
9202
+ return {
9203
+ status: res.status,
9204
+ json: {
9205
+ message: res.error.message
9206
+ }
9207
+ };
9208
+ }
9209
+ return {
9210
+ status: 200,
9211
+ json: {
9212
+ patchGroupId,
9213
+ patchIds: res.patchIds
9214
+ }
9215
+ };
9216
+ },
9217
+ DELETE: async req => {
9218
+ const auth = getAuth(req.cookies);
9219
+ if (auth.error) {
9220
+ return {
9221
+ status: 401,
9222
+ json: {
9223
+ message: auth.error
9224
+ }
9225
+ };
9226
+ }
9227
+ const {
9228
+ patchGroupId,
9229
+ patchIds
9230
+ } = req.body;
9231
+ const withPatchIds = req.body.withPatchIds ?? [];
9232
+ if (serverOps instanceof ValOpsFS) {
9233
+ return {
9234
+ status: 200,
9235
+ json: {
9236
+ patchGroupId,
9237
+ patchIds: [...patchIds, ...withPatchIds]
9238
+ }
9239
+ };
9240
+ }
9241
+ if (!("id" in auth) || !auth.id) {
9242
+ return {
9243
+ status: 401,
9244
+ json: {
9245
+ message: "Unauthorized"
9246
+ }
9247
+ };
9248
+ }
9249
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
9250
+ if (refusal !== null) {
9251
+ return {
9252
+ status: refusal.status,
9253
+ json: {
9254
+ message: refusal.message
9255
+ }
9256
+ };
9257
+ }
9258
+ const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
9259
+ if (res.error) {
9260
+ return {
9261
+ status: res.status,
9262
+ json: {
9263
+ message: res.error.message
9264
+ }
9265
+ };
9266
+ }
9267
+ return {
9268
+ status: 200,
9269
+ json: {
9270
+ patchGroupId,
9271
+ patchIds: res.patchIds
9272
+ }
9273
+ };
9274
+ }
9275
+ },
8614
9276
  //#region patches
8615
9277
  "/patches": {
8616
9278
  PUT: async req => {
8617
9279
  const cookies = req.cookies;
9280
+
9281
+ /**
9282
+ * Group membership travels WITH the patch, in one request.
9283
+ *
9284
+ * Atomic on purpose: the content API runs every refusal before its
9285
+ * insert, so an invalid closure is a 400 with nothing written. Recording
9286
+ * membership in a second call would let a patch exist outside its
9287
+ * author's group whenever that call failed — and a patch outside your
9288
+ * own group is one you cannot publish until a repair puts it back.
9289
+ *
9290
+ */
9291
+ /*
9292
+ * A membership is present if EITHER field is.
9293
+ *
9294
+ * The common case names no group: the content API resolves the author's
9295
+ * open group, creating it if absent, so the client never has to hold an
9296
+ * id across publishes. It still sends the closure, which is the part it
9297
+ * alone can compute.
9298
+ *
9299
+ * `patchGroupId` is nullable, so an explicit `null` means "no group
9300
+ * named" exactly as omitting it does — it is passed through as
9301
+ * `undefined` rather than becoming a membership keyed by null.
9302
+ */
9303
+ const requestedPatchGroupId = req.body.patchGroupId ?? undefined;
9304
+ const requestedWith = req.body.withPatchIds;
9305
+ const patchGroup = requestedPatchGroupId !== undefined || requestedWith !== undefined ? {
9306
+ ...(requestedPatchGroupId !== undefined ? {
9307
+ patchGroupId: requestedPatchGroupId
9308
+ } : {}),
9309
+ withPatchIds: requestedWith ?? []
9310
+ } : undefined;
9311
+ if (patchGroup !== undefined && serverOps instanceof ValOpsFS) {
9312
+ /*
9313
+ * `fs` has no shared store and one author, so there is no group to
9314
+ * join. Refused rather than acknowledged: answering 200 would tell the
9315
+ * client its membership was recorded when it was dropped, and the
9316
+ * client would then believe a publish is scoped when it is not.
9317
+ */
9318
+ return {
9319
+ status: 400,
9320
+ json: {
9321
+ type: "patch-error",
9322
+ message: "Patch groups are not available in fs mode. Omit the patch group fields.",
9323
+ errors: {}
9324
+ }
9325
+ };
9326
+ }
8618
9327
  const auth = getAuth(cookies);
8619
9328
  if (auth.error) {
8620
9329
  return {
@@ -8637,8 +9346,19 @@ const ValServer = (valModules, options, callbacks) => {
8637
9346
  const sessionId = req.body.sessionId ?? null;
8638
9347
  const authorId = "id" in auth ? auth.id : null;
8639
9348
  const newPatchIds = [];
9349
+ /*
9350
+ * The group the content API put these patches in.
9351
+ *
9352
+ * Every patch in one request has the same author and the same
9353
+ * membership, so the last answer is the answer — they all land in the
9354
+ * same group. Reported back because the client cannot learn it any
9355
+ * other way: it names no group (the content API resolves the author's
9356
+ * open one, creating it if absent), and the chain annotation is only
9357
+ * re-read when a fetch has missing ids to ask for.
9358
+ */
9359
+ let patchGroupIdFromStore;
8640
9360
  for (const patch of patches) {
8641
- const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId);
9361
+ const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
8642
9362
  if (fp.result.isErr(createPatchRes)) {
8643
9363
  if (createPatchRes.error.errorType === "patch-head-conflict") {
8644
9364
  return {
@@ -8670,13 +9390,22 @@ const ValServer = (valModules, options, callbacks) => {
8670
9390
  patchId: createPatchRes.value.patchId
8671
9391
  };
8672
9392
  newPatchIds.push(createPatchRes.value.patchId);
9393
+ if (createPatchRes.value.patchGroupId !== undefined) {
9394
+ patchGroupIdFromStore = createPatchRes.value.patchGroupId;
9395
+ }
8673
9396
  }
8674
9397
  }
8675
9398
  return {
8676
9399
  status: 200,
8677
9400
  json: {
8678
9401
  newPatchIds,
8679
- parentRef
9402
+ parentRef,
9403
+ // Absent rather than null where there are no groups: `fs` mode and
9404
+ // a content API that predates them both answer without one, and the
9405
+ // client reads absence as "staging is not available here".
9406
+ ...(patchGroupIdFromStore !== undefined ? {
9407
+ patchGroupId: patchGroupIdFromStore
9408
+ } : {})
8680
9409
  }
8681
9410
  };
8682
9411
  },
@@ -8738,15 +9467,84 @@ const ValServer = (valModules, options, callbacks) => {
8738
9467
  }
8739
9468
  // TODO: we should sort by parentRef instead:
8740
9469
  patches.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
9470
+ /**
9471
+ * Patch groups, ANNOTATED onto the chain rather than filtering it.
9472
+ *
9473
+ * The client computes a new patch's parent as the last id in this
9474
+ * response, so a filtered chain would make every client name a parent
9475
+ * that is not the real head and `POST /patches` would answer 409
9476
+ * forever. Annotate, never filter.
9477
+ *
9478
+ * Absent — not empty — where there are no groups: `fs` mode, a content
9479
+ * API that predates them, or a failed lookup. The client reads absence
9480
+ * as "this deployment has no groups" and leaves staging off, which is
9481
+ * the behaviour every project has today. An empty array would say
9482
+ * "groups exist and hold nothing", which would turn the staging UI on
9483
+ * with everything held.
9484
+ */
9485
+ let patchGroups;
9486
+ if (query.include_patch_groups === true && serverOps instanceof ValOpsHttp) {
9487
+ /*
9488
+ * FRESH, because this answer makes a CLOSING decision.
9489
+ *
9490
+ * The client adopts this annotation as its group membership and
9491
+ * `emptiesOwnPatchGroup` then decides from it whether a publish may
9492
+ * name the group — and the content API closes what it is named
9493
+ * without checking. A one-second-old list is enough to get that
9494
+ * wrong: the same author writing in a second tab joins the open
9495
+ * group, the websocket pushes the chain immediately, so this tab's
9496
+ * fetch for the missing patch lands well inside the cache window and
9497
+ * reads a membership that is one patch short. It then publishes,
9498
+ * names the group, and closes it with the other tab's work still in
9499
+ * it — which leaves that tab wedged on 409 until a reload.
9500
+ *
9501
+ * `resolveOwnPatchScope` keeps the cache deliberately: that read
9502
+ * decides what a draft render SHOWS, it is repeated once per
9503
+ * `fetchVal` in a single render, and being a second stale there costs
9504
+ * a patch appearing late rather than a group closing early.
9505
+ */
9506
+ const groupsRes = await serverOps.getPatchGroups({
9507
+ fresh: true
9508
+ });
9509
+ if (groupsRes.status === "ok" && groupsRes.patchGroups.length > 0) {
9510
+ patchGroups = groupsRes.patchGroups;
9511
+ } else if (groupsRes.status === "error") {
9512
+ // Not fatal: the chain is what this endpoint is for, and staging
9513
+ // simply stays off for this read rather than the whole review
9514
+ // screen failing to load.
9515
+ console.error("Val: could not read patch groups", groupsRes.message);
9516
+ }
9517
+ }
9518
+ const groupIdsByPatchId = new Map();
9519
+ for (const group of patchGroups ?? []) {
9520
+ for (const patchId of group.patchIds) {
9521
+ const existing = groupIdsByPatchId.get(patchId);
9522
+ if (existing) {
9523
+ existing.push(group.patchGroupId);
9524
+ } else {
9525
+ groupIdsByPatchId.set(patchId, [group.patchGroupId]);
9526
+ }
9527
+ }
9528
+ }
8741
9529
  return {
8742
9530
  status: 200,
8743
9531
  json: {
8744
- patches: patches,
9532
+ patches: patches.map(patch => {
9533
+ const patchGroupIds = groupIdsByPatchId.get(patch.patchId);
9534
+ return patchGroupIds ? {
9535
+ ...patch,
9536
+ patchGroupIds
9537
+ } : patch;
9538
+ }),
9539
+ ...(patchGroups ? {
9540
+ patchGroups
9541
+ } : {}),
8745
9542
  baseSha: await serverOps.getBaseSha()
8746
9543
  }
8747
9544
  };
8748
9545
  },
8749
9546
  DELETE: async req => {
9547
+ var _req$body;
8750
9548
  const query = req.query;
8751
9549
  const cookies = req.cookies;
8752
9550
  const auth = getAuth(cookies);
@@ -8767,7 +9565,25 @@ const ValServer = (valModules, options, callbacks) => {
8767
9565
  };
8768
9566
  }
8769
9567
  const ids = query.id;
8770
- const deleteRes = await serverOps.deletePatches(ids);
9568
+ /*
9569
+ * Which OTHER patches lose their group membership because these are
9570
+ * going. Only the client can COMPUTE it — that needs the patch sets,
9571
+ * which need the schema — but it is bounded here rather than trusted,
9572
+ * because the content API strips those memberships from every group
9573
+ * without an ownership check. See `boundUnstageClosure`.
9574
+ *
9575
+ * Only in `http` mode: `ValOpsFS` has no groups and ignores it, and the
9576
+ * client does not send it there.
9577
+ */
9578
+ let unstagePatchIds;
9579
+ const requestedUnstage = (_req$body = req.body) === null || _req$body === void 0 ? void 0 : _req$body.unstagePatchIds;
9580
+ if (serverOps instanceof ValOpsHttp && requestedUnstage !== undefined && requestedUnstage.length > 0) {
9581
+ const chain = await serverOps.fetchPatches({
9582
+ excludePatchOps: true
9583
+ });
9584
+ unstagePatchIds = boundUnstageClosure(chain.patches, ids, requestedUnstage);
9585
+ }
9586
+ const deleteRes = await serverOps.deletePatches(ids, unstagePatchIds);
8771
9587
  if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
8772
9588
  console.error("Val: Failed to delete patches", deleteRes.errors);
8773
9589
  return {
@@ -8881,6 +9697,22 @@ const ValServer = (valModules, options, callbacks) => {
8881
9697
  } = req.query;
8882
9698
  // Defaults to true, mirroring /sources/~. The Studio opts out.
8883
9699
  const applyPatches = req.query.apply_patches !== false;
9700
+ /*
9701
+ * Whose pending work this render may see.
9702
+ *
9703
+ * The same resolution `/sources/~` runs, through the same function: a
9704
+ * draft page renders module content and `jsonValues` entries together,
9705
+ * and this route used to apply every pending patch on the branch while
9706
+ * the modules beside it were scoped — so one screen showed the caller's
9707
+ * view and everybody's unpublished work at once.
9708
+ */
9709
+ const {
9710
+ ownPatchIds
9711
+ } = await resolveOwnPatchScope(serverOps, {
9712
+ explicitPatchIds: undefined,
9713
+ ownGroupsOnly: req.query.own_patch_groups_only === true,
9714
+ authorId: "id" in auth && auth.id || undefined
9715
+ });
8884
9716
  const isWindow = offset !== undefined || limit !== undefined;
8885
9717
  const shapes = [key !== undefined, keys !== undefined, isWindow].filter(Boolean).length;
8886
9718
  if (shapes !== 1) {
@@ -8901,7 +9733,8 @@ const ValServer = (valModules, options, callbacks) => {
8901
9733
  }
8902
9734
  if (key !== undefined) {
8903
9735
  const res = await serverOps.getJsonEntry(moduleFilePath, key, {
8904
- applyPatches
9736
+ applyPatches,
9737
+ patchIds: ownPatchIds
8905
9738
  });
8906
9739
  if (res.status === "unauthorized") {
8907
9740
  return {
@@ -8942,7 +9775,8 @@ const ValServer = (valModules, options, callbacks) => {
8942
9775
  offset: offset,
8943
9776
  limit: limit
8944
9777
  }, {
8945
- applyPatches
9778
+ applyPatches,
9779
+ patchIds: ownPatchIds
8946
9780
  });
8947
9781
  if (res.status === "unauthorized") {
8948
9782
  return {
@@ -9027,10 +9861,87 @@ const ValServer = (valModules, options, callbacks) => {
9027
9861
  patches: []
9028
9862
  };
9029
9863
  if (query.exclude_patches !== true) {
9030
- patchOps = await serverOps.fetchPatches({
9031
- patchIds: undefined,
9032
- excludePatchOps: false
9864
+ /**
9865
+ * The caller's own groups, resolved from their session.
9866
+ *
9867
+ * A draft render cannot name its own group ids — it has no client
9868
+ * state — so it asks for "mine" and the server works out which. Only
9869
+ * when `patch_id` is absent: an explicit list is a caller that already
9870
+ * knows what it wants.
9871
+ *
9872
+ * A group lookup that FAILS renders base rather than everything. Being
9873
+ * shown only committed content while the content API is unreachable is
9874
+ * a degraded preview; being shown another author's unpublished draft
9875
+ * because a lookup failed is the bug this feature exists to prevent,
9876
+ * and it would be silent.
9877
+ */
9878
+ const {
9879
+ ownPatchIds,
9880
+ scopeAlsoIncludesApplied
9881
+ } = await resolveOwnPatchScope(serverOps, {
9882
+ explicitPatchIds: query.patch_id,
9883
+ ownGroupsOnly: query.own_patch_groups_only === true,
9884
+ authorId: "id" in auth && auth.id || undefined
9033
9885
  });
9886
+ const requestedPatchIds = query.patch_id ?? ownPatchIds;
9887
+ if (scopeAlsoIncludesApplied) {
9888
+ /*
9889
+ * Scoped to this caller's groups, PLUS everything already
9890
+ * committed.
9891
+ *
9892
+ * Filtered here rather than through `patchIds`, because the set is
9893
+ * not knowable before the fetch: `appliedAt` lives on the patch,
9894
+ * not on the group. One request either way — the whole chain is
9895
+ * what the unscoped path fetches too — so this costs a filter, not
9896
+ * a round trip.
9897
+ *
9898
+ * This also subsumes the empty-group case below: a caller holding
9899
+ * nothing, on a branch with nothing applied, filters down to no
9900
+ * patches, which is base.
9901
+ */
9902
+ const all = await serverOps.fetchPatches({
9903
+ patchIds: undefined,
9904
+ excludePatchOps: false
9905
+ });
9906
+ patchOps = {
9907
+ ...all,
9908
+ patches: scopedPatches(all.patches, ownPatchIds)
9909
+ };
9910
+ } else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
9911
+ /*
9912
+ * A group that holds nothing renders base, and is handled HERE.
9913
+ *
9914
+ * `fetchPatches` cannot express it: both implementations read an
9915
+ * empty `patchIds` as "no filter" and return the whole chain
9916
+ * (`ValOpsFS`: `patchIds.length > 0 ? new Set(...) : null`;
9917
+ * `ValOpsHttp`: an explicit `length === 0` branch that fetches all).
9918
+ * That is the right default for every caller that has ever passed a
9919
+ * list, since none of them can mean "none" — but it is the most
9920
+ * dangerous possible reading of an EXPLICITLY empty group, which
9921
+ * would render every unpublished patch on the branch instead of
9922
+ * base.
9923
+ *
9924
+ * Answered before the call rather than by changing that shared
9925
+ * default, which seven other call sites rely on.
9926
+ */
9927
+ patchOps = {
9928
+ patches: []
9929
+ };
9930
+ } else {
9931
+ patchOps = await serverOps.fetchPatches({
9932
+ /*
9933
+ * The caller's patch group, when it named one.
9934
+ *
9935
+ * `undefined` means every pending patch, which is what every
9936
+ * existing caller gets and has to keep getting. A draft-mode
9937
+ * render that names its group gets base + that group instead, so
9938
+ * a server-rendered preview shows the same thing the person
9939
+ * editing is looking at rather than everybody's unpublished work.
9940
+ */
9941
+ patchIds: requestedPatchIds,
9942
+ excludePatchOps: false
9943
+ });
9944
+ }
9034
9945
  }
9035
9946
  // We check authorization here, because it is the first call to the backend
9036
9947
  if (patchOps.error && patchOps.unauthorized) {
@@ -9287,6 +10198,43 @@ const ValServer = (valModules, options, callbacks) => {
9287
10198
  * store wholesale.
9288
10199
  */
9289
10200
  const consumed = patches.patches.map(patch => patch.patchId);
10201
+ /*
10202
+ * Has somebody else published since this was decided?
10203
+ *
10204
+ * The client names the newest commit it knew about; anything newer here
10205
+ * means the review screen it acted on described a world that has moved.
10206
+ * The answer is "look again" rather than "your commit was rejected".
10207
+ *
10208
+ * Git's own not-fast-forward guard cannot see this: the chain is
10209
+ * fetched and committed fresh at this point, so the parent commit sent
10210
+ * is always the server's current one.
10211
+ *
10212
+ * Checked HERE, before `analyzePatches` and `prepare`, rather than just
10213
+ * before the commit: a publish that is going to be refused should not
10214
+ * first pay to apply every patch in it. And compared against the
10215
+ * commits `fetchPatches` just returned — `applicable/patches` filters
10216
+ * its PATCHES by the requested ids and never its commits, so the second
10217
+ * whole-chain fetch this used to make asked for a list it already had.
10218
+ *
10219
+ * Only when the client sends a head. One that does not publishes
10220
+ * exactly as it did before, and this cannot start refusing publishes for
10221
+ * a field it never sets.
10222
+ */
10223
+ if (serverOps instanceof ValOpsHttp) {
10224
+ const expectedHead = body.expectedHeadCommitSha;
10225
+ if (expectedHead !== undefined) {
10226
+ const serverHead = internal.newestCommitSha(patches.commits);
10227
+ if (serverHead !== null && serverHead !== expectedHead) {
10228
+ return {
10229
+ status: 409,
10230
+ json: {
10231
+ message: "Someone else published while you were reviewing. Nothing was published — open Review again to see what changed.",
10232
+ headMoved: true
10233
+ }
10234
+ };
10235
+ }
10236
+ }
10237
+ }
9290
10238
  const analysis = serverOps.analyzePatches(patches.patches, patches.commits, commit);
9291
10239
  let preparedCommit = await serverOps.prepare({
9292
10240
  ...analysis,
@@ -9435,8 +10383,53 @@ const ValServer = (valModules, options, callbacks) => {
9435
10383
  } else if (serverOps instanceof ValOpsHttp) {
9436
10384
  if (auth.error === undefined && auth.id) {
9437
10385
  var _options$config$files;
10386
+ /*
10387
+ * The group this commit CLOSES has to be the caller's.
10388
+ *
10389
+ * Stage and unstage go through `refuseUnlessOwn`; this route did
10390
+ * not, and the content API's `postCommit` marks the group published
10391
+ * on id alone with no author clause — so the id was trusted twice
10392
+ * and checked nowhere. Any logged-in editor could name a colleague's
10393
+ * open group and close it: their pending patches land in a closed
10394
+ * group and in no open one, so a scoped draft render shows base for
10395
+ * them, and their own tab still believes the group is open, so their
10396
+ * next stage is refused with 409.
10397
+ *
10398
+ * Same hole as the stage/unstage one this branch already closed, one
10399
+ * route over. The content API needs the same guard — this one is the
10400
+ * convenience, that one is what actually holds.
10401
+ */
10402
+ if (body.patchGroupId !== undefined && body.patchGroupId !== null) {
10403
+ const refusal = await refuseUnlessOwn(serverOps, body.patchGroupId, auth.id);
10404
+ if (refusal !== null) {
10405
+ return {
10406
+ status: refusal.status,
10407
+ json: {
10408
+ message: refusal.message,
10409
+ /*
10410
+ * Flagged, because a bare 409 here reads as git refusing
10411
+ * the commit — which is retryable, and this is the
10412
+ * opposite. `refuseUnlessOwn` answers 409 for one reason
10413
+ * only: the group has already been published, so its id
10414
+ * will never be writable again and retrying reproduces
10415
+ * this forever. The client forgets the id instead.
10416
+ */
10417
+ ...(refusal.status === 409 ? {
10418
+ patchGroupPublished: true
10419
+ } : {})
10420
+ }
10421
+ };
10422
+ }
10423
+ }
9438
10424
  const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
9439
- const commitRes = await serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val");
10425
+ 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,
10426
+ /*
10427
+ * Forwarded verbatim, and only the client can decide it: the
10428
+ * content API closes the group it is named without checking that
10429
+ * the commit shipped all of it, and whether it did needs the
10430
+ * patch sets, which live in the browser.
10431
+ */
10432
+ body.patchGroupId);
9440
10433
  if (commitRes.error) {
9441
10434
  console.error("Failed to commit", commitRes.error);
9442
10435
  if ("isNotFastForward" in commitRes && commitRes.isNotFastForward) {
@@ -9457,9 +10450,20 @@ const ValServer = (valModules, options, callbacks) => {
9457
10450
  };
9458
10451
  }
9459
10452
  // TODO: serverOps.markApplied(patchIds);
10453
+ /*
10454
+ * The new head, back to the client that made it.
10455
+ *
10456
+ * Nothing else tells it in time: `headCommitSha` moves on a `/stat`
10457
+ * response, so until the next poll the client still believes the
10458
+ * pre-publish head — and its next publish sent that as
10459
+ * `expectedHeadCommitSha`, hit the check above against the commit it
10460
+ * had itself just made, and was told somebody else had published.
10461
+ */
9460
10462
  return {
9461
10463
  status: 200,
9462
- json: {} // TODO:
10464
+ json: {
10465
+ commitSha: commitRes.commit
10466
+ }
9463
10467
  };
9464
10468
  }
9465
10469
  return {
@@ -10225,6 +11229,242 @@ const ValServer = (valModules, options, callbacks) => {
10225
11229
  }
10226
11230
  };
10227
11231
  };
11232
+ /**
11233
+ * Refuse to touch a group that is not the caller's.
11234
+ *
11235
+ * Exported for `patchGroupOwnership.test.ts`: this is the whole of the
11236
+ * authorization for stage and unstage, and nothing else in the process checks
11237
+ * it, so it is worth testing as a policy rather than only through a route.
11238
+ *
11239
+ * `getAuth` only proves a session EXISTS; it says nothing about whose
11240
+ * group this is. And the content API cannot decide either — every call
11241
+ * from here carries the app's API key, not the editor's identity — so if
11242
+ * this does not check, nothing does.
11243
+ *
11244
+ * `GET /patches?include_patch_groups=true` hands every editor the id and
11245
+ * author of every open group on the branch, so without this any logged-in
11246
+ * editor can unstage another author's patches (their next publish
11247
+ * silently ships less) or stage into their group (it silently ships
11248
+ * more). The 403 declared for this route in `ApiRoutes.ts` was
11249
+ * unreachable.
11250
+ *
11251
+ * Fails CLOSED: if the groups cannot be read, the mutation is refused
11252
+ * rather than allowed unverified.
11253
+ */
11254
+
11255
+ /**
11256
+ * The patches a scoped draft render should apply: the caller's own group, plus
11257
+ * everything already committed.
11258
+ *
11259
+ * Scoping is about PENDING work. A published patch stays in the chain with
11260
+ * `appliedAt` set until the next deployment moves the base, and it is part of
11261
+ * everyone's view in that window — the unscoped path applies it. Dropping it
11262
+ * meant the moment somebody published, their own draft preview reverted the
11263
+ * field they had just shipped, and nobody else saw it either until the deploy
11264
+ * landed; anything written on top in that window is authored against content
11265
+ * already stale on `main`.
11266
+ *
11267
+ * Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
11268
+ * PARTIAL publish leaves the group open with only some of its patches applied.
11269
+ * Those are committed too, and no flag on the group names them.
11270
+ *
11271
+ * `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
11272
+ * caller holding nothing, and on a branch with nothing applied it correctly
11273
+ * filters down to no patches, which renders base.
11274
+ */
11275
+ function scopedPatches(patches, ownPatchIds) {
11276
+ const own = new Set(ownPatchIds ?? []);
11277
+ return patches.filter(patch => own.has(patch.patchId) || patch.appliedAt !== null);
11278
+ }
11279
+
11280
+ /**
11281
+ * Which of the client's `unstagePatchIds` this server is willing to forward.
11282
+ *
11283
+ * The forward closure of a discard is the client's to compute — it needs the
11284
+ * patch sets, which need the schema — and it was being forwarded verbatim. But
11285
+ * the content API removes those memberships from EVERY group with no ownership
11286
+ * check, so any logged-in editor could strip arbitrary patches out of any other
11287
+ * author's group by attaching them to a delete of one of their own throwaway
11288
+ * patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
11289
+ * reached by a different door: their next publish silently ships less.
11290
+ *
11291
+ * Neither server can compute the true closure, but this one can BOUND it. A
11292
+ * patch can only be invalidated by a delete if it was written after that delete
11293
+ * — its paths were chosen against a view that had it — and if it is in the same
11294
+ * module, since a patch set never spans two. Anything outside those bounds was
11295
+ * not in the closure whatever the client says, so it is dropped rather than
11296
+ * refused: the delete is still correct, and refusing the whole request over an
11297
+ * over-broad extra would turn a discard into an error the user cannot act on.
11298
+ *
11299
+ * Exported for the test. Pure, and given the chain rather than fetching it, so
11300
+ * the ordering it depends on is visible in the test rather than mocked.
11301
+ */
11302
+ function boundUnstageClosure(/** The pending chain, in chain order, as `fetchPatches` returns it. */
11303
+ chain, deleted, requested) {
11304
+ if (requested.length === 0) {
11305
+ return [];
11306
+ }
11307
+ const positionOf = new Map();
11308
+ const moduleOf = new Map();
11309
+ chain.forEach((entry, index) => {
11310
+ positionOf.set(entry.patchId, index);
11311
+ moduleOf.set(entry.patchId, entry.path);
11312
+ });
11313
+ const doomed = new Set(deleted);
11314
+ return requested.filter(patchId => {
11315
+ // A patch being deleted anyway does not need its membership stripped
11316
+ // separately, and naming one is how an over-broad list hides.
11317
+ if (doomed.has(patchId)) return false;
11318
+ const position = positionOf.get(patchId);
11319
+ const moduleFilePath = moduleOf.get(patchId);
11320
+ if (position === undefined || moduleFilePath === undefined) return false;
11321
+ return deleted.some(deletedId => {
11322
+ const deletedPosition = positionOf.get(deletedId);
11323
+ if (deletedPosition === undefined) return false;
11324
+ return position > deletedPosition && moduleOf.get(deletedId) === moduleFilePath;
11325
+ });
11326
+ });
11327
+ }
11328
+
11329
+ /**
11330
+ * Which pending patches this caller may see, when they asked for "only mine".
11331
+ *
11332
+ * Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
11333
+ * both, so two answers to "whose work is this" put one person's half-finished
11334
+ * edit on another person's preview through whichever route was not scoped. That
11335
+ * is exactly what happened — `/json` applied every pending patch on the branch
11336
+ * while the module content beside it was scoped.
11337
+ *
11338
+ * `undefined` means "apply everything", which is what every caller that does
11339
+ * not ask for scoping gets and must keep getting.
11340
+ */
11341
+ async function resolveOwnPatchScope(serverOps, opts) {
11342
+ let ownPatchIds;
11343
+ /** See where this is set: committed work is nobody's to hold back. */
11344
+ let scopeAlsoIncludesApplied = false;
11345
+ if (opts.explicitPatchIds === undefined && opts.ownGroupsOnly) {
11346
+ if (serverOps instanceof ValOpsHttp && opts.authorId) {
11347
+ const groupsRes = await serverOps.getPatchGroups();
11348
+ if (groupsRes.status === "unsupported") {
11349
+ /*
11350
+ * A content API that PREDATES patch groups — the endpoint 404s.
11351
+ *
11352
+ * Unscoped, which is what those deployments do today and must
11353
+ * keep doing. Reading this as a failure and rendering base
11354
+ * would silently drop every pending patch from every draft
11355
+ * preview on every existing http project — the exact opposite
11356
+ * of "keeps working unchanged", and invisible to the reader.
11357
+ */
11358
+ ownPatchIds = undefined;
11359
+ } else if (groupsRes.status === "error") {
11360
+ /*
11361
+ * We could not ask, and this deployment DOES have the endpoint.
11362
+ * Render base rather than everything: a degraded preview is
11363
+ * recoverable, showing another author's unpublished draft is
11364
+ * not, and it would be silent.
11365
+ */
11366
+ ownPatchIds = [];
11367
+ } else if (groupsRes.patchGroups.length === 0) {
11368
+ /*
11369
+ * The branch has no groups AT ALL, so this deployment is not
11370
+ * using them — a content API that predates patch groups, or a
11371
+ * project where nobody has staged anything since they existed.
11372
+ *
11373
+ * Unscoped, which is the behaviour every such project has
11374
+ * today. Collapsing this into "your group is empty" would make
11375
+ * every draft render base and silently drop all pending
11376
+ * content, which is what happened before this branch: nothing
11377
+ * writes a group yet, so EVERY project is in this state right
11378
+ * now.
11379
+ */
11380
+ ownPatchIds = undefined;
11381
+ } else {
11382
+ /*
11383
+ * Groups exist and none are this person's: they have staged
11384
+ * nothing, and base is the honest answer. Distinct from the
11385
+ * case above, which is why the two are not one expression.
11386
+ */
11387
+ ownPatchIds = groupsRes.patchGroups.filter(group => group.publishedAt === null && group.authorId === opts.authorId).flatMap(group => group.patchIds);
11388
+ /*
11389
+ * Scoping applies to PENDING work only. Anything already
11390
+ * committed is part of everyone's view.
11391
+ *
11392
+ * A published patch stays in the chain with `appliedAt` set
11393
+ * until the next deployment moves the base, and the unscoped
11394
+ * path applies it. Filtering to open groups dropped it — so the
11395
+ * moment someone published, their own draft preview reverted
11396
+ * the field they had just shipped, and nobody else saw it
11397
+ * either until the deploy landed. Anything written on top in
11398
+ * that window is authored against content that is already
11399
+ * stale on `main`.
11400
+ *
11401
+ * Unioned by `appliedAt` rather than by pulling in groups with
11402
+ * a `publishedAt`, because a partial publish leaves the group
11403
+ * OPEN with some of its patches applied — those are committed
11404
+ * too, and no group flag names them.
11405
+ */
11406
+ scopeAlsoIncludesApplied = true;
11407
+ }
11408
+ } else {
11409
+ /*
11410
+ * fs mode, or a server with no groups: there is nothing to scope
11411
+ * BY, and every pending patch is this one person's anyway. Left
11412
+ * `undefined` so the existing "apply everything" path runs.
11413
+ */
11414
+ ownPatchIds = undefined;
11415
+ }
11416
+ }
11417
+ return {
11418
+ ownPatchIds,
11419
+ scopeAlsoIncludesApplied
11420
+ };
11421
+ }
11422
+ async function refuseUnlessOwn(ops, patchGroupId, authorId) {
11423
+ /*
11424
+ * Never from the cache. This answers "is this group yours", and a group is at
11425
+ * its youngest exactly when the question is asked — the first write creates
11426
+ * it and the shell flushes its queued stages the moment the save response
11427
+ * names it. A cached list fetched a few hundred milliseconds earlier does not
11428
+ * contain it, and every one of those stages was refused and dropped.
11429
+ */
11430
+ const groupsRes = await ops.getPatchGroups({
11431
+ fresh: true
11432
+ });
11433
+ if (groupsRes.status !== "ok") {
11434
+ return {
11435
+ status: 500,
11436
+ message: "Could not verify that this patch group is yours, so it was not changed."
11437
+ };
11438
+ }
11439
+ const group = groupsRes.patchGroups.find(candidate => candidate.patchGroupId === patchGroupId);
11440
+ if (group === undefined || group.authorId === null || group.authorId !== authorId) {
11441
+ /*
11442
+ * "Not found" and "not yours" are the SAME refusal on purpose: the route
11443
+ * schema has no 404, and `GET /patches` already lists every group on the
11444
+ * branch, so distinguishing them hides nothing and only adds a second
11445
+ * message to keep consistent.
11446
+ *
11447
+ * A null author is a group written by an api key or a PAT. Nobody owns it,
11448
+ * so nobody may stage into it — `null === null` must not read as a match.
11449
+ */
11450
+ return {
11451
+ status: 403,
11452
+ message: "You can only change your own patch group"
11453
+ };
11454
+ }
11455
+ if (group.publishedAt !== null) {
11456
+ /*
11457
+ * Already shipped, so it can never be written again. The content API
11458
+ * answers this too; refusing here saves the round trip and keeps the
11459
+ * wording the same as every other refusal on this route.
11460
+ */
11461
+ return {
11462
+ status: 409,
11463
+ message: "Patch group is already published"
11464
+ };
11465
+ }
11466
+ return null;
11467
+ }
10228
11468
  function verifyCallbackReq(stateCookie, queryParams) {
10229
11469
  if (typeof stateCookie !== "string") {
10230
11470
  return {
@@ -10756,7 +11996,7 @@ function createValApiRouter(route, valServerPromise, convert) {
10756
11996
  }
10757
11997
  let bodyRes;
10758
11998
  try {
10759
- bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await req.json()) : {
11999
+ bodyRes = reqDefinition.body ? reqDefinition.body.safeParse(await readJsonBody(req)) : {
10760
12000
  success: true,
10761
12001
  data: {}
10762
12002
  };
@@ -10835,6 +12075,33 @@ function formatZodErrorString(error) {
10835
12075
  const errors = zodValidationError.fromError(error).toString();
10836
12076
  return errors.length > 640 ? `${errors.slice(0, 640)}...` : errors;
10837
12077
  }
12078
+
12079
+ /**
12080
+ * The request's JSON body, or `undefined` when it has none.
12081
+ *
12082
+ * `req.json()` THROWS on an empty body, and the router used to call it
12083
+ * unconditionally for any route that declares a body — so the moment
12084
+ * `DELETE /patches` gained an optional body, every caller that sent none got
12085
+ * `400 Could not parse request body`. Declaring the schema `.optional()` did
12086
+ * not help: the throw happens before zod is ever consulted. Six e2e tests went
12087
+ * red on a helper doing exactly what the route still permits.
12088
+ *
12089
+ * Told apart by the request rather than by catching, so a body that IS sent and
12090
+ * is malformed still fails: absent means no content type and nothing to read,
12091
+ * and anything else is parsed and allowed to throw. A route whose schema
12092
+ * requires a body is unaffected — it gets `undefined` and zod refuses it, with
12093
+ * the same 400 as before, now naming the field.
12094
+ */
12095
+ async function readJsonBody(req) {
12096
+ if (req.headers.get("content-length") === "0") {
12097
+ return undefined;
12098
+ }
12099
+ const text = await req.text();
12100
+ if (text.length === 0) {
12101
+ return undefined;
12102
+ }
12103
+ return JSON.parse(text);
12104
+ }
10838
12105
  function zodErrorResult(error, message) {
10839
12106
  return {
10840
12107
  status: 400,