@valbuild/server 0.141.1 → 0.142.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.
@@ -2679,18 +2679,41 @@ function encodeJwt(payload, sessionKey) {
2679
2679
  * error somebody had to answer instead of a default that silently applied.
2680
2680
  */
2681
2681
  function clientConfig(options) {
2682
+ const config = withResolvedProject(options.config, options.project);
2682
2683
  if (options.mode !== "http" || options.git === undefined) {
2683
- return options.config;
2684
+ return config;
2684
2685
  }
2685
- if (options.config.gitBranch !== undefined) {
2686
- return options.config;
2686
+ if (config.gitBranch !== undefined) {
2687
+ return config;
2687
2688
  }
2688
2689
  return {
2689
- ...options.config,
2690
+ ...config,
2690
2691
  gitBranch: options.git.branch
2691
2692
  };
2692
2693
  }
2693
2694
 
2695
+ /**
2696
+ * The project, filled in the same way and for the same reason as the branch.
2697
+ *
2698
+ * `project` may come from `VAL_PROJECT` rather than from `val.config`, and the
2699
+ * server talks to that project either way. The Studio decides from
2700
+ * `config.project` whether there is an assistant to offer at all (`wsEnabled`
2701
+ * in `ValProvider`), so a project named only in the environment would have
2702
+ * had it hidden while the server behind it was ready to serve it.
2703
+ *
2704
+ * `val.config` wins, as it does for the branch, and every mode gets it: unlike
2705
+ * a branch, a project means the same thing in fs mode as in http mode.
2706
+ */
2707
+ function withResolvedProject(config, project) {
2708
+ if (config.project !== undefined || project === undefined) {
2709
+ return config;
2710
+ }
2711
+ return {
2712
+ ...config,
2713
+ project
2714
+ };
2715
+ }
2716
+
2694
2717
  /**
2695
2718
  * Which IP addresses this server may open a connection to.
2696
2719
  *
@@ -3564,6 +3587,12 @@ class ValOps {
3564
3587
  * hash. Re-folding with the wrong list changes the base SHA for no reason.
3565
3588
  */
3566
3589
  shaModuleErrors;
3590
+ /**
3591
+ * The SITE's Source for the modules a proposal's snapshot replaced: what the
3592
+ * bundle evaluated to before the snapshot went over it. Kept for
3593
+ * {@link siteSourcesUnderSnapshot}, and only where there is a snapshot.
3594
+ */
3595
+ siteSourcesBeforeSnapshot = null;
3567
3596
  /**
3568
3597
  * What a save has told us each `.jsonValues()` entry now holds.
3569
3598
  *
@@ -3610,6 +3639,7 @@ class ValOps {
3610
3639
  // #region initTree
3611
3640
  async initSources() {
3612
3641
  if (this.baseSha === null || this.sourcesSha === null || this.configSha === null || this.schemaSha === null || this.sources === null || this.schemas === null || this.modulesErrors === null) {
3642
+ var _this$options;
3613
3643
  const extracted = await extractValModules(this.valModules);
3614
3644
  const moduleErrors = extracted.moduleErrors.concat(findNestedJsonValuesModuleErrors(extracted.schemas));
3615
3645
  this.sources = extracted.sources;
@@ -3621,12 +3651,29 @@ class ValOps {
3621
3651
  this.modulesErrors = moduleErrors;
3622
3652
  this.shaEntries = extracted.shaEntries;
3623
3653
  this.shaModuleErrors = extracted.moduleErrors;
3654
+ /*
3655
+ * The snapshot, over what the modules evaluated to. Through the same
3656
+ * door a save uses, so the SHAs move with it: they describe the Source
3657
+ * being served, and a base SHA that still described the bundle would
3658
+ * say the snapshot's content was the bundle's.
3659
+ */
3660
+ if ((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.snapshotSources) {
3661
+ const site = {};
3662
+ for (const moduleFilePath of Object.keys(this.options.snapshotSources)) {
3663
+ const source = extracted.sources[moduleFilePath];
3664
+ if (source !== undefined) {
3665
+ site[moduleFilePath] = source;
3666
+ }
3667
+ }
3668
+ this.siteSourcesBeforeSnapshot = site;
3669
+ this.promoteCommittedSources(this.options.snapshotSources);
3670
+ }
3624
3671
  return {
3625
3672
  baseSha: this.baseSha,
3626
3673
  schemaSha: this.schemaSha,
3627
3674
  sourcesSha: this.sourcesSha,
3628
3675
  configSha: this.configSha,
3629
- sources: extracted.sources,
3676
+ sources: this.sources,
3630
3677
  schemas: extracted.schemas,
3631
3678
  moduleErrors
3632
3679
  };
@@ -3642,6 +3689,19 @@ class ValOps {
3642
3689
  };
3643
3690
  }
3644
3691
 
3692
+ /**
3693
+ * In a proposal: the SITE's Source for every module the proposal has saved,
3694
+ * as the build it is based on evaluated it -- what the proposal is compared
3695
+ * against, and what merging it changes. Every other module is the same in
3696
+ * both, so it is not sent. `null` anywhere that is not a proposal.
3697
+ */
3698
+ async siteSourcesUnderSnapshot() {
3699
+ var _this$options2;
3700
+ if (!((_this$options2 = this.options) !== null && _this$options2 !== void 0 && _this$options2.snapshotSources)) return null;
3701
+ await this.initSources();
3702
+ return this.siteSourcesBeforeSnapshot;
3703
+ }
3704
+
3645
3705
  /**
3646
3706
  * These patches are on disk now: adopt what they produced as the committed
3647
3707
  * sources.
@@ -5238,9 +5298,9 @@ class ValOps {
5238
5298
  // https://github.com/microsoft/TypeScript/issues/36174
5239
5299
  let sourceFileText = null;
5240
5300
  if (tsChanged) {
5241
- var _this$options;
5301
+ var _this$options3;
5242
5302
  sourceFileText = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
5243
- if ((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.formatter) {
5303
+ if ((_this$options3 = this.options) !== null && _this$options3 !== void 0 && _this$options3.formatter) {
5244
5304
  try {
5245
5305
  sourceFileText = await this.options.formatter(sourceFileText, path);
5246
5306
  } catch (err) {
@@ -5252,13 +5312,13 @@ class ValOps {
5252
5312
  }
5253
5313
  const extraFiles = {};
5254
5314
  for (const [jsonPath, content] of Array.from(jsonEntryContents)) {
5255
- var _this$options2;
5315
+ var _this$options4;
5256
5316
  if (content === null) {
5257
5317
  extraFiles[jsonPath] = null;
5258
5318
  continue;
5259
5319
  }
5260
5320
  let jsonText = JSON.stringify(content, null, 2);
5261
- if ((_this$options2 = this.options) !== null && _this$options2 !== void 0 && _this$options2.formatter) {
5321
+ if ((_this$options4 = this.options) !== null && _this$options4 !== void 0 && _this$options4.formatter) {
5262
5322
  try {
5263
5323
  jsonText = await this.options.formatter(jsonText, jsonPath);
5264
5324
  } catch (err) {
@@ -5574,6 +5634,30 @@ class ValOps {
5574
5634
  };
5575
5635
  }
5576
5636
 
5637
+ /**
5638
+ * The proposal this server runs at the address of, or `null` on the site --
5639
+ * and always in `fs` and memory mode, which have no proposals. See
5640
+ * `ValProposal`.
5641
+ */
5642
+ currentProposal() {
5643
+ return null;
5644
+ }
5645
+
5646
+ /**
5647
+ * The content service's proposals API, for the Studio's switcher, list and
5648
+ * dialogs. Refused here for the reason {@link publishApi} is: there is no
5649
+ * content service to forward to. `profileId` is the person asking.
5650
+ */
5651
+ async proposalsApi(_path, _init, _profileId) {
5652
+ return {
5653
+ status: 501,
5654
+ contentType: "application/json",
5655
+ body: JSON.stringify({
5656
+ message: "This Val server has no content service, so it has no proposals."
5657
+ })
5658
+ };
5659
+ }
5660
+
5577
5661
  /**
5578
5662
  * Whether a commit here produces `.val.ts` TEXT as well as data.
5579
5663
  *
@@ -8777,6 +8861,111 @@ function computePatchesToDrop(prepared) {
8777
8861
  return dropped;
8778
8862
  }
8779
8863
 
8864
+ /**
8865
+ * A proposal this server runs at the address of: an editor-managed branch of
8866
+ * the project's content, served from its own copy of the content the
8867
+ * proposal has saved -- the **snapshot**. valbuild/home `docs/proposals.md`,
8868
+ * "Saving".
8869
+ *
8870
+ * A third place Source can come from, beside the two Val already has:
8871
+ *
8872
+ * | source provider | Source comes from |
8873
+ * | --------------- | -------------------------------------- |
8874
+ * | committed | the bundle |
8875
+ * | draft | content, pending patches applied |
8876
+ * | snapshot | the proposal's saves |
8877
+ *
8878
+ * The snapshot REPLACES the bundle's Source for the modules the proposal has
8879
+ * saved and nothing else, on every read: a committed render, the draft a page
8880
+ * starts from, the Studio's tree, validation and the next save's prepare. The
8881
+ * proposal's pending patches apply on top of it, as the site's apply on top of
8882
+ * the bundle.
8883
+ *
8884
+ * Handed over by the platform that serves the address, per isolate: the
8885
+ * snapshot only changes with a save, and a save is a new isolate.
8886
+ */
8887
+
8888
+ /**
8889
+ * The proposal a platform describes in its runtime env, or `undefined` when
8890
+ * this is not a proposal's address.
8891
+ *
8892
+ * `VAL_PROPOSAL` and `VAL_BRANCH` name it; `VAL_OVERLAY` is the snapshot as
8893
+ * JSON (`{ commit, modules, files }`). Throws on a snapshot that does not
8894
+ * parse rather than serving the base build as though it were the proposal: a
8895
+ * reviewer would be looking at the wrong content and nothing would say so.
8896
+ */
8897
+ function proposalFromEnv(env) {
8898
+ const name = env.VAL_PROPOSAL;
8899
+ const branch = env.VAL_BRANCH;
8900
+ if (!name || !branch) {
8901
+ return undefined;
8902
+ }
8903
+ let parsed = {};
8904
+ if (env.VAL_OVERLAY) {
8905
+ try {
8906
+ parsed = JSON.parse(env.VAL_OVERLAY);
8907
+ } catch (err) {
8908
+ throw new Error(`Val: proposal ${name}: VAL_OVERLAY is not JSON (${err instanceof Error ? err.message : String(err)})`, {
8909
+ cause: err
8910
+ });
8911
+ }
8912
+ }
8913
+ if (!isObject$1(parsed)) {
8914
+ throw new Error(`Val: proposal ${name}: VAL_OVERLAY is not an object`);
8915
+ }
8916
+ const modulesIn = parsed["modules"] ?? {};
8917
+ const filesIn = parsed["files"] ?? {};
8918
+ const commit = parsed["commit"] ?? null;
8919
+ if (!isObject$1(modulesIn) || !isObject$1(filesIn)) {
8920
+ throw new Error(`Val: proposal ${name}: VAL_OVERLAY's modules and files must be objects`);
8921
+ }
8922
+ if (commit !== null && typeof commit !== "string") {
8923
+ throw new Error(`Val: proposal ${name}: VAL_OVERLAY's commit must be a string or null`);
8924
+ }
8925
+ const files = {};
8926
+ for (const [path, text] of Object.entries(filesIn)) {
8927
+ if (typeof text === "string") {
8928
+ files[path] = text;
8929
+ }
8930
+ }
8931
+ return {
8932
+ name,
8933
+ branch,
8934
+ commit,
8935
+ modules: modulesIn,
8936
+ files
8937
+ };
8938
+ }
8939
+
8940
+ /**
8941
+ * The proposal's saved Source, as Source: every module whose value is JSON.
8942
+ * One place, so the server and the readers that render without it agree on
8943
+ * what the snapshot holds.
8944
+ */
8945
+ function proposalSnapshot(proposal) {
8946
+ const snapshot = {};
8947
+ for (const [path, source] of Object.entries(proposal.modules)) {
8948
+ if (isJsonValue(source)) {
8949
+ snapshot[path] = source;
8950
+ }
8951
+ }
8952
+ return snapshot;
8953
+ }
8954
+ function isObject$1(value) {
8955
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8956
+ }
8957
+
8958
+ /** What `JSON.parse` can produce, checked rather than assumed. */
8959
+ function isJsonValue(value) {
8960
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
8961
+ return true;
8962
+ }
8963
+ if (Array.isArray(value)) {
8964
+ return value.every(isJsonValue);
8965
+ }
8966
+ return isObject$1(value) && Object.values(value).every(isJsonValue);
8967
+ }
8968
+
8780
8969
  const textEncoder$2 = new TextEncoder();
8781
8970
  const PatchId = z.string().refine(s => !!s); // TODO: validate
8782
8971
  const CommitSha = z.string().refine(s => !!s); // TODO: validate
@@ -9130,6 +9319,8 @@ class ValOpsHttp extends ValOps {
9130
9319
  publishJob;
9131
9320
  /** See `publishBuild` on {@link ValApiOptions}; sent with every position. */
9132
9321
  publishBuild;
9322
+ /** See `proposal` on {@link ValApiOptions}; `null` on the site. */
9323
+ proposal;
9133
9324
 
9134
9325
  /** Did the host hand over the running build's source? See `projectSource`. */
9135
9326
  embedsSource() {
@@ -9202,7 +9393,10 @@ class ValOpsHttp extends ValOps {
9202
9393
  * getSettings / uploadRemoteFile / getPresignedAuthNonce.
9203
9394
  */
9204
9395
  auth, valModules, options) {
9205
- super(valModules, options);
9396
+ super(valModules, options !== null && options !== void 0 && options.proposal ? {
9397
+ ...options,
9398
+ snapshotSources: proposalSnapshot(options.proposal)
9399
+ } : options);
9206
9400
  this.contentUrl = contentUrl;
9207
9401
  this.project = project;
9208
9402
  this.git = git;
@@ -9212,7 +9406,15 @@ class ValOpsHttp extends ValOps {
9212
9406
  Authorization: `Bearer ${auth.apiKey}`
9213
9407
  };
9214
9408
  this.root = (options === null || options === void 0 ? void 0 : options.root) ?? "";
9215
- this.projectSource = (options === null || options === void 0 ? void 0 : options.projectSource) ?? null;
9409
+ this.proposal = (options === null || options === void 0 ? void 0 : options.proposal) ?? null;
9410
+ /*
9411
+ * In a proposal, the text its saves wrote over the text the build was made
9412
+ * from: the next save patches the last one's `.val.ts`, not the base's.
9413
+ */
9414
+ this.projectSource = this.proposal !== null ? {
9415
+ ...((options === null || options === void 0 ? void 0 : options.projectSource) ?? {}),
9416
+ ...this.proposal.files
9417
+ } : (options === null || options === void 0 ? void 0 : options.projectSource) ?? null;
9216
9418
  this.publishJob = (options === null || options === void 0 ? void 0 : options.publishJob) ?? null;
9217
9419
  this.publishBuild = (options === null || options === void 0 ? void 0 : options.publishBuild) ?? null;
9218
9420
  this.mirrorsSourceFiles = git !== null || this.projectSource !== null;
@@ -9241,7 +9443,8 @@ class ValOpsHttp extends ValOps {
9241
9443
  */
9242
9444
  publishRefusal() {
9243
9445
  var _this$projectExpectat;
9244
- if (this.git !== null) {
9446
+ // A save in a proposal mirrors nothing: it reaches git by merging.
9447
+ if (this.git !== null || this.proposal !== null) {
9245
9448
  return null;
9246
9449
  }
9247
9450
  /*
@@ -9278,6 +9481,13 @@ class ValOpsHttp extends ValOps {
9278
9481
  */
9279
9482
  sourceMode() {
9280
9483
  var _this$projectExpectat2;
9484
+ /*
9485
+ * Not reported in a proposal, whatever the project is. The Studio reads
9486
+ * `managed` as "I am the deployer" and follows a commit with a build it
9487
+ * publishes to the SITE -- and a proposal's commit is a Save, which its
9488
+ * address serves without any build at all.
9489
+ */
9490
+ if (this.proposal !== null) return null;
9281
9491
  return ((_this$projectExpectat2 = this.projectExpectation) === null || _this$projectExpectat2 === void 0 ? void 0 : _this$projectExpectat2.sourceMode) ?? null;
9282
9492
  }
9283
9493
 
@@ -9312,6 +9522,8 @@ class ValOpsHttp extends ValOps {
9312
9522
  }).catch(() => undefined);
9313
9523
  }
9314
9524
  publishesAsJobs() {
9525
+ // A proposal's Save is a commit on its branch; its merge is the publish.
9526
+ if (this.proposal !== null) return false;
9315
9527
  const expected = this.projectExpectation;
9316
9528
  if (expected === null) return false;
9317
9529
  return expected.publishJobs ?? expected.sourceMode === "managed";
@@ -9431,6 +9643,10 @@ class ValOpsHttp extends ValOps {
9431
9643
  * segment gets through.
9432
9644
  */
9433
9645
  static publishApiPathAllowed(path) {
9646
+ // A merge's prepare is content's own, from the proposal's last save.
9647
+ if (/^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/merge-prepare$/.test(path)) {
9648
+ return true;
9649
+ }
9434
9650
  if (path === "/build-target" || path === "/project-source" || path === "/update-target" || path === "/publish" ||
9435
9651
  // Where "View run" goes, for a connected build CI reported failed.
9436
9652
  // Read only: reporting a run is CI's, with its own token.
@@ -9453,6 +9669,20 @@ class ValOpsHttp extends ValOps {
9453
9669
  return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
9454
9670
  }
9455
9671
  async publishApi(path, init) {
9672
+ /*
9673
+ * Nothing is published from a proposal's address: its way to the site is
9674
+ * a merge. The publish API reaches the SITE's builds and jobs, so a
9675
+ * Studio there that asked would be publishing over the site.
9676
+ */
9677
+ if (this.proposal !== null && !ValOpsHttp.mergeApiPathAllowed(init.method, path)) {
9678
+ return {
9679
+ status: 403,
9680
+ contentType: "application/json",
9681
+ body: JSON.stringify({
9682
+ message: "This is a proposal's address: nothing is published from here. " + "A proposal reaches the site by being merged."
9683
+ })
9684
+ };
9685
+ }
9456
9686
  if (!ValOpsHttp.publishApiPathAllowed(path)) {
9457
9687
  return {
9458
9688
  status: 403,
@@ -9464,6 +9694,82 @@ class ValOpsHttp extends ValOps {
9464
9694
  }
9465
9695
  return this.callPublishApi(path, init);
9466
9696
  }
9697
+ currentProposal() {
9698
+ return this.proposal === null ? null : {
9699
+ name: this.proposal.name,
9700
+ branch: this.proposal.branch
9701
+ };
9702
+ }
9703
+
9704
+ /**
9705
+ * Which proposal calls the browser may make, by method and the path after
9706
+ * `/proposals`: list and open, read and rename one, and close, reopen and
9707
+ * retry its setup. Not `/save` -- the Studio saves through `/save`, which
9708
+ * prepares the commit here -- and not `/patches`, which moves the site's
9709
+ * changes into a proposal and has no screen yet.
9710
+ */
9711
+ static proposalsApiAllowed(method, path) {
9712
+ if (path === "") return method === "GET" || method === "POST";
9713
+ const one = /^\/[0-9a-f]{20}$/.test(path);
9714
+ if (one) return method === "GET" || method === "PATCH";
9715
+ if (method === "GET") return /^\/[0-9a-f]{20}\/merge-checks$/.test(path);
9716
+ return method === "POST" && /^\/[0-9a-f]{20}\/(close|reopen|setup\/retry|merge)$/.test(path);
9717
+ }
9718
+
9719
+ /**
9720
+ * What a proposal's address may reach of the publish API: building ITS OWN
9721
+ * merge, and nothing that publishes the site's changes. The merge's prepare
9722
+ * (content's, from the proposal's last save), the build it needs -- the
9723
+ * site's build target and source, declaring and uploading the build -- and
9724
+ * the job's own steps. Not a press of the site's Publish, not `next` (which
9725
+ * would hand this tab a site job), and not the site job's prepare.
9726
+ */
9727
+ static mergeApiPathAllowed(method, path) {
9728
+ if (path === "/build-target" || path === "/project-source" || path === "/publish") {
9729
+ return true;
9730
+ }
9731
+ if (/^\/publish\/[A-Za-z0-9_-]+\/artifacts$/.test(path)) return true;
9732
+ // Where its press is -- read only: a POST there is the site's Try again.
9733
+ if (method === "GET" && path !== "/publish-requests/try-again" && /^\/publish-requests\/[A-Za-z0-9_-]{1,100}$/.test(path)) return true;
9734
+ return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
9735
+ }
9736
+ async proposalsApi(path, init, profileId) {
9737
+ const json = "application/json";
9738
+ if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
9739
+ return {
9740
+ status: 403,
9741
+ contentType: json,
9742
+ body: JSON.stringify({
9743
+ message: `'${init.method} ${path}' is not part of the proposals API.`
9744
+ })
9745
+ };
9746
+ }
9747
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/proposals${path}`, {
9748
+ method: init.method,
9749
+ headers: {
9750
+ ...this.authHeaders,
9751
+ /*
9752
+ * WHO is asking, as for a commit: the app's key names the project,
9753
+ * and a proposal is opened, renamed and closed by a person -- content
9754
+ * refuses those writes from a credential that names nobody.
9755
+ */
9756
+ ...(profileId !== null ? {
9757
+ "x-val-profile-id": profileId
9758
+ } : {}),
9759
+ ...(init.body === undefined ? {} : {
9760
+ "Content-Type": json
9761
+ })
9762
+ },
9763
+ ...(init.body === undefined ? {} : {
9764
+ body: init.body
9765
+ })
9766
+ });
9767
+ return {
9768
+ status: res.status,
9769
+ body: await res.text(),
9770
+ contentType: res.headers.get("Content-Type") ?? json
9771
+ };
9772
+ }
9467
9773
 
9468
9774
  /**
9469
9775
  * A publish job's prepare, sent to content: the job's sources, archived
@@ -9764,6 +10070,42 @@ class ValOpsHttp extends ValOps {
9764
10070
  } : {})
9765
10071
  };
9766
10072
  }
10073
+
10074
+ /**
10075
+ * Where this server sits in the content service's chain, as every request
10076
+ * that asks about a position says it.
10077
+ *
10078
+ * In a proposal: its branch, and the save its snapshot is (none before the
10079
+ * first). The content service then answers with what came after that save
10080
+ * and nothing else -- never the site's position, which would replay the
10081
+ * proposal's own saves over a snapshot that already holds them. Otherwise
10082
+ * the commit this build was made from, or, for a build a tab made, its job.
10083
+ */
10084
+ position() {
10085
+ if (this.proposal !== null) {
10086
+ return {
10087
+ branch: this.proposal.branch,
10088
+ ...(this.proposal.commit !== null ? {
10089
+ commit: this.proposal.commit
10090
+ } : {})
10091
+ };
10092
+ }
10093
+ if (this.git) {
10094
+ return {
10095
+ branch: this.git.branch,
10096
+ commit: this.git.commit
10097
+ };
10098
+ }
10099
+ return this.publishJob !== null ? {
10100
+ job: this.publishJob
10101
+ } : {};
10102
+ }
10103
+
10104
+ /** The branch this server's patches are on, where it knows it. */
10105
+ ownBranch() {
10106
+ var _this$proposal, _this$git;
10107
+ return ((_this$proposal = this.proposal) === null || _this$proposal === void 0 ? void 0 : _this$proposal.branch) ?? ((_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
10108
+ }
9767
10109
  async getWebSocketNonce(profileId) {
9768
10110
  return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
9769
10111
  method: "POST",
@@ -9775,12 +10117,24 @@ class ValOpsHttp extends ValOps {
9775
10117
  * the project -- and a nonce is scoped to the project and the person,
9776
10118
  * not to a position in a chain.
9777
10119
  */
9778
- ...(this.git ? {
9779
- branch: this.git.branch,
9780
- commitSha: this.git.commit
9781
- } : this.publishJob !== null ? {
9782
- job: this.publishJob
9783
- } : {}),
10120
+ ...(() => {
10121
+ const {
10122
+ branch,
10123
+ commit,
10124
+ job
10125
+ } = this.position();
10126
+ return {
10127
+ ...(branch !== undefined ? {
10128
+ branch
10129
+ } : {}),
10130
+ ...(commit !== undefined ? {
10131
+ commitSha: commit
10132
+ } : {}),
10133
+ ...(job !== undefined ? {
10134
+ job
10135
+ } : {})
10136
+ };
10137
+ })(),
9784
10138
  // Which build is asking, beside what it says about itself.
9785
10139
  ...(this.publishBuild !== null ? {
9786
10140
  build: this.publishBuild
@@ -9962,12 +10316,10 @@ class ValOpsHttp extends ValOps {
9962
10316
  * holds the chain, and for a project it is the only publisher of, the
9963
10317
  * position IS the head.
9964
10318
  */
9965
- if (this.git) {
9966
- params.push(["branch", this.git.branch]);
9967
- params.push(["commit", this.git.commit]);
9968
- } else if (this.publishJob !== null) {
9969
- // A build a tab made has no commit, and says which job it was made for.
9970
- params.push(["job", this.publishJob]);
10319
+ // A build a tab made has no commit, and says which job it was made for;
10320
+ // a proposal says its branch and its save. See `position`.
10321
+ for (const [key, value] of Object.entries(this.position())) {
10322
+ if (value !== undefined) params.push([key, value]);
9971
10323
  }
9972
10324
  /*
9973
10325
  * And WHICH build this is, when the platform running it says: the only
@@ -10240,6 +10592,23 @@ class ValOpsHttp extends ValOps {
10240
10592
  * cache: a second render a second later asks again.
10241
10593
  */
10242
10594
  patchGroupsCache = null;
10595
+
10596
+ /**
10597
+ * Forget the remembered group list, after a write through this server.
10598
+ *
10599
+ * A write changes membership -- a save joins its author's group, a delete or
10600
+ * an unstage leaves it -- and the window above is long enough for the next
10601
+ * render to be served the list from before it: an editor who saved and
10602
+ * reloaded inside a second was shown their draft without the change they had
10603
+ * just made. Called once the write has answered, so a read that started
10604
+ * while it was in flight cannot leave the old list behind it.
10605
+ *
10606
+ * Only this instance's writes. Another instance's write is still seen at
10607
+ * most one window late, which is what the window is for.
10608
+ */
10609
+ forgetPatchGroups() {
10610
+ this.patchGroupsCache = null;
10611
+ }
10243
10612
  async getPatchGroups(options) {
10244
10613
  const now = Date.now();
10245
10614
  if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
@@ -10271,6 +10640,17 @@ class ValOpsHttp extends ValOps {
10271
10640
  return res;
10272
10641
  }
10273
10642
  async fetchPatchGroups() {
10643
+ /*
10644
+ * A proposal has no patch groups: its changes are one unit, saved
10645
+ * together, and the content service answers 404 for groups on its branch.
10646
+ * "Unsupported" is exactly that answer, and it is what makes a draft in a
10647
+ * proposal show all of the proposal's changes rather than one person's.
10648
+ */
10649
+ if (this.proposal !== null) {
10650
+ return {
10651
+ status: "unsupported"
10652
+ };
10653
+ }
10274
10654
  try {
10275
10655
  /*
10276
10656
  * `branch` is REQUIRED by the endpoint, which answers 400 without it.
@@ -10279,7 +10659,8 @@ class ValOpsHttp extends ValOps {
10279
10659
  * `saveSourceFilePatch`): groups are per branch, so a request without one
10280
10660
  * is not merely under-specified, it is rejected.
10281
10661
  */
10282
- const params = new URLSearchParams(this.git ? [["branch", this.git.branch]] : []);
10662
+ const branch = this.ownBranch();
10663
+ const params = new URLSearchParams(branch !== undefined ? [["branch", branch]] : []);
10283
10664
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
10284
10665
  headers: this.authHeaders
10285
10666
  });
@@ -10330,8 +10711,9 @@ class ValOpsHttp extends ValOps {
10330
10711
  * Without one the content API uses the project's own, as it does for reads.
10331
10712
  */
10332
10713
  ownGroupBranch() {
10333
- return this.git ? {
10334
- branch: this.git.branch
10714
+ const branch = this.ownBranch();
10715
+ return branch !== undefined ? {
10716
+ branch
10335
10717
  } : {};
10336
10718
  }
10337
10719
  async mutatePatchGroup(method, path, body,
@@ -10354,6 +10736,22 @@ class ValOpsHttp extends ValOps {
10354
10736
  * on its own there and this adds nothing.
10355
10737
  */
10356
10738
  authorId) {
10739
+ if (this.proposal !== null) {
10740
+ return {
10741
+ status: 409,
10742
+ patchIds: [],
10743
+ error: {
10744
+ message: "A proposal's changes are saved together, so there is nothing to stage or unstage in one."
10745
+ }
10746
+ };
10747
+ }
10748
+ try {
10749
+ return await this.mutatePatchGroupRequest(method, path, body, authorId);
10750
+ } finally {
10751
+ this.forgetPatchGroups();
10752
+ }
10753
+ }
10754
+ async mutatePatchGroupRequest(method, path, body, authorId) {
10357
10755
  try {
10358
10756
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
10359
10757
  method,
@@ -10446,7 +10844,13 @@ class ValOpsHttp extends ValOps {
10446
10844
  patchId,
10447
10845
  parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
10448
10846
  baseSha,
10449
- ...(this.git ? {
10847
+ /*
10848
+ * In a proposal, its branch alone: the content service files the
10849
+ * patch there, and only for an open proposal.
10850
+ */
10851
+ ...(this.proposal !== null ? {
10852
+ branch: this.proposal.branch
10853
+ } : this.git ? {
10450
10854
  commit: this.git.commit,
10451
10855
  branch: this.git.branch
10452
10856
  } : {}),
@@ -10523,7 +10927,7 @@ class ValOpsHttp extends ValOps {
10523
10927
  errorType: "other",
10524
10928
  message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
10525
10929
  });
10526
- });
10930
+ }).finally(() => this.forgetPatchGroups());
10527
10931
  }
10528
10932
 
10529
10933
  /**
@@ -10815,12 +11219,11 @@ class ValOpsHttp extends ValOps {
10815
11219
  if (res.ok) {
10816
11220
  const parsed = DeletePatchesResponse.safeParse(await res.json());
10817
11221
  if (parsed.success) {
10818
- var _this$git;
10819
11222
  const errors = {};
10820
11223
  for (const err of parsed.data.errors || []) {
10821
11224
  errors[err.patchId] = err;
10822
11225
  }
10823
- const headVersion = ownBranchVersion(parsed.data.headVersions, (_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
11226
+ const headVersion = ownBranchVersion(parsed.data.headVersions, this.ownBranch());
10824
11227
  const version = headVersion !== undefined ? {
10825
11228
  headVersion
10826
11229
  } : {};
@@ -10853,7 +11256,7 @@ class ValOpsHttp extends ValOps {
10853
11256
  message: `Could not delete patches (connection error?): ${e instanceof Error ? e.message : e.toString()}`
10854
11257
  }
10855
11258
  };
10856
- });
11259
+ }).finally(() => this.forgetPatchGroups());
10857
11260
  }
10858
11261
  async commit(prepared, message, committer, filesDirectory, newBranch,
10859
11262
  /**
@@ -10873,7 +11276,13 @@ class ValOpsHttp extends ValOps {
10873
11276
  patchGroupId) {
10874
11277
  try {
10875
11278
  var _res$headers$get3;
10876
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/commit`, {
11279
+ /*
11280
+ * A save in a proposal is a commit on the proposal's branch, at its own
11281
+ * route: the site's `/commit` refuses a proposal's patches, which is
11282
+ * the line that keeps them off the site until a merge.
11283
+ */
11284
+ const commitUrl = this.proposal !== null ? `${this.contentUrl}/v1/${this.project}/proposals/${this.proposal.name}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
11285
+ const res = await fetch(commitUrl, {
10877
11286
  method: "POST",
10878
11287
  headers: {
10879
11288
  ...this.authHeaders,
@@ -12938,6 +13347,9 @@ async function initHandlerOptions(route, opts, config) {
12938
13347
  ...(opts.publishBuild !== undefined ? {
12939
13348
  publishBuild: opts.publishBuild
12940
13349
  } : {}),
13350
+ ...(opts.proposal !== undefined ? {
13351
+ proposal: opts.proposal
13352
+ } : {}),
12941
13353
  project: maybeValProject,
12942
13354
  valEnableRedirectUrl,
12943
13355
  valDisableRedirectUrl,
@@ -13013,6 +13425,9 @@ function createValOps(valModules, options) {
13013
13425
  } : {}),
13014
13426
  ...(options.publishBuild !== undefined ? {
13015
13427
  publishBuild: options.publishBuild
13428
+ } : {}),
13429
+ ...(options.proposal !== undefined ? {
13430
+ proposal: options.proposal
13016
13431
  } : {})
13017
13432
  });
13018
13433
  }
@@ -13611,6 +14026,32 @@ const ValServer = (valModules, options, callbacks) => {
13611
14026
  };
13612
14027
  }
13613
14028
  };
14029
+
14030
+ /** The person a session names, or `null` for an anonymous one. */
14031
+ const profileOf = auth => "id" in auth && auth.id ? auth.id : null;
14032
+
14033
+ /** One proposals-API call, carried as `proxyPublishApi` carries one. */
14034
+ const proxyProposalsApi = async (path, method, profileId, body) => {
14035
+ const answer = await serverOps.proposalsApi(path ?? "", {
14036
+ method,
14037
+ ...(body === undefined ? {} : {
14038
+ body
14039
+ })
14040
+ }, profileId);
14041
+ try {
14042
+ return {
14043
+ status: answer.status,
14044
+ json: JSON.parse(answer.body)
14045
+ };
14046
+ } catch {
14047
+ return {
14048
+ status: answer.status,
14049
+ json: {
14050
+ message: answer.body.trim() === "" ? `The proposals API answered ${answer.status} with no body.` : `The proposals API answered ${answer.status}: ${answer.body.slice(0, 300)}`
14051
+ }
14052
+ };
14053
+ }
14054
+ };
13614
14055
  return {
13615
14056
  "/draft/enable": {
13616
14057
  GET: async req => {
@@ -14044,6 +14485,8 @@ const ValServer = (valModules, options, callbacks) => {
14044
14485
  * shows one story to a project that lives by the other.
14045
14486
  */
14046
14487
  const sourceMode = serverOps.sourceMode();
14488
+ // Spread like the two above: absent is "on the site".
14489
+ const proposal = serverOps.currentProposal();
14047
14490
  return {
14048
14491
  status: 200,
14049
14492
  json: {
@@ -14059,6 +14502,9 @@ const ValServer = (valModules, options, callbacks) => {
14059
14502
  ...(sourceMode ? {
14060
14503
  publishJobs: serverOps.publishesAsJobs()
14061
14504
  } : {}),
14505
+ ...(proposal ? {
14506
+ proposal
14507
+ } : {}),
14062
14508
  // Not `options.config` verbatim: in proxy mode the branch the
14063
14509
  // server resolved is filled in where the file did not name one.
14064
14510
  // See `clientConfig`.
@@ -14108,6 +14554,78 @@ const ValServer = (valModules, options, callbacks) => {
14108
14554
  return proxyPublishApi(req.path, "POST", req.body === undefined ? undefined : JSON.stringify(req.body));
14109
14555
  }
14110
14556
  },
14557
+ /**
14558
+ * The content service's proposals API, reached through this deployment --
14559
+ * the switcher, the list and the dialogs. The session is checked and its
14560
+ * profile sent along, because a proposal is changed by a person; see
14561
+ * `ValOpsHttp.proposalsApi` for what may be reached.
14562
+ */
14563
+ "/proposals-api": {
14564
+ GET: async req => {
14565
+ const auth = getAuth(req.cookies);
14566
+ if (auth.error) {
14567
+ return {
14568
+ status: 401,
14569
+ json: {
14570
+ message: auth.error
14571
+ }
14572
+ };
14573
+ }
14574
+ return proxyProposalsApi(req.path, "GET", profileOf(auth));
14575
+ },
14576
+ POST: async req => {
14577
+ const auth = getAuth(req.cookies);
14578
+ if (auth.error) {
14579
+ return {
14580
+ status: 401,
14581
+ json: {
14582
+ message: auth.error
14583
+ }
14584
+ };
14585
+ }
14586
+ return proxyProposalsApi(req.path, "POST", profileOf(auth), JSON.stringify(req.body ?? {}));
14587
+ },
14588
+ PATCH: async req => {
14589
+ const auth = getAuth(req.cookies);
14590
+ if (auth.error) {
14591
+ return {
14592
+ status: 401,
14593
+ json: {
14594
+ message: auth.error
14595
+ }
14596
+ };
14597
+ }
14598
+ return proxyProposalsApi(req.path, "PATCH", profileOf(auth), JSON.stringify(req.body ?? {}));
14599
+ }
14600
+ },
14601
+ "/proposal-site-sources": {
14602
+ GET: async req => {
14603
+ const auth = getAuth(req.cookies);
14604
+ if (auth.error) {
14605
+ return {
14606
+ status: 401,
14607
+ json: {
14608
+ message: auth.error
14609
+ }
14610
+ };
14611
+ }
14612
+ const modules = await serverOps.siteSourcesUnderSnapshot();
14613
+ if (modules === null) {
14614
+ return {
14615
+ status: 404,
14616
+ json: {
14617
+ message: "This is not a proposal's address."
14618
+ }
14619
+ };
14620
+ }
14621
+ return {
14622
+ status: 200,
14623
+ json: {
14624
+ modules
14625
+ }
14626
+ };
14627
+ }
14628
+ },
14111
14629
  "/upload/patches": {
14112
14630
  POST: async req => {
14113
14631
  if (serverOps instanceof ValOpsHttp) {
@@ -15086,9 +15604,23 @@ const ValServer = (valModules, options, callbacks) => {
15086
15604
  patchIds: undefined,
15087
15605
  excludePatchOps: false
15088
15606
  });
15607
+ /*
15608
+ * Membership from THIS response when it carries one, not from the
15609
+ * group list `resolveOwnPatchScope` read.
15610
+ *
15611
+ * That list is a separate request, remembered for a second
15612
+ * (`ValOpsHttp.getPatchGroups`), so it can be older than the chain
15613
+ * fetched here: an editor who saved and reloaded at once was
15614
+ * rendered without the patch they had just written, because the
15615
+ * list still had their group without it. The content service reads
15616
+ * the chain and each patch's groups in one transaction, and
15617
+ * `fetchPatches` folds that onto `patchGroups` -- so the patches
15618
+ * and who owns them come from the same moment.
15619
+ */
15620
+ const own = ownPatchScopeFor(all, "id" in auth && auth.id || undefined, ownPatchIds);
15089
15621
  patchOps = {
15090
15622
  ...all,
15091
- patches: scopedPatches(all.patches, ownPatchIds)
15623
+ patches: scopedPatches(all.patches, own)
15092
15624
  };
15093
15625
  } else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
15094
15626
  /*
@@ -17061,6 +17593,24 @@ chain, deleted, requested) {
17061
17593
  * `undefined` means "apply everything", which is what every caller that does
17062
17594
  * not ask for scoping gets and must keep getting.
17063
17595
  */
17596
+ /** The patches in this author's OPEN groups: what a scoped draft shows. */
17597
+ function ownPatchIdsIn(patchGroups, authorId) {
17598
+ return patchGroups.filter(group => group.publishedAt === null && group.authorId === authorId).flatMap(group => group.patchIds);
17599
+ }
17600
+
17601
+ /**
17602
+ * Whose pending patches a scoped draft shows, taken from the SAME response as
17603
+ * the patches when it says, and from the list read beforehand only when not.
17604
+ *
17605
+ * See the call in `/sources/~`: the list read beforehand can be older than the
17606
+ * chain, and the response's membership cannot be.
17607
+ */
17608
+ function ownPatchScopeFor(fetched, authorId, remembered) {
17609
+ if (fetched.patchGroups === undefined || authorId === undefined) {
17610
+ return remembered;
17611
+ }
17612
+ return ownPatchIdsIn(fetched.patchGroups, authorId);
17613
+ }
17064
17614
  async function resolveOwnPatchScope(
17065
17615
  /*
17066
17616
  * `ValOps`, not the two concrete stores: the one thing this needs is whether
@@ -17115,7 +17665,7 @@ serverOps, opts) {
17115
17665
  * nothing, and base is the honest answer. Distinct from the
17116
17666
  * case above, which is why the two are not one expression.
17117
17667
  */
17118
- ownPatchIds = groupsRes.patchGroups.filter(group => group.publishedAt === null && group.authorId === opts.authorId).flatMap(group => group.patchIds);
17668
+ ownPatchIds = ownPatchIdsIn(groupsRes.patchGroups, opts.authorId);
17119
17669
  /*
17120
17670
  * Scoping applies to PENDING work only. Anything already
17121
17671
  * committed is part of everyone's view.
@@ -23134,4 +23684,4 @@ function readCapturedReport(snapshotDir) {
23134
23684
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
23135
23685
  }
23136
23686
 
23137
- export { APP_MODE_REQUIRES_REMOTE_FILES, DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, InMemoryPatchStore, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValOpsMemory, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createPrettierFormatter, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, extractVideoMetadata, extractVideoMetadataFromFile, filesOfVideo, filesOfVideosetEntry, findAndEvalValConfigFile, findDefaultExport, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, handleVideosetCheckAllFiles, handleVideosetCheckRemote, handleVideosetMetadata, handleVideosetUploadRemote, initHandlerOptions, isExternalResult, loadValModules, ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, planJsonValuesEntryExtraction, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, resolveRemoteFileAuth, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt, videosetEntryVideoSchema };
23687
+ export { APP_MODE_REQUIRES_REMOTE_FILES, DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, InMemoryPatchStore, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValOpsMemory, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createPrettierFormatter, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, extractVideoMetadata, extractVideoMetadataFromFile, filesOfVideo, filesOfVideosetEntry, findAndEvalValConfigFile, findDefaultExport, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, handleVideosetCheckAllFiles, handleVideosetCheckRemote, handleVideosetMetadata, handleVideosetUploadRemote, initHandlerOptions, isExternalResult, loadValModules, ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, planJsonValuesEntryExtraction, proposalFromEnv, proposalSnapshot, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, resolveRemoteFileAuth, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt, videosetEntryVideoSchema };