@valbuild/server 0.136.10 → 0.138.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.
@@ -8,16 +8,19 @@ import path__default from 'path';
8
8
  import fs from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module } from 'node:module';
11
- import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, PatchId as PatchId$1, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
11
+ import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, PatchId as PatchId$1, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, parseJob, Api } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
13
  import crypto$1 from 'crypto';
14
14
  import z$1, { z } from 'zod';
15
+ import http from 'node:http';
16
+ import https from 'node:https';
17
+ import net from 'node:net';
15
18
  import sizeOf from 'image-size';
16
19
  import { fromError } from 'zod-validation-error';
17
20
  import os from 'os';
18
21
  import { transform } from 'sucrase';
19
- import http from 'http';
20
- import https from 'https';
22
+ import http$1 from 'http';
23
+ import https$1 from 'https';
21
24
  import fs$1 from 'fs/promises';
22
25
 
23
26
  class ValSyntaxError {
@@ -2565,6 +2568,572 @@ function encodeJwt(payload, sessionKey) {
2565
2568
  return `${jwtHeaderBase64}.${payloadBase64}.${crypto$1.createHmac("sha256", sessionKey).update(`${jwtHeaderBase64}.${payloadBase64}`).digest("base64")}`;
2566
2569
  }
2567
2570
 
2571
+ /**
2572
+ * Just the part of `ValServerConfig` this decision needs.
2573
+ *
2574
+ * Narrower than the whole thing on purpose: a `ValServerConfig` carries the api
2575
+ * key, the secret and the content URLs, and a function that took one would look
2576
+ * like it might consult them. It also makes this testable with an object
2577
+ * literal rather than a cast.
2578
+ */
2579
+
2580
+ /**
2581
+ * The `val.config` the Studio is told about, which is not quite the one on disk.
2582
+ *
2583
+ * `gitBranch` is optional in `val.config`, and a proxy-mode server that mirrors
2584
+ * into a repository usually knows the branch when the file does not: setting it
2585
+ * in the environment is how a Vercel deployment does it
2586
+ * (`VERCEL_GIT_COMMIT_REF`), and the example app does not set it at all.
2587
+ *
2588
+ * Git is OPTIONAL in http mode — a project can run on credentials alone, with
2589
+ * no repository to mirror into — so "http" no longer implies a branch exists.
2590
+ * That changed under this function rather than being designed into it: the
2591
+ * original said proxy mode "refuses to start without `VAL_GIT_BRANCH`", which
2592
+ * was true when it was written and is not any more. With no mirror there is
2593
+ * nothing to fill in and nothing to say, which is the same answer fs mode gets.
2594
+ *
2595
+ * The Studio reads `config.gitBranch` and nothing else, so without this a
2596
+ * correctly configured project gets a History pane reading "this project has no
2597
+ * gitBranch configured" while the server behind it is busy committing to one,
2598
+ * and a status bar with no branch on it.
2599
+ *
2600
+ * Narrow on purpose:
2601
+ *
2602
+ * - **`val.config` wins.** A branch named in the file is the author's answer,
2603
+ * and the environment's is the fallback.
2604
+ * - **Every other mode is left alone.** Neither `fs` nor `memory` has a branch:
2605
+ * `ValOpsFS` has no commits of its own, and a memory host holds its own
2606
+ * source and never had any. Nor does an http project with no git mirror. The
2607
+ * shell hides what needs a branch rather than being handed a name that means
2608
+ * nothing.
2609
+ *
2610
+ * The modes are enumerated rather than written as "http, or anything else", and
2611
+ * that is worth keeping: `memory` arrived after this function did, and spelling
2612
+ * every mode out is what turned "does this one have a branch?" into a compile
2613
+ * error somebody had to answer instead of a default that silently applied.
2614
+ */
2615
+ function clientConfig(options) {
2616
+ if (options.mode !== "http" || options.git === undefined) {
2617
+ return options.config;
2618
+ }
2619
+ if (options.config.gitBranch !== undefined) {
2620
+ return options.config;
2621
+ }
2622
+ return {
2623
+ ...options.config,
2624
+ gitBranch: options.git.branch
2625
+ };
2626
+ }
2627
+
2628
+ /**
2629
+ * Which IP addresses this server may open a connection to.
2630
+ *
2631
+ * The link check makes outbound requests to addresses an editor supplies,
2632
+ * which is the definition of SSRF: without this, "check my external links"
2633
+ * is a button that turns the app's server into a probe for whatever is
2634
+ * reachable from inside the network it runs in - a database on a private
2635
+ * subnet, an admin panel on localhost, and on every major cloud the instance
2636
+ * metadata service on 169.254.169.254, which hands out credentials.
2637
+ *
2638
+ * So the rule is a DENY LIST of address ranges rather than an allow list of
2639
+ * hostnames: an editor is allowed to check any real site on the internet, and
2640
+ * nothing else. It is applied to the RESOLVED address, in the lookup the
2641
+ * socket then connects to - a public name is free to resolve to 127.0.0.1,
2642
+ * and checking the name would catch none of it.
2643
+ */
2644
+
2645
+ /** Why an address may not be connected to. Reported, not just counted. */
2646
+
2647
+ const REASON_TEXT = {
2648
+ unspecified: "an unspecified address",
2649
+ loopback: "a loopback address",
2650
+ private: "a private address",
2651
+ "link-local": "a link-local address",
2652
+ "unique-local": "a unique local address",
2653
+ multicast: "a multicast address",
2654
+ reserved: "a reserved address",
2655
+ unparseable: "an address that could not be read"
2656
+ };
2657
+ function describeBlocked(reason) {
2658
+ return REASON_TEXT[reason];
2659
+ }
2660
+
2661
+ /** `null` when the address is a public one this server may talk to. */
2662
+ function blockedAddressReason(address) {
2663
+ // A scope/zone id (`fe80::1%eth0`) is not part of the address, and leaving
2664
+ // it on would make the address unparseable — which fails closed, but for the
2665
+ // wrong reason and with the wrong message.
2666
+ const bare = address.split("%")[0];
2667
+ const v4 = parseIPv4(bare);
2668
+ if (v4 !== null) {
2669
+ return blockedIPv4(v4);
2670
+ }
2671
+ const v6 = parseIPv6(bare);
2672
+ if (v6 !== null) {
2673
+ return blockedIPv6(v6);
2674
+ }
2675
+ // Fail closed. Anything that reaches here is not something `dns.lookup`
2676
+ // produced, and connecting to an address this cannot read is exactly the
2677
+ // case a deny list must not wave through.
2678
+ return "unparseable";
2679
+ }
2680
+ function parseIPv4(text) {
2681
+ const parts = text.split(".");
2682
+ if (parts.length !== 4) {
2683
+ return null;
2684
+ }
2685
+ const octets = [];
2686
+ for (const part of parts) {
2687
+ // Deliberately strict: `010` is octal to `inet_aton` and decimal to
2688
+ // `Number`, and `1e2` parses as 100. A resolver never produces either, so
2689
+ // anything that is not plain digits is not an address - and a leading
2690
+ // zero is refused outright rather than read as decimal, because that is
2691
+ // the one form where this and `inet_aton` would read the SAME string as
2692
+ // two different addresses. `0` itself is an octet; `01` is not.
2693
+ if (!/^(0|[1-9]\d{0,2})$/.test(part)) {
2694
+ return null;
2695
+ }
2696
+ const value = Number(part);
2697
+ if (value > 255) {
2698
+ return null;
2699
+ }
2700
+ octets.push(value);
2701
+ }
2702
+ return [octets[0], octets[1], octets[2], octets[3]];
2703
+ }
2704
+
2705
+ /** Eight hextets, with `::` expanded and a trailing IPv4 form accepted. */
2706
+ function parseIPv6(text) {
2707
+ if (!text.includes(":")) {
2708
+ return null;
2709
+ }
2710
+ // A trailing dotted quad is rewritten into the two hextets it IS, before
2711
+ // anything else looks at the string. Splitting the address around it
2712
+ // instead means handling a `::` that the split lands inside - which is
2713
+ // exactly where `::127.0.0.1` went wrong.
2714
+ let normalized = text;
2715
+ const lastColon = text.lastIndexOf(":");
2716
+ const tail = text.slice(lastColon + 1);
2717
+ if (tail.includes(".")) {
2718
+ const v4 = parseIPv4(tail);
2719
+ if (v4 === null) {
2720
+ return null;
2721
+ }
2722
+ const high = (v4[0] << 8 | v4[1]).toString(16);
2723
+ const low = (v4[2] << 8 | v4[3]).toString(16);
2724
+ normalized = `${text.slice(0, lastColon + 1)}${high}:${low}`;
2725
+ }
2726
+ const doubleColon = normalized.indexOf("::");
2727
+ if (doubleColon !== normalized.lastIndexOf("::")) {
2728
+ return null;
2729
+ }
2730
+ let groups;
2731
+ if (doubleColon === -1) {
2732
+ groups = normalized.split(":");
2733
+ if (groups.length !== 8) {
2734
+ return null;
2735
+ }
2736
+ } else {
2737
+ const left = normalized.slice(0, doubleColon);
2738
+ const right = normalized.slice(doubleColon + 2);
2739
+ const before = left === "" ? [] : left.split(":");
2740
+ const after = right === "" ? [] : right.split(":");
2741
+ if (before.length + after.length > 7) {
2742
+ // `::` stands for AT LEAST one group of zeros, so eight written groups
2743
+ // beside it is one too many.
2744
+ return null;
2745
+ }
2746
+ groups = [...before, ...new Array(8 - before.length - after.length).fill("0"), ...after];
2747
+ }
2748
+ const hextets = [];
2749
+ for (const group of groups) {
2750
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) {
2751
+ return null;
2752
+ }
2753
+ hextets.push(parseInt(group, 16));
2754
+ }
2755
+ return hextets.length === 8 ? hextets : null;
2756
+ }
2757
+ function blockedIPv4(octets) {
2758
+ const [a, b, c] = octets;
2759
+ if (a === 0) return "unspecified"; // 0.0.0.0/8
2760
+ if (a === 10) return "private"; // 10.0.0.0/8
2761
+ if (a === 127) return "loopback"; // 127.0.0.0/8
2762
+ if (a === 100 && b >= 64 && b <= 127) return "private"; // 100.64.0.0/10 CGNAT
2763
+ if (a === 169 && b === 254) return "link-local"; // 169.254.0.0/16 — cloud metadata
2764
+ if (a === 172 && b >= 16 && b <= 31) return "private"; // 172.16.0.0/12
2765
+ if (a === 192 && b === 168) return "private"; // 192.168.0.0/16
2766
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return "reserved"; // 192.0.0.0/24, TEST-NET-1
2767
+ if (a === 192 && b === 88 && c === 99) return "reserved"; // 6to4 relay anycast
2768
+ if (a === 198 && (b === 18 || b === 19)) return "reserved"; // benchmarking
2769
+ if (a === 198 && b === 51 && c === 100) return "reserved"; // TEST-NET-2
2770
+ if (a === 203 && b === 0 && c === 113) return "reserved"; // TEST-NET-3
2771
+ if (a >= 240) return "reserved"; // 240.0.0.0/4, incl. 255.255.255.255
2772
+ if (a >= 224) return "multicast"; // 224.0.0.0/4
2773
+ return null;
2774
+ }
2775
+ function blockedIPv6(h) {
2776
+ const leadingZeros = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0;
2777
+ if (leadingZeros && h[5] === 0xffff) {
2778
+ // ::ffff:0:0/96 — an IPv4 address wearing an IPv6 hat, and the form a
2779
+ // dual-stack resolver hands back. Judged as the IPv4 address it is, or
2780
+ // this whole list would be one `::ffff:` away from being bypassed.
2781
+ return blockedIPv4(v4Of(h[6], h[7]));
2782
+ }
2783
+ if (leadingZeros && h[5] === 0) {
2784
+ // ::/128 unspecified, ::1/128 loopback, and the deprecated
2785
+ // IPv4-compatible ::a.b.c.d — all of which are local or nothing.
2786
+ if (h[6] === 0 && h[7] === 0) return "unspecified";
2787
+ if (h[6] === 0 && h[7] === 1) return "loopback";
2788
+ return blockedIPv4(v4Of(h[6], h[7])) ?? "reserved";
2789
+ }
2790
+ if (h[0] === 0x64 && h[1] === 0xff9b) return "reserved"; // NAT64
2791
+ if (h[0] === 0x100 && h[1] === 0 && h[2] === 0 && h[3] === 0) {
2792
+ return "reserved"; // 100::/64 discard-only
2793
+ }
2794
+ if (h[0] === 0x2001 && h[1] === 0x0db8) return "reserved"; // documentation
2795
+ if ((h[0] & 0xfe00) === 0xfc00) return "unique-local"; // fc00::/7
2796
+ if ((h[0] & 0xffc0) === 0xfe80) return "link-local"; // fe80::/10
2797
+ if ((h[0] & 0xff00) === 0xff00) return "multicast"; // ff00::/8
2798
+ return null;
2799
+ }
2800
+ function v4Of(high, low) {
2801
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff];
2802
+ }
2803
+
2804
+ /**
2805
+ * Opening one external URL and reporting what answered.
2806
+ *
2807
+ * Mirrors `ExternalUrlProbeResult` in the Studio - the two are checked against
2808
+ * each other by the API route's zod schema, which is the only place they meet.
2809
+ */
2810
+
2811
+ const DEFAULT_TIMEOUT_MS$1 = 5000;
2812
+ const DEFAULT_MAX_REDIRECTS = 5;
2813
+
2814
+ /**
2815
+ * A browser-ish identity.
2816
+ *
2817
+ * Not a disguise: plenty of sites answer 403 to a request with no `User-Agent`
2818
+ * at all, and reporting those as broken would be a check that cries wolf. It
2819
+ * says what it is and where to complain.
2820
+ */
2821
+ const USER_AGENT = "Val-Studio-LinkCheck/1.0 (+https://val.build; link checker)";
2822
+
2823
+ /**
2824
+ * A `lookup` that refuses to hand back an address this server may not reach.
2825
+ *
2826
+ * This, rather than a check on the hostname or a check after the fact, is what
2827
+ * closes the hole. The socket connects to the address the lookup RETURNS, so
2828
+ * validating inside it leaves no window between the check and the connection —
2829
+ * a name that resolves to 93.184.216.34 once and 127.0.0.1 the next time gets
2830
+ * no second chance, because there is only ever one resolution and it is this
2831
+ * one.
2832
+ */
2833
+ /**
2834
+ * What a refused lookup tells the CALLER.
2835
+ *
2836
+ * Deliberately the same words `ENETUNREACH` gets, and deliberately without the
2837
+ * address. The guard stops the connection, but the first version of it handed
2838
+ * back the DNS answer it had just refused to use - "vault.prod.svc resolves to
2839
+ * a private address (10.42.7.19)" - which travels through `readableError` into
2840
+ * the response and onto the screen. That turns an endpoint that cannot reach
2841
+ * the internal network into one that MAPS it: twenty names a request, existence
2842
+ * confirmed and address included, without a single connection being made.
2843
+ *
2844
+ * True rather than evasive: this server genuinely did not reach the host. And
2845
+ * because a real external host that is down produces the same sentence, the
2846
+ * answer no longer says which of the two happened. The operator still gets the
2847
+ * detail, in the log, where it belongs.
2848
+ *
2849
+ * A hostname the caller wrote as a literal address is different and keeps its
2850
+ * specific message (see the literal check in `probeUrl`): repeating
2851
+ * `127.0.0.1` to someone who just typed `127.0.0.1` reveals nothing.
2852
+ */
2853
+ const REFUSED_MESSAGE = "the host could not be reached";
2854
+ function guardedLookup(lookup) {
2855
+ const guarded = (hostname, options, callback) => {
2856
+ // The overloads differ only in whether `options` was passed; `net` always
2857
+ // calls through with both, and this forwards whatever it was given.
2858
+ return lookup(hostname, options, (err, address, family) => {
2859
+ if (err) {
2860
+ callback(err, "", 0);
2861
+ return;
2862
+ }
2863
+ const addresses = Array.isArray(address) ? address : [{
2864
+ address,
2865
+ family: family ?? 0
2866
+ }];
2867
+ for (const entry of addresses) {
2868
+ const reason = blockedAddressReason(entry.address);
2869
+ if (reason !== null) {
2870
+ // Server-side only. This is the half an operator needs to work out
2871
+ // why a link check refused a URL, and the half a caller must not
2872
+ // be given.
2873
+ console.warn(`[val] link check refused ${hostname}: resolves to ${describeBlocked(reason)} (${entry.address})`);
2874
+ callback(new Error(REFUSED_MESSAGE), "", 0);
2875
+ return;
2876
+ }
2877
+ }
2878
+ // Forwarded in the shape it arrived in: `net` reads it back according
2879
+ // to whether it asked for `all`.
2880
+ if (Array.isArray(address)) {
2881
+ callback(null, address, family);
2882
+ } else {
2883
+ callback(null, address, family ?? 0);
2884
+ }
2885
+ }
2886
+ // The callback shape above is the union of both overloads, which the
2887
+ // published types split apart.
2888
+ );
2889
+ };
2890
+ return guarded;
2891
+ }
2892
+
2893
+ /** The statuses that mean "ask again with GET" rather than "no". */
2894
+ function needsGetFallback(code) {
2895
+ /*
2896
+ * Only where the server said the METHOD was the problem.
2897
+ *
2898
+ * A 403 is the tempting third case - some WAFs and proxies do refuse HEAD
2899
+ * with one, and this repo's own sandbox is an example - but retrying every
2900
+ * 403 with a GET means downloading a page for each of them, on a check whose
2901
+ * whole point is to be cheap enough to run over a few hundred URLs. And it
2902
+ * would buy little: 403 is already reported as "may be fine for a visitor,
2903
+ * cannot be checked from here" rather than as a broken link, which is the
2904
+ * right answer either way.
2905
+ */
2906
+ return code === 405 || code === 501;
2907
+ }
2908
+ async function probeUrl(rawUrl, options = {}) {
2909
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
2910
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
2911
+ const now = options.now ?? (() => Date.now());
2912
+ const started = now();
2913
+ const deadline = started + timeoutMs;
2914
+ let url;
2915
+ try {
2916
+ url = new URL(rawUrl.trim());
2917
+ } catch {
2918
+ return {
2919
+ kind: "skipped",
2920
+ message: "Not opened: this is not a URL."
2921
+ };
2922
+ }
2923
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
2924
+ return {
2925
+ kind: "skipped",
2926
+ message: "Not opened: only http:// and https:// can be checked."
2927
+ };
2928
+ }
2929
+ let method = "HEAD";
2930
+ let redirects = 0;
2931
+ for (;;) {
2932
+ const remaining = deadline - now();
2933
+ if (remaining <= 0) {
2934
+ return {
2935
+ kind: "timeout",
2936
+ ms: timeoutMs
2937
+ };
2938
+ }
2939
+ /*
2940
+ * A hostname that is ALREADY an address never reaches the lookup.
2941
+ *
2942
+ * `net.connect` checks for an IP literal first and connects straight to
2943
+ * it, so `guardedLookup` is simply not called for
2944
+ * `http://169.254.169.254/` — which is the single most valuable URL an
2945
+ * attacker could ask this server to open. Checked here, per hop, because a
2946
+ * redirect can introduce one at any point in the chain.
2947
+ *
2948
+ * This was a real hole, found by a test that asserted the local server
2949
+ * received nothing and watched it receive something.
2950
+ */
2951
+ const literal = bareHost(url.hostname);
2952
+ if (net.isIP(literal) !== 0) {
2953
+ const reason = blockedAddressReason(literal);
2954
+ if (reason !== null) {
2955
+ return {
2956
+ kind: "unreachable",
2957
+ message: `${literal} is ${describeBlocked(reason)}`
2958
+ };
2959
+ }
2960
+ }
2961
+ let response;
2962
+ try {
2963
+ response = await requestOnce(url, method, remaining, options.request);
2964
+ } catch (error) {
2965
+ const message = error instanceof Error ? error.message : String(error);
2966
+ if (message === TIMED_OUT) {
2967
+ return {
2968
+ kind: "timeout",
2969
+ ms: timeoutMs
2970
+ };
2971
+ }
2972
+ return {
2973
+ kind: "unreachable",
2974
+ message
2975
+ };
2976
+ }
2977
+ if (method === "HEAD" && needsGetFallback(response.code)) {
2978
+ method = "GET";
2979
+ continue;
2980
+ }
2981
+ const location = response.location;
2982
+ if (location !== undefined && response.code >= 300 && response.code < 400) {
2983
+ if (redirects >= maxRedirects) {
2984
+ return {
2985
+ kind: "unreachable",
2986
+ message: `too many redirects (${maxRedirects})`
2987
+ };
2988
+ }
2989
+ let next;
2990
+ try {
2991
+ next = new URL(location, url);
2992
+ } catch {
2993
+ return {
2994
+ kind: "unreachable",
2995
+ message: `redirected to something that is not a URL (${location})`
2996
+ };
2997
+ }
2998
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
2999
+ // A redirect to `file:` or `gopher:` is a redirect out of what this
3000
+ // is allowed to open, and the address guard would never see it.
3001
+ return {
3002
+ kind: "unreachable",
3003
+ message: `redirected to ${next.protocol}//, which cannot be checked`
3004
+ };
3005
+ }
3006
+ url = next;
3007
+ redirects++;
3008
+ // A redirect answers the method question too: a server that refused
3009
+ // HEAD at the old URL says nothing about the new one.
3010
+ method = "HEAD";
3011
+ continue;
3012
+ }
3013
+ return {
3014
+ kind: "answered",
3015
+ code: response.code,
3016
+ finalUrl: url.toString(),
3017
+ ms: Math.max(0, Math.round(now() - started))
3018
+ };
3019
+ }
3020
+ }
3021
+
3022
+ /**
3023
+ * A hostname with the IPv6 brackets off.
3024
+ *
3025
+ * `new URL("http://[::1]/").hostname` is `"[::1]"`, brackets and all, and
3026
+ * `net.isIP` says 0 to that — so the bracketed form sailed past the literal
3027
+ * check and failed later with "host could not be found". Which looks like a
3028
+ * refusal, and is not one.
3029
+ */
3030
+ function bareHost(hostname) {
3031
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
3032
+ }
3033
+ const TIMED_OUT = "val:link-check-timeout";
3034
+ function requestOnce(url, method, timeoutMs, requestImpl) {
3035
+ const impl = requestImpl ?? (url.protocol === "http:" ? http.request : https.request);
3036
+ return new Promise((resolve, reject) => {
3037
+ let settled = false;
3038
+ const finish = fn => {
3039
+ if (settled) return;
3040
+ settled = true;
3041
+ fn();
3042
+ };
3043
+ const req = impl({
3044
+ protocol: url.protocol,
3045
+ hostname: url.hostname,
3046
+ port: url.port === "" ? undefined : url.port,
3047
+ path: `${url.pathname}${url.search}`,
3048
+ method,
3049
+ headers: {
3050
+ // No cookies, no authorization, no forwarded headers: this request
3051
+ // is the server's own and carries none of its identity.
3052
+ "user-agent": USER_AGENT,
3053
+ accept: "*/*",
3054
+ // A redirect chain is followed by hand, one guarded hop at a time.
3055
+ "accept-encoding": "identity"
3056
+ },
3057
+ // Every DNS answer is checked before a socket is opened. See
3058
+ // `guardedLookup` — this is the line that makes the whole feature safe.
3059
+ lookup: guardedLookup(defaultLookup()),
3060
+ // Node's default agent keeps sockets alive between requests, and a
3061
+ // pooled socket was resolved by a PREVIOUS lookup. A fresh connection
3062
+ // per probe is what keeps the guard in front of every one of them.
3063
+ agent: false,
3064
+ timeout: timeoutMs
3065
+ }, res => {
3066
+ const location = res.headers.location;
3067
+ // The body is never read: a status and a `Location` is the whole
3068
+ // answer, and downloading a page to throw it away is bandwidth spent
3069
+ // on somebody else's server.
3070
+ res.destroy();
3071
+ finish(() => resolve({
3072
+ code: res.statusCode ?? 0,
3073
+ location: typeof location === "string" ? location : undefined
3074
+ }));
3075
+ });
3076
+ req.on("timeout", () => {
3077
+ req.destroy();
3078
+ finish(() => reject(new Error(TIMED_OUT)));
3079
+ });
3080
+ req.on("error", error => {
3081
+ finish(() => reject(new Error(readableError(error))));
3082
+ });
3083
+ req.end();
3084
+ });
3085
+ }
3086
+
3087
+ /** `dns.lookup`, read lazily so a test can swap `node:dns` wholesale. */
3088
+ function defaultLookup() {
3089
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
3090
+ const dns = require("node:dns");
3091
+ return dns.lookup;
3092
+ }
3093
+
3094
+ /** What went wrong, in words rather than in errno. */
3095
+ function readableError(error) {
3096
+ /*
3097
+ * The guard's own refusal, recognised by its message rather than by a code.
3098
+ * `net` destroys the socket with the Error the lookup gave it, and the
3099
+ * message survives that trip verbatim; whether a custom `code` does is an
3100
+ * implementation detail this does not need to bet on.
3101
+ */
3102
+ if (error.message === REFUSED_MESSAGE) {
3103
+ return REFUSED_MESSAGE;
3104
+ }
3105
+ switch (error.code) {
3106
+ case "ENOTFOUND":
3107
+ case "EAI_AGAIN":
3108
+ return "the host could not be found";
3109
+ case "ECONNREFUSED":
3110
+ return "the connection was refused";
3111
+ case "ECONNRESET":
3112
+ return "the connection was reset";
3113
+ case "EHOSTUNREACH":
3114
+ case "ENETUNREACH":
3115
+ return "the host could not be reached";
3116
+ case "CERT_HAS_EXPIRED":
3117
+ return "its certificate has expired";
3118
+ case "ERR_TLS_CERT_ALTNAME_INVALID":
3119
+ return "its certificate is for a different host";
3120
+ case "DEPTH_ZERO_SELF_SIGNED_CERT":
3121
+ case "SELF_SIGNED_CERT_IN_CHAIN":
3122
+ return "its certificate is self-signed";
3123
+ default:
3124
+ /*
3125
+ * A fixed string, not `error.message`.
3126
+ *
3127
+ * Whatever Node or OpenSSL put in there is written for a server log, and
3128
+ * some of it names things the caller should not learn - a resolved
3129
+ * address, a certificate's subject, a path. The cases above cover what is
3130
+ * worth telling an editor; anything else is noise to them and a leak
3131
+ * waiting to happen.
3132
+ */
3133
+ return "the request failed";
3134
+ }
3135
+ }
3136
+
2568
3137
  /* eslint-disable @typescript-eslint/no-unused-vars */
