@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.
@@ -14,12 +14,15 @@ var internal = require('@valbuild/shared/internal');
14
14
  var server = require('@valbuild/ui/server');
15
15
  var crypto$1 = require('crypto');
16
16
  var z = require('zod');
17
+ var http = require('node:http');
18
+ var https = require('node:https');
19
+ var net = require('node:net');
17
20
  var sizeOf = require('image-size');
18
21
  var zodValidationError = require('zod-validation-error');
19
22
  var os = require('os');
20
23
  var sucrase = require('sucrase');
21
- var http = require('http');
22
- var https = require('https');
24
+ var http$1 = require('http');
25
+ var https$1 = require('https');
23
26
  var fs$1 = require('fs/promises');
24
27
 
25
28
  function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
@@ -48,10 +51,13 @@ var fs__default = /*#__PURE__*/_interopDefault(fs);
48
51
  var vm__default = /*#__PURE__*/_interopDefault(vm);
49
52
  var crypto__default = /*#__PURE__*/_interopDefault(crypto$1);
50
53
  var z__default = /*#__PURE__*/_interopDefault(z);
51
- var sizeOf__default = /*#__PURE__*/_interopDefault(sizeOf);
52
- var os__default = /*#__PURE__*/_interopDefault(os);
53
54
  var http__default = /*#__PURE__*/_interopDefault(http);
54
55
  var https__default = /*#__PURE__*/_interopDefault(https);
56
+ var net__default = /*#__PURE__*/_interopDefault(net);
57
+ var sizeOf__default = /*#__PURE__*/_interopDefault(sizeOf);
58
+ var os__default = /*#__PURE__*/_interopDefault(os);
59
+ var http__default$1 = /*#__PURE__*/_interopDefault(http$1);
60
+ var https__default$1 = /*#__PURE__*/_interopDefault(https$1);
55
61
  var fs__default$1 = /*#__PURE__*/_interopDefault(fs$1);
56
62
 
