@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.
@@ -8,7 +8,7 @@ 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, parseJob, 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, VAL_STUDIO_HEADER, 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';
@@ -1451,17 +1451,22 @@ function applyJsonValuesEntryPatches(args) {
1451
1451
  serializedSchema,
1452
1452
  entryKey,
1453
1453
  baseContent,
1454
- patches
1454
+ patches,
1455
+ baseContentOf,
1456
+ stopBefore
1455
1457
  } = args;
1456
1458
  let content = baseContent;
1457
1459
  let deleted = false;
1458
1460
  const appliedPatchIds = [];
1459
- for (const {
1461
+ replay: for (const [patchIndex, {
1460
1462
  patchId,
1461
1463
  patch
1462
- } of patches) {
1464
+ }] of patches.entries()) {
1463
1465
  let touched = false;
1464
- for (const rawOp of patch) {
1466
+ for (const [opIndex, rawOp] of patch.entries()) {
1467
+ if (stopBefore && (patchIndex > stopBefore.patch || patchIndex === stopBefore.patch && opIndex >= stopBefore.op)) {
1468
+ break replay;
1469
+ }
1465
1470
  /**
1466
1471
  * A write of the WHOLE record becomes what it says about THIS entry.
1467
1472
  *
@@ -1486,6 +1491,13 @@ function applyJsonValuesEntryPatches(args) {
1486
1491
  const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
1487
1492
  kind: "normal"
1488
1493
  };
1494
+ if (op.op === "move" && wholeEntryKey(serializedSchema, op.from) === entryKey && wholeEntryKey(serializedSchema, op.path) !== entryKey) {
1495
+ // Moved AWAY: under its old key, the entry is gone.
1496
+ touched = true;
1497
+ content = undefined;
1498
+ deleted = true;
1499
+ continue;
1500
+ }
1489
1501
  if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
1490
1502
  continue;
1491
1503
  }
@@ -1531,7 +1543,30 @@ function applyJsonValuesEntryPatches(args) {
1531
1543
  continue;
1532
1544
  } else {
1533
1545
  // move/copy INTO this key: the content comes from the source entry,
1534
- // which the caller must resolve (it is a different `*.val.json`).
1546
+ // as it stood at this op (it is a different `*.val.json`).
1547
+ const fromKey = wholeEntryKey(serializedSchema, op.from);
1548
+ const fromBase = fromKey === undefined ? undefined : baseContentOf === null || baseContentOf === void 0 ? void 0 : baseContentOf(fromKey);
1549
+ if (fromKey !== undefined && fromBase !== undefined) {
1550
+ const source = applyJsonValuesEntryPatches({
1551
+ serializedSchema,
1552
+ entryKey: fromKey,
1553
+ baseContent: fromBase.content,
1554
+ patches,
1555
+ baseContentOf,
1556
+ stopBefore: {
1557
+ patch: patchIndex,
1558
+ op: opIndex
1559
+ }
1560
+ });
1561
+ if (source.kind === "error") {
1562
+ return source;
1563
+ }
1564
+ if (source.kind === "content") {
1565
+ content = deepClone(source.content);
1566
+ deleted = false;
1567
+ continue;
1568
+ }
1569
+ }
1535
1570
  return {
1536
1571
  kind: "error",
1537
1572
  message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
@@ -1583,6 +1618,18 @@ function applyJsonValuesEntryPatches(args) {
1583
1618
  };
1584
1619
  }
1585
1620
 
1621
+ /**
1622
+ * The entry a path names when it names a WHOLE entry of the root
1623
+ * `.jsonValues()` record, and `undefined` for anything else.
1624
+ */
1625
+ function wholeEntryKey(serializedSchema, path) {
1626
+ if (!serializedSchema) {
1627
+ return undefined;
1628
+ }
1629
+ const cls = classifyJsonValuesOp(serializedSchema, path);
1630
+ return cls.kind === "entry" && cls.recordPath.length === 0 && cls.subPath.length === 0 ? cls.entryKey : undefined;
1631
+ }
1632
+
1586
1633
  /**
1587
1634
  * Resolves an EXISTING entry's `*.val.json` path (relative to rootDir) from the
1588
1635
  * `import(...)` path recorded in the `.val.ts` thunk (from
@@ -2808,7 +2855,7 @@ function v4Of(high, low) {
2808
2855
  * each other by the API route's zod schema, which is the only place they meet.
2809
2856
  */
2810
2857
 
2811
- const DEFAULT_TIMEOUT_MS$1 = 5000;
2858
+ const DEFAULT_TIMEOUT_MS$2 = 5000;
2812
2859
  const DEFAULT_MAX_REDIRECTS = 5;
2813
2860
 
2814
2861
  /**
@@ -2906,7 +2953,7 @@ function needsGetFallback(code) {
2906
2953
  return code === 405 || code === 501;
2907
2954
  }
2908
2955
  async function probeUrl(rawUrl, options = {}) {
2909
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
2956
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$2;
2910
2957
  const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
2911
2958
  const now = options.now ?? (() => Date.now());
2912
2959
  const started = now();
@@ -3554,7 +3601,7 @@ class ValOps {
3554
3601
  message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
3555
3602
  };
3556
3603
  }
3557
- modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds).map(p => ({
3604
+ modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds, patchOps.commits).map(p => ({
3558
3605
  patchId: p.patchId,
3559
3606
  patch: p.patch
3560
3607
  }));
@@ -3568,7 +3615,25 @@ class ValOps {
3568
3615
  const entries = [];
3569
3616
  const missing = [];
3570
3617
  const errors = [];
3571
- const resolved = await Promise.all(requestedKeys.map(async entryKey => {
3618
+ /*
3619
+ * The entries a whole-entry `move` or `copy` reads from. A renamed or
3620
+ * duplicated entry's content is theirs, so they are loaded beside the
3621
+ * ones asked for, and not reported.
3622
+ */
3623
+ const sourceKeys = new Set();
3624
+ for (const {
3625
+ patch
3626
+ } of modulePatches) {
3627
+ for (const op of patch) {
3628
+ if (op.op === "move" || op.op === "copy") {
3629
+ const fromKey = wholeEntryKey(serializedSchema, op.from);
3630
+ if (fromKey !== undefined && !requestedKeys.includes(fromKey)) {
3631
+ sourceKeys.add(fromKey);
3632
+ }
3633
+ }
3634
+ }
3635
+ }
3636
+ const loaded = await Promise.all([...requestedKeys, ...sourceKeys].map(async entryKey => {
3572
3637
  const marker = record[entryKey];
3573
3638
  if (marker !== undefined && !Internal.isJson(marker)) {
3574
3639
  // Not a jsonValues entry — return the inlined value as-is (defensive).
@@ -3630,6 +3695,15 @@ class ValOps {
3630
3695
  };
3631
3696
  }
3632
3697
  }));
3698
+ const resolved = loaded.slice(0, requestedKeys.length);
3699
+ const bases = new Map();
3700
+ for (const entry of loaded) {
3701
+ if (!("message" in entry) && !("inline" in entry)) {
3702
+ bases.set(entry.entryKey, {
3703
+ content: entry.baseContent
3704
+ });
3705
+ }
3706
+ }
3633
3707
  for (const result of resolved) {
3634
3708
  const {
3635
3709
  entryKey
@@ -3659,7 +3733,8 @@ class ValOps {
3659
3733
  serializedSchema,
3660
3734
  entryKey,
3661
3735
  baseContent,
3662
- patches: modulePatches
3736
+ patches: modulePatches,
3737
+ baseContentOf: key => bases.get(key)
3663
3738
  });
3664
3739
  if (res.kind === "error") {
3665
3740
  errors.push({
@@ -5392,8 +5467,10 @@ function formatPatchSourceError(error) {
5392
5467
  * 1. this module's, since the chain is branch-wide;
5393
5468
  * 2. this caller's, when they asked to be scoped. `undefined` is "everything",
5394
5469
  * which is what every unscoped caller gets and must keep getting;
5395
- * 3. not already applied — a fact about this path rather than about scoping,
5396
- * and true with or without a scope.
5470
+ * 3. a patch published after this build is kept whatever the scope: it is
5471
+ * nobody's to hold back, and this build does not have it. One already in
5472
+ * the build is dropped. `draftOverlay` decides which is which, from
5473
+ * `commits`.
5397
5474
  *
5398
5475
  * Filtered here rather than by asking `fetchPatches` for a list, and that is
5399
5476
  * load-bearing: both implementations read an empty `patchIds` as "no filter"
@@ -5403,9 +5480,52 @@ function formatPatchSourceError(error) {
5403
5480
  * instead of base. It costs no round trip either: the whole chain is what the
5404
5481
  * unscoped path fetches anyway.
5405
5482
  */
5406
- function scopedModulePatches(patches, moduleFilePath, patchIds) {
5483
+ function scopedModulePatches(patches, moduleFilePath, patchIds, /** The commits content says came after this build: see `draftOverlay`. */
5484
+ commits) {
5407
5485
  const scope = patchIds && new Set(patchIds);
5408
- return patches.filter(patch => patch.path === moduleFilePath && !patch.appliedAt && (scope === undefined || scope.has(patch.patchId)));
5486
+ return draftOverlay(patches.filter(patch => patch.path === moduleFilePath && (patch.appliedAt !== null || scope === undefined || scope.has(patch.patchId))), commits);
5487
+ }
5488
+
5489
+ /**
5490
+ * The patches a DRAFT applies on top of this build's own source: the pending
5491
+ * ones, and the ones published AFTER this build, in chain order -- with
5492
+ * `appliedAt` cleared on the latter, because this build has none of them.
5493
+ *
5494
+ * Content places every caller at its own build (`getApplicablePatchesAndCommits`
5495
+ * in valbuild/home: "the earliest the caller has not seen") and returns the
5496
+ * commits after it as `commits`. A patch applied at one of those was
5497
+ * published after this build and is not in it, so a draft has to apply it --
5498
+ * skipping it showed the build's old value from the publish until the next
5499
+ * build went live. `/sources/~` already applies them (`getSources` walks every
5500
+ * patch it is given); the `.jsonValues()` entry path skipped them.
5501
+ *
5502
+ * A patch applied at a commit NOT in that list is already in this build --
5503
+ * content returns one only because it was asked for by id -- and is dropped:
5504
+ * applying it again would apply it twice. Without `commits` (a store that
5505
+ * does not report them) every applied patch is dropped, as before. Only
5506
+ * `ValOpsHttp` ever reports `appliedAt`; `fs` and memory stores report `null`.
5507
+ *
5508
+ * Chain order, not published-first, because that is the order the Studio
5509
+ * applies them in, and a draft page starts from the server's answer and is
5510
+ * then kept up to date by the Studio's: the two must agree.
5511
+ *
5512
+ * For the reads that render a draft only. What to COMMIT still keys on
5513
+ * `appliedAt`: a published patch must never be published again.
5514
+ */
5515
+ function draftOverlay(patches, commits) {
5516
+ const afterThisBuild = new Set((commits ?? []).map(commit => commit.commitSha));
5517
+ const overlay = [];
5518
+ for (const patch of patches) {
5519
+ if (patch.appliedAt === null) {
5520
+ overlay.push(patch);
5521
+ } else if (afterThisBuild.has(patch.appliedAt.commitSha)) {
5522
+ overlay.push({
5523
+ ...patch,
5524
+ appliedAt: null
5525
+ });
5526
+ }
5527
+ }
5528
+ return overlay;
5409
5529
  }
5410
5530
  function getFieldsForType(type) {
5411
5531
  if (type === "file") {
@@ -6542,7 +6662,7 @@ function describeLogProblem(problem) {
6542
6662
  */
6543
6663
 
6544
6664
  const PATCH_LOCK_FILE_NAME = "patches.lock";
6545
- const DEFAULT_TIMEOUT_MS = 10_000;
6665
+ const DEFAULT_TIMEOUT_MS$1 = 10_000;
6546
6666
  const DEFAULT_POLL_MS = 25;
6547
6667
 
6548
6668
  /**
@@ -6661,7 +6781,7 @@ function tryCreate(lockFilePath, info) {
6661
6781
  }
6662
6782
  async function acquirePatchLock(lockFilePath, options) {
6663
6783
  const now = options.now ?? Date.now;
6664
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
6784
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
6665
6785
  const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
6666
6786
  const deadline = now() + timeoutMs;
6667
6787
  try {
@@ -8332,6 +8452,8 @@ const GetApplicablePatches = z.object({
8332
8452
  * Optional for the same reason.
8333
8453
  */
8334
8454
  headVersion: z.number().optional(),
8455
+ /** Of `patches`, the ones a running publish holds. See `OrderedPatches.publishingPatches`. */
8456
+ publishingPatches: z.array(PatchId$1).optional(),
8335
8457
  commits: z.array(z.object({
8336
8458
  commitSha: z.string(),
8337
8459
  /*
@@ -9216,6 +9338,13 @@ class ValOpsHttp extends ValOps {
9216
9338
  deployments: allPatchData.deployments || [],
9217
9339
  patches,
9218
9340
  appliedPatches,
9341
+ /*
9342
+ * Absent, not empty, from a content service that does not say: an empty
9343
+ * list would claim no publish is running when it cannot know.
9344
+ */
9345
+ ...(allPatchData.publishingPatches !== undefined ? {
9346
+ publishingPatches: allPatchData.publishingPatches
9347
+ } : {}),
9219
9348
  /*
9220
9349
  * The CHAIN head, not the publish head below. Spread: absent is a content
9221
9350
  * service that does not report it, which is not the same as `null`.
@@ -9583,6 +9712,9 @@ class ValOpsHttp extends ValOps {
9583
9712
  } : {}),
9584
9713
  ...(data.headVersion !== undefined ? {
9585
9714
  headVersion: data.headVersion
9715
+ } : {}),
9716
+ ...(data.publishingPatches !== undefined ? {
9717
+ publishingPatches: data.publishingPatches
9586
9718
  } : {})
9587
9719
  };
9588
9720
  }
@@ -11932,6 +12064,135 @@ contentUrl) {
11932
12064
  }
11933
12065
  }
11934
12066
 
12067
+ /**
12068
+ * Forwarding for `/admin/proxy/*`: Val Build's Studio API, `/v1/studio/*` on
12069
+ * the content server, called on the editor's behalf with the token from their
12070
+ * session.
12071
+ *
12072
+ * The web components the Studio mounts from content.val.build cannot call Val
12073
+ * Build themselves. The editor's credential is in this app's httpOnly
12074
+ * `val_session` cookie, and a cookie of admin.val.build's own would be
12075
+ * third-party on this page, which Safari and Firefox block. So they call this
12076
+ * app, and this is where the token is attached.
12077
+ *
12078
+ * It is a pipe for the path, the query and a JSON body, so a new component or
12079
+ * endpoint needs no Val release. It is NOT a pipe for where the request goes:
12080
+ * the URL is built from `contentUrl` (the server's `valContentUrl`) and must
12081
+ * still be under `/v1/studio/` after the URL parser has resolved `..`,
12082
+ * `%2e%2e` and backslashes. The editor's token can reach the endpoints written
12083
+ * for the Studio and nothing else on Val Build.
12084
+ */
12085
+
12086
+ const STUDIO_API_PREFIX = "/v1/studio/";
12087
+ const DEFAULT_TIMEOUT_MS = 10_000;
12088
+ async function forwardToStudioApi({
12089
+ method,
12090
+ path,
12091
+ rawQuery,
12092
+ body,
12093
+ credential,
12094
+ contentUrl,
12095
+ fetchImpl = fetch,
12096
+ timeoutMs = DEFAULT_TIMEOUT_MS
12097
+ }) {
12098
+ const url = studioApiUrl(contentUrl, path, rawQuery);
12099
+ if (url === null) {
12100
+ return {
12101
+ status: 404,
12102
+ json: {
12103
+ code: "not-found",
12104
+ message: "No such Val Build endpoint."
12105
+ }
12106
+ };
12107
+ }
12108
+ // Every method the route accepts may carry a JSON body: a DELETE that
12109
+ // names what it removes in its body is as valid as a PUT.
12110
+ const sendsBody = method !== "GET" && body !== undefined;
12111
+ let response;
12112
+ try {
12113
+ response = await fetchImpl(url, {
12114
+ method,
12115
+ headers: {
12116
+ // `x-val-pat` is the header the rest of Val Build reads a PAT from.
12117
+ ...("pat" in credential ? {
12118
+ "x-val-pat": credential.pat
12119
+ } : {
12120
+ authorization: `Bearer ${credential.bearer}`
12121
+ }),
12122
+ accept: "application/json",
12123
+ ...(sendsBody ? {
12124
+ "content-type": "application/json"
12125
+ } : {})
12126
+ },
12127
+ body: sendsBody ? JSON.stringify(body) : undefined,
12128
+ // Never follow a redirect with the token attached.
12129
+ redirect: "manual",
12130
+ signal: AbortSignal.timeout(timeoutMs)
12131
+ });
12132
+ } catch {
12133
+ return {
12134
+ status: 500,
12135
+ json: {
12136
+ code: "unreachable",
12137
+ message: "Could not reach Val Build."
12138
+ }
12139
+ };
12140
+ }
12141
+ let json;
12142
+ try {
12143
+ json = await response.json();
12144
+ } catch {
12145
+ json = {
12146
+ code: "bad-gateway",
12147
+ message: `Val Build answered ${response.status} without JSON.`
12148
+ };
12149
+ }
12150
+ return {
12151
+ status: mapStatus(response.status),
12152
+ json
12153
+ };
12154
+ }
12155
+
12156
+ /**
12157
+ * `{contentUrl}/v1/studio{path}{rawQuery}`, or null when the result is not
12158
+ * under that prefix on that origin — the check that keeps the proxy from being
12159
+ * pointed anywhere else with the editor's token.
12160
+ */
12161
+ function studioApiUrl(contentUrl, path, rawQuery) {
12162
+ if (!path.startsWith("/") || rawQuery !== "" && !rawQuery.startsWith("?")) {
12163
+ return null;
12164
+ }
12165
+ let base;
12166
+ let url;
12167
+ try {
12168
+ base = new URL(contentUrl);
12169
+ url = new URL(`${STUDIO_API_PREFIX}${path.slice(1)}${rawQuery}`, base);
12170
+ } catch {
12171
+ return null;
12172
+ }
12173
+ if (url.origin !== base.origin || !url.pathname.startsWith(STUDIO_API_PREFIX)) {
12174
+ return null;
12175
+ }
12176
+ return url.toString();
12177
+ }
12178
+
12179
+ /**
12180
+ * Onto the statuses this server speaks. The JSON is passed through untouched,
12181
+ * so a `code` from Val Build still says what happened.
12182
+ */
12183
+ function mapStatus(status) {
12184
+ if (status >= 200 && status < 300) {
12185
+ return 200;
12186
+ }
12187
+ if (status === 400 || status === 401 || status === 403 || status === 404) {
12188
+ return status;
12189
+ }
12190
+ if (status >= 400 && status < 500) {
12191
+ return 400;
12192
+ }
12193
+ return 500;
12194
+ }
12195
+
11935
12196
  function getPersonalAccessTokenPath(root) {
11936
12197
  return path__default.join(path__default.resolve(root), ".val", "pat.json");
11937
12198
  }
@@ -12475,6 +12736,27 @@ function warnIfInsecureUrls(urls) {
12475
12736
  * genuinely confusing afternoon.
12476
12737
  */
12477
12738
 
12739
+ /**
12740
+ * The personal access token `val login` wrote for this project, or null when
12741
+ * there is none (or it does not parse). Only `fs` mode has a working directory
12742
+ * to read it from, so every other mode answers null.
12743
+ *
12744
+ * Unlike `resolveRemoteFileAuth` this never answers with the api key: the
12745
+ * caller wants to act as the DEVELOPER, and an api key is the project.
12746
+ */
12747
+ async function readValLoginToken(options) {
12748
+ if (options.mode !== "fs") {
12749
+ return null;
12750
+ }
12751
+ const fs = await import('fs');
12752
+ try {
12753
+ const file = await fs.promises.readFile(getPersonalAccessTokenPath(options.cwd), "utf-8");
12754
+ const parsed = parsePersonalAccessTokenFile(file);
12755
+ return parsed.success ? parsed.data.pat : null;
12756
+ } catch {
12757
+ return null;
12758
+ }
12759
+ }
12478
12760
  async function resolveRemoteFileAuth(options) {
12479
12761
  if (options.apiKey) {
12480
12762
  return {
@@ -12634,6 +12916,129 @@ const ValServer = (valModules, options, callbacks) => {
12634
12916
  return null;
12635
12917
  });
12636
12918
  };
12919
+
12920
+ /**
12921
+ * `/admin/proxy/*`, for Val Build's web components in the Studio: forwarded
12922
+ * to the Studio API on the content server. See `adminProxy.ts` for what is
12923
+ * forwarded and why.
12924
+ *
12925
+ * Three refusals before anything leaves this server, and the `code`s are
12926
+ * the ones the components act on:
12927
+ * - no `x-val-studio` header: 403, since a cross-site request cannot set it;
12928
+ * - no Val Build behind this Studio (no `valBuildUrl`, or fs mode without
12929
+ * a `val login`): 404 `not-connected`, which the component shows as such
12930
+ * rather than as an outage — though `/admin/status` keeps the Studio from
12931
+ * mounting it at all in that case;
12932
+ * - a deployed Studio without a valid session: 401 `unauthenticated`, which
12933
+ * the component answers by asking the Studio to sign in.
12934
+ */
12935
+ const adminProxy = async (method, req) => {
12936
+ if (req.headers[VAL_STUDIO_HEADER] !== "1") {
12937
+ return {
12938
+ status: 403,
12939
+ json: {
12940
+ code: "forbidden",
12941
+ message: `Requests to the Val Build proxy need the ${VAL_STUDIO_HEADER} header.`
12942
+ }
12943
+ };
12944
+ }
12945
+ const credential = await getValBuildCredential(req.cookies);
12946
+ if (credential.status !== "ok" || !options.valBuildUrl) {
12947
+ if (!serverOps.requiresAuth || !options.valBuildUrl) {
12948
+ return {
12949
+ status: 404,
12950
+ json: {
12951
+ code: "not-connected",
12952
+ message: "This Studio is not connected to Val Build."
12953
+ }
12954
+ };
12955
+ }
12956
+ return {
12957
+ status: 401,
12958
+ json: {
12959
+ code: "unauthenticated",
12960
+ message: credential.status === "invalid" ? credential.message : "Sign in to Val Build."
12961
+ }
12962
+ };
12963
+ }
12964
+ // To the content server, which serves the Studio API: Val Build's sign-in
12965
+ // (`valBuildUrl`) is what makes the credential, the content server is
12966
+ // what the components' data comes from.
12967
+ return forwardToStudioApi({
12968
+ method,
12969
+ path: req.path,
12970
+ rawQuery: req.rawQuery,
12971
+ body: req.body,
12972
+ credential: credential.credential,
12973
+ contentUrl: options.valContentUrl
12974
+ });
12975
+ };
12976
+
12977
+ /**
12978
+ * What this Studio can act on Val Build with, for the editor in front of it:
12979
+ *
12980
+ * - the session's token, in a deployed Studio (and in a local one where
12981
+ * someone signed in through the Studio anyway);
12982
+ * - otherwise, in fs mode, the developer's own `val login` token from
12983
+ * `.val/pat.json` — the same file `val validate --fix` reads.
12984
+ *
12985
+ * An api key is never one of them: it is the project, not a person, and
12986
+ * these requests are answered with a person's projects.
12987
+ */
12988
+ const getValBuildCredential = async cookies => {
12989
+ const session = getSessionToken(cookies);
12990
+ if (session.status === "ok") {
12991
+ return {
12992
+ status: "ok",
12993
+ credential: {
12994
+ bearer: session.token
12995
+ }
12996
+ };
12997
+ }
12998
+ if (options.mode === "fs") {
12999
+ const pat = await readValLoginToken(options);
13000
+ if (pat !== null) {
13001
+ return {
13002
+ status: "ok",
13003
+ credential: {
13004
+ pat
13005
+ }
13006
+ };
13007
+ }
13008
+ return {
13009
+ status: "none"
13010
+ };
13011
+ }
13012
+ return session;
13013
+ };
13014
+
13015
+ /** The editor's Val Build token, from inside their session cookie. */
13016
+ const getSessionToken = cookies => {
13017
+ const cookie = cookies[VAL_SESSION_COOKIE];
13018
+ if (typeof cookie !== "string" || !options.valSecret) {
13019
+ return {
13020
+ status: "none"
13021
+ };
13022
+ }
13023
+ const verified = verifyJwt(cookie, options.valSecret);
13024
+ if (!verified.success) {
13025
+ return {
13026
+ status: "invalid",
13027
+ message: sessionErrorMessage(verified.reason)
13028
+ };
13029
+ }
13030
+ const payload = IntegratedServerJwtPayload.safeParse(verified.data);
13031
+ if (!payload.success) {
13032
+ return {
13033
+ status: "invalid",
13034
+ message: "Session invalid. You will need to login again."
13035
+ };
13036
+ }
13037
+ return {
13038
+ status: "ok",
13039
+ token: payload.data.token
13040
+ };
13041
+ };
12637
13042
  const getAuth = cookies => {
12638
13043
  const cookie = cookies[VAL_SESSION_COOKIE];
12639
13044
  /*
@@ -14313,6 +14718,20 @@ const ValServer = (valModules, options, callbacks) => {
14313
14718
  }
14314
14719
  };
14315
14720
  }
14721
+ /*
14722
+ * What this build's draft applies: the pending patches and the ones
14723
+ * published AFTER this build, which it does not have -- see
14724
+ * `draftOverlay`. `getSources` already applied both, but
14725
+ * `analyzePatches` skips every patch with `appliedAt`, so a module
14726
+ * changed only by a post-build publish was reported with no
14727
+ * `patches`, and a reader that sends only changed modules (TanStack's
14728
+ * `fetchValDraft`) left it out: the page was rendered with this
14729
+ * build's older value. Chain order, which is what the Studio applies.
14730
+ */
14731
+ patchOps = {
14732
+ ...patchOps,
14733
+ patches: draftOverlay(patchOps.patches, patchOps.commits)
14734
+ };
14316
14735
  const patchAnalysis = serverOps.analyzePatches(patchOps.patches);
14317
14736
  const schemasRes = await serverOps.getSchemas();
14318
14737
  let sourcesRes = await serverOps.getSources();
@@ -14561,6 +14980,31 @@ const ValServer = (valModules, options, callbacks) => {
14561
14980
  });
14562
14981
  }
14563
14982
  },
14983
+ "/admin/status": {
14984
+ GET: async req => {
14985
+ const credential = await getValBuildCredential(req.cookies);
14986
+ return {
14987
+ status: 200,
14988
+ json: {
14989
+ connected: Boolean(options.valBuildUrl) && credential.status === "ok",
14990
+ /*
14991
+ * How an editor signs in to Val Build here, which only the server
14992
+ * knows. NOT the stat `mode`: that says whether patches are local,
14993
+ * and a deployed memory-mode host reports `fs` while having no
14994
+ * working directory to run `val login` in. Only a developer's own
14995
+ * checkout (fs mode) signs in with `val login`.
14996
+ */
14997
+ signIn: options.mode === "fs" ? "val-login" : "studio"
14998
+ }
14999
+ };
15000
+ }
15001
+ },
15002
+ "/admin/proxy": {
15003
+ GET: req => adminProxy("GET", req),
15004
+ POST: req => adminProxy("POST", req),
15005
+ PUT: req => adminProxy("PUT", req),
15006
+ DELETE: req => adminProxy("DELETE", req)
15007
+ },
14564
15008
  "/profiles": {
14565
15009
  GET: async req => {
14566
15010
  const cookies = req.cookies;
@@ -17041,6 +17485,13 @@ function createValApiRouter(route, valServerPromise, convert) {
17041
17485
  if (!cookiesRes.success) {
17042
17486
  return zodErrorResult(cookiesRes.error, "invalid cookies");
17043
17487
  }
17488
+ const headersRes = reqDefinition.headers ? getHeaders(req, reqDefinition.headers) : {
17489
+ success: true,
17490
+ data: {}
17491
+ };
17492
+ if (!headersRes.success) {
17493
+ return zodErrorResult(headersRes.error, "invalid headers");
17494
+ }
17044
17495
  const actualQueryParams = groupQueryParams(Array.from(url.searchParams.entries()));
17045
17496
  let query = {};
17046
17497
  if (reqDefinition.query) {
@@ -17089,6 +17540,8 @@ function createValApiRouter(route, valServerPromise, convert) {
17089
17540
  res = await endpointImpl({
17090
17541
  body: bodyRes.data,
17091
17542
  cookies: cookiesRes.data,
17543
+ headers: headersRes.data,
17544
+ rawQuery: reqDefinition.rawQuery ? url.search : undefined,
17092
17545
  query,
17093
17546
  path
17094
17547
  });
@@ -17185,10 +17638,22 @@ function zodErrorResult(error, message) {
17185
17638
  }
17186
17639
 
17187
17640
  // TODO: is this naive implementation is too naive?
17641
+ /** The declared headers, by name; `Headers.get` is case-insensitive. */
17642
+ function getHeaders(req, headersDef) {
17643
+ const input = {};
17644
+ for (const name of Object.keys(headersDef)) {
17645
+ var _req$headers;
17646
+ const value = (_req$headers = req.headers) === null || _req$headers === void 0 ? void 0 : _req$headers.get(name);
17647
+ if (value !== null && value !== undefined) {
17648
+ input[name] = value;
17649
+ }
17650
+ }
17651
+ return z.object(headersDef).safeParse(input);
17652
+ }
17188
17653
  function getCookies(req, cookiesDef) {
17189
- var _req$headers;
17654
+ var _req$headers2;
17190
17655
  const input = {};
17191
- 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("; ");
17656
+ 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("; ");
17192
17657
  for (const name of Object.keys(cookiesDef)) {
17193
17658
  const cookie = cookieParts === null || cookieParts === void 0 ? void 0 : cookieParts.find(cookie => cookie.startsWith(`${name}=`));
17194
17659
  const value = cookie ? decodeURIComponent(cookie === null || cookie === void 0 ? void 0 : cookie.split("=")[1]) : undefined;