2569
3138
  const jsonOps = new JSONOps();
2570
3139
  /**
@@ -4520,6 +5089,9 @@ class ValOps {
4520
5089
  // group whose id is undefined, all the way to the client.
4521
5090
  ...(saveRes.value.patchGroupId !== undefined ? {
4522
5091
  patchGroupId: saveRes.value.patchGroupId
5092
+ } : {}),
5093
+ ...(saveRes.value.headVersion !== undefined ? {
5094
+ headVersion: saveRes.value.headVersion
4523
5095
  } : {})
4524
5096
  });
4525
5097
  }
@@ -4561,6 +5133,15 @@ class ValOps {
4561
5133
  return null;
4562
5134
  }
4563
5135
 
5136
+ /**
5137
+ * Does a press of Publish run as a publish job (valbuild/home,
5138
+ * docs/app-mode.md, "Publishing is a queued job")? Never for a project
5139
+ * with no content service.
5140
+ */
5141
+ publishesAsJobs() {
5142
+ return false;
5143
+ }
5144
+
4564
5145
  /**
4565
5146
  * The branch the content service keeps this project's commits on, or `null`
4566
5147
  * where there is no such service, or it has not said yet.
@@ -7536,6 +8117,59 @@ async function readCommittedBinaryFiles(ops, descriptors) {
7536
8117
  };
7537
8118
  }
7538
8119
 
8120
+ /**
8121
+ * A publish job's prepare, split into what the tab builds and what content
8122
+ * archives.
8123
+ *
8124
+ * A job's content is its base plus every change it took (valbuild/home,
8125
+ * docs/app-mode.md, "Publishing is a queued job"). The server has two
8126
+ * `prepare`s to hand: `chainOnly` -- this build's embedded source with every
8127
+ * commit since applied, which is the branch's head -- and `withJob`, the same
8128
+ * with the job's pending changes on top. Anything applied since the job
8129
+ * started came out of the job's own change set, so `withJob` IS the job's
8130
+ * content; were that ever not so, the seal's containment check would restart
8131
+ * the job rather than publish it.
8132
+ *
8133
+ * - The BUILD needs all of `withJob`'s source files: it is a whole site.
8134
+ * - The ARCHIVE is what the commit publishes, so content is sent only what the
8135
+ * job changes: the files whose text differs from the head's, and the modules
8136
+ * and binary files its changes touch. Everything else is the head's, and an
8137
+ * archive that listed it would say this commit changed it.
8138
+ *
8139
+ * Files are compared rather than derived from module paths because a change to
8140
+ * a module can write a file under another path -- a `.val.json` entry of a
8141
+ * record -- which only the prepared output knows about.
8142
+ */
8143
+ function splitJobPrepare(input) {
8144
+ const job = new Set(input.jobPatchIds);
8145
+ const before = input.chainOnly.patchedSourceFiles;
8146
+ const after = input.withJob.patchedSourceFiles;
8147
+ const patchedSourceFiles = {};
8148
+ for (const [path, text] of Object.entries(after)) {
8149
+ if (!(path in before) || before[path] !== text) {
8150
+ patchedSourceFiles[path] = text;
8151
+ }
8152
+ }
8153
+ const patchedBinaryFilesDescriptors = {};
8154
+ for (const [path, descriptor] of Object.entries(input.withJob.patchedBinaryFilesDescriptors)) {
8155
+ if (job.has(descriptor.patchId)) {
8156
+ patchedBinaryFilesDescriptors[path] = descriptor;
8157
+ }
8158
+ }
8159
+
8160
+ // The modules any of the job's changes were applied to.
8161
+ const touched = new Set(Object.entries(input.withJob.appliedPatches).filter(([, applied]) => applied.some(id => job.has(id))).map(([path]) => path));
8162
+ const modules = Object.fromEntries(Object.entries(input.withJob.moduleVersions).filter(([path]) => touched.has(path)));
8163
+ return {
8164
+ buildSourceFiles: after,
8165
+ archive: {
8166
+ patchedSourceFiles,
8167
+ patchedBinaryFilesDescriptors,
8168
+ modules
8169
+ }
8170
+ };
8171
+ }
8172
+
7539
8173
  /**
7540
8174
  * Which unpublished changes a failed save has to throw away to make progress.
7541
8175
  *
@@ -7677,6 +8311,11 @@ const GetApplicablePatches = z.object({
7677
8311
  * a content service that predates it sends nothing.
7678
8312
  */