57
63
  class ValSyntaxError {
@@ -2599,6 +2605,572 @@ function encodeJwt(payload, sessionKey) {
2599
2605
  return `${jwtHeaderBase64}.${payloadBase64}.${crypto__default["default"].createHmac("sha256", sessionKey).update(`${jwtHeaderBase64}.${payloadBase64}`).digest("base64")}`;
2600
2606
  }
2601
2607
 
2608
+ /**
2609
+ * Just the part of `ValServerConfig` this decision needs.
2610
+ *
2611
+ * Narrower than the whole thing on purpose: a `ValServerConfig` carries the api
2612
+ * key, the secret and the content URLs, and a function that took one would look
2613
+ * like it might consult them. It also makes this testable with an object
2614
+ * literal rather than a cast.
2615
+ */
2616
+
2617
+ /**
2618
+ * The `val.config` the Studio is told about, which is not quite the one on disk.
2619
+ *
2620
+ * `gitBranch` is optional in `val.config`, and a proxy-mode server that mirrors
2621
+ * into a repository usually knows the branch when the file does not: setting it
2622
+ * in the environment is how a Vercel deployment does it
2623
+ * (`VERCEL_GIT_COMMIT_REF`), and the example app does not set it at all.
2624
+ *
2625
+ * Git is OPTIONAL in http mode — a project can run on credentials alone, with
2626
+ * no repository to mirror into — so "http" no longer implies a branch exists.
2627
+ * That changed under this function rather than being designed into it: the
2628
+ * original said proxy mode "refuses to start without `VAL_GIT_BRANCH`", which
2629
+ * was true when it was written and is not any more. With no mirror there is
2630
+ * nothing to fill in and nothing to say, which is the same answer fs mode gets.
2631
+ *
2632
+ * The Studio reads `config.gitBranch` and nothing else, so without this a
2633
+ * correctly configured project gets a History pane reading "this project has no
2634
+ * gitBranch configured" while the server behind it is busy committing to one,
2635
+ * and a status bar with no branch on it.
2636
+ *
2637
+ * Narrow on purpose:
2638
+ *
2639
+ * - **`val.config` wins.** A branch named in the file is the author's answer,
2640
+ * and the environment's is the fallback.
2641
+ * - **Every other mode is left alone.** Neither `fs` nor `memory` has a branch:
2642
+ * `ValOpsFS` has no commits of its own, and a memory host holds its own
2643
+ * source and never had any. Nor does an http project with no git mirror. The
2644
+ * shell hides what needs a branch rather than being handed a name that means
2645
+ * nothing.
2646
+ *
2647
+ * The modes are enumerated rather than written as "http, or anything else", and
2648
+ * that is worth keeping: `memory` arrived after this function did, and spelling
2649
+ * every mode out is what turned "does this one have a branch?" into a compile
2650
+ * error somebody had to answer instead of a default that silently applied.
2651
+ */
2652
+ function clientConfig(options) {
2653
+ if (options.mode !== "http" || options.git === undefined) {
2654
+ return options.config;
2655
+ }
2656
+ if (options.config.gitBranch !== undefined) {
2657
+ return options.config;
2658
+ }
2659
+ return {
2660
+ ...options.config,
2661
+ gitBranch: options.git.branch
2662
+ };
2663
+ }
2664
+
2665
+ /**
2666
+ * Which IP addresses this server may open a connection to.
2667
+ *
2668
+ * The link check makes outbound requests to addresses an editor supplies,
2669
+ * which is the definition of SSRF: without this, "check my external links"
2670
+ * is a button that turns the app's server into a probe for whatever is
2671
+ * reachable from inside the network it runs in - a database on a private
2672
+ * subnet, an admin panel on localhost, and on every major cloud the instance
2673
+ * metadata service on 169.254.169.254, which hands out credentials.
2674
+ *
2675
+ * So the rule is a DENY LIST of address ranges rather than an allow list of
2676
+ * hostnames: an editor is allowed to check any real site on the internet, and
2677
+ * nothing else. It is applied to the RESOLVED address, in the lookup the
2678
+ * socket then connects to - a public name is free to resolve to 127.0.0.1,
2679
+ * and checking the name would catch none of it.
2680
+ */
2681
+
2682
+ /** Why an address may not be connected to. Reported, not just counted. */
2683
+
2684
+ const REASON_TEXT = {
2685
+ unspecified: "an unspecified address",
2686
+ loopback: "a loopback address",
2687
+ private: "a private address",
2688
+ "link-local": "a link-local address",
2689
+ "unique-local": "a unique local address",
2690
+ multicast: "a multicast address",
2691
+ reserved: "a reserved address",
2692
+ unparseable: "an address that could not be read"
2693
+ };
2694
+ function describeBlocked(reason) {
2695
+ return REASON_TEXT[reason];
2696
+ }
2697
+
2698
+ /** `null` when the address is a public one this server may talk to. */
2699
+ function blockedAddressReason(address) {
2700
+ // A scope/zone id (`fe80::1%eth0`) is not part of the address, and leaving
2701
+ // it on would make the address unparseable — which fails closed, but for the
2702
+ // wrong reason and with the wrong message.
2703
+ const bare = address.split("%")[0];
2704
+ const v4 = parseIPv4(bare);
2705
+ if (v4 !== null) {
2706
+ return blockedIPv4(v4);
2707
+ }
2708
+ const v6 = parseIPv6(bare);
2709
+ if (v6 !== null) {
2710
+ return blockedIPv6(v6);
2711
+ }
2712
+ // Fail closed. Anything that reaches here is not something `dns.lookup`
2713
+ // produced, and connecting to an address this cannot read is exactly the
2714
+ // case a deny list must not wave through.
2715
+ return "unparseable";
2716
+ }
2717
+ function parseIPv4(text) {
2718
+ const parts = text.split(".");
2719
+ if (parts.length !== 4) {
2720
+ return null;
2721
+ }
2722
+ const octets = [];
2723
+ for (const part of parts) {
2724
+ // Deliberately strict: `010` is octal to `inet_aton` and decimal to
2725
+ // `Number`, and `1e2` parses as 100. A resolver never produces either, so
2726
+ // anything that is not plain digits is not an address - and a leading
2727
+ // zero is refused outright rather than read as decimal, because that is
2728
+ // the one form where this and `inet_aton` would read the SAME string as
2729
+ // two different addresses. `0` itself is an octet; `01` is not.
2730
+ if (!/^(0|[1-9]\d{0,2})$/.test(part)) {
2731
+ return null;
2732
+ }
2733
+ const value = Number(part);
2734
+ if (value > 255) {
2735
+ return null;
2736
+ }
2737
+ octets.push(value);
2738
+ }
2739
+ return [octets[0], octets[1], octets[2], octets[3]];
2740
+ }
2741
+
2742
+ /** Eight hextets, with `::` expanded and a trailing IPv4 form accepted. */
2743
+ function parseIPv6(text) {
2744
+ if (!text.includes(":")) {
2745
+ return null;
2746
+ }
2747
+ // A trailing dotted quad is rewritten into the two hextets it IS, before
2748
+ // anything else looks at the string. Splitting the address around it
2749
+ // instead means handling a `::` that the split lands inside - which is
2750
+ // exactly where `::127.0.0.1` went wrong.
2751
+ let normalized = text;
2752
+ const lastColon = text.lastIndexOf(":");
2753
+ const tail = text.slice(lastColon + 1);
2754
+ if (tail.includes(".")) {
2755
+ const v4 = parseIPv4(tail);
2756
+ if (v4 === null) {
2757
+ return null;
2758
+ }
2759
+ const high = (v4[0] << 8 | v4[1]).toString(16);
2760
+ const low = (v4[2] << 8 | v4[3]).toString(16);
2761
+ normalized = `${text.slice(0, lastColon + 1)}${high}:${low}`;
2762
+ }
2763
+ const doubleColon = normalized.indexOf("::");
2764
+ if (doubleColon !== normalized.lastIndexOf("::")) {
2765
+ return null;
2766
+ }
2767
+ let groups;
2768
+ if (doubleColon === -1) {
2769
+ groups = normalized.split(":");
2770
+ if (groups.length !== 8) {
2771
+ return null;
2772
+ }
2773
+ } else {
2774
+ const left = normalized.slice(0, doubleColon);
2775
+ const right = normalized.slice(doubleColon + 2);
2776
+ const before = left === "" ? [] : left.split(":");
2777
+ const after = right === "" ? [] : right.split(":");
2778
+ if (before.length + after.length > 7) {
2779
+ // `::` stands for AT LEAST one group of zeros, so eight written groups
2780
+ // beside it is one too many.
2781
+ return null;
2782
+ }
2783
+ groups = [...before, ...new Array(8 - before.length - after.length).fill("0"), ...after];
2784
+ }
2785
+ const hextets = [];
2786
+ for (const group of groups) {
2787
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) {
2788
+ return null;
2789
+ }
2790
+ hextets.push(parseInt(group, 16));
2791
+ }
2792
+ return hextets.length === 8 ? hextets : null;
2793
+ }
2794
+ function blockedIPv4(octets) {
2795
+ const [a, b, c] = octets;
2796
+ if (a === 0) return "unspecified"; // 0.0.0.0/8
2797
+ if (a === 10) return "private"; // 10.0.0.0/8
2798
+ if (a === 127) return "loopback"; // 127.0.0.0/8
2799
+ if (a === 100 && b >= 64 && b <= 127) return "private"; // 100.64.0.0/10 CGNAT
2800
+ if (a === 169 && b === 254) return "link-local"; // 169.254.0.0/16 — cloud metadata
2801
+ if (a === 172 && b >= 16 && b <= 31) return "private"; // 172.16.0.0/12
2802
+ if (a === 192 && b === 168) return "private"; // 192.168.0.0/16
2803
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return "reserved"; // 192.0.0.0/24, TEST-NET-1
2804
+ if (a === 192 && b === 88 && c === 99) return "reserved"; // 6to4 relay anycast
2805
+ if (a === 198 && (b === 18 || b === 19)) return "reserved"; // benchmarking
2806
+ if (a === 198 && b === 51 && c === 100) return "reserved"; // TEST-NET-2
2807
+ if (a === 203 && b === 0 && c === 113) return "reserved"; // TEST-NET-3
2808
+ if (a >= 240) return "reserved"; // 240.0.0.0/4, incl. 255.255.255.255
2809
+ if (a >= 224) return "multicast"; // 224.0.0.0/4
2810
+ return null;
2811
+ }
2812
+ function blockedIPv6(h) {
2813
+ const leadingZeros = h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0;
2814
+ if (leadingZeros && h[5] === 0xffff) {
2815
+ // ::ffff:0:0/96 — an IPv4 address wearing an IPv6 hat, and the form a
2816
+ // dual-stack resolver hands back. Judged as the IPv4 address it is, or
2817
+ // this whole list would be one `::ffff:` away from being bypassed.
2818
+ return blockedIPv4(v4Of(h[6], h[7]));
2819
+ }
2820
+ if (leadingZeros && h[5] === 0) {
2821
+ // ::/128 unspecified, ::1/128 loopback, and the deprecated
2822
+ // IPv4-compatible ::a.b.c.d — all of which are local or nothing.
2823
+ if (h[6] === 0 && h[7] === 0) return "unspecified";
2824
+ if (h[6] === 0 && h[7] === 1) return "loopback";
2825
+ return blockedIPv4(v4Of(h[6], h[7])) ?? "reserved";
2826
+ }
2827
+ if (h[0] === 0x64 && h[1] === 0xff9b) return "reserved"; // NAT64
2828
+ if (h[0] === 0x100 && h[1] === 0 && h[2] === 0 && h[3] === 0) {
2829
+ return "reserved"; // 100::/64 discard-only
2830
+ }
2831
+ if (h[0] === 0x2001 && h[1] === 0x0db8) return "reserved"; // documentation
2832
+ if ((h[0] & 0xfe00) === 0xfc00) return "unique-local"; // fc00::/7
2833
+ if ((h[0] & 0xffc0) === 0xfe80) return "link-local"; // fe80::/10
2834
+ if ((h[0] & 0xff00) === 0xff00) return "multicast"; // ff00::/8
2835
+ return null;
2836
+ }
2837
+ function v4Of(high, low) {
2838
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff];
2839
+ }
2840
+
2841
+ /**
2842
+ * Opening one external URL and reporting what answered.
2843
+ *
2844
+ * Mirrors `ExternalUrlProbeResult` in the Studio - the two are checked against
2845
+ * each other by the API route's zod schema, which is the only place they meet.
2846
+ */
2847
+
2848
+ const DEFAULT_TIMEOUT_MS$1 = 5000;
2849
+ const DEFAULT_MAX_REDIRECTS = 5;
2850
+
2851
+ /**
2852
+ * A browser-ish identity.
2853
+ *
2854
+ * Not a disguise: plenty of sites answer 403 to a request with no `User-Agent`
2855
+ * at all, and reporting those as broken would be a check that cries wolf. It
2856
+ * says what it is and where to complain.
2857
+ */
2858
+ const USER_AGENT = "Val-Studio-LinkCheck/1.0 (+https://val.build; link checker)";
2859
+
2860
+ /**
2861
+ * A `lookup` that refuses to hand back an address this server may not reach.
2862
+ *
2863
+ * This, rather than a check on the hostname or a check after the fact, is what
2864
+ * closes the hole. The socket connects to the address the lookup RETURNS, so
2865
+ * validating inside it leaves no window between the check and the connection —
2866
+ * a name that resolves to 93.184.216.34 once and 127.0.0.1 the next time gets
2867
+ * no second chance, because there is only ever one resolution and it is this
2868
+ * one.
2869
+ */
2870
+ /**
2871
+ * What a refused lookup tells the CALLER.
2872
+ *
2873
+ * Deliberately the same words `ENETUNREACH` gets, and deliberately without the
2874
+ * address. The guard stops the connection, but the first version of it handed
2875
+ * back the DNS answer it had just refused to use - "vault.prod.svc resolves to
2876
+ * a private address (10.42.7.19)" - which travels through `readableError` into
2877
+ * the response and onto the screen. That turns an endpoint that cannot reach
2878
+ * the internal network into one that MAPS it: twenty names a request, existence
2879
+ * confirmed and address included, without a single connection being made.
2880
+ *
2881
+ * True rather than evasive: this server genuinely did not reach the host. And
2882
+ * because a real external host that is down produces the same sentence, the
2883
+ * answer no longer says which of the two happened. The operator still gets the
2884
+ * detail, in the log, where it belongs.
2885
+ *
2886
+ * A hostname the caller wrote as a literal address is different and keeps its
2887
+ * specific message (see the literal check in `probeUrl`): repeating
2888
+ * `127.0.0.1` to someone who just typed `127.0.0.1` reveals nothing.
2889
+ */
2890
+ const REFUSED_MESSAGE = "the host could not be reached";
2891
+ function guardedLookup(lookup) {
2892
+ const guarded = (hostname, options, callback) => {
2893
+ // The overloads differ only in whether `options` was passed; `net` always
2894
+ // calls through with both, and this forwards whatever it was given.
2895
+ return lookup(hostname, options, (err, address, family) => {
2896
+ if (err) {
2897
+ callback(err, "", 0);
2898
+ return;
2899
+ }
2900
+ const addresses = Array.isArray(address) ? address : [{
2901
+ address,
2902
+ family: family ?? 0
2903
+ }];
2904
+ for (const entry of addresses) {
2905
+ const reason = blockedAddressReason(entry.address);
2906
+ if (reason !== null) {
2907
+ // Server-side only. This is the half an operator needs to work out
2908
+ // why a link check refused a URL, and the half a caller must not
2909
+ // be given.
2910
+ console.warn(`[val] link check refused ${hostname}: resolves to ${describeBlocked(reason)} (${entry.address})`);
2911
+ callback(new Error(REFUSED_MESSAGE), "", 0);
2912
+ return;
2913
+ }
2914
+ }
2915
+ // Forwarded in the shape it arrived in: `net` reads it back according
2916
+ // to whether it asked for `all`.
2917
+ if (Array.isArray(address)) {
2918
+ callback(null, address, family);
2919
+ } else {
2920
+ callback(null, address, family ?? 0);
2921
+ }
2922
+ }
2923
+ // The callback shape above is the union of both overloads, which the
2924
+ // published types split apart.
2925
+ );
2926
+ };
2927
+ return guarded;
2928
+ }
2929
+
2930
+ /** The statuses that mean "ask again with GET" rather than "no". */
2931
+ function needsGetFallback(code) {
2932
+ /*
2933
+ * Only where the server said the METHOD was the problem.
2934
+ *
2935
+ * A 403 is the tempting third case - some WAFs and proxies do refuse HEAD
2936
+ * with one, and this repo's own sandbox is an example - but retrying every
2937
+ * 403 with a GET means downloading a page for each of them, on a check whose
2938
+ * whole point is to be cheap enough to run over a few hundred URLs. And it
2939
+ * would buy little: 403 is already reported as "may be fine for a visitor,
2940
+ * cannot be checked from here" rather than as a broken link, which is the
2941
+ * right answer either way.
2942
+ */
2943
+ return code === 405 || code === 501;
2944
+ }
2945
+ async function probeUrl(rawUrl, options = {}) {
2946
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
2947
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
2948
+ const now = options.now ?? (() => Date.now());
2949
+ const started = now();
2950
+ const deadline = started + timeoutMs;
2951
+ let url;
2952
+ try {
2953
+ url = new URL(rawUrl.trim());
2954
+ } catch {
2955
+ return {
2956
+ kind: "skipped",
2957
+ message: "Not opened: this is not a URL."
2958
+ };
2959
+ }
2960
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
2961
+ return {
2962
+ kind: "skipped",
2963
+ message: "Not opened: only http:// and https:// can be checked."
2964
+ };
2965
+ }
2966
+ let method = "HEAD";
2967
+ let redirects = 0;
2968
+ for (;;) {
2969
+ const remaining = deadline - now();
2970
+ if (remaining <= 0) {
2971
+ return {
2972
+ kind: "timeout",
2973
+ ms: timeoutMs
2974
+ };
2975
+ }
2976
+ /*
2977
+ * A hostname that is ALREADY an address never reaches the lookup.
2978
+ *
2979
+ * `net.connect` checks for an IP literal first and connects straight to
2980
+ * it, so `guardedLookup` is simply not called for
2981
+ * `http://169.254.169.254/` — which is the single most valuable URL an
2982
+ * attacker could ask this server to open. Checked here, per hop, because a
2983
+ * redirect can introduce one at any point in the chain.
2984
+ *
2985
+ * This was a real hole, found by a test that asserted the local server
2986
+ * received nothing and watched it receive something.
2987
+ */
2988
+ const literal = bareHost(url.hostname);
2989
+ if (net__default["default"].isIP(literal) !== 0) {
2990
+ const reason = blockedAddressReason(literal);
2991
+ if (reason !== null) {
2992
+ return {
2993
+ kind: "unreachable",
2994
+ message: `${literal} is ${describeBlocked(reason)}`
2995
+ };
2996
+ }
2997
+ }
2998
+ let response;
2999
+ try {
3000
+ response = await requestOnce(url, method, remaining, options.request);
3001
+ } catch (error) {
3002
+ const message = error instanceof Error ? error.message : String(error);
3003
+ if (message === TIMED_OUT) {
3004
+ return {
3005
+ kind: "timeout",
3006
+ ms: timeoutMs
3007
+ };
3008
+ }
3009
+ return {
3010
+ kind: "unreachable",
3011
+ message
3012
+ };
3013
+ }
3014
+ if (method === "HEAD" && needsGetFallback(response.code)) {
3015
+ method = "GET";
3016
+ continue;
3017
+ }
3018
+ const location = response.location;
3019
+ if (location !== undefined && response.code >= 300 && response.code < 400) {
3020
+ if (redirects >= maxRedirects) {
3021
+ return {
3022
+ kind: "unreachable",
3023
+ message: `too many redirects (${maxRedirects})`
3024
+ };
3025
+ }
3026
+ let next;
3027
+ try {
3028
+ next = new URL(location, url);
3029
+ } catch {
3030
+ return {
3031
+ kind: "unreachable",
3032
+ message: `redirected to something that is not a URL (${location})`
3033
+ };
3034
+ }
3035
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
3036
+ // A redirect to `file:` or `gopher:` is a redirect out of what this
3037
+ // is allowed to open, and the address guard would never see it.
3038
+ return {
3039
+ kind: "unreachable",
3040
+ message: `redirected to ${next.protocol}//, which cannot be checked`
3041
+ };
3042
+ }
3043
+ url = next;
3044
+ redirects++;
3045
+ // A redirect answers the method question too: a server that refused
3046
+ // HEAD at the old URL says nothing about the new one.
3047
+ method = "HEAD";
3048
+ continue;
3049
+ }
3050
+ return {
3051
+ kind: "answered",
3052
+ code: response.code,
3053
+ finalUrl: url.toString(),
3054
+ ms: Math.max(0, Math.round(now() - started))
3055
+ };
3056
+ }
3057
+ }
3058
+
3059
+ /**
3060
+ * A hostname with the IPv6 brackets off.
3061
+ *
3062
+ * `new URL("http://[::1]/").hostname` is `"[::1]"`, brackets and all, and
3063
+ * `net.isIP` says 0 to that — so the bracketed form sailed past the literal
3064
+ * check and failed later with "host could not be found". Which looks like a
3065
+ * refusal, and is not one.
3066
+ */
3067
+ function bareHost(hostname) {
3068
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
3069
+ }
3070
+ const TIMED_OUT = "val:link-check-timeout";
3071
+ function requestOnce(url, method, timeoutMs, requestImpl) {
3072
+ const impl = requestImpl ?? (url.protocol === "http:" ? http__default["default"].request : https__default["default"].request);
3073
+ return new Promise((resolve, reject) => {
3074
+ let settled = false;
3075
+ const finish = fn => {
3076
+ if (settled) return;
3077
+ settled = true;
3078
+ fn();
3079
+ };
3080
+ const req = impl({
3081
+ protocol: url.protocol,
3082
+ hostname: url.hostname,
3083
+ port: url.port === "" ? undefined : url.port,
3084
+ path: `${url.pathname}${url.search}`,
3085
+ method,
3086
+ headers: {
3087
+ // No cookies, no authorization, no forwarded headers: this request
3088
+ // is the server's own and carries none of its identity.
3089
+ "user-agent": USER_AGENT,
3090
+ accept: "*/*",
3091
+ // A redirect chain is followed by hand, one guarded hop at a time.
3092
+ "accept-encoding": "identity"
3093
+ },
3094
+ // Every DNS answer is checked before a socket is opened. See
3095
+ // `guardedLookup` — this is the line that makes the whole feature safe.
3096
+ lookup: guardedLookup(defaultLookup()),
3097
+ // Node's default agent keeps sockets alive between requests, and a
3098
+ // pooled socket was resolved by a PREVIOUS lookup. A fresh connection
3099
+ // per probe is what keeps the guard in front of every one of them.
3100
+ agent: false,
3101
+ timeout: timeoutMs
3102
+ }, res => {
3103
+ const location = res.headers.location;
3104
+ // The body is never read: a status and a `Location` is the whole
3105
+ // answer, and downloading a page to throw it away is bandwidth spent
3106
+ // on somebody else's server.
3107
+ res.destroy();
3108
+ finish(() => resolve({
3109
+ code: res.statusCode ?? 0,
3110
+ location: typeof location === "string" ? location : undefined
3111
+ }));
3112
+ });
3113
+ req.on("timeout", () => {
3114
+ req.destroy();
3115
+ finish(() => reject(new Error(TIMED_OUT)));
3116
+ });
3117
+ req.on("error", error => {
3118
+ finish(() => reject(new Error(readableError(error))));
3119
+ });
3120
+ req.end();
3121
+ });
3122
+ }
3123
+
3124
+ /** `dns.lookup`, read lazily so a test can swap `node:dns` wholesale. */
3125
+ function defaultLookup() {
3126
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
3127
+ const dns = require("node:dns");
3128
+ return dns.lookup;
3129
+ }
3130
+
3131
+ /** What went wrong, in words rather than in errno. */
3132
+ function readableError(error) {
3133
+ /*
3134
+ * The guard's own refusal, recognised by its message rather than by a code.
3135
+ * `net` destroys the socket with the Error the lookup gave it, and the
3136
+ * message survives that trip verbatim; whether a custom `code` does is an
3137
+ * implementation detail this does not need to bet on.
3138
+ */
3139
+ if (error.message === REFUSED_MESSAGE) {
3140
+ return REFUSED_MESSAGE;
3141
+ }
3142
+ switch (error.code) {
3143
+ case "ENOTFOUND":
3144
+ case "EAI_AGAIN":
3145
+ return "the host could not be found";
3146
+ case "ECONNREFUSED":
3147
+ return "the connection was refused";
3148
+ case "ECONNRESET":
3149
+ return "the connection was reset";
3150
+ case "EHOSTUNREACH":
3151
+ case "ENETUNREACH":
3152
+ return "the host could not be reached";
3153
+ case "CERT_HAS_EXPIRED":
3154
+ return "its certificate has expired";
3155
+ case "ERR_TLS_CERT_ALTNAME_INVALID":
3156
+ return "its certificate is for a different host";
3157
+ case "DEPTH_ZERO_SELF_SIGNED_CERT":
3158
+ case "SELF_SIGNED_CERT_IN_CHAIN":
3159
+ return "its certificate is self-signed";
3160
+ default:
3161
+ /*
3162
+ * A fixed string, not `error.message`.
3163
+ *
3164
+ * Whatever Node or OpenSSL put in there is written for a server log, and
3165
+ * some of it names things the caller should not learn - a resolved
3166
+ * address, a certificate's subject, a path. The cases above cover what is
3167
+ * worth telling an editor; anything else is noise to them and a leak
3168
+ * waiting to happen.
3169
+ */
3170
+ return "the request failed";
3171
+ }
3172
+ }
3173
+
2602
3174
  /* eslint-disable @typescript-eslint/no-unused-vars */
