@valbuild/server 0.136.0 → 0.136.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @valbuild/server
2
2
 
3
+ ## 0.136.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#716](https://github.com/valbuild/val/pull/716) [`4450be5`](https://github.com/valbuild/val/commit/4450be570ec3a66d7d98ba273c333ca2d2f163d9) Thanks [@freekh](https://github.com/freekh)! - `rebakeGit` rewires a project record to a different commit, and the Studio's publish proxy can now reach `/project-source`.
8
+
9
+ Both are groundwork for a publish that happens in the browser. `rebakeGit` replaces the one line in the generated `val.server.ts` that carries the commit, rather than regenerating the file: a publisher running inside the deployment does not know the project id or the platform's address, and reconstructing the file from what it could guess would throw away wiring it cannot see.
10
+
11
+ - [#716](https://github.com/valbuild/val/pull/716) [`d2f385a`](https://github.com/valbuild/val/commit/d2f385a04978992a5036379235e447ba7775faef) Thanks [@freekh](https://github.com/freekh)! - The Studio works again when served from the published package, and a managed project's publish now puts the saved edit on the site.
12
+
13
+ - **The Studio did not start in 0.136.0.** Its main bundle imports sibling chunks by relative path, and the Studio is served at `/api/val/static/<version>/app`, so those imports resolved to paths the handler did not know and came back as the HTML fallback — which a browser refuses as a module script. Preview was gone with it. The handler now serves those chunks, and the package build follows every import the bundle makes before it lets a release through.
14
+ - **The in-browser builder could not load.** The Studio's bundle carried rolldown's Node WASI binding, which throws `process is not defined` in a tab. It now uses the browser binding, and the build refuses a bundle that contains the Node one.
15
+ - **A managed publish builds what was just saved.** `/save` returns the source files the commit wrote, the Studio builds with them laid over the project's stored source, and publishes that source back with the build — so the next edit starts from this one rather than undoing it. The save's commit is also passed through to the build, which previously ran as though no commit had been made.
16
+ - **The bundler's WebAssembly is served from `content.val.build/v1/static`** (`DEFAULT_STATIC_HOST`), still addressed by its SHA-256. `globalThis.__VAL_ROLLDOWN_WASM_URL__` still overrides it.
17
+ - The `val.server.ts` that `@valbuild/tanstack-build` generates no longer hands a save's files to the platform (`platform.internal/__api/source`). A managed project's Studio builds in its own tab, and the platform has closed that door. `isWired` recognises files wired either way.
18
+ - `@valbuild/tanstack-build/constants` exports the contract paths and the artifact key namespace from an entrypoint that imports nothing else, for callers that must not load the bundler. The namespace gains a `source` key.
19
+
20
+ - Updated dependencies [[`cf89e74`](https://github.com/valbuild/val/commit/cf89e7429a79c4304ecbedd4f8b571a0de0f145f), [`c219574`](https://github.com/valbuild/val/commit/c21957498f3c7f4f47cef197c5dc0d591aa744ca), [`e673b43`](https://github.com/valbuild/val/commit/e673b43feaf48b7f30881d6786c858a0cb6a44a2), [`5c82928`](https://github.com/valbuild/val/commit/5c82928992d29f133f81cf0704a273c0449b18f1), [`d2f385a`](https://github.com/valbuild/val/commit/d2f385a04978992a5036379235e447ba7775faef)]:
21
+ - @valbuild/ui@0.136.1
22
+ - @valbuild/shared@0.136.1
23
+ - @valbuild/core@0.136.1
24
+
3
25
  ## 0.136.0
4
26
 
5
27
  ### Minor Changes
@@ -433,6 +433,31 @@ export declare abstract class ValOps {
433
433
  * an older service.
434
434
  */
435
435
  sourceMode(): "managed" | "connected" | null;
436
+ /**
437
+ * Forward one call to the content service's publish API, as this project.
438
+ *
439
+ * The Studio builds a managed project in the tab and then has to publish what
440
+ * it built -- but a browser cannot talk to content directly. It holds a
441
+ * session cookie for THIS origin and no credential content would accept, so
442
+ * the conversation goes through here, exactly as patches already do.
443
+ *
444
+ * Refused by default, and that is the honest answer rather than a gap: `fs`
445
+ * and memory mode have no content service to forward to, and publishing there
446
+ * is writing to disk.
447
+ *
448
+ * The path is content's, minus the `/v1` prefix -- `/publish`,
449
+ * `/publish/{id}/artifacts`, `/build-target`. The ALLOW LIST lives in the
450
+ * implementation rather than here, because it is what keeps this from being
451
+ * a way to reach the rest of content with the project's credential.
452
+ */
453
+ publishApi(_path: string, _init: {
454
+ method: string;
455
+ body?: string;
456
+ }): Promise<{
457
+ status: number;
458
+ body: string;
459
+ contentType: string;
460
+ }>;
436
461
  /**
437
462
  * Whether a commit here produces `.val.ts` TEXT as well as data.
438
463
  *
@@ -141,6 +141,67 @@ export declare class ValOpsHttp extends ValOps {
141
141
  * has just arrived.
142
142
  */
143
143
  sourceMode(): "managed" | "connected" | null;
144
+ /**
145
+ * The short-lived publish token, and when it stops being usable.
146
+ *
147
+ * `null` until something publishes, which for most deployments is never.
148
+ */
149
+ private publishToken;
150
+ /**
151
+ * Trade this deployment's credential for one that can do one thing.
152
+ *
153
+ * Content's publish API takes a PROJECT TOKEN and nothing else --
154
+ * `authenticateProjectToken` refuses anything that is not one, deliberately,
155
+ * because a personal access token is a person's credential and does not name
156
+ * a project. What this deployment holds is the project's api key, which is
157
+ * neither.
158
+ *
159
+ * `POST /v1/{org}/{project}/publish-token` is the exchange, and it was built
160
+ * for exactly this: its own docblock names the case where "the caller was the
161
+ * project's api key ... a machine exchanging one machine credential for a
162
+ * narrower one". What comes back can publish one project for ten minutes.
163
+ *
164
+ * So the api key never leaves this process and the browser never sees any
165
+ * credential at all. That is the point of routing the publish through here
166
+ * rather than letting the tab talk to content.
167
+ */
168
+ private mintPublishToken;
169
+ /**
170
+ * A usable publish token, minting one when what we have will not last.
171
+ *
172
+ * The margin is what stops a token that is valid when the publish starts from
173
+ * expiring in the middle of it: a publish is five calls and an upload of
174
+ * every artifact, and the upload is the slow one.
175
+ */
176
+ private currentPublishToken;
177
+ /**
178
+ * The content paths this may reach, and nothing else.
179
+ *
180
+ * An allow list rather than a prefix check, because this method holds a
181
+ * credential and the browser chooses the path. `/publish/{id}` and its three
182
+ * steps are the publish conversation; `/build-target` is what a build needs
183
+ * to know before it starts; `/project-source` is what it builds.
184
+ *
185
+ * `/project-source` is here rather than on a route of its own because it is
186
+ * one of the three things a publish asks for and none of them are useful
187
+ * apart -- and because the credential is the same one. The Studio cannot get
188
+ * the project's files any other way: it runs inside the deployment, which
189
+ * holds a session for its own origin and an api key for content, and content
190
+ * is the only thing it is allowed to talk to at all.
191
+ *
192
+ * A publish id is opaque and content-generated, so it is matched rather than
193
+ * parsed -- what matters is that nothing with a `..`, a query or another
194
+ * segment gets through.
195
+ */
196
+ private static publishApiPathAllowed;
197
+ publishApi(path: string, init: {
198
+ method: string;
199
+ body?: string;
200
+ }): Promise<{
201
+ status: number;
202
+ body: string;
203
+ contentType: string;
204
+ }>;
144
205
  onInit(): Promise<void>;
145
206
  getPresignedAuthNonce(profileId: string, corsOrigin: string): Promise<{
146
207
  status: "success";
@@ -4595,6 +4595,33 @@ class ValOps {
4595
4595
  return null;
4596
4596
  }
4597
4597
 
4598
+ /**
4599
+ * Forward one call to the content service's publish API, as this project.
4600
+ *
4601
+ * The Studio builds a managed project in the tab and then has to publish what
4602
+ * it built -- but a browser cannot talk to content directly. It holds a
4603
+ * session cookie for THIS origin and no credential content would accept, so
4604
+ * the conversation goes through here, exactly as patches already do.
4605
+ *
4606
+ * Refused by default, and that is the honest answer rather than a gap: `fs`
4607
+ * and memory mode have no content service to forward to, and publishing there
4608
+ * is writing to disk.
4609
+ *
4610
+ * The path is content's, minus the `/v1` prefix -- `/publish`,
4611
+ * `/publish/{id}/artifacts`, `/build-target`. The ALLOW LIST lives in the
4612
+ * implementation rather than here, because it is what keeps this from being
4613
+ * a way to reach the rest of content with the project's credential.
4614
+ */
4615
+ async publishApi(_path, _init) {
4616
+ return {
4617
+ status: 501,
4618
+ contentType: "application/json",
4619
+ body: JSON.stringify({
4620
+ 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."
4621
+ })
4622
+ };
4623
+ }
4624
+
4598
4625
  /**
4599
4626
  * Whether a commit here produces `.val.ts` TEXT as well as data.
4600
4627
  *
@@ -7973,6 +8000,183 @@ class ValOpsHttp extends ValOps {
7973
8000
  var _this$projectExpectat2;
7974
8001
  return ((_this$projectExpectat2 = this.projectExpectation) === null || _this$projectExpectat2 === void 0 ? void 0 : _this$projectExpectat2.sourceMode) ?? null;
7975
8002
  }
8003
+
8004
+ /**
8005
+ * The short-lived publish token, and when it stops being usable.
8006
+ *
8007
+ * `null` until something publishes, which for most deployments is never.
8008
+ */
8009
+ publishToken = null;
8010
+
8011
+ /**
8012
+ * Trade this deployment's credential for one that can do one thing.
8013
+ *
8014
+ * Content's publish API takes a PROJECT TOKEN and nothing else --
8015
+ * `authenticateProjectToken` refuses anything that is not one, deliberately,
8016
+ * because a personal access token is a person's credential and does not name
8017
+ * a project. What this deployment holds is the project's api key, which is
8018
+ * neither.
8019
+ *
8020
+ * `POST /v1/{org}/{project}/publish-token` is the exchange, and it was built
8021
+ * for exactly this: its own docblock names the case where "the caller was the
8022
+ * project's api key ... a machine exchanging one machine credential for a
8023
+ * narrower one". What comes back can publish one project for ten minutes.
8024
+ *
8025
+ * So the api key never leaves this process and the browser never sees any
8026
+ * credential at all. That is the point of routing the publish through here
8027
+ * rather than letting the tab talk to content.
8028
+ */
8029
+ async mintPublishToken() {
8030
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/publish-token`, {
8031
+ method: "POST",
8032
+ headers: this.authHeaders
8033
+ });
8034
+ const text = await res.text();
8035
+ if (!res.ok) {
8036
+ return {
8037
+ status: res.status,
8038
+ error: `Could not get a publish token for '${this.project}': ` + `${res.status} ${text.slice(0, 300)}`
8039
+ };
8040
+ }
8041
+ let parsed;
8042
+ try {
8043
+ parsed = JSON.parse(text);
8044
+ } catch {
8045
+ return {
8046
+ status: 502,
8047
+ error: "The publish token exchange did not answer with JSON."
8048
+ };
8049
+ }
8050
+ const token = typeof parsed === "object" && parsed !== null && "token" in parsed && typeof parsed.token === "string" ? parsed.token : null;
8051
+ if (token === null) {
8052
+ return {
8053
+ status: 502,
8054
+ error: "The publish token exchange sent no token."
8055
+ };
8056
+ }
8057
+ const expiresAtRaw = typeof parsed === "object" && parsed !== null && "expiresAt" in parsed ? parsed.expiresAt : null;
8058
+ const expiresAt = typeof expiresAtRaw === "string" ? Date.parse(expiresAtRaw) : NaN;
8059
+ return {
8060
+ token,
8061
+ /*
8062
+ * A token with no expiry, or one we cannot read, is treated as expiring
8063
+ * NOW -- so it is used for this call and minted again for the next.
8064
+ * Caching one we cannot reason about is how a publish starts failing
8065
+ * halfway through, days later, for no reason anyone can see.
8066
+ */
8067
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0
8068
+ };
8069
+ }
8070
+
8071
+ /**
8072
+ * A usable publish token, minting one when what we have will not last.
8073
+ *
8074
+ * The margin is what stops a token that is valid when the publish starts from
8075
+ * expiring in the middle of it: a publish is five calls and an upload of
8076
+ * every artifact, and the upload is the slow one.
8077
+ */
8078
+ async currentPublishToken() {
8079
+ const margin = 60_000;
8080
+ if (this.publishToken !== null && this.publishToken.expiresAt - margin > Date.now()) {
8081
+ return {
8082
+ token: this.publishToken.token
8083
+ };
8084
+ }
8085
+ const minted = await this.mintPublishToken();
8086
+ if ("error" in minted) {
8087
+ this.publishToken = null;
8088
+ return minted;
8089
+ }
8090
+ this.publishToken = minted;
8091
+ return {
8092
+ token: minted.token
8093
+ };
8094
+ }
8095
+
8096
+ /**
8097
+ * The content paths this may reach, and nothing else.
8098
+ *
8099
+ * An allow list rather than a prefix check, because this method holds a
8100
+ * credential and the browser chooses the path. `/publish/{id}` and its three
8101
+ * steps are the publish conversation; `/build-target` is what a build needs
8102
+ * to know before it starts; `/project-source` is what it builds.
8103
+ *
8104
+ * `/project-source` is here rather than on a route of its own because it is
8105
+ * one of the three things a publish asks for and none of them are useful
8106
+ * apart -- and because the credential is the same one. The Studio cannot get
8107
+ * the project's files any other way: it runs inside the deployment, which
8108
+ * holds a session for its own origin and an api key for content, and content
8109
+ * is the only thing it is allowed to talk to at all.
8110
+ *
8111
+ * A publish id is opaque and content-generated, so it is matched rather than
8112
+ * parsed -- what matters is that nothing with a `..`, a query or another
8113
+ * segment gets through.
8114
+ */
8115
+ static publishApiPathAllowed(path) {
8116
+ if (path === "/build-target" || path === "/project-source" || path === "/publish") {
8117
+ return true;
8118
+ }
8119
+ return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
8120
+ }
8121
+ async publishApi(path, init) {
8122
+ const json = "application/json";
8123
+ if (!ValOpsHttp.publishApiPathAllowed(path)) {
8124
+ return {
8125
+ status: 403,
8126
+ contentType: json,
8127
+ body: JSON.stringify({
8128
+ message: `'${path}' is not part of the publish API.`
8129
+ })
8130
+ };
8131
+ }
8132
+ const send = async token => fetch(`${this.contentUrl}/v1${path}`, {
8133
+ method: init.method,
8134
+ headers: {
8135
+ Authorization: `Bearer ${token}`,
8136
+ ...(init.body === undefined ? {} : {
8137
+ "Content-Type": json
8138
+ })
8139
+ },
8140
+ ...(init.body === undefined ? {} : {
8141
+ body: init.body
8142
+ })
8143
+ });
8144
+ const credential = await this.currentPublishToken();
8145
+ if ("error" in credential) {
8146
+ return {
8147
+ status: credential.status,
8148
+ contentType: json,
8149
+ body: JSON.stringify({
8150
+ message: credential.error
8151
+ })
8152
+ };
8153
+ }
8154
+ let res = await send(credential.token);
8155
+ if (res.status === 401) {
8156
+ /*
8157
+ * Revoked, or expired sooner than it said. One retry with a fresh token,
8158
+ * because the alternative is a publish that fails for a reason the editor
8159
+ * cannot act on and a retry that fails the same way.
8160
+ */
8161
+ this.publishToken = null;
8162
+ const second = await this.currentPublishToken();
8163
+ if ("error" in second) {
8164
+ return {
8165
+ status: second.status,
8166
+ contentType: json,
8167
+ body: JSON.stringify({
8168
+ message: second.error
8169
+ })
8170
+ };
8171
+ }
8172
+ res = await send(second.token);
8173
+ }
8174
+ return {
8175
+ status: res.status,
8176
+ contentType: res.headers.get("content-type") ?? json,
8177
+ body: await res.text()
8178
+ };
8179
+ }
7976
8180
  async onInit() {
7977
8181
  // TODO: unused for now. Implement or remove
7978
8182
  }
@@ -11531,6 +11735,38 @@ const ValServer = (valModules, options, callbacks) => {
11531
11735
  }
11532
11736
  };
11533
11737
  };
11738
+
11739
+ /**
11740
+ * Hand one publish-API call to content and carry the answer back.
11741
+ *
11742
+ * `JSON.parse` and nothing more. That is not validation -- there is no schema
11743
+ * here and no knowledge of content's shapes -- it is the round trip a JSON
11744
+ * body has to make to travel as `json` rather than as a string. A body that
11745
+ * does not parse is a gateway's error page rather than content answering, so
11746
+ * it comes back as a message with the original status instead of throwing:
11747
+ * a publish that failed is something the editor has to be told in words.
11748
+ */
11749
+ const proxyPublishApi = async (path, method, body) => {
11750
+ const answer = await serverOps.publishApi(path ?? "", {
11751
+ method,
11752
+ ...(body === undefined ? {} : {
11753
+ body
11754
+ })
11755
+ });
11756
+ try {
11757
+ return {
11758
+ status: answer.status,
11759
+ json: JSON.parse(answer.body)
11760
+ };
11761
+ } catch {
11762
+ return {
11763
+ status: answer.status,
11764
+ json: {
11765
+ message: answer.body.trim() === "" ? `The publish API answered ${answer.status} with no body.` : `The publish API answered ${answer.status}: ${answer.body.slice(0, 300)}`
11766
+ }
11767
+ };
11768
+ }
11769
+ };
11534
11770
  return {
11535
11771
  "/draft/enable": {
11536
11772
  GET: async req => {
@@ -11981,6 +12217,47 @@ const ValServer = (valModules, options, callbacks) => {
11981
12217
  };
11982
12218
  }
11983
12219
  },
12220
+ /**
12221
+ * The content service's publish API, reached through this deployment.
12222
+ *
12223
+ * The Studio builds a managed project in its own tab and then publishes
12224
+ * what it built. It cannot talk to content directly -- it holds a session
12225
+ * cookie for this origin and no credential content would accept -- so the
12226
+ * whole conversation comes through here.
12227
+ *
12228
+ * Three things happen, and nothing else: the session is checked, the
12229
+ * credential is swapped for a publish-scoped one (see
12230
+ * `ValOpsHttp.publishApi`), and content's answer is carried back with its
12231
+ * status intact. The Studio parses that answer with the same module
12232
+ * `val publish` uses, so there is no copy of content's shapes here to
12233
+ * fall out of step.
12234
+ */
12235
+ "/publish-api": {
12236
+ GET: async req => {
12237
+ const auth = getAuth(req.cookies);
12238
+ if (auth.error) {
12239
+ return {
12240
+ status: 401,
12241
+ json: {
12242
+ message: auth.error
12243
+ }
12244
+ };
12245
+ }
12246
+ return proxyPublishApi(req.path, "GET");
12247
+ },
12248
+ POST: async req => {
12249
+ const auth = getAuth(req.cookies);
12250
+ if (auth.error) {
12251
+ return {
12252
+ status: 401,
12253
+ json: {
12254
+ message: auth.error
12255
+ }
12256
+ };
12257
+ }
12258
+ return proxyPublishApi(req.path, "POST", req.body === undefined ? undefined : JSON.stringify(req.body));
12259
+ }
12260
+ },
11984
12261
  "/upload/patches": {
11985
12262
  POST: async req => {
11986
12263
  if (serverOps instanceof ValOpsHttp) {
@@ -13538,7 +13815,15 @@ const ValServer = (valModules, options, callbacks) => {
13538
13815
  return {
13539
13816
  status: 200,
13540
13817
  json: {
13541
- commitSha: commitRes.commit
13818
+ commitSha: commitRes.commit,
13819
+ /*
13820
+ * For the Studio to BUILD, when it is the deployer. See
13821
+ * `sourceFiles` on the route: the build must contain the text
13822
+ * this commit wrote, and only this handler has it.
13823
+ */
13824
+ ...(serverOps.sourceMode() === "managed" ? {
13825
+ sourceFiles: preparedCommit.patchedSourceFiles
13826
+ } : {})
13542
13827
  }
13543
13828
  };
13544
13829
  }
@@ -4595,6 +4595,33 @@ class ValOps {
4595
4595
  return null;
4596
4596
  }
4597
4597
 
4598
+ /**
4599
+ * Forward one call to the content service's publish API, as this project.
4600
+ *
4601
+ * The Studio builds a managed project in the tab and then has to publish what
4602
+ * it built -- but a browser cannot talk to content directly. It holds a
4603
+ * session cookie for THIS origin and no credential content would accept, so
4604
+ * the conversation goes through here, exactly as patches already do.
4605
+ *
4606
+ * Refused by default, and that is the honest answer rather than a gap: `fs`
4607
+ * and memory mode have no content service to forward to, and publishing there
4608
+ * is writing to disk.
4609
+ *
4610
+ * The path is content's, minus the `/v1` prefix -- `/publish`,
4611
+ * `/publish/{id}/artifacts`, `/build-target`. The ALLOW LIST lives in the
4612
+ * implementation rather than here, because it is what keeps this from being
4613
+ * a way to reach the rest of content with the project's credential.
4614
+ */
4615
+ async publishApi(_path, _init) {
4616
+ return {
4617
+ status: 501,
4618
+ contentType: "application/json",
4619
+ body: JSON.stringify({
4620
+ 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."
4621
+ })
4622
+ };
4623
+ }
4624
+
4598
4625
  /**
4599
4626
  * Whether a commit here produces `.val.ts` TEXT as well as data.
4600
4627
  *
@@ -7973,6 +8000,183 @@ class ValOpsHttp extends ValOps {
7973
8000
  var _this$projectExpectat2;
7974
8001
  return ((_this$projectExpectat2 = this.projectExpectation) === null || _this$projectExpectat2 === void 0 ? void 0 : _this$projectExpectat2.sourceMode) ?? null;
7975
8002
  }
8003
+
8004
+ /**
8005
+ * The short-lived publish token, and when it stops being usable.
8006
+ *
8007
+ * `null` until something publishes, which for most deployments is never.
8008
+ */
8009
+ publishToken = null;
8010
+
8011
+ /**
8012
+ * Trade this deployment's credential for one that can do one thing.
8013
+ *
8014
+ * Content's publish API takes a PROJECT TOKEN and nothing else --
8015
+ * `authenticateProjectToken` refuses anything that is not one, deliberately,
8016
+ * because a personal access token is a person's credential and does not name
8017
+ * a project. What this deployment holds is the project's api key, which is
8018
+ * neither.
8019
+ *
8020
+ * `POST /v1/{org}/{project}/publish-token` is the exchange, and it was built
8021
+ * for exactly this: its own docblock names the case where "the caller was the
8022
+ * project's api key ... a machine exchanging one machine credential for a
8023
+ * narrower one". What comes back can publish one project for ten minutes.
8024
+ *
8025
+ * So the api key never leaves this process and the browser never sees any
8026
+ * credential at all. That is the point of routing the publish through here
8027
+ * rather than letting the tab talk to content.
8028
+ */
8029
+ async mintPublishToken() {
8030
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/publish-token`, {
8031
+ method: "POST",
8032
+ headers: this.authHeaders
8033
+ });
8034
+ const text = await res.text();
8035
+ if (!res.ok) {
8036
+ return {
8037
+ status: res.status,
8038
+ error: `Could not get a publish token for '${this.project}': ` + `${res.status} ${text.slice(0, 300)}`
8039
+ };
8040
+ }
8041
+ let parsed;
8042
+ try {
8043
+ parsed = JSON.parse(text);
8044
+ } catch {
8045
+ return {
8046
+ status: 502,
8047
+ error: "The publish token exchange did not answer with JSON."
8048
+ };
8049
+ }
8050
+ const token = typeof parsed === "object" && parsed !== null && "token" in parsed && typeof parsed.token === "string" ? parsed.token : null;
8051
+ if (token === null) {
8052
+ return {
8053
+ status: 502,
8054
+ error: "The publish token exchange sent no token."
8055
+ };
8056
+ }
8057
+ const expiresAtRaw = typeof parsed === "object" && parsed !== null && "expiresAt" in parsed ? parsed.expiresAt : null;
8058
+ const expiresAt = typeof expiresAtRaw === "string" ? Date.parse(expiresAtRaw) : NaN;
8059
+ return {
8060
+ token,
8061
+ /*
8062
+ * A token with no expiry, or one we cannot read, is treated as expiring
8063
+ * NOW -- so it is used for this call and minted again for the next.
8064
+ * Caching one we cannot reason about is how a publish starts failing
8065
+ * halfway through, days later, for no reason anyone can see.
8066
+ */
8067
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0
8068
+ };
8069
+ }
8070
+
8071
+ /**
8072
+ * A usable publish token, minting one when what we have will not last.
8073
+ *
8074
+ * The margin is what stops a token that is valid when the publish starts from
8075
+ * expiring in the middle of it: a publish is five calls and an upload of
8076
+ * every artifact, and the upload is the slow one.
8077
+ */
8078
+ async currentPublishToken() {
8079
+ const margin = 60_000;
8080
+ if (this.publishToken !== null && this.publishToken.expiresAt - margin > Date.now()) {
8081
+ return {
8082
+ token: this.publishToken.token
8083
+ };
8084
+ }
8085
+ const minted = await this.mintPublishToken();
8086
+ if ("error" in minted) {
8087
+ this.publishToken = null;
8088
+ return minted;
8089
+ }
8090
+ this.publishToken = minted;
8091
+ return {
8092
+ token: minted.token
8093
+ };
8094
+ }
8095
+
8096
+ /**
8097
+ * The content paths this may reach, and nothing else.
8098
+ *
8099
+ * An allow list rather than a prefix check, because this method holds a
8100
+ * credential and the browser chooses the path. `/publish/{id}` and its three
8101
+ * steps are the publish conversation; `/build-target` is what a build needs
8102
+ * to know before it starts; `/project-source` is what it builds.
8103
+ *
8104
+ * `/project-source` is here rather than on a route of its own because it is
8105
+ * one of the three things a publish asks for and none of them are useful
8106
+ * apart -- and because the credential is the same one. The Studio cannot get
8107
+ * the project's files any other way: it runs inside the deployment, which
8108
+ * holds a session for its own origin and an api key for content, and content
8109
+ * is the only thing it is allowed to talk to at all.
8110
+ *
8111
+ * A publish id is opaque and content-generated, so it is matched rather than
8112
+ * parsed -- what matters is that nothing with a `..`, a query or another
8113
+ * segment gets through.
8114
+ */
8115
+ static publishApiPathAllowed(path) {
8116
+ if (path === "/build-target" || path === "/project-source" || path === "/publish") {
8117
+ return true;
8118
+ }
8119
+ return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
8120
+ }
8121
+ async publishApi(path, init) {
8122
+ const json = "application/json";
8123
+ if (!ValOpsHttp.publishApiPathAllowed(path)) {
8124
+ return {
8125
+ status: 403,
8126
+ contentType: json,
8127
+ body: JSON.stringify({
8128
+ message: `'${path}' is not part of the publish API.`
8129
+ })
8130
+ };
8131
+ }
8132
+ const send = async token => fetch(`${this.contentUrl}/v1${path}`, {
8133
+ method: init.method,
8134
+ headers: {
8135
+ Authorization: `Bearer ${token}`,
8136
+ ...(init.body === undefined ? {} : {
8137
+ "Content-Type": json
8138
+ })
8139
+ },
8140
+ ...(init.body === undefined ? {} : {
8141
+ body: init.body
8142
+ })
8143
+ });
8144
+ const credential = await this.currentPublishToken();
8145
+ if ("error" in credential) {
8146
+ return {
8147
+ status: credential.status,
8148
+ contentType: json,
8149
+ body: JSON.stringify({
8150
+ message: credential.error
8151
+ })
8152
+ };
8153
+ }
8154
+ let res = await send(credential.token);
8155
+ if (res.status === 401) {
8156
+ /*
8157
+ * Revoked, or expired sooner than it said. One retry with a fresh token,
8158
+ * because the alternative is a publish that fails for a reason the editor
8159
+ * cannot act on and a retry that fails the same way.
8160
+ */
8161
+ this.publishToken = null;
8162
+ const second = await this.currentPublishToken();
8163
+ if ("error" in second) {
8164
+ return {
8165
+ status: second.status,
8166
+ contentType: json,
8167
+ body: JSON.stringify({
8168
+ message: second.error
8169
+ })
8170
+ };
8171
+ }
8172
+ res = await send(second.token);
8173
+ }
8174
+ return {
8175
+ status: res.status,
8176
+ contentType: res.headers.get("content-type") ?? json,
8177
+ body: await res.text()
8178
+ };
8179
+ }
7976
8180
  async onInit() {
7977
8181
  // TODO: unused for now. Implement or remove
7978
8182
  }
@@ -11531,6 +11735,38 @@ const ValServer = (valModules, options, callbacks) => {
11531
11735
  }
11532
11736
  };
11533
11737
  };
11738
+
11739
+ /**
11740
+ * Hand one publish-API call to content and carry the answer back.
11741
+ *
11742
+ * `JSON.parse` and nothing more. That is not validation -- there is no schema
11743
+ * here and no knowledge of content's shapes -- it is the round trip a JSON
11744
+ * body has to make to travel as `json` rather than as a string. A body that
11745
+ * does not parse is a gateway's error page rather than content answering, so
11746
+ * it comes back as a message with the original status instead of throwing:
11747
+ * a publish that failed is something the editor has to be told in words.
11748
+ */
11749
+ const proxyPublishApi = async (path, method, body) => {
11750
+ const answer = await serverOps.publishApi(path ?? "", {
11751
+ method,
11752
+ ...(body === undefined ? {} : {
11753
+ body
11754
+ })
11755
+ });
11756
+ try {
11757
+ return {
11758
+ status: answer.status,
11759
+ json: JSON.parse(answer.body)
11760
+ };
11761
+ } catch {
11762
+ return {
11763
+ status: answer.status,
11764
+ json: {
11765
+ message: answer.body.trim() === "" ? `The publish API answered ${answer.status} with no body.` : `The publish API answered ${answer.status}: ${answer.body.slice(0, 300)}`
11766
+ }
11767
+ };
11768
+ }
11769
+ };
11534
11770
  return {
11535
11771
  "/draft/enable": {
11536
11772
  GET: async req => {
@@ -11981,6 +12217,47 @@ const ValServer = (valModules, options, callbacks) => {
11981
12217
  };
11982
12218
  }
11983
12219
  },
12220
+ /**
12221
+ * The content service's publish API, reached through this deployment.
12222
+ *
12223
+ * The Studio builds a managed project in its own tab and then publishes
12224
+ * what it built. It cannot talk to content directly -- it holds a session
12225
+ * cookie for this origin and no credential content would accept -- so the
12226
+ * whole conversation comes through here.
12227
+ *
12228
+ * Three things happen, and nothing else: the session is checked, the
12229
+ * credential is swapped for a publish-scoped one (see
12230
+ * `ValOpsHttp.publishApi`), and content's answer is carried back with its
12231
+ * status intact. The Studio parses that answer with the same module
12232
+ * `val publish` uses, so there is no copy of content's shapes here to
12233
+ * fall out of step.
12234
+ */
12235
+ "/publish-api": {
12236
+ GET: async req => {
12237
+ const auth = getAuth(req.cookies);
12238
+ if (auth.error) {
12239
+ return {
12240
+ status: 401,
12241
+ json: {
12242
+ message: auth.error
12243
+ }
12244
+ };
12245
+ }
12246
+ return proxyPublishApi(req.path, "GET");
12247
+ },
12248
+ POST: async req => {
12249
+ const auth = getAuth(req.cookies);
12250
+ if (auth.error) {
12251
+ return {
12252
+ status: 401,
12253
+ json: {
12254
+ message: auth.error
12255
+ }
12256
+ };
12257
+ }
12258
+ return proxyPublishApi(req.path, "POST", req.body === undefined ? undefined : JSON.stringify(req.body));
12259
+ }
12260
+ },
11984
12261
  "/upload/patches": {
11985
12262
  POST: async req => {
11986
12263
  if (serverOps instanceof ValOpsHttp) {
@@ -13538,7 +13815,15 @@ const ValServer = (valModules, options, callbacks) => {
13538
13815
  return {
13539
13816
  status: 200,
13540
13817
  json: {
13541
- commitSha: commitRes.commit
13818
+ commitSha: commitRes.commit,
13819
+ /*
13820
+ * For the Studio to BUILD, when it is the deployer. See
13821
+ * `sourceFiles` on the route: the build must contain the text
13822
+ * this commit wrote, and only this handler has it.
13823
+ */
13824
+ ...(serverOps.sourceMode() === "managed" ? {
13825
+ sourceFiles: preparedCommit.patchedSourceFiles
13826
+ } : {})
13542
13827
  }
13543
13828
  };
13544
13829
  }
@@ -4561,6 +4561,33 @@ class ValOps {
4561
4561
  return null;
4562
4562
  }
4563
4563
 
4564
+ /**
4565
+ * Forward one call to the content service's publish API, as this project.
4566
+ *
4567
+ * The Studio builds a managed project in the tab and then has to publish what
4568
+ * it built -- but a browser cannot talk to content directly. It holds a
4569
+ * session cookie for THIS origin and no credential content would accept, so
4570
+ * the conversation goes through here, exactly as patches already do.
4571
+ *
4572
+ * Refused by default, and that is the honest answer rather than a gap: `fs`
4573
+ * and memory mode have no content service to forward to, and publishing there
4574
+ * is writing to disk.
4575
+ *
4576
+ * The path is content's, minus the `/v1` prefix -- `/publish`,
4577
+ * `/publish/{id}/artifacts`, `/build-target`. The ALLOW LIST lives in the
4578
+ * implementation rather than here, because it is what keeps this from being
4579
+ * a way to reach the rest of content with the project's credential.
4580
+ */
4581
+ async publishApi(_path, _init) {
4582
+ return {
4583
+ status: 501,
4584
+ contentType: "application/json",
4585
+ body: JSON.stringify({
4586
+ 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."
4587
+ })
4588
+ };
4589
+ }
4590
+
4564
4591
  /**
4565
4592
  * Whether a commit here produces `.val.ts` TEXT as well as data.
4566
4593
  *
@@ -7939,6 +7966,183 @@ class ValOpsHttp extends ValOps {
7939
7966
  var _this$projectExpectat2;
7940
7967
  return ((_this$projectExpectat2 = this.projectExpectation) === null || _this$projectExpectat2 === void 0 ? void 0 : _this$projectExpectat2.sourceMode) ?? null;
7941
7968
  }
7969
+
7970
+ /**
7971
+ * The short-lived publish token, and when it stops being usable.
7972
+ *
7973
+ * `null` until something publishes, which for most deployments is never.
7974
+ */
7975
+ publishToken = null;
7976
+
7977
+ /**
7978
+ * Trade this deployment's credential for one that can do one thing.
7979
+ *
7980
+ * Content's publish API takes a PROJECT TOKEN and nothing else --
7981
+ * `authenticateProjectToken` refuses anything that is not one, deliberately,
7982
+ * because a personal access token is a person's credential and does not name
7983
+ * a project. What this deployment holds is the project's api key, which is
7984
+ * neither.
7985
+ *
7986
+ * `POST /v1/{org}/{project}/publish-token` is the exchange, and it was built
7987
+ * for exactly this: its own docblock names the case where "the caller was the
7988
+ * project's api key ... a machine exchanging one machine credential for a
7989
+ * narrower one". What comes back can publish one project for ten minutes.
7990
+ *
7991
+ * So the api key never leaves this process and the browser never sees any
7992
+ * credential at all. That is the point of routing the publish through here
7993
+ * rather than letting the tab talk to content.
7994
+ */
7995
+ async mintPublishToken() {
7996
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/publish-token`, {
7997
+ method: "POST",
7998
+ headers: this.authHeaders
7999
+ });
8000
+ const text = await res.text();
8001
+ if (!res.ok) {
8002
+ return {
8003
+ status: res.status,
8004
+ error: `Could not get a publish token for '${this.project}': ` + `${res.status} ${text.slice(0, 300)}`
8005
+ };
8006
+ }
8007
+ let parsed;
8008
+ try {
8009
+ parsed = JSON.parse(text);
8010
+ } catch {
8011
+ return {
8012
+ status: 502,
8013
+ error: "The publish token exchange did not answer with JSON."
8014
+ };
8015
+ }
8016
+ const token = typeof parsed === "object" && parsed !== null && "token" in parsed && typeof parsed.token === "string" ? parsed.token : null;
8017
+ if (token === null) {
8018
+ return {
8019
+ status: 502,
8020
+ error: "The publish token exchange sent no token."
8021
+ };
8022
+ }
8023
+ const expiresAtRaw = typeof parsed === "object" && parsed !== null && "expiresAt" in parsed ? parsed.expiresAt : null;
8024
+ const expiresAt = typeof expiresAtRaw === "string" ? Date.parse(expiresAtRaw) : NaN;
8025
+ return {
8026
+ token,
8027
+ /*
8028
+ * A token with no expiry, or one we cannot read, is treated as expiring
8029
+ * NOW -- so it is used for this call and minted again for the next.
8030
+ * Caching one we cannot reason about is how a publish starts failing
8031
+ * halfway through, days later, for no reason anyone can see.
8032
+ */
8033
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0
8034
+ };
8035
+ }
8036
+
8037
+ /**
8038
+ * A usable publish token, minting one when what we have will not last.
8039
+ *
8040
+ * The margin is what stops a token that is valid when the publish starts from
8041
+ * expiring in the middle of it: a publish is five calls and an upload of
8042
+ * every artifact, and the upload is the slow one.
8043
+ */
8044
+ async currentPublishToken() {
8045
+ const margin = 60_000;
8046
+ if (this.publishToken !== null && this.publishToken.expiresAt - margin > Date.now()) {
8047
+ return {
8048
+ token: this.publishToken.token
8049
+ };
8050
+ }
8051
+ const minted = await this.mintPublishToken();
8052
+ if ("error" in minted) {
8053
+ this.publishToken = null;
8054
+ return minted;
8055
+ }
8056
+ this.publishToken = minted;
8057
+ return {
8058
+ token: minted.token
8059
+ };
8060
+ }
8061
+
8062
+ /**
8063
+ * The content paths this may reach, and nothing else.
8064
+ *
8065
+ * An allow list rather than a prefix check, because this method holds a
8066
+ * credential and the browser chooses the path. `/publish/{id}` and its three
8067
+ * steps are the publish conversation; `/build-target` is what a build needs
8068
+ * to know before it starts; `/project-source` is what it builds.
8069
+ *
8070
+ * `/project-source` is here rather than on a route of its own because it is
8071
+ * one of the three things a publish asks for and none of them are useful
8072
+ * apart -- and because the credential is the same one. The Studio cannot get
8073
+ * the project's files any other way: it runs inside the deployment, which
8074
+ * holds a session for its own origin and an api key for content, and content
8075
+ * is the only thing it is allowed to talk to at all.
8076
+ *
8077
+ * A publish id is opaque and content-generated, so it is matched rather than
8078
+ * parsed -- what matters is that nothing with a `..`, a query or another
8079
+ * segment gets through.
8080
+ */
8081
+ static publishApiPathAllowed(path) {
8082
+ if (path === "/build-target" || path === "/project-source" || path === "/publish") {
8083
+ return true;
8084
+ }
8085
+ return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
8086
+ }
8087
+ async publishApi(path, init) {
8088
+ const json = "application/json";
8089
+ if (!ValOpsHttp.publishApiPathAllowed(path)) {
8090
+ return {
8091
+ status: 403,
8092
+ contentType: json,
8093
+ body: JSON.stringify({
8094
+ message: `'${path}' is not part of the publish API.`
8095
+ })
8096
+ };
8097
+ }
8098
+ const send = async token => fetch(`${this.contentUrl}/v1${path}`, {
8099
+ method: init.method,
8100
+ headers: {
8101
+ Authorization: `Bearer ${token}`,
8102
+ ...(init.body === undefined ? {} : {
8103
+ "Content-Type": json
8104
+ })
8105
+ },
8106
+ ...(init.body === undefined ? {} : {
8107
+ body: init.body
8108
+ })
8109
+ });
8110
+ const credential = await this.currentPublishToken();
8111
+ if ("error" in credential) {
8112
+ return {
8113
+ status: credential.status,
8114
+ contentType: json,
8115
+ body: JSON.stringify({
8116
+ message: credential.error
8117
+ })
8118
+ };
8119
+ }
8120
+ let res = await send(credential.token);
8121
+ if (res.status === 401) {
8122
+ /*
8123
+ * Revoked, or expired sooner than it said. One retry with a fresh token,
8124
+ * because the alternative is a publish that fails for a reason the editor
8125
+ * cannot act on and a retry that fails the same way.
8126
+ */
8127
+ this.publishToken = null;
8128
+ const second = await this.currentPublishToken();
8129
+ if ("error" in second) {
8130
+ return {
8131
+ status: second.status,
8132
+ contentType: json,
8133
+ body: JSON.stringify({
8134
+ message: second.error
8135
+ })
8136
+ };
8137
+ }
8138
+ res = await send(second.token);
8139
+ }
8140
+ return {
8141
+ status: res.status,
8142
+ contentType: res.headers.get("content-type") ?? json,
8143
+ body: await res.text()
8144
+ };
8145
+ }
7942
8146
  async onInit() {
7943
8147
  // TODO: unused for now. Implement or remove
7944
8148
  }
@@ -11497,6 +11701,38 @@ const ValServer = (valModules, options, callbacks) => {
11497
11701
  }
11498
11702
  };
11499
11703
  };
11704
+
11705
+ /**
11706
+ * Hand one publish-API call to content and carry the answer back.
11707
+ *
11708
+ * `JSON.parse` and nothing more. That is not validation -- there is no schema
11709
+ * here and no knowledge of content's shapes -- it is the round trip a JSON
11710
+ * body has to make to travel as `json` rather than as a string. A body that
11711
+ * does not parse is a gateway's error page rather than content answering, so
11712
+ * it comes back as a message with the original status instead of throwing:
11713
+ * a publish that failed is something the editor has to be told in words.
11714
+ */
11715
+ const proxyPublishApi = async (path, method, body) => {
11716
+ const answer = await serverOps.publishApi(path ?? "", {
11717
+ method,
11718
+ ...(body === undefined ? {} : {
11719
+ body
11720
+ })
11721
+ });
11722
+ try {
11723
+ return {
11724
+ status: answer.status,
11725
+ json: JSON.parse(answer.body)
11726
+ };
11727
+ } catch {
11728
+ return {
11729
+ status: answer.status,
11730
+ json: {
11731
+ message: answer.body.trim() === "" ? `The publish API answered ${answer.status} with no body.` : `The publish API answered ${answer.status}: ${answer.body.slice(0, 300)}`
11732
+ }
11733
+ };
11734
+ }
11735
+ };
11500
11736
  return {
11501
11737
  "/draft/enable": {
11502
11738
  GET: async req => {
@@ -11947,6 +12183,47 @@ const ValServer = (valModules, options, callbacks) => {
11947
12183
  };
11948
12184
  }
11949
12185
  },
12186
+ /**
12187
+ * The content service's publish API, reached through this deployment.
12188
+ *
12189
+ * The Studio builds a managed project in its own tab and then publishes
12190
+ * what it built. It cannot talk to content directly -- it holds a session
12191
+ * cookie for this origin and no credential content would accept -- so the
12192
+ * whole conversation comes through here.
12193
+ *
12194
+ * Three things happen, and nothing else: the session is checked, the
12195
+ * credential is swapped for a publish-scoped one (see
12196
+ * `ValOpsHttp.publishApi`), and content's answer is carried back with its
12197
+ * status intact. The Studio parses that answer with the same module
12198
+ * `val publish` uses, so there is no copy of content's shapes here to
12199
+ * fall out of step.
12200
+ */
12201
+ "/publish-api": {
12202
+ GET: async req => {
12203
+ const auth = getAuth(req.cookies);
12204
+ if (auth.error) {
12205
+ return {
12206
+ status: 401,
12207
+ json: {
12208
+ message: auth.error
12209
+ }
12210
+ };
12211
+ }
12212
+ return proxyPublishApi(req.path, "GET");
12213
+ },
12214
+ POST: async req => {
12215
+ const auth = getAuth(req.cookies);
12216
+ if (auth.error) {
12217
+ return {
12218
+ status: 401,
12219
+ json: {
12220
+ message: auth.error
12221
+ }
12222
+ };
12223
+ }
12224
+ return proxyPublishApi(req.path, "POST", req.body === undefined ? undefined : JSON.stringify(req.body));
12225
+ }
12226
+ },
11950
12227
  "/upload/patches": {
11951
12228
  POST: async req => {
11952
12229
  if (serverOps instanceof ValOpsHttp) {
@@ -13504,7 +13781,15 @@ const ValServer = (valModules, options, callbacks) => {
13504
13781
  return {
13505
13782
  status: 200,
13506
13783
  json: {
13507
- commitSha: commitRes.commit
13784
+ commitSha: commitRes.commit,
13785
+ /*
13786
+ * For the Studio to BUILD, when it is the deployer. See
13787
+ * `sourceFiles` on the route: the build must contain the text
13788
+ * this commit wrote, and only this handler has it.
13789
+ */
13790
+ ...(serverOps.sourceMode() === "managed" ? {
13791
+ sourceFiles: preparedCommit.patchedSourceFiles
13792
+ } : {})
13508
13793
  }
13509
13794
  };
13510
13795
  }
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.1",
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/core": "0.136.1",
34
+ "@valbuild/shared": "0.136.1",
35
+ "@valbuild/ui": "0.136.1"
36
36
  },
37
37
  "engines": {
38
38
  "node": "^20.19.0 || >=22"