@valbuild/server 0.141.0 → 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.
@@ -2498,6 +2498,24 @@ function defineExternal(definition) {
2498
2498
  throw new Error("defineExternal is not implemented yet: phase 0 lands the contract, the registry that executes it arrives with the read endpoints.");
2499
2499
  }
2500
2500
 
2501
+ /**
2502
+ * Does saving this commit upload a file to Val's remote host?
2503
+ *
2504
+ * The question `/save` asks before it reads remote credentials. It used to ask
2505
+ * the SCHEMAS instead -- "is any of them remote?" -- and with
2506
+ * `files: { remote: true }` every media schema is, so every save needed a
2507
+ * `val login` token or an api key: a corrected typo included. Only a remote
2508
+ * FILE is uploaded, so only a commit carrying one needs credentials.
2509
+ */
2510
+ function commitCarriesRemoteFiles(preparedCommit) {
2511
+ return Object.values(preparedCommit.patchedBinaryFilesDescriptors).some(descriptor => descriptor.remote);
2512
+ }
2513
+
2514
+ /** Does this commit carry any file at all, remote or local? */
2515
+ function commitCarriesFiles(preparedCommit) {
2516
+ return Object.keys(preparedCommit.patchedBinaryFilesDescriptors).length > 0;
2517
+ }
2518
+
2501
2519
  const JwtPayloadSchema = z.z.object({
2502
2520
  sub: z.z.string(),
2503
2521
  exp: z.z.number(),
@@ -2698,18 +2716,41 @@ function encodeJwt(payload, sessionKey) {
2698
2716
  * error somebody had to answer instead of a default that silently applied.
2699
2717
  */
2700
2718
  function clientConfig(options) {
2719
+ const config = withResolvedProject(options.config, options.project);
2701
2720
  if (options.mode !== "http" || options.git === undefined) {
2702
- return options.config;
2721
+ return config;
2703
2722
  }
2704
- if (options.config.gitBranch !== undefined) {
2705
- return options.config;
2723
+ if (config.gitBranch !== undefined) {
2724
+ return config;
2706
2725
  }
2707
2726
  return {
2708
- ...options.config,
2727
+ ...config,
2709
2728
  gitBranch: options.git.branch
2710
2729
  };
2711
2730
  }
2712
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
+
2713
2754
  /**
2714
2755
  * Which IP addresses this server may open a connection to.
2715
2756
  *
@@ -3583,6 +3624,12 @@ class ValOps {
3583
3624
  * hash. Re-folding with the wrong list changes the base SHA for no reason.
3584
3625
  */
3585
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;
3586
3633
  /**
3587
3634
  * What a save has told us each `.jsonValues()` entry now holds.
3588
3635
  *
@@ -3629,6 +3676,7 @@ class ValOps {
3629
3676
  // #region initTree
3630
3677
  async initSources() {
3631
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;
3632
3680
  const extracted = await core.extractValModules(this.valModules);
3633
3681
  const moduleErrors = extracted.moduleErrors.concat(findNestedJsonValuesModuleErrors(extracted.schemas));
3634
3682
  this.sources = extracted.sources;
@@ -3640,12 +3688,29 @@ class ValOps {
3640
3688
  this.modulesErrors = moduleErrors;
3641
3689
  this.shaEntries = extracted.shaEntries;
3642
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
+ }
3643
3708
  return {
3644
3709
  baseSha: this.baseSha,
3645
3710
  schemaSha: this.schemaSha,
3646
3711
  sourcesSha: this.sourcesSha,
3647
3712
  configSha: this.configSha,
3648
- sources: extracted.sources,
3713
+ sources: this.sources,
3649
3714
  schemas: extracted.schemas,
3650
3715
  moduleErrors
3651
3716
  };
@@ -3661,6 +3726,19 @@ class ValOps {
3661
3726
  };
3662
3727
  }
3663
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
+
3664
3742
  /**
3665
3743
  * These patches are on disk now: adopt what they produced as the committed
3666
3744
  * sources.
@@ -5257,9 +5335,9 @@ class ValOps {
5257
5335
  // https://github.com/microsoft/TypeScript/issues/36174
5258
5336
  let sourceFileText = null;
5259
5337
  if (tsChanged) {
5260
- var _this$options;
5338
+ var _this$options3;
5261
5339
  sourceFileText = unescape(tsSourceFile.getText(tsSourceFile).replace(/\\u/g, "%u"));
5262
- 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) {
5263
5341
  try {
5264
5342
  sourceFileText = await this.options.formatter(sourceFileText, path);
5265
5343
  } catch (err) {
@@ -5271,13 +5349,13 @@ class ValOps {
5271
5349
  }
5272
5350
  const extraFiles = {};
5273
5351
  for (const [jsonPath, content] of Array.from(jsonEntryContents)) {
5274
- var _this$options2;
5352
+ var _this$options4;
5275
5353
  if (content === null) {
5276
5354
  extraFiles[jsonPath] = null;
5277
5355
  continue;
5278
5356
  }
5279
5357
  let jsonText = JSON.stringify(content, null, 2);
5280
- 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) {
5281
5359
  try {
5282
5360
  jsonText = await this.options.formatter(jsonText, jsonPath);
5283
5361
  } catch (err) {
@@ -5593,6 +5671,30 @@ class ValOps {
5593
5671
  };
5594
5672
  }
5595
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
+
5596
5698
  /**
5597
5699
  * Whether a commit here produces `.val.ts` TEXT as well as data.
5598
5700
  *
@@ -8796,6 +8898,111 @@ function computePatchesToDrop(prepared) {
8796
8898
  return dropped;
8797
8899
  }
8798
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
+
8799
9006
  const textEncoder$2 = new TextEncoder();
8800
9007
  const PatchId = z.z.string().refine(s => !!s); // TODO: validate
8801
9008
  const CommitSha = z.z.string().refine(s => !!s); // TODO: validate
@@ -9149,6 +9356,8 @@ class ValOpsHttp extends ValOps {
9149
9356
  publishJob;
9150
9357
  /** See `publishBuild` on {@link ValApiOptions}; sent with every position. */
9151
9358
  publishBuild;
9359
+ /** See `proposal` on {@link ValApiOptions}; `null` on the site. */
9360
+ proposal;
9152
9361
 
9153
9362
  /** Did the host hand over the running build's source? See `projectSource`. */
9154
9363
  embedsSource() {
@@ -9221,7 +9430,10 @@ class ValOpsHttp extends ValOps {
9221
9430
  * getSettings / uploadRemoteFile / getPresignedAuthNonce.
9222
9431
  */
9223
9432
  auth, valModules, options) {
9224
- super(valModules, options);
9433
+ super(valModules, options !== null && options !== void 0 && options.proposal ? {
9434
+ ...options,
9435
+ snapshotSources: proposalSnapshot(options.proposal)
9436
+ } : options);
9225
9437
  this.contentUrl = contentUrl;
9226
9438
  this.project = project;
9227
9439
  this.git = git;
@@ -9231,7 +9443,15 @@ class ValOpsHttp extends ValOps {
9231
9443
  Authorization: `Bearer ${auth.apiKey}`
9232
9444
  };
9233
9445
  this.root = (options === null || options === void 0 ? void 0 : options.root) ?? "";
9234
- 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;
9235
9455
  this.publishJob = (options === null || options === void 0 ? void 0 : options.publishJob) ?? null;
9236
9456
  this.publishBuild = (options === null || options === void 0 ? void 0 : options.publishBuild) ?? null;
9237
9457
  this.mirrorsSourceFiles = git !== null || this.projectSource !== null;
@@ -9260,7 +9480,8 @@ class ValOpsHttp extends ValOps {
9260
9480
  */
9261
9481
  publishRefusal() {
9262
9482
  var _this$projectExpectat;
9263
- 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) {
9264
9485
  return null;
9265
9486
  }
9266
9487
  /*
@@ -9297,6 +9518,13 @@ class ValOpsHttp extends ValOps {
9297
9518
  */
9298
9519
  sourceMode() {
9299
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;
9300
9528
  return ((_this$projectExpectat2 = this.projectExpectation) === null || _this$projectExpectat2 === void 0 ? void 0 : _this$projectExpectat2.sourceMode) ?? null;
9301
9529
  }
9302
9530
 
@@ -9331,6 +9559,8 @@ class ValOpsHttp extends ValOps {
9331
9559
  }).catch(() => undefined);
9332
9560
  }
9333
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;
9334
9564
  const expected = this.projectExpectation;
9335
9565
  if (expected === null) return false;
9336
9566
  return expected.publishJobs ?? expected.sourceMode === "managed";
@@ -9450,6 +9680,10 @@ class ValOpsHttp extends ValOps {
9450
9680
  * segment gets through.
9451
9681
  */
9452
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
+ }
9453
9687
  if (path === "/build-target" || path === "/project-source" || path === "/update-target" || path === "/publish" ||
9454
9688
  // Where "View run" goes, for a connected build CI reported failed.
9455
9689
  // Read only: reporting a run is CI's, with its own token.
@@ -9472,6 +9706,20 @@ class ValOpsHttp extends ValOps {
9472
9706
  return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
9473
9707
  }
9474
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
+ }
9475
9723
  if (!ValOpsHttp.publishApiPathAllowed(path)) {
9476
9724
  return {
9477
9725
  status: 403,
@@ -9483,6 +9731,82 @@ class ValOpsHttp extends ValOps {
9483
9731
  }
9484
9732
  return this.callPublishApi(path, init);
9485
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
+ }
9486
9810
 
9487
9811
  /**
9488
9812
  * A publish job's prepare, sent to content: the job's sources, archived
@@ -9783,6 +10107,42 @@ class ValOpsHttp extends ValOps {
9783
10107
  } : {})
9784
10108
  };
9785
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
+ }
9786
10146
  async getWebSocketNonce(profileId) {
9787
10147
  return fetch(`${this.contentUrl}/v1/${this.project}/websocket/nonces`, {
9788
10148
  method: "POST",
@@ -9794,12 +10154,24 @@ class ValOpsHttp extends ValOps {
9794
10154
  * the project -- and a nonce is scoped to the project and the person,
9795
10155
  * not to a position in a chain.
9796
10156
  */
9797
- ...(this.git ? {
9798
- branch: this.git.branch,
9799
- commitSha: this.git.commit
9800
- } : this.publishJob !== null ? {
9801
- job: this.publishJob
9802
- } : {}),
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
+ })(),
9803
10175
  // Which build is asking, beside what it says about itself.
9804
10176
  ...(this.publishBuild !== null ? {
9805
10177
  build: this.publishBuild
@@ -9981,12 +10353,10 @@ class ValOpsHttp extends ValOps {
9981
10353
  * holds the chain, and for a project it is the only publisher of, the
9982
10354
  * position IS the head.
9983
10355
  */
9984
- if (this.git) {
9985
- params.push(["branch", this.git.branch]);
9986
- params.push(["commit", this.git.commit]);
9987
- } else if (this.publishJob !== null) {
9988
- // A build a tab made has no commit, and says which job it was made for.
9989
- 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]);
9990
10360
  }
9991
10361
  /*
9992
10362
  * And WHICH build this is, when the platform running it says: the only
@@ -10259,6 +10629,23 @@ class ValOpsHttp extends ValOps {
10259
10629
  * cache: a second render a second later asks again.
10260
10630
  */
10261
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
+ }
10262
10649
  async getPatchGroups(options) {
10263
10650
  const now = Date.now();
10264
10651
  if ((options === null || options === void 0 ? void 0 : options.fresh) !== true && this.patchGroupsCache !== null && now - this.patchGroupsCache.at < PATCH_GROUPS_CACHE_MS) {
@@ -10290,6 +10677,17 @@ class ValOpsHttp extends ValOps {
10290
10677
  return res;
10291
10678
  }
10292
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
+ }
10293
10691
  try {
10294
10692
  /*
10295
10693
  * `branch` is REQUIRED by the endpoint, which answers 400 without it.
@@ -10298,7 +10696,8 @@ class ValOpsHttp extends ValOps {
10298
10696
  * `saveSourceFilePatch`): groups are per branch, so a request without one
10299
10697
  * is not merely under-specified, it is rejected.
10300
10698
  */
10301
- const params = new URLSearchParams(this.git ? [["branch", this.git.branch]] : []);
10699
+ const branch = this.ownBranch();
10700
+ const params = new URLSearchParams(branch !== undefined ? [["branch", branch]] : []);
10302
10701
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/patch-groups?${params}`, {
10303
10702
  headers: this.authHeaders
10304
10703
  });
@@ -10349,8 +10748,9 @@ class ValOpsHttp extends ValOps {
10349
10748
  * Without one the content API uses the project's own, as it does for reads.
10350
10749
  */
10351
10750
  ownGroupBranch() {
10352
- return this.git ? {
10353
- branch: this.git.branch
10751
+ const branch = this.ownBranch();
10752
+ return branch !== undefined ? {
10753
+ branch
10354
10754
  } : {};
10355
10755
  }
10356
10756
  async mutatePatchGroup(method, path, body,
@@ -10373,6 +10773,22 @@ class ValOpsHttp extends ValOps {
10373
10773
  * on its own there and this adds nothing.
10374
10774
  */
10375
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) {
10376
10792
  try {
10377
10793
  const res = await fetch(`${this.contentUrl}/v1/${this.project}/${path}`, {
10378
10794
  method,
@@ -10465,7 +10881,13 @@ class ValOpsHttp extends ValOps {
10465
10881
  patchId,
10466
10882
  parentPatchId: parentRef.type === "patch" ? parentRef.patchId : null,
10467
10883
  baseSha,
10468
- ...(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 ? {
10469
10891
  commit: this.git.commit,
10470
10892
  branch: this.git.branch
10471
10893
  } : {}),
@@ -10542,7 +10964,7 @@ class ValOpsHttp extends ValOps {
10542
10964
  errorType: "other",
10543
10965
  message: `Could save source file patch (connection error?): ${e instanceof Error ? e.message : e.toString()}`
10544
10966
  });
10545
- });
10967
+ }).finally(() => this.forgetPatchGroups());
10546
10968
  }
10547
10969
 
10548
10970
  /**
@@ -10834,12 +11256,11 @@ class ValOpsHttp extends ValOps {
10834
11256
  if (res.ok) {
10835
11257
  const parsed = DeletePatchesResponse.safeParse(await res.json());
10836
11258
  if (parsed.success) {
10837
- var _this$git;
10838
11259
  const errors = {};
10839
11260
  for (const err of parsed.data.errors || []) {
10840
11261
  errors[err.patchId] = err;
10841
11262
  }
10842
- 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());
10843
11264
  const version = headVersion !== undefined ? {
10844
11265
  headVersion
10845
11266
  } : {};
@@ -10872,7 +11293,7 @@ class ValOpsHttp extends ValOps {
10872
11293
  message: `Could not delete patches (connection error?): ${e instanceof Error ? e.message : e.toString()}`
10873
11294
  }
10874
11295
  };
10875
- });
11296
+ }).finally(() => this.forgetPatchGroups());
10876
11297
  }
10877
11298
  async commit(prepared, message, committer, filesDirectory, newBranch,
10878
11299
  /**
@@ -10892,7 +11313,13 @@ class ValOpsHttp extends ValOps {
10892
11313
  patchGroupId) {
10893
11314
  try {
10894
11315
  var _res$headers$get3;
10895
- 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, {
10896
11323
  method: "POST",
10897
11324
  headers: {
10898
11325
  ...this.authHeaders,
@@ -12957,6 +13384,9 @@ async function initHandlerOptions(route, opts, config) {
12957
13384
  ...(opts.publishBuild !== undefined ? {
12958
13385
  publishBuild: opts.publishBuild
12959
13386
  } : {}),
13387
+ ...(opts.proposal !== undefined ? {
13388
+ proposal: opts.proposal
13389
+ } : {}),
12960
13390
  project: maybeValProject,
12961
13391
  valEnableRedirectUrl,
12962
13392
  valDisableRedirectUrl,
@@ -13032,6 +13462,9 @@ function createValOps(valModules, options) {
13032
13462
  } : {}),
13033
13463
  ...(options.publishBuild !== undefined ? {
13034
13464
  publishBuild: options.publishBuild
13465
+ } : {}),
13466
+ ...(options.proposal !== undefined ? {
13467
+ proposal: options.proposal
13035
13468
  } : {})
13036
13469
  });
13037
13470
  }
@@ -13630,6 +14063,32 @@ const ValServer = (valModules, options, callbacks) => {
13630
14063
  };
13631
14064
  }
13632
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
+ };
13633
14092
  return {
13634
14093
  "/draft/enable": {
13635
14094
  GET: async req => {
@@ -14063,6 +14522,8 @@ const ValServer = (valModules, options, callbacks) => {
14063
14522
  * shows one story to a project that lives by the other.
14064
14523
  */
14065
14524
  const sourceMode = serverOps.sourceMode();
14525
+ // Spread like the two above: absent is "on the site".
14526
+ const proposal = serverOps.currentProposal();
14066
14527
  return {
14067
14528
  status: 200,
14068
14529
  json: {
@@ -14078,6 +14539,9 @@ const ValServer = (valModules, options, callbacks) => {
14078
14539
  ...(sourceMode ? {
14079
14540
  publishJobs: serverOps.publishesAsJobs()
14080
14541
  } : {}),
14542
+ ...(proposal ? {
14543
+ proposal
14544
+ } : {}),
14081
14545
  // Not `options.config` verbatim: in proxy mode the branch the
14082
14546
  // server resolved is filled in where the file did not name one.
14083
14547
  // See `clientConfig`.
@@ -14127,6 +14591,78 @@ const ValServer = (valModules, options, callbacks) => {
14127
14591
  return proxyPublishApi(req.path, "POST", req.body === undefined ? undefined : JSON.stringify(req.body));
14128
14592
  }
14129
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
+ },
14130
14666
  "/upload/patches": {
14131
14667
  POST: async req => {
14132
14668
  if (serverOps instanceof ValOpsHttp) {
@@ -15105,9 +15641,23 @@ const ValServer = (valModules, options, callbacks) => {
15105
15641
  patchIds: undefined,
15106
15642
  excludePatchOps: false
15107
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);
15108
15658
  patchOps = {
15109
15659
  ...all,
15110
- patches: scopedPatches(all.patches, ownPatchIds)
15660
+ patches: scopedPatches(all.patches, own)
15111
15661
  };
15112
15662
  } else if (requestedPatchIds !== undefined && requestedPatchIds.length === 0) {
15113
15663
  /*
@@ -15732,7 +16282,13 @@ const ValServer = (valModules, options, callbacks) => {
15732
16282
  * tree; a store with no working tree does only the push.
15733
16283
  */
15734
16284
  if (serverOps instanceof ValOpsMemory) {
15735
- const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
16285
+ /*
16286
+ * A commit with no files uploads nothing and needs no credentials;
16287
+ * see `commitCarriesRemoteFiles`. One with a LOCAL file still goes
16288
+ * through `uploadRemoteFiles` where the project is remote, because
16289
+ * that is where a local file is refused rather than dropped.
16290
+ */
16291
+ const isRemoteRequired = commitCarriesRemoteFiles(preparedCommit) || commitCarriesFiles(preparedCommit) && getIsRemoteRequired(await serverOps.getSchemas());
15736
16292
  if (isRemoteRequired) {
15737
16293
  const authRes = await getRemoteFileAuth();
15738
16294
  if (authRes.status !== 200) {
@@ -15755,7 +16311,9 @@ const ValServer = (valModules, options, callbacks) => {
15755
16311
  }
15756
16312
  if (serverOps instanceof ValOpsFS) {
15757
16313
  var _remoteFileAuthRes;
15758
- const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
16314
+ // Credentials when THIS save uploads a remote file, not whenever
16315
+ // the project has a remote schema. See `commitCarriesRemoteFiles`.
16316
+ const isRemoteRequired = commitCarriesRemoteFiles(preparedCommit);
15759
16317
  let mode;
15760
16318
  let remoteFileAuthRes;
15761
16319
  if (isRemoteRequired) {
@@ -17072,6 +17630,24 @@ chain, deleted, requested) {
17072
17630
  * `undefined` means "apply everything", which is what every caller that does
17073
17631
  * not ask for scoping gets and must keep getting.
17074
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
+ }
17075
17651
  async function resolveOwnPatchScope(
17076
17652
  /*
17077
17653
  * `ValOps`, not the two concrete stores: the one thing this needs is whether
@@ -17126,7 +17702,7 @@ serverOps, opts) {
17126
17702
  * nothing, and base is the honest answer. Distinct from the
17127
17703
  * case above, which is why the two are not one expression.
17128
17704
  */
17129
- ownPatchIds = groupsRes.patchGroups.filter(group => group.publishedAt === null && group.authorId === opts.authorId).flatMap(group => group.patchIds);
17705
+ ownPatchIds = ownPatchIdsIn(groupsRes.patchGroups, opts.authorId);
17130
17706
  /*
17131
17707
  * Scoping applies to PENDING work only. Anything already
17132
17708
  * committed is part of everyone's view.
@@ -23229,6 +23805,8 @@ exports.parsePersonalAccessTokenFile = parsePersonalAccessTokenFile;
23229
23805
  exports.patchSourceFile = patchSourceFile;
23230
23806
  exports.persistPersonalAccessToken = persistPersonalAccessToken;
23231
23807
  exports.planJsonValuesEntryExtraction = planJsonValuesEntryExtraction;
23808
+ exports.proposalFromEnv = proposalFromEnv;
23809
+ exports.proposalSnapshot = proposalSnapshot;
23232
23810
  exports.readCapturedReport = readCapturedReport;
23233
23811
  exports.readPatchStore = readPatchStore;
23234
23812
  exports.rebaseContentOp = rebaseContentOp;