2603
3175
  const jsonOps = new patch.JSONOps();
2604
3176
  /**
@@ -4554,6 +5126,9 @@ class ValOps {
4554
5126
  // group whose id is undefined, all the way to the client.
4555
5127
  ...(saveRes.value.patchGroupId !== undefined ? {
4556
5128
  patchGroupId: saveRes.value.patchGroupId
5129
+ } : {}),
5130
+ ...(saveRes.value.headVersion !== undefined ? {
5131
+ headVersion: saveRes.value.headVersion
4557
5132
  } : {})
4558
5133
  });
4559
5134
  }
@@ -4595,6 +5170,15 @@ class ValOps {
4595
5170
  return null;
4596
5171
  }
4597
5172
 
5173
+ /**
5174
+ * Does a press of Publish run as a publish job (valbuild/home,
5175
+ * docs/app-mode.md, "Publishing is a queued job")? Never for a project
5176
+ * with no content service.
5177
+ */
5178
+ publishesAsJobs() {
5179
+ return false;
5180
+ }
5181
+
4598
5182
  /**
4599
5183
  * The branch the content service keeps this project's commits on, or `null`
4600
5184
  * where there is no such service, or it has not said yet.
@@ -7570,6 +8154,59 @@ async function readCommittedBinaryFiles(ops, descriptors) {
7570
8154
  };
7571
8155
  }
7572
8156
 
8157
+ /**
8158
+ * A publish job's prepare, split into what the tab builds and what content
8159
+ * archives.
8160
+ *
8161
+ * A job's content is its base plus every change it took (valbuild/home,
8162
+ * docs/app-mode.md, "Publishing is a queued job"). The server has two
8163
+ * `prepare`s to hand: `chainOnly` -- this build's embedded source with every
8164
+ * commit since applied, which is the branch's head -- and `withJob`, the same
8165
+ * with the job's pending changes on top. Anything applied since the job
8166
+ * started came out of the job's own change set, so `withJob` IS the job's
8167
+ * content; were that ever not so, the seal's containment check would restart
8168
+ * the job rather than publish it.
8169
+ *
8170
+ * - The BUILD needs all of `withJob`'s source files: it is a whole site.
8171
+ * - The ARCHIVE is what the commit publishes, so content is sent only what the
8172
+ * job changes: the files whose text differs from the head's, and the modules
8173
+ * and binary files its changes touch. Everything else is the head's, and an
8174
+ * archive that listed it would say this commit changed it.
8175
+ *
8176
+ * Files are compared rather than derived from module paths because a change to
8177
+ * a module can write a file under another path -- a `.val.json` entry of a
8178
+ * record -- which only the prepared output knows about.
8179
+ */
8180
+ function splitJobPrepare(input) {
8181
+ const job = new Set(input.jobPatchIds);
8182
+ const before = input.chainOnly.patchedSourceFiles;
8183
+ const after = input.withJob.patchedSourceFiles;
8184
+ const patchedSourceFiles = {};
8185
+ for (const [path, text] of Object.entries(after)) {
8186
+ if (!(path in before) || before[path] !== text) {
8187
+ patchedSourceFiles[path] = text;
8188
+ }
8189
+ }
8190
+ const patchedBinaryFilesDescriptors = {};
8191
+ for (const [path, descriptor] of Object.entries(input.withJob.patchedBinaryFilesDescriptors)) {
8192
+ if (job.has(descriptor.patchId)) {
8193
+ patchedBinaryFilesDescriptors[path] = descriptor;
8194
+ }
8195
+ }
8196
+
8197
+ // The modules any of the job's changes were applied to.
8198
+ const touched = new Set(Object.entries(input.withJob.appliedPatches).filter(([, applied]) => applied.some(id => job.has(id))).map(([path]) => path));
8199
+ const modules = Object.fromEntries(Object.entries(input.withJob.moduleVersions).filter(([path]) => touched.has(path)));
8200
+ return {
8201
+ buildSourceFiles: after,
8202
+ archive: {
8203
+ patchedSourceFiles,
8204
+ patchedBinaryFilesDescriptors,
8205
+ modules
8206
+ }
8207
+ };
8208
+ }
8209
+
7573
8210
  /**
7574
8211
  * Which unpublished changes a failed save has to throw away to make progress.
7575
8212
  *
@@ -7711,6 +8348,11 @@ const GetApplicablePatches = z.z.object({
7711
8348
  * a content service that predates it sends nothing.
7712
8349
  */
