@valbuild/server 0.139.2 → 0.140.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1488,17 +1488,22 @@ function applyJsonValuesEntryPatches(args) {
1488
1488
  serializedSchema,
1489
1489
  entryKey,
1490
1490
  baseContent,
1491
- patches
1491
+ patches,
1492
+ baseContentOf,
1493
+ stopBefore
1492
1494
  } = args;
1493
1495
  let content = baseContent;
1494
1496
  let deleted = false;
1495
1497
  const appliedPatchIds = [];
1496
- for (const {
1498
+ replay: for (const [patchIndex, {
1497
1499
  patchId,
1498
1500
  patch: patch$1
1499
- } of patches) {
1501
+ }] of patches.entries()) {
1500
1502
  let touched = false;
1501
- for (const rawOp of patch$1) {
1503
+ for (const [opIndex, rawOp] of patch$1.entries()) {
1504
+ if (stopBefore && (patchIndex > stopBefore.patch || patchIndex === stopBefore.patch && opIndex >= stopBefore.op)) {
1505
+ break replay;
1506
+ }
1502
1507
  /**
1503
1508
  * A write of the WHOLE record becomes what it says about THIS entry.
1504
1509
  *
@@ -1523,6 +1528,13 @@ function applyJsonValuesEntryPatches(args) {
1523
1528
  const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1524
1529
  kind: "normal"
1525
1530
  };
1531
+ if (op.op === "move" && wholeEntryKey(serializedSchema, op.from) === entryKey && wholeEntryKey(serializedSchema, op.path) !== entryKey) {
1532
+ // Moved AWAY: under its old key, the entry is gone.
1533
+ touched = true;
1534
+ content = undefined;
1535
+ deleted = true;
1536
+ continue;
1537
+ }
1526
1538
  if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
1527
1539
  continue;
1528
1540
  }
@@ -1568,7 +1580,30 @@ function applyJsonValuesEntryPatches(args) {
1568
1580
  continue;
1569
1581
  } else {
1570
1582
  // move/copy INTO this key: the content comes from the source entry,
1571
- // which the caller must resolve (it is a different `*.val.json`).
1583
+ // as it stood at this op (it is a different `*.val.json`).
1584
+ const fromKey = wholeEntryKey(serializedSchema, op.from);
1585
+ const fromBase = fromKey === undefined ? undefined : baseContentOf === null || baseContentOf === void 0 ? void 0 : baseContentOf(fromKey);
1586
+ if (fromKey !== undefined && fromBase !== undefined) {
1587
+ const source = applyJsonValuesEntryPatches({
1588
+ serializedSchema,
1589
+ entryKey: fromKey,
1590
+ baseContent: fromBase.content,
1591
+ patches,
1592
+ baseContentOf,
1593
+ stopBefore: {
1594
+ patch: patchIndex,
1595
+ op: opIndex
1596
+ }
1597
+ });
1598
+ if (source.kind === "error") {
1599
+ return source;
1600
+ }
1601
+ if (source.kind === "content") {
1602
+ content = patch.deepClone(source.content);
1603
+ deleted = false;
1604
+ continue;
1605
+ }
1606
+ }
1572
1607
  return {
1573
1608
  kind: "error",
1574
1609
  message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
@@ -1620,6 +1655,18 @@ function applyJsonValuesEntryPatches(args) {
1620
1655
  };
1621
1656
  }
1622
1657
 
1658
+ /**
1659
+ * The entry a path names when it names a WHOLE entry of the root
1660
+ * `.jsonValues()` record, and `undefined` for anything else.
1661
+ */
1662
+ function wholeEntryKey(serializedSchema, path) {
1663
+ if (!serializedSchema) {
1664
+ return undefined;
1665
+ }
1666
+ const cls = classifyJsonValuesOp(serializedSchema, path);
1667
+ return cls.kind === "entry" && cls.recordPath.length === 0 && cls.subPath.length === 0 ? cls.entryKey : undefined;
1668
+ }
1669
+
1623
1670
  /**
1624
1671
  * Resolves an EXISTING entry's `*.val.json` path (relative to rootDir) from the
1625
1672
  * `import(...)` path recorded in the `.val.ts` thunk (from
@@ -2845,7 +2892,7 @@ function v4Of(high, low) {
2845
2892
  * each other by the API route's zod schema, which is the only place they meet.
2846
2893
  */
2847
2894
 
2848
- const DEFAULT_TIMEOUT_MS$1 = 5000;
2895
+ const DEFAULT_TIMEOUT_MS$2 = 5000;
2849
2896
  const DEFAULT_MAX_REDIRECTS = 5;
2850
2897
 
2851
2898
  /**
@@ -2943,7 +2990,7 @@ function needsGetFallback(code) {
2943
2990
  return code === 405 || code === 501;
2944
2991
  }
2945
2992
  async function probeUrl(rawUrl, options = {}) {
2946
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
2993
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$2;
2947
2994
  const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
2948
2995
  const now = options.now ?? (() => Date.now());
2949
2996
  const started = now();
@@ -3591,7 +3638,7 @@ class ValOps {
3591
3638
  message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
3592
3639
  };
3593
3640
  }
3594
- modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds).map(p => ({
3641
+ modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds, patchOps.commits).map(p => ({
3595
3642
  patchId: p.patchId,
3596
3643
  patch: p.patch
3597
3644
  }));
@@ -3605,7 +3652,25 @@ class ValOps {
3605
3652
  const entries = [];
3606
3653
  const missing = [];
3607
3654
  const errors = [];
3608
- const resolved = await Promise.all(requestedKeys.map(async entryKey => {
3655
+ /*
3656
+ * The entries a whole-entry `move` or `copy` reads from. A renamed or
3657
+ * duplicated entry's content is theirs, so they are loaded beside the
3658
+ * ones asked for, and not reported.
3659
+ */
3660
+ const sourceKeys = new Set();
3661
+ for (const {
3662
+ patch
3663
+ } of modulePatches) {
3664
+ for (const op of patch) {
3665
+ if (op.op === "move" || op.op === "copy") {
3666
+ const fromKey = wholeEntryKey(serializedSchema, op.from);
3667
+ if (fromKey !== undefined && !requestedKeys.includes(fromKey)) {
3668
+ sourceKeys.add(fromKey);
3669
+ }
3670
+ }
3671
+ }
3672
+ }
3673
+ const loaded = await Promise.all([...requestedKeys, ...sourceKeys].map(async entryKey => {
3609
3674
  const marker = record[entryKey];
3610
3675
  if (marker !== undefined && !core.Internal.isJson(marker)) {
3611
3676
  // Not a jsonValues entry — return the inlined value as-is (defensive).
@@ -3667,6 +3732,15 @@ class ValOps {
3667
3732
  };
3668
3733
  }
3669
3734
  }));
3735
+ const resolved = loaded.slice(0, requestedKeys.length);
3736
+ const bases = new Map();
3737
+ for (const entry of loaded) {
3738
+ if (!("message" in entry) && !("inline" in entry)) {
3739
+ bases.set(entry.entryKey, {
3740
+ content: entry.baseContent
3741
+ });
3742
+ }
3743
+ }
3670
3744
  for (const result of resolved) {
3671
3745
  const {
3672
3746
  entryKey
@@ -3696,7 +3770,8 @@ class ValOps {
3696
3770
  serializedSchema,
3697
3771
  entryKey,
3698
3772
  baseContent,
3699
- patches: modulePatches
3773
+ patches: modulePatches,
3774
+ baseContentOf: key => bases.get(key)
3700
3775
  });
3701
3776
  if (res.kind === "error") {
3702
3777
  errors.push({
@@ -5429,8 +5504,10 @@ function formatPatchSourceError(error) {
5429
5504
  * 1. this module's, since the chain is branch-wide;
5430
5505
  * 2. this caller's, when they asked to be scoped. `undefined` is "everything",
5431
5506
  * which is what every unscoped caller gets and must keep getting;
5432
- * 3. not already applied — a fact about this path rather than about scoping,
5433
- * and true with or without a scope.
5507
+ * 3. a patch published after this build is kept whatever the scope: it is
5508
+ * nobody's to hold back, and this build does not have it. One already in
5509
+ * the build is dropped. `draftOverlay` decides which is which, from
5510
+ * `commits`.
5434
5511
  *
5435
5512
  * Filtered here rather than by asking `fetchPatches` for a list, and that is
5436
5513
  * load-bearing: both implementations read an empty `patchIds` as "no filter"
@@ -5440,9 +5517,52 @@ function formatPatchSourceError(error) {
5440
5517
  * instead of base. It costs no round trip either: the whole chain is what the
5441
5518
  * unscoped path fetches anyway.
5442
5519
  */
5443
- function scopedModulePatches(patches, moduleFilePath, patchIds) {
5520
+ function scopedModulePatches(patches, moduleFilePath, patchIds, /** The commits content says came after this build: see `draftOverlay`. */
5521
+ commits) {
5444
5522
  const scope = patchIds && new Set(patchIds);
5445
- return patches.filter(patch => patch.path === moduleFilePath && !patch.appliedAt && (scope === undefined || scope.has(patch.patchId)));
5523
+ return draftOverlay(patches.filter(patch => patch.path === moduleFilePath && (patch.appliedAt !== null || scope === undefined || scope.has(patch.patchId))), commits);
5524
+ }
5525
+
5526
+ /**
5527
+ * The patches a DRAFT applies on top of this build's own source: the pending
5528
+ * ones, and the ones published AFTER this build, in chain order -- with
5529
+ * `appliedAt` cleared on the latter, because this build has none of them.
5530
+ *
5531
+ * Content places every caller at its own build (`getApplicablePatchesAndCommits`
5532
+ * in valbuild/home: "the earliest the caller has not seen") and returns the
5533
+ * commits after it as `commits`. A patch applied at one of those was
5534
+ * published after this build and is not in it, so a draft has to apply it --
5535
+ * skipping it showed the build's old value from the publish until the next
5536
+ * build went live. `/sources/~` already applies them (`getSources` walks every
5537
+ * patch it is given); the `.jsonValues()` entry path skipped them.
5538
+ *
5539
+ * A patch applied at a commit NOT in that list is already in this build --
5540
+ * content returns one only because it was asked for by id -- and is dropped:
5541
+ * applying it again would apply it twice. Without `commits` (a store that
5542
+ * does not report them) every applied patch is dropped, as before. Only
5543
+ * `ValOpsHttp` ever reports `appliedAt`; `fs` and memory stores report `null`.
5544
+ *
5545
+ * Chain order, not published-first, because that is the order the Studio
5546
+ * applies them in, and a draft page starts from the server's answer and is
5547
+ * then kept up to date by the Studio's: the two must agree.
5548
+ *
5549
+ * For the reads that render a draft only. What to COMMIT still keys on
5550
+ * `appliedAt`: a published patch must never be published again.
5551
+ */
5552
+ function draftOverlay(patches, commits) {
5553
+ const afterThisBuild = new Set((commits ?? []).map(commit => commit.commitSha));
5554
+ const overlay = [];
5555
+ for (const patch of patches) {
5556
+ if (patch.appliedAt === null) {
5557
+ overlay.push(patch);
5558
+ } else if (afterThisBuild.has(patch.appliedAt.commitSha)) {
5559
+ overlay.push({
5560
+ ...patch,
5561
+ appliedAt: null
5562
+ });
5563
+ }
5564
+ }
5565
+ return overlay;
5446
5566
  }
5447
5567
  function getFieldsForType(type) {
5448
5568
  if (type === "file") {
@@ -6579,7 +6699,7 @@ function describeLogProblem(problem) {
6579
6699
  */
6580
6700
 
6581
6701
  const PATCH_LOCK_FILE_NAME = "patches.lock";
6582
- const DEFAULT_TIMEOUT_MS = 10_000;
6702
+ const DEFAULT_TIMEOUT_MS$1 = 10_000;
6583
6703
  const DEFAULT_POLL_MS = 25;
6584
6704
 
6585
6705
  /**
@@ -6698,7 +6818,7 @@ function tryCreate(lockFilePath, info) {
6698
6818
  }
6699
6819
  async function acquirePatchLock(lockFilePath, options) {
6700
6820
  const now = options.now ?? Date.now;
6701
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
6821
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
6702
6822
  const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
6703
6823
  const deadline = now() + timeoutMs;
6704
6824
  try {
@@ -8369,6 +8489,8 @@ const GetApplicablePatches = z.z.object({
8369
8489
  * Optional for the same reason.
8370
8490
  */
8371
8491
  headVersion: z.z.number().optional(),
8492
+ /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
8493
+ publishingPatches: z.z.array(internal.PatchId).optional(),
8372
8494
  commits: z.z.array(z.z.object({
8373
8495
  commitSha: z.z.string(),
8374
8496
  /*
@@ -9253,6 +9375,13 @@ class ValOpsHttp extends ValOps {
9253
9375
  deployments: allPatchData.deployments || [],
9254
9376
  patches,
9255
9377
  appliedPatches,
9378
+ /*
9379
+ * Absent, not empty, from a content service that does not say: an empty
9380
+ * list would claim no publish is running when it cannot know.
9381
+ */
9382
+ ...(allPatchData.publishingPatches !== undefined ? {
9383
+ publishingPatches: allPatchData.publishingPatches
9384
+ } : {}),
9256
9385
  /*
9257
9386
  * The CHAIN head, not the publish head below. Spread: absent is a content
9258
9387
  * service that does not report it, which is not the same as `null`.
@@ -9620,6 +9749,9 @@ class ValOpsHttp extends ValOps {
9620
9749
  } : {}),
9621
9750
  ...(data.headVersion !== undefined ? {
9622
9751
  headVersion: data.headVersion
9752
+ } : {}),
9753
+ ...(data.publishingPatches !== undefined ? {
9754
+ publishingPatches: data.publishingPatches
9623
9755
  } : {})
9624
9756
  };
9625
9757
  }
@@ -11969,6 +12101,135 @@ contentUrl) {
11969
12101
  }
11970
12102
  }
11971
12103
 
12104
+ /**
12105
+ * Forwarding for `/admin/proxy/*`: Val Build's Studio API, `/v1/studio/*` on
12106
+ * the content server, called on the editor's behalf with the token from their
12107
+ * session.
12108
+ *
12109
+ * The web components the Studio mounts from content.val.build cannot call Val
12110
+ * Build themselves. The editor's credential is in this app's httpOnly
12111
+ * `val_session` cookie, and a cookie of admin.val.build's own would be
12112
+ * third-party on this page, which Safari and Firefox block. So they call this
12113
+ * app, and this is where the token is attached.
12114
+ *
12115
+ * It is a pipe for the path, the query and a JSON body, so a new component or
12116
+ * endpoint needs no Val release. It is NOT a pipe for where the request goes:
12117
+ * the URL is built from `contentUrl` (the server's `valContentUrl`) and must
12118
+ * still be under `/v1/studio/` after the URL parser has resolved `..`,
12119
+ * `%2e%2e` and backslashes. The editor's token can reach the endpoints written
12120
+ * for the Studio and nothing else on Val Build.
12121
+ */
12122
+
12123
+ const STUDIO_API_PREFIX = "/v1/studio/";
12124
+ const DEFAULT_TIMEOUT_MS = 10_000;
12125
+ async function forwardToStudioApi({
12126
+ method,
12127
+ path,
12128
+ rawQuery,
12129
+ body,
12130
+ credential,
12131
+ contentUrl,
12132
+ fetchImpl = fetch,
12133
+ timeoutMs = DEFAULT_TIMEOUT_MS
12134
+ }) {
12135
+ const url = studioApiUrl(contentUrl, path, rawQuery);
12136
+ if (url === null) {
12137
+ return {
12138
+ status: 404,
12139
+ json: {
12140
+ code: "not-found",
12141
+ message: "No such Val Build endpoint."
12142
+ }
12143
+ };
12144
+ }
12145
+ // Every method the route accepts may carry a JSON body: a DELETE that
12146
+ // names what it removes in its body is as valid as a PUT.
12147
+ const sendsBody = method !== "GET" && body !== undefined;
12148
+ let response;
12149
+ try {
12150
+ response = await fetchImpl(url, {
12151
+ method,
12152
+ headers: {
12153
+ // `x-val-pat` is the header the rest of Val Build reads a PAT from.
12154
+ ...("pat" in credential ? {
12155
+ "x-val-pat": credential.pat
12156
+ } : {
12157
+ authorization: `Bearer ${credential.bearer}`
12158
+ }),
12159
+ accept: "application/json",
12160
+ ...(sendsBody ? {
12161
+ "content-type": "application/json"
12162
+ } : {})
12163
+ },
12164
+ body: sendsBody ? JSON.stringify(body) : undefined,
12165
+ // Never follow a redirect with the token attached.
12166
+ redirect: "manual",
12167
+ signal: AbortSignal.timeout(timeoutMs)
12168
+ });
12169
+ } catch {
12170
+ return {
12171
+ status: 500,
12172
+ json: {
12173
+ code: "unreachable",
12174
+ message: "Could not reach Val Build."
12175
+ }
12176
+ };
12177
+ }
12178
+ let json;
12179
+ try {
12180
+ json = await response.json();
12181
+ } catch {
12182
+ json = {
12183
+ code: "bad-gateway",
12184
+ message: `Val Build answered ${response.status} without JSON.`
12185
+ };
12186
+ }
12187
+ return {
12188
+ status: mapStatus(response.status),
12189
+ json
12190
+ };
12191
+ }
12192
+
12193
+ /**
12194
+ * `{contentUrl}/v1/studio{path}{rawQuery}`, or null when the result is not
12195
+ * under that prefix on that origin — the check that keeps the proxy from being
12196
+ * pointed anywhere else with the editor's token.
12197
+ */
12198
+ function studioApiUrl(contentUrl, path, rawQuery) {
12199
+ if (!path.startsWith("/") || rawQuery !== "" && !rawQuery.startsWith("?")) {
12200
+ return null;
12201
+ }
12202
+ let base;
12203
+ let url;
12204
+ try {
12205
+ base = new URL(contentUrl);
12206
+ url = new URL(`${STUDIO_API_PREFIX}${path.slice(1)}${rawQuery}`, base);
12207
+ } catch {
12208
+ return null;
12209
+ }
12210
+ if (url.origin !== base.origin || !url.pathname.startsWith(STUDIO_API_PREFIX)) {
12211
+ return null;
12212
+ }
12213
+ return url.toString();
12214
+ }
12215
+
12216
+ /**
12217
+ * Onto the statuses this server speaks. The JSON is passed through untouched,
12218
+ * so a `code` from Val Build still says what happened.
12219
+ */
12220
+ function mapStatus(status) {
12221
+ if (status >= 200 && status < 300) {
12222
+ return 200;
12223
+ }
12224
+ if (status === 400 || status === 401 || status === 403 || status === 404) {
12225
+ return status;
12226
+ }
12227
+ if (status >= 400 && status < 500) {
12228
+ return 400;
12229
+ }
12230
+ return 500;
12231
+ }
12232
+
11972
12233
  function getPersonalAccessTokenPath(root) {
11973
12234
  return path__namespace["default"].join(path__namespace["default"].resolve(root), ".val", "pat.json");
11974
12235
  }
@@ -12512,6 +12773,27 @@ function warnIfInsecureUrls(urls) {
12512
12773
  * genuinely confusing afternoon.
12513
12774
  */
12514
12775
 
12776
+ /**
12777
+ * The personal access token `val login` wrote for this project, or null when
12778
+ * there is none (or it does not parse). Only `fs` mode has a working directory
12779
+ * to read it from, so every other mode answers null.
12780
+ *
12781
+ * Unlike `resolveRemoteFileAuth` this never answers with the api key: the
12782
+ * caller wants to act as the DEVELOPER, and an api key is the project.
12783
+ */
12784
+ async function readValLoginToken(options) {
12785
+ if (options.mode !== "fs") {
12786
+ return null;
12787
+ }
12788
+ const fs = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
12789
+ try {
12790
+ const file = await fs.promises.readFile(getPersonalAccessTokenPath(options.cwd), "utf-8");
12791
+ const parsed = parsePersonalAccessTokenFile(file);
12792
+ return parsed.success ? parsed.data.pat : null;
12793
+ } catch {
12794
+ return null;
12795
+ }
12796
+ }
12515
12797
  async function resolveRemoteFileAuth(options) {
12516
12798
  if (options.apiKey) {
12517
12799
  return {
@@ -12671,6 +12953,129 @@ const ValServer = (valModules, options, callbacks) => {
12671
12953
  return null;
12672
12954
  });
12673
12955
  };
12956
+
12957
+ /**
12958
+ * `/admin/proxy/*`, for Val Build's web components in the Studio: forwarded
12959
+ * to the Studio API on the content server. See `adminProxy.ts` for what is
12960
+ * forwarded and why.
12961
+ *
12962
+ * Three refusals before anything leaves this server, and the `code`s are
12963
+ * the ones the components act on:
12964
+ * - no `x-val-studio` header: 403, since a cross-site request cannot set it;
12965
+ * - no Val Build behind this Studio (no `valBuildUrl`, or fs mode without
12966
+ * a `val login`): 404 `not-connected`, which the component shows as such
12967
+ * rather than as an outage — though `/admin/status` keeps the Studio from
12968
+ * mounting it at all in that case;
12969
+ * - a deployed Studio without a valid session: 401 `unauthenticated`, which
12970
+ * the component answers by asking the Studio to sign in.
12971
+ */
12972
+ const adminProxy = async (method, req) => {
12973
+ if (req.headers[internal.VAL_STUDIO_HEADER] !== "1") {
12974
+ return {
12975
+ status: 403,
12976
+ json: {
12977
+ code: "forbidden",
12978
+ message: `Requests to the Val Build proxy need the ${internal.VAL_STUDIO_HEADER} header.`
12979
+ }
12980
+ };
12981
+ }
12982
+ const credential = await getValBuildCredential(req.cookies);
12983
+ if (credential.status !== "ok" || !options.valBuildUrl) {
12984
+ if (!serverOps.requiresAuth || !options.valBuildUrl) {
12985
+ return {
12986
+ status: 404,
12987
+ json: {
12988
+ code: "not-connected",
12989
+ message: "This Studio is not connected to Val Build."
12990
+ }
12991
+ };
12992
+ }
12993
+ return {
12994
+ status: 401,
12995
+ json: {
12996
+ code: "unauthenticated",
12997
+ message: credential.status === "invalid" ? credential.message : "Sign in to Val Build."
12998
+ }
12999
+ };
13000
+ }
13001
+ // To the content server, which serves the Studio API: Val Build's sign-in
13002
+ // (`valBuildUrl`) is what makes the credential, the content server is
13003
+ // what the components' data comes from.
13004
+ return forwardToStudioApi({
13005
+ method,
13006
+ path: req.path,
13007
+ rawQuery: req.rawQuery,
13008
+ body: req.body,
13009
+ credential: credential.credential,
13010
+ contentUrl: options.valContentUrl
13011
+ });
13012
+ };
13013
+
13014
+ /**
13015
+ * What this Studio can act on Val Build with, for the editor in front of it:
13016
+ *
13017
+ * - the session's token, in a deployed Studio (and in a local one where
13018
+ * someone signed in through the Studio anyway);
13019
+ * - otherwise, in fs mode, the developer's own `val login` token from
13020
+ * `.val/pat.json` — the same file `val validate --fix` reads.
13021
+ *
13022
+ * An api key is never one of them: it is the project, not a person, and
13023
+ * these requests are answered with a person's projects.
13024
+ */
13025
+ const getValBuildCredential = async cookies => {
13026
+ const session = getSessionToken(cookies);
13027
+ if (session.status === "ok") {
13028
+ return {
13029
+ status: "ok",
13030
+ credential: {
13031
+ bearer: session.token
13032
+ }
13033
+ };
13034
+ }
13035
+ if (options.mode === "fs") {
13036
+ const pat = await readValLoginToken(options);
13037
+ if (pat !== null) {
13038
+ return {
13039
+ status: "ok",
13040
+ credential: {
13041
+ pat
13042
+ }
13043
+ };
13044
+ }
13045
+ return {
13046
+ status: "none"
13047
+ };
13048
+ }
13049
+ return session;
13050
+ };
13051
+
13052
+ /** The editor's Val Build token, from inside their session cookie. */
13053
+ const getSessionToken = cookies => {
13054
+ const cookie = cookies[internal.VAL_SESSION_COOKIE];
13055
+ if (typeof cookie !== "string" || !options.valSecret) {
13056
+ return {
13057
+ status: "none"
13058
+ };
13059
+ }
13060
+ const verified = verifyJwt(cookie, options.valSecret);
13061
+ if (!verified.success) {
13062
+ return {
13063
+ status: "invalid",
13064
+ message: sessionErrorMessage(verified.reason)
13065
+ };
13066
+ }
13067
+ const payload = IntegratedServerJwtPayload.safeParse(verified.data);
13068
+ if (!payload.success) {
13069
+ return {
13070
+ status: "invalid",
13071
+ message: "Session invalid. You will need to login again."
13072
+ };
13073
+ }
13074
+ return {
13075
+ status: "ok",
13076
+ token: payload.data.token
13077
+ };
13078
+ };
12674
13079
  const getAuth = cookies => {
12675
13080
  const cookie = cookies[internal.VAL_SESSION_COOKIE];
12676
13081
  /*
@@ -14350,6 +14755,20 @@ const ValServer = (valModules, options, callbacks) => {
14350
14755
  }
14351
14756
  };
14352
14757
  }
14758
+ /*
14759
+ * What this build's draft applies: the pending patches and the ones
14760
+ * published AFTER this build, which it does not have -- see
14761
+ * `draftOverlay`. `getSources` already applied both, but
14762
+ * `analyzePatches` skips every patch with `appliedAt`, so a module
14763
+ * changed only by a post-build publish was reported with no
14764
+ * `patches`, and a reader that sends only changed modules (TanStack's
14765
+ * `fetchValDraft`) left it out: the page was rendered with this
14766
+ * build's older value. Chain order, which is what the Studio applies.
14767
+ */
14768
+ patchOps = {
14769
+ ...patchOps,
14770
+ patches: draftOverlay(patchOps.patches, patchOps.commits)
14771
+ };
14353
14772
  const patchAnalysis = serverOps.analyzePatches(patchOps.patches);
14354
14773
  const schemasRes = await serverOps.getSchemas();
14355
14774
  let sourcesRes = await serverOps.getSources();
@@ -14598,6 +15017,31 @@ const ValServer = (valModules, options, callbacks) => {
14598
15017
  });
14599
15018
  }
14600
15019
  },
15020
+ "/admin/status": {
15021
+ GET: async req => {
15022
+ const credential = await getValBuildCredential(req.cookies);
15023
+ return {
15024
+ status: 200,
15025
+ json: {
15026
+ connected: Boolean(options.valBuildUrl) && credential.status === "ok",
15027
+ /*
15028
+ * How an editor signs in to Val Build here, which only the server
15029
+ * knows. NOT the stat `mode`: that says whether patches are local,
15030
+ * and a deployed memory-mode host reports `fs` while having no
15031
+ * working directory to run `val login` in. Only a developer's own
15032
+ * checkout (fs mode) signs in with `val login`.
15033
+ */
15034
+ signIn: options.mode === "fs" ? "val-login" : "studio"
15035
+ }
15036
+ };
15037
+ }
15038
+ },
15039
+ "/admin/proxy": {
15040
+ GET: req => adminProxy("GET", req),
15041
+ POST: req => adminProxy("POST", req),
15042
+ PUT: req => adminProxy("PUT", req),
15043
+ DELETE: req => adminProxy("DELETE", req)
15044
+ },
14601
15045
  "/profiles": {
14602
15046
  GET: async req => {
14603
15047
  const cookies = req.cookies;
@@ -17078,6 +17522,13 @@ function createValApiRouter(route, valServerPromise, convert) {
17078
17522
  if (!cookiesRes.success) {
17079
17523
  return zodErrorResult(cookiesRes.error, "invalid cookies");
17080
17524
  }
17525
+ const headersRes = reqDefinition.headers ? getHeaders(req, reqDefinition.headers) : {
17526
+ success: true,
17527
+ data: {}
17528
+ };
17529
+ if (!headersRes.success) {
17530
+ return zodErrorResult(headersRes.error, "invalid headers");
17531
+ }
17081
17532
  const actualQueryParams = groupQueryParams(Array.from(url.searchParams.entries()));
17082
17533
  let query = {};
17083
17534
  if (reqDefinition.query) {
@@ -17126,6 +17577,8 @@ function createValApiRouter(route, valServerPromise, convert) {
17126
17577
  res = await endpointImpl({
17127
17578
  body: bodyRes.data,
17128
17579
  cookies: cookiesRes.data,
17580
+ headers: headersRes.data,
17581
+ rawQuery: reqDefinition.rawQuery ? url.search : undefined,
17129
17582
  query,
17130
17583
  path
17131
17584
  });
@@ -17222,10 +17675,22 @@ function zodErrorResult(error, message) {
17222
17675
  }
17223
17676
 
17224
17677
  // TODO: is this naive implementation is too naive?
17678
+ /** The declared headers, by name; `Headers.get` is case-insensitive. */
17679
+ function getHeaders(req, headersDef) {
17680
+ const input = {};
17681
+ for (const name of Object.keys(headersDef)) {
17682
+ var _req$headers;
17683
+ const value = (_req$headers = req.headers) === null || _req$headers === void 0 ? void 0 : _req$headers.get(name);
17684
+ if (value !== null && value !== undefined) {
17685
+ input[name] = value;
17686
+ }
17687
+ }
17688
+ return z.z.object(headersDef).safeParse(input);
17689
+ }
17225
17690
  function getCookies(req, cookiesDef) {
17226
- var _req$headers;
17691
+ var _req$headers2;
17227
17692
  const input = {};
17228
- const cookieParts = (_req$headers = req.headers) === null || _req$headers === void 0 || (_req$headers = _req$headers.get("Cookie")) === null || _req$headers === void 0 ? void 0 : _req$headers.split("; ");
17693
+ const cookieParts = (_req$headers2 = req.headers) === null || _req$headers2 === void 0 || (_req$headers2 = _req$headers2.get("Cookie")) === null || _req$headers2 === void 0 ? void 0 : _req$headers2.split("; ");
17229
17694
  for (const name of Object.keys(cookiesDef)) {
17230
17695
  const cookie = cookieParts === null || cookieParts === void 0 ? void 0 : cookieParts.find(cookie => cookie.startsWith(`${name}=`));
17231
17696
  const value = cookie ? decodeURIComponent(cookie === null || cookie === void 0 ? void 0 : cookie.split("=")[1]) : undefined;