7679
8313
  headPatchId: PatchId$1.nullable().optional(),
8314
+ /**
8315
+ * The chain version of that head. See `OrderedPatches.headVersion`.
8316
+ * Optional for the same reason.
8317
+ */
8318
+ headVersion: z.number().optional(),
7680
8319
  commits: z.array(z.object({
7681
8320
  commitSha: z.string(),
7682
8321
  /*
@@ -7715,7 +8354,13 @@ const GetApplicablePatches = z.object({
7715
8354
  */
7716
8355
  project: z.object({
7717
8356
  sourceMode: z.union([z.literal("managed"), z.literal("connected")]),
7718
- branch: z.string()
8357
+ branch: z.string(),
8358
+ /**
8359
+ * Whether a press of Publish is a publish JOB. Absent from a content
8360
+ * service that predates connected jobs: then managed projects publish
8361
+ * as jobs, and connected ones by commit, as they did.
8362
+ */
8363
+ publishJobs: z.boolean().optional()
7719
8364
  }).optional()
7720
8365
  });
7721
8366
  const FilesResponse = z.object({
@@ -7752,15 +8397,39 @@ const SavePatchResponse = z.object({
7752
8397
  * Optional: a content API that predates patch groups does not send one, and
7753
8398
  * absence has to keep meaning "no groups here" rather than failing the save.
7754
8399
  */
7755
- patchGroupId: z.string().optional()
8400
+ patchGroupId: z.string().optional(),
8401
+ /**
8402
+ * The chain version this write made, comparable with `headVersion` on the
8403
+ * listing. Optional: a content service that predates it sends nothing.
8404
+ */
8405
+ headVersion: z.number().optional()
7756
8406
  });
7757
8407
  const DeletePatchesResponse = z.object({
7758
8408
  deleted: z.array(PatchId),
7759
8409
  errors: z.array(z.object({
7760
8410
  message: z.string(),
7761
8411
  patchId: PatchId
7762
- })).optional()
8412
+ })).optional(),
8413
+ /**
8414
+ * The chain version each branch the delete changed is now at, keyed by
8415
+ * branch. Optional: a content service that predates it sends none.
8416
+ */
8417
+ headVersions: z.record(z.string(), z.number()).optional()
7763
8418
  });