7713
8350
  headPatchId: internal.PatchId.nullable().optional(),
8351
+ /**
8352
+ * The chain version of that head. See `OrderedPatches.headVersion`.
8353
+ * Optional for the same reason.
8354
+ */
8355
+ headVersion: z.z.number().optional(),
7714
8356
  commits: z.z.array(z.z.object({
7715
8357
  commitSha: z.z.string(),
7716
8358
  /*
@@ -7749,7 +8391,13 @@ const GetApplicablePatches = z.z.object({
7749
8391
  */
7750
8392
  project: z.z.object({
7751
8393
  sourceMode: z.z.union([z.z.literal("managed"), z.z.literal("connected")]),
7752
- branch: z.z.string()
8394
+ branch: z.z.string(),
8395
+ /**
8396
+ * Whether a press of Publish is a publish JOB. Absent from a content
8397
+ * service that predates connected jobs: then managed projects publish
8398
+ * as jobs, and connected ones by commit, as they did.
8399
+ */
8400
+ publishJobs: z.z.boolean().optional()
7753
8401
  }).optional()
7754
8402
  });
7755
8403
  const FilesResponse = z.z.object({
@@ -7786,15 +8434,39 @@ const SavePatchResponse = z.z.object({
7786
8434
  * Optional: a content API that predates patch groups does not send one, and
7787
8435
  * absence has to keep meaning "no groups here" rather than failing the save.
7788
8436
  */
7789
- patchGroupId: z.z.string().optional()
8437
+ patchGroupId: z.z.string().optional(),
8438
+ /**
8439
+ * The chain version this write made, comparable with `headVersion` on the
8440
+ * listing. Optional: a content service that predates it sends nothing.
8441
+ */
8442
+ headVersion: z.z.number().optional()
7790
8443
  });
7791
8444
  const DeletePatchesResponse = z.z.object({
7792
8445
  deleted: z.z.array(PatchId),
7793
8446
  errors: z.z.array(z.z.object({
7794
8447
  message: z.z.string(),
7795
8448
  patchId: PatchId
7796
- })).optional()
8449
+ })).optional(),
8450
+ /**
8451
+ * The chain version each branch the delete changed is now at, keyed by
8452
+ * branch. Optional: a content service that predates it sends none.
8453
+ */
8454
+ headVersions: z.z.record(z.z.string(), z.z.number()).optional()
7797
8455
  });
