@valbuild/server 0.136.0 → 0.136.2

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.
@@ -4561,6 +4561,48 @@ class ValOps {
4561
4561
  return null;
4562
4562
  }
4563
4563
 
4564
+ /**
4565
+ * The branch the content service keeps this project's commits on, or `null`
4566
+ * where there is no such service, or it has not said yet.
4567
+ *
4568
+ * For the Studio's in-tab build of a managed project, which has to name the
4569
+ * branch its build was made at. Its other source is the `val.server.ts` the
4570
+ * last build was wired with -- and a project cloned from a template seed has
4571
+ * one wired at NO commit and no branch, so its first publish went out
4572
+ * branchless and the loader refused it for a project whose pointer follows a
4573
+ * branch. This is the project's own word for it.
4574
+ */
4575
+ projectBranch() {
4576
+ return null;
4577
+ }
4578
+
4579
+ /**
4580
+ * Forward one call to the content service's publish API, as this project.
4581
+ *
4582
+ * The Studio builds a managed project in the tab and then has to publish what
4583
+ * it built -- but a browser cannot talk to content directly. It holds a
4584
+ * session cookie for THIS origin and no credential content would accept, so
4585
+ * the conversation goes through here, exactly as patches already do.
4586
+ *
4587
+ * Refused by default, and that is the honest answer rather than a gap: `fs`
4588
+ * and memory mode have no content service to forward to, and publishing there
4589
+ * is writing to disk.
4590
+ *
4591
+ * The path is content's, minus the `/v1` prefix -- `/publish`,
4592
+ * `/publish/{id}/artifacts`, `/build-target`. The ALLOW LIST lives in the
4593
+ * implementation rather than here, because it is what keeps this from being
4594
+ * a way to reach the rest of content with the project's credential.
4595
+ */
4596
+ async publishApi(_path, _init) {
4597
+ return {
4598
+ status: 501,
4599
+ contentType: "application/json",
4600
+ body: JSON.stringify({
4601
+ message: "This Val server has no content service to publish through. " + "Publishing from the browser is for a project whose content is " + "served over HTTP."
4602
+ })
4603
+ };
4604
+ }
4605
+
4564
4606
  /**
4565
4607
  * Whether a commit here produces `.val.ts` TEXT as well as data.
4566
4608
  *
@@ -7458,6 +7500,42 @@ class FSOpsHost {
7458
7500
  }
7459
7501
  }
7460
7502
 
7503
+ /**
7504
+ * The bytes of the local binary files a commit is about to write, base64.
7505
+ *
7506
+ * For the Studio's in-tab build of a managed project, which publishes the site:
7507
+ * an image uploaded in this save is in no earlier build, so unless the build
7508
+ * is given it the published page links a file that 404s. Remote files are left
7509
+ * out -- they are served from their own URL and never were part of a build.
7510
+ *
7511
+ * A file that cannot be read is NAMED rather than dropped, and does not stop
7512
+ * the save: the commit is what makes an edit durable, and the build is the
7513
+ * step that can refuse.
7514
+ *
7515
+ * Read BEFORE the commit, because after it the files are no longer the
7516
+ * patch's to read. Generic over the patch id so it asks for exactly the one
7517
+ * method it uses, and a test can hand it a plain object.
7518
+ */
7519
+ async function readCommittedBinaryFiles(ops, descriptors) {
7520
+ const files = {};
7521
+ const unread = [];
7522
+ await Promise.all(Object.entries(descriptors).map(async ([filePath, {
7523
+ patchId,
7524
+ remote
7525
+ }]) => {
7526
+ if (remote) return;
7527
+ const bytes = await ops.getBase64EncodedBinaryFileFromPatch(filePath, patchId, remote).catch(error => {
7528
+ console.error("Could not read a committed file", filePath, error);
7529
+ return null;
7530
+ });
7531
+ if (bytes === null) unread.push(filePath);else files[filePath] = bytes.toString("base64");
7532
+ }));
7533
+ return {
7534
+ files,
7535
+ unread: unread.sort()
7536
+ };
7537
+ }
7538
+
7461
7539
  /**
7462
7540
  * Which unpublished changes a failed save has to throw away to make progress.
7463
7541
  *
@@ -7939,6 +8017,189 @@ class ValOpsHttp extends ValOps {
7939
8017
  var _this$projectExpectat2;
7940
8018
  return ((_this$projectExpectat2 = this.projectExpectation) === null || _this$projectExpectat2 === void 0 ? void 0 : _this$projectExpectat2.sourceMode) ?? null;
7941
8019
  }
8020
+
8021
+ /** Remembered with {@link sourceMode}, from the same response. */
8022
+ projectBranch() {
8023
+ var _this$projectExpectat3;
8024
+ return ((_this$projectExpectat3 = this.projectExpectation) === null || _this$projectExpectat3 === void 0 ? void 0 : _this$projectExpectat3.branch) ?? null;
8025
+ }
8026
+
8027
+ /**
8028
+ * The short-lived publish token, and when it stops being usable.
8029
+ *
8030
+ * `null` until something publishes, which for most deployments is never.
8031
+ */
8032
+ publishToken = null;
8033
+
8034
+ /**
8035
+ * Trade this deployment's credential for one that can do one thing.
8036
+ *
8037
+ * Content's publish API takes a PROJECT TOKEN and nothing else --
8038
+ * `authenticateProjectToken` refuses anything that is not one, deliberately,
8039
+ * because a personal access token is a person's credential and does not name
8040
+ * a project. What this deployment holds is the project's api key, which is
8041
+ * neither.
8042
+ *
8043
+ * `POST /v1/{org}/{project}/publish-token` is the exchange, and it was built
8044
+ * for exactly this: its own docblock names the case where "the caller was the
8045
+ * project's api key ... a machine exchanging one machine credential for a
8046
+ * narrower one". What comes back can publish one project for ten minutes.
8047
+ *
8048
+ * So the api key never leaves this process and the browser never sees any
8049
+ * credential at all. That is the point of routing the publish through here
8050
+ * rather than letting the tab talk to content.
8051
+ */
8052
+ async mintPublishToken() {
8053
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/publish-token`, {
8054
+ method: "POST",
8055
+ headers: this.authHeaders
8056
+ });
8057
+ const text = await res.text();
8058
+ if (!res.ok) {
8059
+ return {
8060
+ status: res.status,
8061
+ error: `Could not get a publish token for '${this.project}': ` + `${res.status} ${text.slice(0, 300)}`
8062
+ };
8063
+ }
8064
+ let parsed;
8065
+ try {
8066
+ parsed = JSON.parse(text);
8067
+ } catch {
8068
+ return {
8069
+ status: 502,
8070
+ error: "The publish token exchange did not answer with JSON."
8071
+ };
8072
+ }
8073
+ const token = typeof parsed === "object" && parsed !== null && "token" in parsed && typeof parsed.token === "string" ? parsed.token : null;
8074
+ if (token === null) {
8075
+ return {
8076
+ status: 502,
8077
+ error: "The publish token exchange sent no token."
8078
+ };
8079
+ }
8080
+ const expiresAtRaw = typeof parsed === "object" && parsed !== null && "expiresAt" in parsed ? parsed.expiresAt : null;
8081
+ const expiresAt = typeof expiresAtRaw === "string" ? Date.parse(expiresAtRaw) : NaN;
8082
+ return {
8083
+ token,
8084
+ /*
8085
+ * A token with no expiry, or one we cannot read, is treated as expiring
8086
+ * NOW -- so it is used for this call and minted again for the next.
8087
+ * Caching one we cannot reason about is how a publish starts failing
8088
+ * halfway through, days later, for no reason anyone can see.
8089
+ */
8090
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0
8091
+ };
8092
+ }
8093
+
8094
+ /**
8095
+ * A usable publish token, minting one when what we have will not last.
8096
+ *
8097
+ * The margin is what stops a token that is valid when the publish starts from
8098
+ * expiring in the middle of it: a publish is five calls and an upload of
8099
+ * every artifact, and the upload is the slow one.
8100
+ */
8101
+ async currentPublishToken() {
8102
+ const margin = 60_000;
8103
+ if (this.publishToken !== null && this.publishToken.expiresAt - margin > Date.now()) {
8104
+ return {
8105
+ token: this.publishToken.token
8106
+ };
8107
+ }
8108
+ const minted = await this.mintPublishToken();
8109
+ if ("error" in minted) {
8110
+ this.publishToken = null;
8111
+ return minted;
8112
+ }
8113
+ this.publishToken = minted;
8114
+ return {
8115
+ token: minted.token
8116
+ };
8117
+ }
8118
+
8119
+ /**
8120
+ * The content paths this may reach, and nothing else.
8121
+ *
8122
+ * An allow list rather than a prefix check, because this method holds a
8123
+ * credential and the browser chooses the path. `/publish/{id}` and its three
8124
+ * steps are the publish conversation; `/build-target` is what a build needs
8125
+ * to know before it starts; `/project-source` is what it builds.
8126
+ *
8127
+ * `/project-source` is here rather than on a route of its own because it is
8128
+ * one of the three things a publish asks for and none of them are useful
8129
+ * apart -- and because the credential is the same one. The Studio cannot get
8130
+ * the project's files any other way: it runs inside the deployment, which
8131
+ * holds a session for its own origin and an api key for content, and content
8132
+ * is the only thing it is allowed to talk to at all.
8133
+ *
8134
+ * A publish id is opaque and content-generated, so it is matched rather than
8135
+ * parsed -- what matters is that nothing with a `..`, a query or another
8136
+ * segment gets through.
8137
+ */
8138
+ static publishApiPathAllowed(path) {
8139
+ if (path === "/build-target" || path === "/project-source" || path === "/publish") {
8140
+ return true;
8141
+ }
8142
+ return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
8143
+ }
8144
+ async publishApi(path, init) {
8145
+ const json = "application/json";
8146
+ if (!ValOpsHttp.publishApiPathAllowed(path)) {
8147
+ return {
8148
+ status: 403,
8149
+ contentType: json,
8150
+ body: JSON.stringify({
8151
+ message: `'${path}' is not part of the publish API.`
8152
+ })
8153
+ };
8154
+ }
8155
+ const send = async token => fetch(`${this.contentUrl}/v1${path}`, {
8156
+ method: init.method,
8157
+ headers: {
8158
+ Authorization: `Bearer ${token}`,
8159
+ ...(init.body === undefined ? {} : {
8160
+ "Content-Type": json
8161
+ })
8162
+ },
8163
+ ...(init.body === undefined ? {} : {
8164
+ body: init.body
8165
+ })
8166
+ });
8167
+ const credential = await this.currentPublishToken();
8168
+ if ("error" in credential) {
8169
+ return {
8170
+ status: credential.status,
8171
+ contentType: json,
8172
+ body: JSON.stringify({
8173
+ message: credential.error
8174
+ })
8175
+ };
8176
+ }
8177
+ let res = await send(credential.token);
8178
+ if (res.status === 401) {
8179
+ /*
8180
+ * Revoked, or expired sooner than it said. One retry with a fresh token,
8181
+ * because the alternative is a publish that fails for a reason the editor
8182
+ * cannot act on and a retry that fails the same way.
8183
+ */
8184
+ this.publishToken = null;
8185
+ const second = await this.currentPublishToken();
8186
+ if ("error" in second) {
8187
+ return {
8188
+ status: second.status,
8189
+ contentType: json,
8190
+ body: JSON.stringify({
8191
+ message: second.error
8192
+ })
8193
+ };
8194
+ }
8195
+ res = await send(second.token);
8196
+ }
8197
+ return {
8198
+ status: res.status,
8199
+ contentType: res.headers.get("content-type") ?? json,
8200
+ body: await res.text()
8201
+ };
8202
+ }
7942
8203
  async onInit() {
7943
8204
  // TODO: unused for now. Implement or remove
7944
8205
  }
@@ -11497,6 +11758,38 @@ const ValServer = (valModules, options, callbacks) => {
11497
11758
  }
11498
11759
  };
11499
11760
  };
11761
+
11762
+ /**
11763
+ * Hand one publish-API call to content and carry the answer back.
11764
+ *
11765
+ * `JSON.parse` and nothing more. That is not validation -- there is no schema
11766
+ * here and no knowledge of content's shapes -- it is the round trip a JSON
11767
+ * body has to make to travel as `json` rather than as a string. A body that
11768
+ * does not parse is a gateway's error page rather than content answering, so
11769
+ * it comes back as a message with the original status instead of throwing:
11770
+ * a publish that failed is something the editor has to be told in words.
11771
+ */
11772
+ const proxyPublishApi = async (path, method, body) => {
11773
+ const answer = await serverOps.publishApi(path ?? "", {
11774
+ method,
11775
+ ...(body === undefined ? {} : {
11776
+ body
11777
+ })
11778
+ });
11779
+ try {
11780
+ return {
11781
+ status: answer.status,
11782
+ json: JSON.parse(answer.body)
11783
+ };
11784
+ } catch {
11785
+ return {
11786
+ status: answer.status,
11787
+ json: {
11788
+ message: answer.body.trim() === "" ? `The publish API answered ${answer.status} with no body.` : `The publish API answered ${answer.status}: ${answer.body.slice(0, 300)}`
11789
+ }
11790
+ };
11791
+ }
11792
+ };
11500
11793
  return {
11501
11794
  "/draft/enable": {
11502
11795
  GET: async req => {
@@ -11947,6 +12240,47 @@ const ValServer = (valModules, options, callbacks) => {
11947
12240
  };
11948
12241
  }
11949
12242
  },
12243
+ /**
12244
+ * The content service's publish API, reached through this deployment.
12245
+ *
12246
+ * The Studio builds a managed project in its own tab and then publishes
12247
+ * what it built. It cannot talk to content directly -- it holds a session
12248
+ * cookie for this origin and no credential content would accept -- so the
12249
+ * whole conversation comes through here.
12250
+ *
12251
+ * Three things happen, and nothing else: the session is checked, the
12252
+ * credential is swapped for a publish-scoped one (see
12253
+ * `ValOpsHttp.publishApi`), and content's answer is carried back with its
12254
+ * status intact. The Studio parses that answer with the same module
12255
+ * `val publish` uses, so there is no copy of content's shapes here to
12256
+ * fall out of step.
12257
+ */
12258
+ "/publish-api": {
12259
+ GET: async req => {
12260
+ const auth = getAuth(req.cookies);
12261
+ if (auth.error) {
12262
+ return {
12263
+ status: 401,
12264
+ json: {
12265
+ message: auth.error
12266
+ }
12267
+ };
12268
+ }
12269
+ return proxyPublishApi(req.path, "GET");
12270
+ },
12271
+ POST: async req => {
12272
+ const auth = getAuth(req.cookies);
12273
+ if (auth.error) {
12274
+ return {
12275
+ status: 401,
12276
+ json: {
12277
+ message: auth.error
12278
+ }
12279
+ };
12280
+ }
12281
+ return proxyPublishApi(req.path, "POST", req.body === undefined ? undefined : JSON.stringify(req.body));
12282
+ }
12283
+ },
11950
12284
  "/upload/patches": {
11951
12285
  POST: async req => {
11952
12286
  if (serverOps instanceof ValOpsHttp) {
@@ -13446,6 +13780,12 @@ const ValServer = (valModules, options, callbacks) => {
13446
13780
  }
13447
13781
  }
13448
13782
  const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
13783
+ /*
13784
+ * Before the commit, because after it the files are no longer the
13785
+ * patch's to read. See `binaryFiles` on the route.
13786
+ */
13787
+ const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
13788
+ const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
13449
13789
  const commitToGit = () => {
13450
13790
  var _options$config$files;
13451
13791
  return serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val", undefined,
@@ -13504,7 +13844,24 @@ const ValServer = (valModules, options, callbacks) => {
13504
13844
  return {
13505
13845
  status: 200,
13506
13846
  json: {
13507
- commitSha: commitRes.commit
13847
+ commitSha: commitRes.commit,
13848
+ /*
13849
+ * For the Studio to BUILD, when it is the deployer. See
13850
+ * `sourceFiles` on the route: the build must contain the text
13851
+ * this commit wrote, and only this handler has it.
13852
+ */
13853
+ ...(serverOps.sourceMode() === "managed" ? {
13854
+ sourceFiles: preparedCommit.patchedSourceFiles
13855
+ } : {}),
13856
+ ...(managedBranch !== null ? {
13857
+ branch: managedBranch
13858
+ } : {}),
13859
+ ...(committedBinaries !== null ? {
13860
+ binaryFiles: committedBinaries.files,
13861
+ ...(committedBinaries.unread.length > 0 ? {
13862
+ binaryFilesUnread: committedBinaries.unread
13863
+ } : {})
13864
+ } : {})
13508
13865
  }
13509
13866
  };
13510
13867
  }
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.136.0",
19
+ "version": "0.136.2",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0",
@@ -30,9 +30,9 @@
30
30
  "typescript": "^6.0.3",
31
31
  "zod": "^4.4.3",
32
32
  "zod-validation-error": "^5.0.0",
33
- "@valbuild/core": "0.136.0",
34
- "@valbuild/shared": "0.136.0",
35
- "@valbuild/ui": "0.136.0"
33
+ "@valbuild/shared": "0.136.2",
34
+ "@valbuild/core": "0.136.1",
35
+ "@valbuild/ui": "0.136.2"
36
36
  },
37
37
  "engines": {
38
38
  "node": "^20.19.0 || >=22"