8419
+ /**
8420
+ * This client's own branch's entry in a per-branch version map.
8421
+ *
8422
+ * A delete can span branches, so the content service answers per branch. With
8423
+ * no branch of its own (a build with no repository, where the service resolves
8424
+ * it), the only entry is ours when there is exactly one; with several, none can
8425
+ * be picked, and a wrong one would floor this client at another chain's count.
8426
+ */
8427
+ function ownBranchVersion(headVersions, branch) {
8428
+ if (headVersions === undefined) return undefined;
8429
+ if (branch !== undefined) return headVersions[branch];
8430
+ const versions = Object.values(headVersions);
8431
+ return versions.length === 1 ? versions[0] : undefined;
8432
+ }
7764
8433
  const SavePatchFileResponse = z.object({
7765
8434
  patchId: PatchId,
7766
8435
  filePath: ModuleFilePath
@@ -7780,7 +8449,9 @@ const CommitResponse = z.object({
7780
8449
  */
7781
8450
  parent: z.string().optional(),
7782
8451
  tree: z.string().optional(),
7783
- branch: z.string()
8452
+ branch: z.string(),
8453
+ /** The chain version the publish moved the branch to; absent if it applied nothing. */
8454
+ headVersion: z.number().optional()
7784
8455
  });
7785
8456
  // #region history wire schemas
7786
8457
  //
@@ -8046,6 +8717,17 @@ class ValOpsHttp extends ValOps {
8046
8717
  return ((_this$projectExpectat3 = this.projectExpectation) === null || _this$projectExpectat3 === void 0 ? void 0 : _this$projectExpectat3.branch) ?? null;
8047
8718
  }
8048
8719
 
8720
+ /**
8721
+ * Does a press of Publish run as a publish job? Every managed project, and
8722
+ * a connected one content says is hosted on the platform (its CI publishes
8723
+ * through content). `false` before anything has been heard.
8724
+ */
8725
+ publishesAsJobs() {
8726
+ const expected = this.projectExpectation;
8727
+ if (expected === null) return false;
8728
+ return expected.publishJobs ?? expected.sourceMode === "managed";
8729
+ }
8730
+
8049
8731
  /**
8050
8732
  * The short-lived publish token, and when it stops being usable.
8051
8733
  *
@@ -8144,7 +8826,9 @@ class ValOpsHttp extends ValOps {
8144
8826
  * An allow list rather than a prefix check, because this method holds a
8145
8827
  * credential and the browser chooses the path. `/publish/{id}` and its three
8146
8828
  * steps are the publish conversation; `/build-target` is what a build needs
8147
- * to know before it starts; `/project-source` is what it builds.
8829
+ * to know before it starts; `/project-source` is what it builds;
8830
+ * `/update-target` is the same question as `/build-target` for a managed
8831
+ * project moving onto its template's current dependencies.
8148
8832
  *
8149
8833
  * `/project-source` is here rather than on a route of its own because it is
8150
8834
  * one of the three things a publish asks for and none of them are useful
@@ -8158,22 +8842,66 @@ class ValOpsHttp extends ValOps {
8158
8842
  * segment gets through.
8159
8843
  */
8160
8844
  static publishApiPathAllowed(path) {
8161
- if (path === "/build-target" || path === "/project-source" || path === "/publish") {
8845
+ if (path === "/build-target" || path === "/project-source" || path === "/update-target" || path === "/publish" ||
8846
+ // Where "View run" goes, for a connected build CI reported failed.
8847
+ // Read only: reporting a run is CI's, with its own token.
8848
+ path === "/ci-runs/newest") {
8849
+ return true;
8850
+ }
8851
+ /*
8852
+ * Publishing as a queued job: a press, where it is, Try again, and the
8853
+ * tab's reports about the job it runs. NOT `/publish-jobs/{id}/prepare`:
8854
+ * its body is the job's source files and modules, which this server
8855
+ * computes (`prepareJob`) -- a browser allowed to send it could publish
8856
+ * any text at all as a commit's archive.
8857
+ */
8858
+ if (/^\/publish-requests(\/[A-Za-z0-9_-]{1,100})?$/.test(path)) {
8859
+ return true;
8860
+ }
8861
+ if (/^\/publish-jobs\/(next|[A-Za-z0-9_-]{1,100}\/(steps|renew|cancel|discard))$/.test(path)) {
8162
8862
  return true;
8163
8863
  }
8164
8864
  return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
8165
8865
  }
8166
8866
  async publishApi(path, init) {
8167
- const json = "application/json";
8168
8867
  if (!ValOpsHttp.publishApiPathAllowed(path)) {
8169
8868
  return {
8170
8869
  status: 403,
8171
- contentType: json,
8870
+ contentType: "application/json",
8172
8871
  body: JSON.stringify({
8173
8872
  message: `'${path}' is not part of the publish API.`
8174
8873
  })
8175
8874
  };
8176
8875
  }
8876
+ return this.callPublishApi(path, init);
8877
+ }
8878
+
8879
+ /**
8880
+ * A publish job's prepare, sent to content: the job's sources, archived
8881
+ * there before the job is built (valbuild/home, docs/app-mode.md, "The
8882
+ * archive, before the seal"). With this server's publish token, as every
8883
+ * publish call is, and never through `publishApi` -- see the allowlist.
8884
+ */
8885
+ async prepareJob(jobId, body) {
8886
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(jobId)) {
8887
+ return {
8888
+ status: 400,
8889
+ contentType: "application/json",
8890
+ body: JSON.stringify({
8891
+ message: `'${jobId}' is not a job id.`
8892
+ })
8893
+ };
8894
+ }
8895
+ return this.callPublishApi(`/publish-jobs/${jobId}/prepare`, {
8896
+ method: "POST",
8897
+ body: JSON.stringify({
8898
+ ...body,
8899
+ root: this.root
8900
+ })
8901
+ });
8902
+ }
8903
+ async callPublishApi(path, init) {
8904
+ const json = "application/json";
8177
8905
  const send = async token => fetch(`${this.contentUrl}/v1${path}`, {
8178
8906
  method: init.method,
8179
8907
  headers: {
@@ -8409,6 +9137,9 @@ class ValOpsHttp extends ValOps {
8409
9137
  ...(allPatchData.headPatchId !== undefined ? {
8410
9138
  headPatchId: allPatchData.headPatchId
8411
9139
  } : {}),
9140
+ ...(allPatchData.headVersion !== undefined ? {
9141
+ headVersion: allPatchData.headVersion
9142
+ } : {}),
8412
9143
  /*
8413
9144
  * The PUBLISH head, which is not `commitSha`.
8414
9145
  *
@@ -8536,6 +9267,7 @@ class ValOpsHttp extends ValOps {
8536
9267
  // A fact about the branch too, so taken from the first chunk that has it
8537
9268
  // for the same reason `commits` is.
8538
9269
  let headPatchId;
9270
+ let headVersion;
8539
9271
  if (patchIds === undefined || patchIds.length === 0) {
8540
9272
  return this.fetchPatchesInternal({
8541
9273
  patchIds: patchIds,
@@ -8556,8 +9288,15 @@ class ValOpsHttp extends ValOps {
8556
9288
  if (commits === undefined && res.commits !== undefined) {
8557
9289
  commits = res.commits;
8558
9290
  }
8559
- if (headPatchId === undefined && res.headPatchId !== undefined) {
9291
+ /*
9292
+ * The NEWEST head among the chunks: each is its own request, so a later
9293
+ * one can have seen a write an earlier one did not. Taken with its own
9294
+ * version, so the two always describe one read. Without versions (an
9295
+ * older content service) the first head stands, as before.
9296
+ */
9297
+ if (res.headPatchId !== undefined && (headPatchId === undefined || res.headVersion !== undefined && (headVersion === undefined || res.headVersion > headVersion))) {
8560
9298
  headPatchId = res.headPatchId;
9299
+ headVersion = res.headVersion;
8561
9300
  }
8562
9301
  }
8563
9302
  // Chunking is a query-string-length workaround, NOT a filter: the content
@@ -8594,6 +9333,9 @@ class ValOpsHttp extends ValOps {
8594
9333
  } : {}),
8595
9334
  ...(headPatchId !== undefined ? {
8596
9335
  headPatchId
9336
+ } : {}),
9337
+ ...(headVersion !== undefined ? {
9338
+ headVersion
8597
9339
  } : {})
8598
9340
  };
8599
9341
  }
@@ -8692,6 +9434,9 @@ class ValOpsHttp extends ValOps {
8692
9434
  errors,
8693
9435
  ...(data.headPatchId !== undefined ? {
8694
9436
  headPatchId: data.headPatchId
9437
+ } : {}),
9438
+ ...(data.headVersion !== undefined ? {
9439
+ headVersion: data.headVersion
8695
9440
  } : {})
8696
9441
  };
8697
9442
  }
@@ -9062,6 +9807,9 @@ class ValOpsHttp extends ValOps {
9062
9807
  */
9063
9808
  ...(parsed.data.patchGroupId !== undefined ? {
9064
9809
  patchGroupId: parsed.data.patchGroupId
9810
+ } : {}),
9811
+ ...(parsed.data.headVersion !== undefined ? {
9812
+ headVersion: parsed.data.headVersion
9065
9813
  } : {})
9066
9814
  });
9067
9815
  }