8456
+ /**
8457
+ * This client's own branch's entry in a per-branch version map.
8458
+ *
8459
+ * A delete can span branches, so the content service answers per branch. With
8460
+ * no branch of its own (a build with no repository, where the service resolves
8461
+ * it), the only entry is ours when there is exactly one; with several, none can
8462
+ * be picked, and a wrong one would floor this client at another chain's count.
8463
+ */
8464
+ function ownBranchVersion(headVersions, branch) {
8465
+ if (headVersions === undefined) return undefined;
8466
+ if (branch !== undefined) return headVersions[branch];
8467
+ const versions = Object.values(headVersions);
8468
+ return versions.length === 1 ? versions[0] : undefined;
8469
+ }
7798
8470
  const SavePatchFileResponse = z.z.object({
7799
8471
  patchId: PatchId,
7800
8472
  filePath: ModuleFilePath
@@ -7814,7 +8486,9 @@ const CommitResponse = z.z.object({
7814
8486
  */
7815
8487
  parent: z.z.string().optional(),
7816
8488
  tree: z.z.string().optional(),
7817
- branch: z.z.string()
8489
+ branch: z.z.string(),
8490
+ /** The chain version the publish moved the branch to; absent if it applied nothing. */
8491
+ headVersion: z.z.number().optional()
7818
8492
  });
7819
8493
  // #region history wire schemas
7820
8494
  //
@@ -8080,6 +8754,17 @@ class ValOpsHttp extends ValOps {
8080
8754
  return ((_this$projectExpectat3 = this.projectExpectation) === null || _this$projectExpectat3 === void 0 ? void 0 : _this$projectExpectat3.branch) ?? null;
8081
8755
  }
8082
8756
 
8757
+ /**
8758
+ * Does a press of Publish run as a publish job? Every managed project, and
8759
+ * a connected one content says is hosted on the platform (its CI publishes
8760
+ * through content). `false` before anything has been heard.
8761
+ */
8762
+ publishesAsJobs() {
8763
+ const expected = this.projectExpectation;
8764
+ if (expected === null) return false;
8765
+ return expected.publishJobs ?? expected.sourceMode === "managed";
8766
+ }
8767
+
8083
8768
  /**
8084
8769
  * The short-lived publish token, and when it stops being usable.
8085
8770
  *
@@ -8178,7 +8863,9 @@ class ValOpsHttp extends ValOps {
8178
8863
  * An allow list rather than a prefix check, because this method holds a
8179
8864
  * credential and the browser chooses the path. `/publish/{id}` and its three
8180
8865
  * steps are the publish conversation; `/build-target` is what a build needs
8181
- * to know before it starts; `/project-source` is what it builds.
8866
+ * to know before it starts; `/project-source` is what it builds;
8867
+ * `/update-target` is the same question as `/build-target` for a managed
8868
+ * project moving onto its template's current dependencies.
8182
8869
  *
8183
8870
  * `/project-source` is here rather than on a route of its own because it is
8184
8871
  * one of the three things a publish asks for and none of them are useful
@@ -8192,22 +8879,66 @@ class ValOpsHttp extends ValOps {
8192
8879
  * segment gets through.
8193
8880
  */
8194
8881
  static publishApiPathAllowed(path) {
8195
- if (path === "/build-target" || path === "/project-source" || path === "/publish") {
8882
+ if (path === "/build-target" || path === "/project-source" || path === "/update-target" || path === "/publish" ||
8883
+ // Where "View run" goes, for a connected build CI reported failed.
8884
+ // Read only: reporting a run is CI's, with its own token.
8885
+ path === "/ci-runs/newest") {
8886
+ return true;
8887
+ }
8888
+ /*
8889
+ * Publishing as a queued job: a press, where it is, Try again, and the
8890
+ * tab's reports about the job it runs. NOT `/publish-jobs/{id}/prepare`:
8891
+ * its body is the job's source files and modules, which this server
8892
+ * computes (`prepareJob`) -- a browser allowed to send it could publish
8893
+ * any text at all as a commit's archive.
8894
+ */
8895
+ if (/^\/publish-requests(\/[A-Za-z0-9_-]{1,100})?$/.test(path)) {
8896
+ return true;
8897
+ }
8898
+ if (/^\/publish-jobs\/(next|[A-Za-z0-9_-]{1,100}\/(steps|renew|cancel|discard))$/.test(path)) {
8196
8899
  return true;
8197
8900
  }
8198
8901
  return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
8199
8902
  }
8200
8903
  async publishApi(path, init) {
8201
- const json = "application/json";
8202
8904
  if (!ValOpsHttp.publishApiPathAllowed(path)) {
8203
8905
  return {
8204
8906
  status: 403,
8205
- contentType: json,
8907
+ contentType: "application/json",
8206
8908
  body: JSON.stringify({
8207
8909
  message: `'${path}' is not part of the publish API.`
8208
8910
  })
8209
8911
  };
8210
8912
  }
8913
+ return this.callPublishApi(path, init);
8914
+ }
8915
+
8916
+ /**
8917
+ * A publish job's prepare, sent to content: the job's sources, archived
8918
+ * there before the job is built (valbuild/home, docs/app-mode.md, "The
8919
+ * archive, before the seal"). With this server's publish token, as every
8920
+ * publish call is, and never through `publishApi` -- see the allowlist.
8921
+ */
8922
+ async prepareJob(jobId, body) {
8923
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(jobId)) {
8924
+ return {
8925
+ status: 400,
8926
+ contentType: "application/json",
8927
+ body: JSON.stringify({
8928
+ message: `'${jobId}' is not a job id.`
8929
+ })
8930
+ };
8931
+ }
8932
+ return this.callPublishApi(`/publish-jobs/${jobId}/prepare`, {
8933
+ method: "POST",
8934
+ body: JSON.stringify({
8935
+ ...body,
8936
+ root: this.root
8937
+ })
8938
+ });
8939
+ }
8940
+ async callPublishApi(path, init) {
8941
+ const json = "application/json";
8211
8942
  const send = async token => fetch(`${this.contentUrl}/v1${path}`, {
8212
8943
  method: init.method,
8213
8944
  headers: {
@@ -8443,6 +9174,9 @@ class ValOpsHttp extends ValOps {
8443
9174
  ...(allPatchData.headPatchId !== undefined ? {
8444
9175
  headPatchId: allPatchData.headPatchId
8445
9176
  } : {}),
9177
+ ...(allPatchData.headVersion !== undefined ? {
9178
+ headVersion: allPatchData.headVersion
9179
+ } : {}),
8446
9180
  /*
8447
9181
  * The PUBLISH head, which is not `commitSha`.
8448
9182
  *
@@ -8570,6 +9304,7 @@ class ValOpsHttp extends ValOps {
8570
9304
  // A fact about the branch too, so taken from the first chunk that has it
8571
9305
  // for the same reason `commits` is.
8572
9306
  let headPatchId;
9307
+ let headVersion;
8573
9308
  if (patchIds === undefined || patchIds.length === 0) {
8574
9309
  return this.fetchPatchesInternal({
8575
9310
  patchIds: patchIds,
@@ -8590,8 +9325,15 @@ class ValOpsHttp extends ValOps {
8590
9325
  if (commits === undefined && res.commits !== undefined) {
8591
9326
  commits = res.commits;
8592
9327
  }
8593
- if (headPatchId === undefined && res.headPatchId !== undefined) {
9328
+ /*
9329
+ * The NEWEST head among the chunks: each is its own request, so a later
9330
+ * one can have seen a write an earlier one did not. Taken with its own
9331
+ * version, so the two always describe one read. Without versions (an
9332
+ * older content service) the first head stands, as before.
9333
+ */
9334
+ if (res.headPatchId !== undefined && (headPatchId === undefined || res.headVersion !== undefined && (headVersion === undefined || res.headVersion > headVersion))) {
8594
9335
  headPatchId = res.headPatchId;
9336
+ headVersion = res.headVersion;
8595
9337
  }
8596
9338
  }
8597
9339
  // Chunking is a query-string-length workaround, NOT a filter: the content
@@ -8628,6 +9370,9 @@ class ValOpsHttp extends ValOps {
8628
9370
  } : {}),
8629
9371
  ...(headPatchId !== undefined ? {
8630
9372
  headPatchId
9373
+ } : {}),
9374
+ ...(headVersion !== undefined ? {
9375
+ headVersion
8631
9376
  } : {})
8632
9377
  };
8633
9378
  }
@@ -8726,6 +9471,9 @@ class ValOpsHttp extends ValOps {
8726
9471
  errors,
8727
9472
  ...(data.headPatchId !== undefined ? {
8728
9473
  headPatchId: data.headPatchId
9474
+ } : {}),
9475
+ ...(data.headVersion !== undefined ? {
9476
+ headVersion: data.headVersion
8729
9477
  } : {})
8730
9478
  };
8731
9479
  }
@@ -9096,6 +9844,9 @@ class ValOpsHttp extends ValOps {
9096
9844
  */
9097
9845
  ...(parsed.data.patchGroupId !== undefined ? {
9098
9846
  patchGroupId: parsed.data.patchGroupId
9847
+ } : {}),
9848
+ ...(parsed.data.headVersion !== undefined ? {
9849
+ headVersion: parsed.data.headVersion
9099
9850
  } : {})
9100
9851
  });
9101
9852
  }
