@valbuild/server 0.141.1 → 0.143.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.
@@ -2716,18 +2716,41 @@ function encodeJwt(payload, sessionKey) {
2716
2716
  * error somebody had to answer instead of a default that silently applied.
2717
2717
  */
2718
2718
  function clientConfig(options) {
2719
+ const config = withResolvedProject(options.config, options.project);
2719
2720
  if (options.mode !== "http" || options.git === undefined) {
2720
- return options.config;
2721
+ return config;
2721
2722
  }
2722
- if (options.config.gitBranch !== undefined) {
2723
- return options.config;
2723
+ if (config.gitBranch !== undefined) {
2724
+ return config;
2724
2725
  }
2725
2726
  return {
2726
- ...options.config,
2727
+ ...config,
2727
2728
  gitBranch: options.git.branch
2728
2729
  };
2729
2730
  }
2730
2731
 
2732
+ /**
2733
+ * The project, filled in the same way and for the same reason as the branch.
2734
+ *
2735
+ * `project` may come from `VAL_PROJECT` rather than from `val.config`, and the
2736
+ * server talks to that project either way. The Studio decides from
2737
+ * `config.project` whether there is an assistant to offer at all (`wsEnabled`
2738
+ * in `ValProvider`), so a project named only in the environment would have
2739
+ * had it hidden while the server behind it was ready to serve it.
2740
+ *
2741
+ * `val.config` wins, as it does for the branch, and every mode gets it: unlike
2742
+ * a branch, a project means the same thing in fs mode as in http mode.
2743
+ */
2744
+ function withResolvedProject(config, project) {
2745
+ if (config.project !== undefined || project === undefined) {
2746
+ return config;
2747
+ }
2748
+ return {
2749
+ ...config,
2750
+ project
2751
+ };
2752
+ }
2753
+
2731
2754
  /**
2732
2755
  * Which IP addresses this server may open a connection to.
2733
2756
  *
@@ -3601,6 +3624,12 @@ class ValOps {
3601
3624
  * hash. Re-folding with the wrong list changes the base SHA for no reason.
3602
3625
  */
3603
3626
  shaModuleErrors;
3627
+ /**
3628
+ * The SITE's Source for the modules a proposal's snapshot replaced: what the
3629
+ * bundle evaluated to before the snapshot went over it. Kept for
3630
+ * {@link siteSourcesUnderSnapshot}, and only where there is a snapshot.
3631
+ */
3632
+ siteSourcesBeforeSnapshot = null;
3604
3633
  /**
3605
3634
  * What a save has told us each `.jsonValues()` entry now holds.
3606
3635
  *
@@ -3647,6 +3676,7 @@ class ValOps {
3647
3676
  // #region initTree
3648
3677
  async initSources() {
3649
3678
  if (this.baseSha === null || this.sourcesSha === null || this.configSha === null || this.schemaSha === null || this.sources === null || this.schemas === null || this.modulesErrors === null) {
3679
+ var _this$options;
3650
3680
  const extracted = await core.extractValModules(this.valModules);
3651
3681
  const moduleErrors = extracted.moduleErrors.concat(findNestedJsonValuesModuleErrors(extracted.schemas));
3652
3682
  this.sources = extracted.sources;
@@ -3658,12 +3688,29 @@ class ValOps {
3658
3688
  this.modulesErrors = moduleErrors;
3659
3689
  this.shaEntries = extracted.shaEntries;
3660
3690
  this.shaModuleErrors = extracted.moduleErrors;
3691
+ /*
3692
+ * The snapshot, over what the modules evaluated to. Through the same
3693
+ * door a save uses, so the SHAs move with it: they describe the Source
3694
+ * being served, and a base SHA that still described the bundle would
3695
+ * say the snapshot's content was the bundle's.
3696
+ */
3697
+ if ((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.snapshotSources) {
3698
+ const site = {};
3699
+ for (const moduleFilePath of Object.keys(this.options.snapshotSources)) {
3700
+ const source = extracted.sources[moduleFilePath];
3701
+ if (source !== undefined) {
3702
+ site[moduleFilePath] = source;
3703
+ }
3704
+ }
3705
+ this.siteSourcesBeforeSnapshot = site;
3706
+ this.promoteCommittedSources(this.options.snapshotSources);
3707
+ }
3661
3708
  return {
3662
3709
  baseSha: this.baseSha,
3663
3710
  schemaSha: this.schemaSha,
3664
3711
  sourcesSha: this.sourcesSha,
3665
3712
  configSha: this.configSha,
3666
- sources: extracted.sources,
3713
+ sources: this.sources,
3667
3714
  schemas: extracted.schemas,
3668
3715
  moduleErrors
3669
3716
  };
@@ -3679,6 +3726,19 @@ class ValOps {
3679
3726
  };
3680
3727
  }
3681
3728
 
3729
+ /**
3730
+ * In a proposal: the SITE's Source for every module the proposal has saved,
3731
+ * as the build it is based on evaluated it -- what the proposal is compared
3732
+ * against, and what merging it changes. Every other module is the same in
3733
+ * both, so it is not sent. `null` anywhere that is not a proposal.
3734
+ */
3735
+ async siteSourcesUnderSnapshot() {
3736
+ var _this$options2;
3737
+ if (!((_this$options2 = this.options) !== null && _this$options2 !== void 0 && _this$options2.snapshotSources)) return null;
3738
+ await this.initSources();
3739
+ return this.siteSourcesBeforeSnapshot;
3740
+ }
3741
+
3682
3742
  /**
3683
3743
  * These patches are on disk now: adopt what they produced as the committed
3684
3744
  * sources.
@@ -5275,9 +5335,9 @@ class ValOps {
5275
5335
  // https://github.com/microsoft/TypeScript/issues/36174
5276
5336
  let sourceFileText = null;
5277
5337
  if (tsChanged) {
5278
- var _this$options;
5338
+ var _this$options3;
5279
5339
  sourceFileText = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
5280
- if ((_this$options = this.options) !== null && _this$options !== void 0 && _this$options.formatter) {
5340
+ if ((_this$options3 = this.options) !== null && _this$options3 !== void 0 && _this$options3.formatter) {
5281
5341
  try {
5282
5342
  sourceFileText = await this.options.formatter(sourceFileText, path);
5283
5343
  } catch (err) {
@@ -5289,13 +5349,13 @@ class ValOps {
5289
5349
  }
5290
5350
  const extraFiles = {};
5291
5351
  for (const [jsonPath, content] of Array.from(jsonEntryContents)) {
5292
- var _this$options2;
5352
+ var _this$options4;
5293
5353
  if (content === null) {
5294
5354
  extraFiles[jsonPath] = null;
5295
5355
  continue;
5296
5356
  }
5297
5357
  let jsonText = JSON.stringify(content, null, 2);
5298
- if ((_this$options2 = this.options) !== null && _this$options2 !== void 0 && _this$options2.formatter) {
5358
+ if ((_this$options4 = this.options) !== null && _this$options4 !== void 0 && _this$options4.formatter) {
5299
5359
  try {
5300
5360
  jsonText = await this.options.formatter(jsonText, jsonPath);
5301
5361
  } catch (err) {
@@ -5611,6 +5671,30 @@ class ValOps {
5611
5671
  };
5612
5672
  }
5613
5673
 
5674
+ /**
5675
+ * The proposal this server runs at the address of, or `null` on the site --
5676
+ * and always in `fs` and memory mode, which have no proposals. See
5677
+ * `ValProposal`.
5678
+ */
5679
+ currentProposal() {
5680
+ return null;
5681
+ }
5682
+
5683
+ /**
5684
+ * The content service's proposals API, for the Studio's switcher, list and
5685
+ * dialogs. Refused here for the reason {@link publishApi} is: there is no
5686
+ * content service to forward to. `profileId` is the person asking.
5687
+ */
5688
+ async proposalsApi(_path, _init, _profileId) {
5689
+ return {
5690
+ status: 501,
5691
+ contentType: "application/json",
5692
+ body: JSON.stringify({
5693
+ message: "This Val server has no content service, so it has no proposals."
5694
+ })
5695
+ };
5696
+ }
5697
+
5614
5698
  /**
5615
5699
  * Whether a commit here produces `.val.ts` TEXT as well as data.
5616
5700
  *
@@ -8814,6 +8898,111 @@ function computePatchesToDrop(prepared) {
8814
8898
  return dropped;
8815
8899
  }
8816
8900
 
8901
+ /**
8902
+ * A proposal this server runs at the address of: an editor-managed branch of
8903
+ * the project's content, served from its own copy of the content the
8904
+ * proposal has saved -- the **snapshot**. valbuild/home `docs/proposals.md`,
8905
+ * "Saving".
8906
+ *
8907
+ * A third place Source can come from, beside the two Val already has:
8908
+ *
8909
+ * | source provider | Source comes from |
8910
+ * | --------------- | -------------------------------------- |
8911
+ * | committed | the bundle |
8912
+ * | draft | content, pending patches applied |
8913
+ * | snapshot | the proposal's saves |
8914
+ *
8915
+ * The snapshot REPLACES the bundle's Source for the modules the proposal has
8916
+ * saved and nothing else, on every read: a committed render, the draft a page
8917
+ * starts from, the Studio's tree, validation and the next save's prepare. The
8918
+ * proposal's pending patches apply on top of it, as the site's apply on top of
8919
+ * the bundle.
8920
+ *
8921
+ * Handed over by the platform that serves the address, per isolate: the
8922
+ * snapshot only changes with a save, and a save is a new isolate.
8923
+ */
8924
+
8925
+ /**
8926
+ * The proposal a platform describes in its runtime env, or `undefined` when
8927
+ * this is not a proposal's address.
8928
+ *
8929
+ * `VAL_PROPOSAL` and `VAL_BRANCH` name it; `VAL_OVERLAY` is the snapshot as
8930
+ * JSON (`{ commit, modules, files }`). Throws on a snapshot that does not
8931
+ * parse rather than serving the base build as though it were the proposal: a
8932
+ * reviewer would be looking at the wrong content and nothing would say so.
8933
+ */
8934
+ function proposalFromEnv(env) {
8935
+ const name = env.VAL_PROPOSAL;
8936
+ const branch = env.VAL_BRANCH;
8937
+ if (!name || !branch) {
8938
+ return undefined;
8939
+ }
8940
+ let parsed = {};
8941
+ if (env.VAL_OVERLAY) {
8942
+ try {
8943
+ parsed = JSON.parse(env.VAL_OVERLAY);
8944
+ } catch (err) {
8945
+ throw new Error(`Val: proposal ${name}: VAL_OVERLAY is not JSON (${err instanceof Error ? err.message : String(err)})`, {
8946
+ cause: err
8947
+ });
8948
+ }
8949
+ }
8950
+ if (!isObject$1(parsed)) {
8951
+ throw new Error(`Val: proposal ${name}: VAL_OVERLAY is not an object`);
8952
+ }
8953
+ const modulesIn = parsed["modules"] ?? {};
8954
+ const filesIn = parsed["files"] ?? {};
8955
+ const commit = parsed["commit"] ?? null;
8956
+ if (!isObject$1(modulesIn) || !isObject$1(filesIn)) {
8957
+ throw new Error(`Val: proposal ${name}: VAL_OVERLAY's modules and files must be objects`);
8958
+ }
8959
+ if (commit !== null && typeof commit !== "string") {
8960
+ throw new Error(`Val: proposal ${name}: VAL_OVERLAY's commit must be a string or null`);
8961
+ }
8962
+ const files = {};
8963
+ for (const [path, text] of Object.entries(filesIn)) {
8964
+ if (typeof text === "string") {
8965
+ files[path] = text;
8966
+ }
8967
+ }
8968
+ return {
8969
+ name,
8970
+ branch,
8971
+ commit,
8972
+ modules: modulesIn,
8973
+ files
8974
+ };
8975
+ }
8976
+
8977
+ /**
8978
+ * The proposal's saved Source, as Source: every module whose value is JSON.
8979
+ * One place, so the server and the readers that render without it agree on
8980
+ * what the snapshot holds.
8981
+ */
8982
+ function proposalSnapshot(proposal) {
8983
+ const snapshot = {};
8984
+ for (const [path, source] of Object.entries(proposal.modules)) {
8985
+ if (isJsonValue(source)) {
8986
+ snapshot[path] = source;
8987
+ }
8988
+ }
8989
+ return snapshot;
8990
+ }
8991
+ function isObject$1(value) {
8992
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8993
+ }
8994
+
8995
+ /** What `JSON.parse` can produce, checked rather than assumed. */
8996
+ function isJsonValue(value) {
8997
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
8998
+ return true;
8999
+ }
9000
+ if (Array.isArray(value)) {
9001
+ return value.every(isJsonValue);
9002
+ }
9003
+ return isObject$1(value) && Object.values(value).every(isJsonValue);
9004
+ }
9005
+
8817
9006
  const textEncoder$2 = new TextEncoder();
8818
9007
  const PatchId = z.z.string().refine(s => !!s); // TODO: validate
8819
9008
  const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
@@ -9167,6 +9356,8 @@ class ValOpsHttp extends ValOps {
9167
9356
  publishJob;
9168
9357
  /** See `publishBuild` on {@link ValApiOptions}; sent with every position. */
9169
9358
  publishBuild;
9359
+ /** See `proposal` on {@link ValApiOptions}; `null` on the site. */
9360
+ proposal;
9170
9361
 
9171
9362
  /** Did the host hand over the running build's source? See `projectSource`. */
9172
9363
  embedsSource() {
@@ -9239,7 +9430,10 @@ class ValOpsHttp extends ValOps {
9239
9430
  * getSettings / uploadRemoteFile / getPresignedAuthNonce.
9240
9431
  */
9241
9432
  auth, valModules, options) {
9242
- super(valModules, options);
9433
+ super(valModules, options !== null && options !== void 0 && options.proposal ? {
9434
+ ...options,
9435
+ snapshotSources: proposalSnapshot(options.proposal)
9436
+ } : options);
9243
9437
  this.contentUrl = contentUrl;
9244
9438
  this.project = project;
9245
9439
  this.git = git;
@@ -9249,7 +9443,15 @@ class ValOpsHttp extends ValOps {
9249
9443
  Authorization: `Bearer ${auth.apiKey}`
9250
9444
  };
9251
9445
  this.root = (options === null || options === void 0 ? void 0 : options.root) ?? "";
9252
- this.projectSource = (options === null || options === void 0 ? void 0 : options.projectSource) ?? null;
9446
+ this.proposal = (options === null || options === void 0 ? void 0 : options.proposal) ?? null;
9447
+ /*
9448
+ * In a proposal, the text its saves wrote over the text the build was made
9449
+ * from: the next save patches the last one's `.val.ts`, not the base's.
9450
+ */
9451
+ this.projectSource = this.proposal !== null ? {
9452
+ ...((options === null || options === void 0 ? void 0 : options.projectSource) ?? {}),
9453
+ ...this.proposal.files
9454
+ } : (options === null || options === void 0 ? void 0 : options.projectSource) ?? null;
9253
9455
  this.publishJob = (options === null || options === void 0 ? void 0 : options.publishJob) ?? null;
9254
9456
  this.publishBuild = (options === null || options === void 0 ? void 0 : options.publishBuild) ?? null;
9255
9457
  this.mirrorsSourceFiles = git !== null || this.projectSource !== null;
@@ -9278,7 +9480,8 @@ class ValOpsHttp extends ValOps {
9278
9480
  */
9279
9481
  publishRefusal() {
9280
9482
  var _this$projectExpectat;
9281
- if (this.git !== null) {
9483
+ // A save in a proposal mirrors nothing: it reaches git by merging.
9484
+ if (this.git !== null || this.proposal !== null) {
9282
9485
  return null;
9283
9486
  }
9284
9487
  /*
@@ -9315,6 +9518,13 @@ class ValOpsHttp extends ValOps {
9315
9518
  */
9316
9519
  sourceMode() {
9317
9520
  var _this$projectExpectat2;
9521
+ /*
9522
+ * Not reported in a proposal, whatever the project is. The Studio reads
9523
+ * `managed` as "I am the deployer" and follows a commit with a build it
9524
+ * publishes to the SITE -- and a proposal's commit is a Save, which its
9525
+ * address serves without any build at all.
9526
+ */
9527
+ if (this.proposal !== null) return null;
9318
9528
  return ((_this$projectExpectat2 = this.projectExpectation) === null || _this$projectExpectat2 === void 0 ? void 0 : _this$projectExpectat2.sourceMode) ?? null;
9319
9529
  }
9320
9530
 
@@ -9349,6 +9559,8 @@ class ValOpsHttp extends ValOps {
9349
9559
  }).catch(() => undefined);
9350
9560
  }
9351
9561
  publishesAsJobs() {
9562
+ // A proposal's Save is a commit on its branch; its merge is the publish.
9563
+ if (this.proposal !== null) return false;
9352
9564
  const expected = this.projectExpectation;
9353
9565
  if (expected === null) return false;
9354
9566
  return expected.publishJobs ?? expected.sourceMode === "managed";
@@ -9468,6 +9680,10 @@ class ValOpsHttp extends ValOps {
9468
9680
  * segment gets through.
9469
9681
  */
9470
9682
  static publishApiPathAllowed(path) {
9683
+ // A merge's prepare is content's own, from the proposal's last save.
9684
+ if (/^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/merge-prepare$/.test(path)) {
9685
+ return true;
9686
+ }
9471
9687
  if (path === "/build-target" || path === "/project-source" || path === "/update-target" || path === "/publish" ||
9472
9688
  // Where "View run" goes, for a connected build CI reported failed.
9473
9689
  // Read only: reporting a run is CI's, with its own token.
@@ -9490,6 +9706,20 @@ class ValOpsHttp extends ValOps {
9490
9706
  return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
9491
9707
  }
9492
9708
  async publishApi(path, init) {
9709
+ /*
9710
+ * Nothing is published from a proposal's address: its way to the site is
9711
+ * a merge. The publish API reaches the SITE's builds and jobs, so a
9712
+ * Studio there that asked would be publishing over the site.
9713
+ */
9714
+ if (this.proposal !== null && !ValOpsHttp.mergeApiPathAllowed(init.method, path)) {
9715
+ return {
9716
+ status: 403,
9717
+ contentType: "application/json",
9718
+ body: JSON.stringify({
9719
+ message: "This is a proposal's address: nothing is published from here. " + "A proposal reaches the site by being merged."
9720
+ })
9721
+ };
9722
+ }
9493
9723
  if (!ValOpsHttp.publishApiPathAllowed(path)) {
9494
9724
  return {
9495
9725
  status: 403,
@@ -9501,6 +9731,82 @@ class ValOpsHttp extends ValOps {
9501
9731
  }
9502
9732
  return this.callPublishApi(path, init);
9503
9733
  }
9734
+ currentProposal() {
9735
+ return this.proposal === null ? null : {
9736
+ name: this.proposal.name,
9737
+ branch: this.proposal.branch
9738
+ };
9739
+ }
9740
+
9741
+ /**
9742
+ * Which proposal calls the browser may make, by method and the path after
9743
+ * `/proposals`: list and open, read and rename one, and close, reopen and
9744
+ * retry its setup. Not `/save` -- the Studio saves through `/save`, which
9745
+ * prepares the commit here -- and not `/patches`, which moves the site's
9746
+ * changes into a proposal and has no screen yet.
9747
+ */
9748
+ static proposalsApiAllowed(method, path) {
9749
+ if (path === "") return method === "GET" || method === "POST";
9750
+ const one = /^\/[0-9a-f]{20}$/.test(path);
9751
+ if (one) return method === "GET" || method === "PATCH";
9752
+ if (method === "GET") return /^\/[0-9a-f]{20}\/merge-checks$/.test(path);
9753
+ return method === "POST" && /^\/[0-9a-f]{20}\/(close|reopen|setup\/retry|merge)$/.test(path);
9754
+ }
9755
+
9756
+ /**
9757
+ * What a proposal's address may reach of the publish API: building ITS OWN
9758
+ * merge, and nothing that publishes the site's changes. The merge's prepare
9759
+ * (content's, from the proposal's last save), the build it needs -- the
9760
+ * site's build target and source, declaring and uploading the build -- and
9761
+ * the job's own steps. Not a press of the site's Publish, not `next` (which
9762
+ * would hand this tab a site job), and not the site job's prepare.
9763
+ */
9764
+ static mergeApiPathAllowed(method, path) {
9765
+ if (path === "/build-target" || path === "/project-source" || path === "/publish") {
9766
+ return true;
9767
+ }
9768
+ if (/^\/publish\/[A-Za-z0-9_-]+\/artifacts$/.test(path)) return true;
9769
+ // Where its press is -- read only: a POST there is the site's Try again.
9770
+ if (method === "GET" && path !== "/publish-requests/try-again" && /^\/publish-requests\/[A-Za-z0-9_-]{1,100}$/.test(path)) return true;
9771
+ return /^\/publish-jobs\/[A-Za-z0-9_-]{1,100}\/(merge-prepare|steps|renew|cancel)$/.test(path);
9772
+ }
9773
+ async proposalsApi(path, init, profileId) {
9774
+ const json = "application/json";
9775
+ if (!ValOpsHttp.proposalsApiAllowed(init.method, path)) {
9776
+ return {
9777
+ status: 403,
9778
+ contentType: json,
9779
+ body: JSON.stringify({
9780
+ message: `'${init.method} ${path}' is not part of the proposals API.`
9781
+ })
9782
+ };
9783
+ }
9784
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/proposals${path}`, {
9785
+ method: init.method,
9786
+ headers: {
9787
+ ...this.authHeaders,
9788
+ /*
9789
+ * WHO is asking, as for a commit: the app's key names the project,
9790
+ * and a proposal is opened, renamed and closed by a person -- content
9791
+ * refuses those writes from a credential that names nobody.
9792
+ */
9793
+ ...(profileId !== null ? {
9794
+ "x-val-profile-id": profileId
9795
+ } : {}),
9796
+ ...(init.body === undefined ? {} : {
9797
+ "Content-Type": json
9798
+ })
9799
+ },
9800
+ ...(init.body === undefined ? {} : {
9801
+ body: init.body
9802
+ })
9803
+ });
9804
+ return {
9805
+ status: res.status,
9806
+ body: await res.text(),
9807
+ contentType: res.headers.get("Content-Type") ?? json
9808
+ };
9809
+ }
9504
9810
 
9505
9811
  /**
9506
9812
  * A publish job's prepare, sent to content: the job's sources, archived
@@ -9801,6 +10107,42 @@ class ValOpsHttp extends ValOps {
9801
10107
  } : {})
9802
10108
  };
9803
10109
  }
10110
+
10111
+ /**
10112
+ * Where this server sits in the content service's chain, as every request
10113
+ * that asks about a position says it.
10114
+ *
10115
+ * In a proposal: its branch, and the save its snapshot is (none before the
10116
+ * first). The content service then answers with what came after that save
10117
+ * and nothing else -- never the site's position, which would replay the
10118
+ * proposal's own saves over a snapshot that already holds them. Otherwise
10119
+ * the commit this build was made from, or, for a build a tab made, its job.
10120
+ */
10121
+ position() {
10122
+ if (this.proposal !== null) {
10123
+ return {
10124
+ branch: this.proposal.branch,
10125
+ ...(this.proposal.commit !== null ? {
10126
+ commit: this.proposal.commit
10127
+ } : {})
10128
+ };
10129
+ }
10130
+ if (this.git) {
10131
+ return {
10132
+ branch: this.git.branch,
10133
+ commit: this.git.commit
10134
+ };
10135
+ }
10136
+ return this.publishJob !== null ? {
10137
+ job: this.publishJob
10138
+ } : {};
10139
+ }
10140
+
10141
+ /** The branch this server's patches are on, where it knows it. */
10142
+ ownBranch() {
10143
+ var _this$proposal, _this$git;
10144
+ 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);
10145
+ }
9804
10146
  async getWebSocketNonce(profileId) {
9805
10147
  return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
9806
10148
  method: "POST",
@@ -9812,12 +10154,24 @@ class ValOpsHttp extends ValOps {
9812
10154
  * the project -- and a nonce is scoped to the project and the person,
9813
10155
  * not to a position in a chain.
9814
10156
  */
9815
- ...(this.git ? {
9816
- branch: this.git.branch,
9817
- commitSha: this.git.commit
9818
- } : this.publishJob !== null ? {
9819
- job: this.publishJob
9820
- } : {}),
10157
+ ...(() => {
10158
+ const {
10159
+ branch,
10160
+ commit,
10161
+ job
10162
+ } = this.position();
10163
+ return {
10164
+ ...(branch !== undefined ? {
10165
+ branch
10166
+ } : {}),
10167
+ ...(commit !== undefined ? {
10168
+ commitSha: commit
10169
+ } : {}),
10170
+ ...(job !== undefined ? {
10171
+ job
10172
+ } : {})
10173
+ };
10174
+ })(),
9821
10175
  // Which build is asking, beside what it says about itself.
9822
10176
  ...(this.publishBuild !== null ? {
9823
10177
  build: this.publishBuild
@@ -9999,12 +10353,10 @@ class ValOpsHttp extends ValOps {
9999
10353
  * holds the chain, and for a project it is the only publisher of, the
10000
10354
  * position IS the head.
10001
10355
  */
10002
- if (this.git) {
10003
- params.push(["branch", this.git.branch]);
10004
- params.push(["commit", this.git.commit]);
10005
- } else if (this.publishJob !== null) {
10006
- // A build a tab made has no commit, and says which job it was made for.
10007
- params.push(["job", this.publishJob]);
10356
+ // A build a tab made has no commit, and says which job it was made for;
10357
+ // a proposal says its branch and its save. See `position`.
10358
+ for (const [key, value] of Object.entries(this.position())) {
10359
+ if (value !== undefined) params.push([key, value]);
10008
10360
  }
10009
10361
  /*
10010
10362
  * And WHICH build this is, when the platform running it says: the only
@@ -10277,6 +10629,23 @@ class ValOpsHttp extends ValOps {
10277
10629
  * cache: a second render a second later asks again.
10278
10630
  */
10279
10631
  patchGroupsCache = null;
10632
+
10633
+ /**
10634
+ * Forget the remembered group list, after a write through this server.
10635
+ *
10636
+ * A write changes membership -- a save joins its author's group, a delete or
10637
+ * an unstage leaves it -- and the window above is long enough for the next
10638
+ * render to be served the list from before it: an editor who saved and
10639
+ * reloaded inside a second was shown their draft without the change they had
10640
+ * just made. Called once the write has answered, so a read that started
10641
+ * while it was in flight cannot leave the old list behind it.
10642
+ *
10643
+ * Only this instance's writes. Another instance's write is still seen at
10644
+ * most one window late, which is what the window is for.
10645
+ */
10646
+ forgetPatchGroups() {
10647
+ this.patchGroupsCache = null;
10648
+ }
10280
10649
  async getPatchGroups(options) {
10281
10650
  const now = Date.now();
10282
10651
  if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
@@ -10308,6 +10677,17 @@ class ValOpsHttp extends ValOps {
10308
10677
  return res;
10309
10678
  }
10310
10679
  async fetchPatchGroups() {
10680
+ /*
10681
+ * A proposal has no patch groups: its changes are one unit, saved
10682
+ * together, and the content service answers 404 for groups on its branch.
10683
+ * "Unsupported" is exactly that answer, and it is what makes a draft in a
10684
+ * proposal show all of the proposal's changes rather than one person's.
10685
+ */
10686
+ if (this.proposal !== null) {
10687
+ return {
10688
+ status: "unsupported"
10689
+ };
10690
+ }
10311
10691
  try {
10312
10692
  /*
10313
10693
  * `branch` is REQUIRED by the endpoint, which answers 400 without it.
@@ -10316,7 +10696,8 @@ class ValOpsHttp extends ValOps {
10316
10696
  * `saveSourceFilePatch`): groups are per branch, so a request without one
10317
10697
  * is not merely under-specified, it is rejected.
10318
10698
  */
10319
- const params = new URLSearchParams(this.git ? [["branch", this.git.branch]] : []);
10699
+ const branch = this.ownBranch();
10700
+ const params = new URLSearchParams(branch !== undefined ? [["branch", branch]] : []);
10320
10701
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
10321
10702
  headers: this.authHeaders
10322
10703
  });
@@ -10367,8 +10748,9 @@ class ValOpsHttp extends ValOps {
10367
10748
  * Without one the content API uses the project's own, as it does for reads.
10368
10749
  */
10369
10750
  ownGroupBranch() {
10370
- return this.git ? {
10371
- branch: this.git.branch
10751
+ const branch = this.ownBranch();
10752
+ return branch !== undefined ? {
10753
+ branch
10372
10754
  } : {};
10373
10755
  }
10374
10756
  async mutatePatchGroup(method, path, body,
@@ -10391,6 +10773,22 @@ class ValOpsHttp extends ValOps {
10391
10773
  * on its own there and this adds nothing.
10392
10774
  */
10393
10775
  authorId) {
10776
+ if (this.proposal !== null) {
10777
+ return {
10778
+ status: 409,
10779
+ patchIds: [],
10780
+ error: {
10781
+ message: "A proposal's changes are saved together, so there is nothing to stage or unstage in one."
10782
+ }
10783
+ };
10784
+ }
10785
+ try {
10786
+ return await this.mutatePatchGroupRequest(method, path, body, authorId);
10787
+ } finally {
10788
+ this.forgetPatchGroups();
10789
+ }
10790
+ }
10791
+ async mutatePatchGroupRequest(method, path, body, authorId) {
10394
10792
  try {
10395
10793
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
10396
10794
  method,
@@ -10483,7 +10881,13 @@ class ValOpsHttp extends ValOps {
10483
10881
  patchId,
10484
10882
  parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
10485
10883
  baseSha,
10486
- ...(this.git ? {
10884
+ /*
10885
+ * In a proposal, its branch alone: the content service files the
10886
+ * patch there, and only for an open proposal.
10887
+ */
10888
+ ...(this.proposal !== null ? {
10889
+ branch: this.proposal.branch
10890
+ } : this.git ? {
10487
10891
  commit: this.git.commit,
10488
10892
  branch: this.git.branch
10489
10893
  } : {}),
@@ -10560,7 +10964,7 @@ class ValOpsHttp extends ValOps {
10560
10964
  errorType: "other",
10561
10965
  message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
10562
10966
  });
10563
- });
10967
+ }).finally(() => this.forgetPatchGroups());
10564
10968
  }
10565
10969
 
10566
10970
  /**
@@ -10852,12 +11256,11 @@ class ValOpsHttp extends ValOps {
10852
11256
  if (res.ok) {
10853
11257
  const parsed = DeletePatchesResponse.safeParse(await res.json());
10854
11258
  if (parsed.success) {
10855
- var _this$git;
10856
11259
  const errors = {};
10857
11260
  for (const err of parsed.data.errors || []) {
10858
11261
  errors[err.patchId] = err;
10859
11262
  }
10860
- const headVersion = ownBranchVersion(parsed.data.headVersions, (_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
11263
+ const headVersion = ownBranchVersion(parsed.data.headVersions, this.ownBranch());
10861
11264
  const version = headVersion !== undefined ? {
10862
11265
  headVersion
10863
11266
  } : {};
@@ -10890,7 +11293,7 @@ class ValOpsHttp extends ValOps {
10890
11293
  message: `Could not delete patches (connection error?): ${e instanceof Error ? e.message : e.toString()}`
10891
11294
  }
10892
11295
  };
10893
- });
11296
+ }).finally(() => this.forgetPatchGroups());
10894
11297
  }
10895
11298
  async commit(prepared, message, committer, filesDirectory, newBranch,
10896
11299
  /**
@@ -10910,7 +11313,13 @@ class ValOpsHttp extends ValOps {
10910
11313
  patchGroupId) {
10911
11314
  try {
10912
11315
  var _res$headers$get3;
10913
- const res = await fetch(`${this.contentUrl}/v1/${this.project}/commit`, {
11316
+ /*
11317
+ * A save in a proposal is a commit on the proposal's branch, at its own
11318
+ * route: the site's `/commit` refuses a proposal's patches, which is
11319
+ * the line that keeps them off the site until a merge.
11320
+ */
11321
+ const commitUrl = this.proposal !== null ? `${this.contentUrl}/v1/${this.project}/proposals/${this.proposal.name}/save` : `${this.contentUrl}/v1/${this.project}/commit`;
11322
+ const res = await fetch(commitUrl, {
10914
11323
  method: "POST",
10915
11324
  headers: {
10916
11325
  ...this.authHeaders,
@@ -12975,6 +13384,9 @@ async function initHandlerOptions(route, opts, config) {
12975
13384
  ...(opts.publishBuild !== undefined ? {
12976
13385
  publishBuild: opts.publishBuild
12977
13386
  } : {}),
13387
+ ...(opts.proposal !== undefined ? {
13388
+ proposal: opts.proposal
13389
+ } : {}),
12978
13390
  project: maybeValProject,
12979
13391
  valEnableRedirectUrl,
12980
13392
  valDisableRedirectUrl,
@@ -13050,6 +13462,9 @@ function createValOps(valModules, options) {
13050
13462
  } : {}),
13051
13463
  ...(options.publishBuild !== undefined ? {
13052
13464
  publishBuild: options.publishBuild
13465
+ } : {}),
13466
+ ...(options.proposal !== undefined ? {
13467
+ proposal: options.proposal
13053
13468
  } : {})
13054
13469
  });
13055
13470
  }
@@ -13648,6 +14063,32 @@ const ValServer = (valModules, options, callbacks) => {
13648
14063
  };
13649
14064
  }
13650
14065
  };
14066
+
14067
+ /** The person a session names, or `null` for an anonymous one. */
14068
+ const profileOf = auth => "id" in auth && auth.id ? auth.id : null;
14069
+
14070
+ /** One proposals-API call, carried as `proxyPublishApi` carries one. */
14071
+ const proxyProposalsApi = async (path, method, profileId, body) => {
14072
+ const answer = await serverOps.proposalsApi(path ?? "", {
14073
+ method,
14074
+ ...(body === undefined ? {} : {
14075
+ body
14076
+ })
14077
+ }, profileId);
14078
+ try {
14079
+ return {
14080
+ status: answer.status,
14081
+ json: JSON.parse(answer.body)
14082
+ };
14083
+ } catch {
14084
+ return {
14085
+ status: answer.status,
14086
+ json: {
14087
+ message: answer.body.trim() === "" ? `The proposals API answered ${answer.status} with no body.` : `The proposals API answered ${answer.status}: ${answer.body.slice(0, 300)}`
14088
+ }
14089
+ };
14090
+ }
14091
+ };
13651
14092
  return {
13652
14093
  "/draft/enable": {
13653
14094
  GET: async req => {
@@ -14081,6 +14522,8 @@ const ValServer = (valModules, options, callbacks) => {
14081
14522
  * shows one story to a project that lives by the other.
14082
14523
  */
14083
14524
  const sourceMode = serverOps.sourceMode();
14525
+ // Spread like the two above: absent is "on the site".
14526
+ const proposal = serverOps.currentProposal();
14084
14527
  return {
14085
14528
  status: 200,
14086
14529
  json: {
@@ -14096,6 +14539,9 @@ const ValServer = (valModules, options, callbacks) => {
14096
14539
  ...(sourceMode ? {
14097
14540
  publishJobs: serverOps.publishesAsJobs()
14098
14541
  } : {}),
14542
+ ...(proposal ? {
14543
+ proposal
14544
+ } : {}),
14099
14545
  // Not `options.config` verbatim: in proxy mode the branch the
14100
14546
  // server resolved is filled in where the file did not name one.
14101
14547
  // See `clientConfig`.
@@ -14145,6 +14591,78 @@ const ValServer = (valModules, options, callbacks) => {
14145
14591
  return proxyPublishApi(req.path, "POST", req.body === undefined ? undefined : JSON.stringify(req.body));
14146
14592
  }
14147
14593
  },
14594
+ /**
14595
+ * The content service's proposals API, reached through this deployment --
14596
+ * the switcher, the list and the dialogs. The session is checked and its
14597
+ * profile sent along, because a proposal is changed by a person; see
14598
+ * `ValOpsHttp.proposalsApi` for what may be reached.
14599
+ */
14600
+ "/proposals-api": {
14601
+ GET: async req => {
14602
+ const auth = getAuth(req.cookies);
14603
+ if (auth.error) {
14604
+ return {
14605
+ status: 401,
14606
+ json: {
14607
+ message: auth.error
14608
+ }
14609
+ };
14610
+ }
14611
+ return proxyProposalsApi(req.path, "GET", profileOf(auth));
14612
+ },
14613
+ POST: async req => {
14614
+ const auth = getAuth(req.cookies);
14615
+ if (auth.error) {
14616
+ return {
14617
+ status: 401,
14618
+ json: {
14619
+ message: auth.error
14620
+ }
14621
+ };
14622
+ }
14623
+ return proxyProposalsApi(req.path, "POST", profileOf(auth), JSON.stringify(req.body ?? {}));
14624
+ },
14625
+ PATCH: async req => {
14626
+ const auth = getAuth(req.cookies);
14627
+ if (auth.error) {
14628
+ return {
14629
+ status: 401,
14630
+ json: {
14631
+ message: auth.error
14632
+ }
14633
+ };
14634
+ }
14635
+ return proxyProposalsApi(req.path, "PATCH", profileOf(auth), JSON.stringify(req.body ?? {}));
14636
+ }
14637
+ },
14638
+ "/proposal-site-sources": {
14639
+ GET: async req => {
14640
+ const auth = getAuth(req.cookies);
14641
+ if (auth.error) {
14642
+ return {
14643
+ status: 401,
14644
+ json: {
14645
+ message: auth.error
14646
+ }
14647
+ };
14648
+ }
14649
+ const modules = await serverOps.siteSourcesUnderSnapshot();
14650
+ if (modules === null) {
14651
+ return {
14652
+ status: 404,
14653
+ json: {
14654
+ message: "This is not a proposal's address."
14655
+ }
14656
+ };
14657
+ }
14658
+ return {
14659
+ status: 200,
14660
+ json: {
14661
+ modules
14662
+ }
14663
+ };
14664
+ }
14665
+ },
14148
14666
  "/upload/patches": {
14149
14667
  POST: async req => {
14150
14668
  if (serverOps instanceof ValOpsHttp) {
@@ -15123,9 +15641,23 @@ const ValServer = (valModules, options, callbacks) => {
15123
15641
  patchIds: undefined,
15124
15642
  excludePatchOps: false
15125
15643
  });
15644
+ /*
15645
+ * Membership from THIS response when it carries one, not from the
15646
+ * group list `resolveOwnPatchScope` read.
15647
+ *
15648
+ * That list is a separate request, remembered for a second
15649
+ * (`ValOpsHttp.getPatchGroups`), so it can be older than the chain
15650
+ * fetched here: an editor who saved and reloaded at once was
15651
+ * rendered without the patch they had just written, because the
15652
+ * list still had their group without it. The content service reads
15653
+ * the chain and each patch's groups in one transaction, and
15654
+ * `fetchPatches` folds that onto `patchGroups` -- so the patches
15655
+ * and who owns them come from the same moment.
15656
+ */
15657
+ const own = ownPatchScopeFor(all, "id" in auth && auth.id || undefined, ownPatchIds);
15126
15658
  patchOps = {
15127
15659
  ...all,
15128
- patches: scopedPatches(all.patches, ownPatchIds)
15660
+ patches: scopedPatches(all.patches, own)
15129
15661
  };
15130
15662
  } else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
15131
15663
  /*
@@ -17098,6 +17630,24 @@ chain, deleted, requested) {
17098
17630
  * `undefined` means "apply everything", which is what every caller that does
17099
17631
  * not ask for scoping gets and must keep getting.
17100
17632
  */
17633
+ /** The patches in this author's OPEN groups: what a scoped draft shows. */
17634
+ function ownPatchIdsIn(patchGroups, authorId) {
17635
+ return patchGroups.filter(group => group.publishedAt === null && group.authorId === authorId).flatMap(group => group.patchIds);
17636
+ }
17637
+
17638
+ /**
17639
+ * Whose pending patches a scoped draft shows, taken from the SAME response as
17640
+ * the patches when it says, and from the list read beforehand only when not.
17641
+ *
17642
+ * See the call in `/sources/~`: the list read beforehand can be older than the
17643
+ * chain, and the response's membership cannot be.
17644
+ */
17645
+ function ownPatchScopeFor(fetched, authorId, remembered) {
17646
+ if (fetched.patchGroups === undefined || authorId === undefined) {
17647
+ return remembered;
17648
+ }
17649
+ return ownPatchIdsIn(fetched.patchGroups, authorId);
17650
+ }
17101
17651
  async function resolveOwnPatchScope(
17102
17652
  /*
17103
17653
  * `ValOps`, not the two concrete stores: the one thing this needs is whether
@@ -17152,7 +17702,7 @@ serverOps, opts) {
17152
17702
  * nothing, and base is the honest answer. Distinct from the
17153
17703
  * case above, which is why the two are not one expression.
17154
17704
  */
17155
- ownPatchIds = groupsRes.patchGroups.filter(group => group.publishedAt === null && group.authorId === opts.authorId).flatMap(group => group.patchIds);
17705
+ ownPatchIds = ownPatchIdsIn(groupsRes.patchGroups, opts.authorId);
17156
17706
  /*
17157
17707
  * Scoping applies to PENDING work only. Anything already
17158
17708
  * committed is part of everyone's view.
@@ -23255,6 +23805,8 @@ exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
23255
23805
  exports.patchSourceFile = patchSourceFile;
23256
23806
  exports.persistPersonalAccessToken = persistPersonalAccessToken;
23257
23807
  exports.planJsonValuesEntryExtraction = planJsonValuesEntryExtraction;
23808
+ exports.proposalFromEnv = proposalFromEnv;
23809
+ exports.proposalSnapshot = proposalSnapshot;
23258
23810
  exports.readCapturedReport = readCapturedReport;
23259
23811
  exports.readPatchStore = readPatchStore;
23260
23812
  exports.rebaseContentOp = rebaseContentOp;