@@ -9385,18 +10133,25 @@ class ValOpsHttp extends ValOps {
9385
10133
  if (res.ok) {
9386
10134
  const parsed = DeletePatchesResponse.safeParse(await res.json());
9387
10135
  if (parsed.success) {
10136
+ var _this$git;
9388
10137
  const errors = {};
9389
10138
  for (const err of parsed.data.errors || []) {
9390
10139
  errors[err.patchId] = err;
9391
10140
  }
10141
+ const headVersion = ownBranchVersion(parsed.data.headVersions, (_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
10142
+ const version = headVersion !== undefined ? {
10143
+ headVersion
10144
+ } : {};
9392
10145
  if (Object.keys(errors).length === 0) {
9393
10146
  return {
9394
- deleted: parsed.data.deleted
10147
+ deleted: parsed.data.deleted,
10148
+ ...version
9395
10149
  };
9396
10150
  }
9397
10151
  return {
9398
10152
  deleted: parsed.data.deleted,
9399
- errors
10153
+ errors,
10154
+ ...version
9400
10155
  };
9401
10156
  }
9402
10157
  return {
@@ -9526,6 +10281,9 @@ class ValOpsHttp extends ValOps {
9526
10281
  } : {}),
9527
10282
  ...(parsed.data.tree !== undefined ? {
9528
10283
  tree: parsed.data.tree
10284
+ } : {}),
10285
+ ...(parsed.data.headVersion !== undefined ? {
10286
+ headVersion: parsed.data.headVersion
9529
10287
  } : {})
9530
10288
  };
9531
10289
  }
@@ -12295,7 +13053,13 @@ const ValServer = (valModules, options, callbacks) => {
12295
13053
  ...(sourceMode ? {
12296
13054
  sourceMode
12297
13055
  } : {}),
12298
- config: options.config
13056
+ ...(sourceMode ? {
13057
+ publishJobs: serverOps.publishesAsJobs()
13058
+ } : {}),
13059
+ // Not `options.config` verbatim: in proxy mode the branch the
13060
+ // server resolved is filled in where the file did not name one.
13061
+ // See `clientConfig`.
13062
+ config: clientConfig(options)
12299
13063
  }
12300
13064
  };
12301
13065
  }