@@ -9419,18 +10170,25 @@ class ValOpsHttp extends ValOps {
9419
10170
  if (res.ok) {
9420
10171
  const parsed = DeletePatchesResponse.safeParse(await res.json());
9421
10172
  if (parsed.success) {
10173
+ var _this$git;
9422
10174
  const errors = {};
9423
10175
  for (const err of parsed.data.errors || []) {
9424
10176
  errors[err.patchId] = err;
9425
10177
  }
10178
+ const headVersion = ownBranchVersion(parsed.data.headVersions, (_this$git = this.git) === null || _this$git === void 0 ? void 0 : _this$git.branch);
10179
+ const version = headVersion !== undefined ? {
10180
+ headVersion
10181
+ } : {};
9426
10182
  if (Object.keys(errors).length === 0) {
9427
10183
  return {
9428
- deleted: parsed.data.deleted
10184
+ deleted: parsed.data.deleted,
10185
+ ...version
9429
10186
  };
9430
10187
  }
9431
10188
  return {
9432
10189
  deleted: parsed.data.deleted,
9433
- errors
10190
+ errors,
10191
+ ...version
9434
10192
  };
9435
10193
  }
9436
10194
  return {
@@ -9560,6 +10318,9 @@ class ValOpsHttp extends ValOps {
9560
10318
  } : {}),
9561
10319
  ...(parsed.data.tree !== undefined ? {
9562
10320
  tree: parsed.data.tree
10321
+ } : {}),
10322
+ ...(parsed.data.headVersion !== undefined ? {
10323
+ headVersion: parsed.data.headVersion
9563
10324
  } : {})
9564
10325
  };
9565
10326
  }
@@ -12329,7 +13090,13 @@ const ValServer = (valModules, options, callbacks) => {
12329
13090
  ...(sourceMode ? {
12330
13091
  sourceMode
12331
13092
  } : {}),
12332
- config: options.config
13093
+ ...(sourceMode ? {
13094
+ publishJobs: serverOps.publishesAsJobs()
13095
+ } : {}),
13096
+ // Not `options.config` verbatim: in proxy mode the branch the
13097
+ // server resolved is filled in where the file did not name one.
13098
+ // See `clientConfig`.
13099
+ config: clientConfig(options)
12333
13100
  }
12334
13101
  };
12335
13102
  }
@@ -12422,6 +13189,46 @@ const ValServer = (valModules, options, callbacks) => {
12422
13189
  };
12423
13190
  }
12424
13191
  },
13192
+ "/external-urls/check": {
13193
+ POST: async req => {
13194
+ /*
13195
+ * The same door as every other route, which in fs mode is an open one.
13196
+ *
13197
+ * `getAuth` returns anonymous success when there is no `valSecret` and
13198
+ * nothing requires auth — a developer's own machine, where there is no
13199
+ * credential to require and 29 other routes already behave this way.
13200
+ * So this is not a session gate in fs mode, and it is not meant to be
13201
+ * the thing that makes the endpoint safe: what does that is
13202
+ * `addressGuard`, which refuses every address that is not on the
13203
+ * public internet, on the RESOLVED address, at every redirect hop.
13204
+ */
13205
+ const auth = getAuth(req.cookies);
13206
+ if (auth.error) {
13207
+ return {
13208
+ status: 401,
13209
+ json: {
13210
+ message: auth.error
13211
+ }
13212
+ };
13213
+ }
13214
+ /*
13215
+ * Every URL in the batch at once, and nothing beyond it.
13216
+ *
13217
+ * The concurrency here is the batch size the route caps at, which is
13218
+ * what keeps "check a thousand links" from becoming a thousand
13219
+ * simultaneous outbound sockets: the client sends ten, waits, sends
13220
+ * the next ten. Doing them in parallel WITHIN a batch is what makes a
13221
+ * batch worth having — ten five-second timeouts in series is a minute.
13222
+ */
13223
+ const results = await Promise.all(req.body.urls.map(async url => [url, await probeUrl(url)]));
13224
+ return {
13225
+ status: 200,
13226
+ json: {
13227
+ results: Object.fromEntries(results)
13228
+ }
13229
+ };
13230
+ }
13231
+ },
12425
13232
  "/direct-file-upload-settings": {
12426
13233
  POST: async req => {
12427
13234
  const cookies = req.cookies;
@@ -12729,6 +13536,13 @@ const ValServer = (valModules, options, callbacks) => {
12729
13536
  * re-read when a fetch has missing ids to ask for.
12730
13537
  */
12731
13538
  let patchGroupIdFromStore;
13539
+ /*
13540
+ * The chain version the LAST patch made — the head this batch leaves.
13541
+ * The client keeps the highest version it has seen, so a `/stat` read
13542
+ * before this write cannot put its parent back behind it. Absent in
13543
+ * `fs` mode and from an older content API.
13544
+ */
13545
+ let headVersion;
12732
13546
  for (const patch of patches) {
12733
13547
  const createPatchRes = await serverOps.createPatch(patch.path, patch.patch, patch.patchId, parentRef, sessionId, authorId, patchGroup);
12734
13548
  if (fp.result.isErr(createPatchRes)) {
@@ -12765,6 +13579,7 @@ const ValServer = (valModules, options, callbacks) => {
12765
13579
  if (createPatchRes.value.patchGroupId !== undefined) {
12766
13580
  patchGroupIdFromStore = createPatchRes.value.patchGroupId;
12767
13581
  }
13582
+ headVersion = createPatchRes.value.headVersion;
12768
13583
  }
12769
13584
  }
12770
13585
  return {
@@ -12777,6 +13592,9 @@ const ValServer = (valModules, options, callbacks) => {
12777
13592
  // client reads absence as "staging is not available here".
12778
13593
  ...(patchGroupIdFromStore !== undefined ? {
12779
13594
  patchGroupId: patchGroupIdFromStore
13595
+ } : {}),
13596
+ ...(headVersion !== undefined ? {
13597
+ headVersion
12780
13598
  } : {})
12781
13599
  }
12782
13600
  };
@@ -12916,7 +13734,7 @@ const ValServer = (valModules, options, callbacks) => {
12916
13734
  };
12917
13735
  },
12918
13736
  DELETE: async req => {
12919
- var _req$body;
13737
+ var _req$body, _req$body2;
12920
13738
  const query = req.query;
12921
13739
  const cookies = req.cookies;
12922
13740
  const auth = getAuth(cookies);
@@ -12969,6 +13787,17 @@ const ValServer = (valModules, options, callbacks) => {
12969
13787
  }
12970
13788
  };
12971
13789
  }
13790
+ if (((_req$body2 = req.body) === null || _req$body2 === void 0 ? void 0 : _req$body2.reportHeadVersion) === true) {
13791
+ return {
13792
+ status: 200,
13793
+ json: {
13794
+ deleted: ids,
13795
+ ...("headVersion" in deleteRes && deleteRes.headVersion !== undefined ? {
13796
+ headVersion: deleteRes.headVersion
13797
+ } : {})
13798
+ }
13799
+ };
13800
+ }
12972
13801
  return {
12973
13802
  status: 200,
12974
13803
  json: ids
@@ -13429,8 +14258,9 @@ const ValServer = (valModules, options, callbacks) => {
13429
14258
  return res;
13430
14259
  }
13431
14260
  },
13432
- "/built-source": {
13433
- GET: async req => {
14261
+ "/publish-job-prepare": {
14262
+ POST: async req => {
14263
+ var _options$config$files2;
13434
14264
  const auth = getAuth(req.cookies);
13435
14265
  if (auth.error) {
13436
14266
  return {
@@ -13440,14 +14270,93 @@ const ValServer = (valModules, options, callbacks) => {
13440
14270
  }
13441
14271
  };
13442
14272
  }
13443
- if (!(serverOps instanceof ValOpsHttp) || !serverOps.embedsSource()) {
14273
+ if (!(serverOps instanceof ValOpsHttp) || !serverOps.publishesAsJobs() || serverOps.sourceMode() === "managed" && !serverOps.embedsSource()) {
14274
+ return {
14275
+ status: 409,
14276
+ json: {
14277
+ message: "This deployment cannot prepare a publish job for this project."
14278
+ }
14279
+ };
14280
+ }
14281
+ const refusal = serverOps.publishRefusal();
14282
+ if (refusal) {
13444
14283
  return {
13445
14284
  status: 409,
13446
14285
  json: {
13447
- message: "This server has no source of its own to render commits into."
14286
+ message: refusal.message
13448
14287
  }
13449
14288
  };
13450
14289
  }
14290
+ const {
14291
+ jobId,
14292
+ tab,
14293
+ patchIds
14294
+ } = req.body;
14295
+ /*
14296
+ * CONNECTED: the job's sources are what `/save` would commit -- its
14297
+ * own changes, applied as `/save` applies them -- and content pushes
14298
+ * them at the seal. Nothing is built here: CI builds after the push,
14299
+ * so the answer carries the job and no sources.
14300
+ */
14301
+ if (serverOps.sourceMode() === "connected") {
14302
+ var _options$config$files;
14303
+ const jobPatches = await serverOps.fetchPatches({
14304
+ patchIds,
14305
+ excludePatchOps: false
14306
+ });
14307
+ if (jobPatches.error) {
14308
+ return {
14309
+ status: 500,
14310
+ json: {
14311
+ message: jobPatches.error.message
14312
+ }
14313
+ };
14314
+ }
14315
+ const found = new Set(jobPatches.patches.map(patch => patch.patchId));
14316
+ const gone = patchIds.filter(id => !found.has(id));
14317
+ if (gone.length > 0) {
14318
+ return {
14319
+ status: 409,
14320
+ json: {
14321
+ message: `This publish's changes are no longer all there (${gone.length} discarded).`
14322
+ }
14323
+ };
14324
+ }
14325
+ const prepared = await serverOps.prepare({
14326
+ ...serverOps.analyzePatches(jobPatches.patches, jobPatches.commits, commit),
14327
+ ...jobPatches
14328
+ });
14329
+ if (prepared.hasErrors) {
14330
+ return {
14331
+ status: 500,
14332
+ json: {
14333
+ message: "This publish's changes could not be rendered into source: " + JSON.stringify(prepared.sourceFilePatchErrors).slice(0, 500)
14334
+ }
14335
+ };
14336
+ }
14337
+ const split = splitJobPrepare({
14338
+ // Every file the job's changes wrote is what the push writes.
14339
+ chainOnly: {
14340
+ patchedSourceFiles: {}
14341
+ },
14342
+ withJob: prepared,
14343
+ jobPatchIds: patchIds
14344
+ });
14345
+ const sent = await serverOps.prepareJob(jobId, {
14346
+ tab,
14347
+ filesDirectory: ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val",
14348
+ ...split.archive,
14349
+ ...(commit !== undefined ? {
14350
+ gitCommit: commit
14351
+ } : {})
14352
+ });
14353
+ return jobPrepareAnswer(sent, {
14354
+ sourceFiles: {},
14355
+ binaryFiles: {},
14356
+ binaryFilesUnread: [],
14357
+ branch: serverOps.projectBranch()
14358
+ });
14359
+ }
13451
14360
  const chain = await serverOps.fetchPatches({
13452
14361
  patchIds: undefined,
13453
14362
  excludePatchOps: false
@@ -13461,37 +14370,63 @@ const ValServer = (valModules, options, callbacks) => {
13461
14370
  };
13462
14371
  }
13463
14372
  /*
13464
- * The patches committed SINCE this build, as though pending.
14373
+ * The job's content, in two steps from this build's embedded source.
13465
14374
  *
13466
- * `applicable/patches` answers with the ones applied at a commit after
13467
- * the one this build was made from, which is exactly what the text
13468
- * embedded in it is missing. `analyzePatches` skips applied patches --
13469
- * for a save they are already in the file it reads -- so they are
13470
- * handed over unapplied: here the file predates them.
14375
+ * The commits since this build, as though pending, are the branch's
14376
+ * head. The job's pending changes on top of them are its content: whatever was applied since the job started came
14377
+ * out of its own changes (docs/app-mode.md, "Publishing is a queued
14378
+ * job"), and were that ever not so, the seal would restart the job
14379
+ * rather than publish it. Chain order, both times.
13471
14380
  */
14381
+ const job = new Set(patchIds);
13472
14382
  const committed = chain.patches.filter(patch => patch.appliedAt !== null).map(patch => ({
13473
14383
  ...patch,
13474
14384
  appliedAt: null
13475
14385
  }));
13476
- const prepared = await serverOps.prepare({
13477
- ...serverOps.analyzePatches(committed, chain.commits, commit),
14386
+ const pending = chain.patches.filter(patch => patch.appliedAt === null && job.has(patch.patchId));
14387
+ const known = new Set(chain.patches.map(patch => patch.patchId));
14388
+ const gone = patchIds.filter(id => !known.has(id));
14389
+ if (gone.length > 0) {
14390
+ return {
14391
+ status: 409,
14392
+ json: {
14393
+ message: `This publish's changes are no longer all there (${gone.length} discarded).`
14394
+ }
14395
+ };
14396
+ }
14397
+ const prepareOf = patches => serverOps.prepare({
14398
+ ...serverOps.analyzePatches(patches, chain.commits, commit),
13478
14399
  ...chain,
13479
- patches: committed
14400
+ patches
13480
14401
  });
13481
- if (prepared.hasErrors) {
14402
+ const chainOnly = await prepareOf(committed);
14403
+ const withJob = await prepareOf([...committed, ...pending]);
14404
+ if (chainOnly.hasErrors || withJob.hasErrors) {
13482
14405
  return {
13483
14406
  status: 500,
13484
14407
  json: {
13485
- message: "The commits since this build could not be rendered into source: " + JSON.stringify(prepared.sourceFilePatchErrors).slice(0, 500)
14408
+ message: "This publish's changes could not be rendered into source: " + JSON.stringify((withJob.hasErrors ? withJob : chainOnly).sourceFilePatchErrors).slice(0, 500)
13486
14409
  }
13487
14410
  };
13488
14411
  }
13489
- return {
13490
- status: 200,
13491
- json: {
13492
- files: prepared.patchedSourceFiles
13493
- }
13494
- };
14412
+ const split = splitJobPrepare({
14413
+ chainOnly,
14414
+ withJob,
14415
+ jobPatchIds: patchIds
14416
+ });
14417
+ // Read now, while the job's changes still hold their files.
14418
+ const binaries = await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors);
14419
+ const sent = await serverOps.prepareJob(jobId, {
14420
+ tab,
14421
+ filesDirectory: ((_options$config$files2 = options.config.files) === null || _options$config$files2 === void 0 ? void 0 : _options$config$files2.directory) || "/public/val",
14422
+ ...split.archive
14423
+ });
14424
+ return jobPrepareAnswer(sent, {
14425
+ sourceFiles: split.buildSourceFiles,
14426
+ binaryFiles: binaries.files,
14427
+ binaryFilesUnread: binaries.unread,
14428
+ branch: serverOps.projectBranch()
14429
+ });
13495
14430
  }
13496
14431
  },
13497
14432
  "/profiles": {
@@ -13946,8 +14881,8 @@ const ValServer = (valModules, options, callbacks) => {
13946
14881
  const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
13947
14882
  const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
13948
14883
  const commitToGit = () => {
13949
- var _options$config$files;
13950
- 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,
14884
+ var _options$config$files3;
14885
+ 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,
13951
14886
  /*
13952
14887
  * Forwarded verbatim, and only the client can decide it: the
13953
14888
  * content API closes the group it is named without checking
@@ -14015,6 +14950,9 @@ const ValServer = (valModules, options, callbacks) => {
14015
14950
  ...(managedBranch !== null ? {
14016
14951
  branch: managedBranch
14017
14952
  } : {}),
14953
+ ...(commitRes.headVersion !== undefined ? {
14954
+ headVersion: commitRes.headVersion
14955
+ } : {}),
14018
14956
  ...(committedBinaries !== null ? {
14019
14957
  binaryFiles: committedBinaries.files,
14020
14958
  ...(committedBinaries.unread.length > 0 ? {
@@ -15598,6 +16536,49 @@ function getIsRemoteRequired(schemas) {
15598
16536
  return false;
15599
16537
  }
15600
16538
 
16539
+ /**
16540
+ * `/publish-job-prepare`'s answer from content's: the job as content has it
16541
+ * now, and what the tab builds from. Content's refusal is passed on -- 409
16542
+ * as 409, anything else as 502, which the tab reads as "content answered,
16543
+ * and has counted the attempt" (see `runStudioJob`).
16544
+ */
16545
+ function jobPrepareAnswer(sent, build) {
16546
+ if (sent.status !== 200) {
16547
+ let message = `The content service did not prepare the publish (${sent.status}).`;
16548
+ try {
16549
+ const parsed = JSON.parse(sent.body);
16550
+ if (typeof parsed === "object" && parsed !== null && "message" in parsed && typeof parsed.message === "string") {
16551
+ message = parsed.message;
16552
+ }
16553
+ } catch {
16554
+ // not JSON: the status says enough
16555
+ }
16556
+ return {
16557
+ status: sent.status === 409 ? 409 : 502,
16558
+ json: {
16559
+ message
16560
+ }
16561
+ };
16562
+ }
16563
+ try {
16564
+ const prepared = internal.parseJob(JSON.parse(sent.body), "POST /v1/publish-jobs/{id}/prepare");
16565
+ return {
16566
+ status: 200,
16567
+ json: {
16568
+ job: prepared.job,
16569
+ ...build
16570
+ }
16571
+ };
16572
+ } catch (e) {
16573
+ return {
16574
+ status: 502,
16575
+ json: {
16576
+ message: e instanceof Error ? e.message : String(e)
16577
+ }
16578
+ };
16579
+ }
16580
+ }
16581
+
15601
16582
  async function createValServer(valModules, route, opts, config, callbacks, formatter,
15602
16583
  /**
15603
16584
  * Called after a save has applied its patches. EXPERIMENTAL — see
@@ -16453,7 +17434,7 @@ async function getFileBufferFromRemote(ref, fileExt, currentFileHash, projectRoo
16453
17434
  async function downloadFileFromRemote(ref, filePath) {
16454
17435
  return new Promise((resolve, reject) => {
16455
17436
  const url = new URL(ref);
16456
- const client = url.protocol === "https:" ? https__default["default"] : http__default["default"];
17437
+ const client = url.protocol === "https:" ? https__default$1["default"] : http__default$1["default"];
16457
17438
  const request = client.get(url, response => {
16458
17439
  if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
16459
17440
  // Handle redirects