@@ -12388,6 +13152,46 @@ const ValServer = (valModules, options, callbacks) => {
12388
13152
  };
12389
13153
  }
12390
13154
  },
13155
+ "/external-urls/check": {
13156
+ POST: async req => {
13157
+ /*
13158
+ * The same door as every other route, which in fs mode is an open one.
13159
+ *
13160
+ * `getAuth` returns anonymous success when there is no `valSecret` and
13161
+ * nothing requires auth — a developer's own machine, where there is no
13162
+ * credential to require and 29 other routes already behave this way.
13163
+ * So this is not a session gate in fs mode, and it is not meant to be
13164
+ * the thing that makes the endpoint safe: what does that is
13165
+ * `addressGuard`, which refuses every address that is not on the
13166
+ * public internet, on the RESOLVED address, at every redirect hop.
13167
+ */
13168
+ const auth = getAuth(req.cookies);
13169
+ if (auth.error) {
13170
+ return {
13171
+ status: 401,
13172
+ json: {
13173
+ message: auth.error
13174
+ }
13175
+ };
13176
+ }
13177
+ /*
13178
+ * Every URL in the batch at once, and nothing beyond it.
13179
+ *
13180
+ * The concurrency here is the batch size the route caps at, which is
13181
+ * what keeps "check a thousand links" from becoming a thousand
13182
+ * simultaneous outbound sockets: the client sends ten, waits, sends
13183
+ * the next ten. Doing them in parallel WITHIN a batch is what makes a
13184
+ * batch worth having — ten five-second timeouts in series is a minute.
13185
+ */
13186
+ const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
13187
+ return {
13188
+ status: 200,
13189
+ json: {
13190
+ results: Object.fromEntries(results)
13191
+ }
13192
+ };
13193
+ }
13194
+ },
12391
13195
  "/direct-file-upload-settings": {
12392
13196
  POST: async req => {
12393
13197
  const cookies = req.cookies;
@@ -12695,6 +13499,13 @@ const ValServer = (valModules, options, callbacks) => {
12695
13499
  * re-read when a fetch has missing ids to ask for.
12696
13500
  */
12697
13501
  let patchGroupIdFromStore;
13502
+ /*
13503
+ * The chain version the LAST patch made — the head this batch leaves.
13504
+ * The client keeps the highest version it has seen, so a `/stat` read
13505
+ * before this write cannot put its parent back behind it. Absent in
13506
+ * `fs` mode and from an older content API.
13507
+ */
13508
+ let headVersion;
12698
13509
  for (const patch of patches) {
12699
13510
  const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
12700
13511
  if (result.isErr(createPatchRes)) {
@@ -12731,6 +13542,7 @@ const ValServer = (valModules, options, callbacks) => {
12731
13542
  if (createPatchRes.value.patchGroupId !== undefined) {
12732
13543
  patchGroupIdFromStore = createPatchRes.value.patchGroupId;
12733
13544
  }
13545
+ headVersion = createPatchRes.value.headVersion;
12734
13546
  }
12735
13547
  }
12736
13548
  return {
@@ -12743,6 +13555,9 @@ const ValServer = (valModules, options, callbacks) => {
12743
13555
  // client reads absence as "staging is not available here".
12744
13556
  ...(patchGroupIdFromStore !== undefined ? {
12745
13557
  patchGroupId: patchGroupIdFromStore
13558
+ } : {}),
13559
+ ...(headVersion !== undefined ? {
13560
+ headVersion
12746
13561
  } : {})
12747
13562
  }
12748
13563
  };
@@ -12882,7 +13697,7 @@ const ValServer = (valModules, options, callbacks) => {
12882
13697
  };
12883
13698
  },
12884
13699
  DELETE: async req => {
12885
- var _req$body;
13700
+ var _req$body, _req$body2;
12886
13701
  const query = req.query;
12887
13702
  const cookies = req.cookies;
12888
13703
  const auth = getAuth(cookies);
@@ -12935,6 +13750,17 @@ const ValServer = (valModules, options, callbacks) => {
12935
13750
  }
12936
13751
  };
12937
13752
  }
13753
+ if (((_req$body2 = req.body) === null || _req$body2 === void 0 ? void 0 : _req$body2.reportHeadVersion) === true) {
13754
+ return {
13755
+ status: 200,
13756
+ json: {
13757
+ deleted: ids,
13758
+ ...("headVersion" in deleteRes && deleteRes.headVersion !== undefined ? {
13759
+ headVersion: deleteRes.headVersion
13760
+ } : {})
13761
+ }
13762
+ };
13763
+ }
12938
13764
  return {
12939
13765
  status: 200,
12940
13766
  json: ids
@@ -13395,8 +14221,9 @@ const ValServer = (valModules, options, callbacks) => {
13395
14221
  return res;
13396
14222
  }
13397
14223
  },
13398
- "/built-source": {
13399
- GET: async req => {
14224
+ "/publish-job-prepare": {
14225
+ POST: async req => {
14226
+ var _options$config$files2;
13400
14227
  const auth = getAuth(req.cookies);
13401
14228
  if (auth.error) {
13402
14229
  return {
@@ -13406,14 +14233,93 @@ const ValServer = (valModules, options, callbacks) => {
13406
14233
  }
13407
14234
  };
13408
14235
  }
13409
- if (!(serverOps instanceof ValOpsHttp) || !serverOps.embedsSource()) {
14236
+ if (!(serverOps instanceof ValOpsHttp) || !serverOps.publishesAsJobs() || serverOps.sourceMode() === "managed" && !serverOps.embedsSource()) {
13410
14237
  return {
13411
14238
  status: 409,
13412
14239
  json: {
13413
- message: "This server has no source of its own to render commits into."
14240
+ message: "This deployment cannot prepare a publish job for this project."
13414
14241
  }
13415
14242
  };
13416
14243
  }
14244
+ const refusal = serverOps.publishRefusal();
14245
+ if (refusal) {
14246
+ return {
14247
+ status: 409,
14248
+ json: {
14249
+ message: refusal.message
14250
+ }
14251
+ };
14252
+ }
14253
+ const {
14254
+ jobId,
14255
+ tab,
14256
+ patchIds
14257
+ } = req.body;
14258
+ /*
14259
+ * CONNECTED: the job's sources are what `/save` would commit -- its
14260
+ * own changes, applied as `/save` applies them -- and content pushes
14261
+ * them at the seal. Nothing is built here: CI builds after the push,
14262
+ * so the answer carries the job and no sources.
14263
+ */
14264
+ if (serverOps.sourceMode() === "connected") {
14265
+ var _options$config$files;
14266
+ const jobPatches = await serverOps.fetchPatches({
14267
+ patchIds,
14268
+ excludePatchOps: false
14269
+ });
14270
+ if (jobPatches.error) {
14271
+ return {
14272
+ status: 500,
14273
+ json: {
14274
+ message: jobPatches.error.message
14275
+ }
14276
+ };
14277
+ }
14278
+ const found = new Set(jobPatches.patches.map(patch => patch.patchId));
14279
+ const gone = patchIds.filter(id => !found.has(id));
14280
+ if (gone.length > 0) {
14281
+ return {
14282
+ status: 409,
14283
+ json: {
14284
+ message: `This publish's changes are no longer all there (${gone.length} discarded).`
14285
+ }
14286
+ };
14287
+ }
14288
+ const prepared = await serverOps.prepare({
14289
+ ...serverOps.analyzePatches(jobPatches.patches, jobPatches.commits, commit),
14290
+ ...jobPatches
14291
+ });
14292
+ if (prepared.hasErrors) {
14293
+ return {
14294
+ status: 500,
14295
+ json: {
14296
+ message: "This publish's changes could not be rendered into source: " + JSON.stringify(prepared.sourceFilePatchErrors).slice(0, 500)
14297
+ }
14298
+ };
14299
+ }
14300
+ const split = splitJobPrepare({
14301
+ // Every file the job's changes wrote is what the push writes.
14302
+ chainOnly: {
14303
+ patchedSourceFiles: {}
14304
+ },
14305
+ withJob: prepared,
14306
+ jobPatchIds: patchIds
14307
+ });
14308
+ const sent = await serverOps.prepareJob(jobId, {
14309
+ tab,
14310
+ filesDirectory: ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val",
14311
+ ...split.archive,
14312
+ ...(commit !== undefined ? {
14313
+ gitCommit: commit
14314
+ } : {})
14315
+ });
14316
+ return jobPrepareAnswer(sent, {
14317
+ sourceFiles: {},
14318
+ binaryFiles: {},
14319
+ binaryFilesUnread: [],
14320
+ branch: serverOps.projectBranch()
14321
+ });
14322
+ }
13417
14323
  const chain = await serverOps.fetchPatches({
13418
14324
  patchIds: undefined,
13419
14325
  excludePatchOps: false
@@ -13427,37 +14333,63 @@ const ValServer = (valModules, options, callbacks) => {
13427
14333
  };
13428
14334
  }
13429
14335
  /*
13430
- * The patches committed SINCE this build, as though pending.
14336
+ * The job's content, in two steps from this build's embedded source.
13431
14337
  *
13432
- * `applicable/patches` answers with the ones applied at a commit after
13433
- * the one this build was made from, which is exactly what the text
13434
- * embedded in it is missing. `analyzePatches` skips applied patches --
13435
- * for a save they are already in the file it reads -- so they are
13436
- * handed over unapplied: here the file predates them.
14338
+ * The commits since this build, as though pending, are the branch's
14339
+ * head. The job's pending changes on top of them are its content: whatever was applied since the job started came
14340
+ * out of its own changes (docs/app-mode.md, "Publishing is a queued
14341
+ * job"), and were that ever not so, the seal would restart the job
14342
+ * rather than publish it. Chain order, both times.
13437
14343
  */
14344
+ const job = new Set(patchIds);
13438
14345
  const committed = chain.patches.filter(patch => patch.appliedAt !== null).map(patch => ({
13439
14346
  ...patch,
13440
14347
  appliedAt: null
13441
14348
  }));
13442
- const prepared = await serverOps.prepare({
13443
- ...serverOps.analyzePatches(committed, chain.commits, commit),
14349
+ const pending = chain.patches.filter(patch => patch.appliedAt === null && job.has(patch.patchId));
14350
+ const known = new Set(chain.patches.map(patch => patch.patchId));
14351
+ const gone = patchIds.filter(id => !known.has(id));
14352
+ if (gone.length > 0) {
14353
+ return {
14354
+ status: 409,
14355
+ json: {
14356
+ message: `This publish's changes are no longer all there (${gone.length} discarded).`
14357
+ }
14358
+ };
14359
+ }
14360
+ const prepareOf = patches => serverOps.prepare({
14361
+ ...serverOps.analyzePatches(patches, chain.commits, commit),
13444
14362
  ...chain,
13445
- patches: committed
14363
+ patches
13446
14364
  });
13447
- if (prepared.hasErrors) {
14365
+ const chainOnly = await prepareOf(committed);
14366
+ const withJob = await prepareOf([...committed, ...pending]);
14367
+ if (chainOnly.hasErrors || withJob.hasErrors) {
13448
14368
  return {
13449
14369
  status: 500,
13450
14370
  json: {
13451
- message: "The commits since this build could not be rendered into source: " + JSON.stringify(prepared.sourceFilePatchErrors).slice(0, 500)
14371
+ message: "This publish's changes could not be rendered into source: " + JSON.stringify((withJob.hasErrors ? withJob : chainOnly).sourceFilePatchErrors).slice(0, 500)
13452
14372
  }
13453
14373
  };
13454
14374
  }
13455
- return {
13456
- status: 200,
13457
- json: {
13458
- files: prepared.patchedSourceFiles
13459
- }
13460
- };
14375
+ const split = splitJobPrepare({
14376
+ chainOnly,
14377
+ withJob,
14378
+ jobPatchIds: patchIds
14379
+ });
14380
+ // Read now, while the job's changes still hold their files.
14381
+ const binaries = await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors);
14382
+ const sent = await serverOps.prepareJob(jobId, {
14383
+ tab,
14384
+ filesDirectory: ((_options$config$files2 = options.config.files) === null || _options$config$files2 === void 0 ? void 0 : _options$config$files2.directory) || "/public/val",
14385
+ ...split.archive
14386
+ });
14387
+ return jobPrepareAnswer(sent, {
14388
+ sourceFiles: split.buildSourceFiles,
14389
+ binaryFiles: binaries.files,
14390
+ binaryFilesUnread: binaries.unread,
14391
+ branch: serverOps.projectBranch()
14392
+ });
13461
14393
  }
13462
14394
  },
13463
14395
  "/profiles": {
@@ -13912,8 +14844,8 @@ const ValServer = (valModules, options, callbacks) => {
13912
14844
  const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
13913
14845
  const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
13914
14846
  const commitToGit = () => {
13915
- var _options$config$files;
13916
- 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,
14847
+ var _options$config$files3;
14848
+ return serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files3 = options.config.files) === null || _options$config$files3 === void 0 ? void 0 : _options$config$files3.directory) || "/public/val", undefined,
13917
14849
  /*
13918
14850
  * Forwarded verbatim, and only the client can decide it: the
13919
14851
  * content API closes the group it is named without checking
@@ -13981,6 +14913,9 @@ const ValServer = (valModules, options, callbacks) => {
13981
14913
  ...(managedBranch !== null ? {
13982
14914
  branch: managedBranch
13983
14915
  } : {}),
14916
+ ...(commitRes.headVersion !== undefined ? {
14917
+ headVersion: commitRes.headVersion
14918
+ } : {}),
13984
14919
  ...(committedBinaries !== null ? {
13985
14920
  binaryFiles: committedBinaries.files,
13986
14921
  ...(committedBinaries.unread.length > 0 ? {
@@ -15564,6 +16499,49 @@ function getIsRemoteRequired(schemas) {
15564
16499
  return false;
15565
16500
  }
15566
16501
 
16502
+ /**
16503
+ * `/publish-job-prepare`'s answer from content's: the job as content has it
16504
+ * now, and what the tab builds from. Content's refusal is passed on -- 409
16505
+ * as 409, anything else as 502, which the tab reads as "content answered,
16506
+ * and has counted the attempt" (see `runStudioJob`).
16507
+ */
16508
+ function jobPrepareAnswer(sent, build) {
16509
+ if (sent.status !== 200) {
16510
+ let message = `The content service did not prepare the publish (${sent.status}).`;
16511
+ try {
16512
+ const parsed = JSON.parse(sent.body);
16513
+ if (typeof parsed === "object" && parsed !== null && "message" in parsed && typeof parsed.message === "string") {
16514
+ message = parsed.message;
16515
+ }
16516
+ } catch {
16517
+ // not JSON: the status says enough
16518
+ }
16519
+ return {
16520
+ status: sent.status === 409 ? 409 : 502,
16521
+ json: {
16522
+ message
16523
+ }
16524
+ };
16525
+ }
16526
+ try {
16527
+ const prepared = parseJob(JSON.parse(sent.body), "POST /v1/publish-jobs/{id}/prepare");
16528
+ return {
16529
+ status: 200,
16530
+ json: {
16531
+ job: prepared.job,
16532
+ ...build
16533
+ }
16534
+ };
16535
+ } catch (e) {
16536
+ return {
16537
+ status: 502,
16538
+ json: {
16539
+ message: e instanceof Error ? e.message : String(e)
16540
+ }
16541
+ };
16542
+ }
16543
+ }
16544
+
15567
16545
  async function createValServer(valModules, route, opts, config, callbacks, formatter,
15568
16546
  /**
15569
16547
  * Called after a save has applied its patches. EXPERIMENTAL — see
@@ -16419,7 +17397,7 @@ async function getFileBufferFromRemote(ref, fileExt, currentFileHash, projectRoo
16419
17397
  async function downloadFileFromRemote(ref, filePath) {
16420
17398
  return new Promise((resolve, reject) => {
16421
17399
  const url = new URL(ref);
16422
- const client = url.protocol === "https:" ? https : http;
17400
+ const client = url.protocol === "https:" ? https$1 : http$1;
16423
17401
  const request = client.get(url, response => {
16424
17402
  if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
16425
17403
  // Handle redirects