@valbuild/server 0.139.2 → 0.140.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,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 {
@@ -11932,6 +12052,135 @@ contentUrl) {
11932
12052
  }
11933
12053
  }
11934
12054
 
12055
+ /**
12056
+ * Forwarding for `/admin/proxy/*`: Val Build's Studio API, `/v1/studio/*` on
12057
+ * the content server, called on the editor's behalf with the token from their
12058
+ * session.
12059
+ *
12060
+ * The web components the Studio mounts from content.val.build cannot call Val
12061
+ * Build themselves. The editor's credential is in this app's httpOnly
12062
+ * `val_session` cookie, and a cookie of admin.val.build's own would be
12063
+ * third-party on this page, which Safari and Firefox block. So they call this
12064
+ * app, and this is where the token is attached.
12065
+ *
12066
+ * It is a pipe for the path, the query and a JSON body, so a new component or
12067
+ * endpoint needs no Val release. It is NOT a pipe for where the request goes:
12068
+ * the URL is built from `contentUrl` (the server's `valContentUrl`) and must
12069
+ * still be under `/v1/studio/` after the URL parser has resolved `..`,
12070
+ * `%2e%2e` and backslashes. The editor's token can reach the endpoints written
12071
+ * for the Studio and nothing else on Val Build.
12072
+ */
12073
+
12074
+ const STUDIO_API_PREFIX = "/v1/studio/";
12075
+ const DEFAULT_TIMEOUT_MS = 10_000;
12076
+ async function forwardToStudioApi({
12077
+ method,
12078
+ path,
12079
+ rawQuery,
12080
+ body,
12081
+ credential,
12082
+ contentUrl,
12083
+ fetchImpl = fetch,
12084
+ timeoutMs = DEFAULT_TIMEOUT_MS
12085
+ }) {
12086
+ const url = studioApiUrl(contentUrl, path, rawQuery);
12087
+ if (url === null) {
12088
+ return {
12089
+ status: 404,
12090
+ json: {
12091
+ code: "not-found",
12092
+ message: "No such Val Build endpoint."
12093
+ }
12094
+ };
12095
+ }
12096
+ // Every method the route accepts may carry a JSON body: a DELETE that
12097
+ // names what it removes in its body is as valid as a PUT.
12098
+ const sendsBody = method !== "GET" && body !== undefined;
12099
+ let response;
12100
+ try {
12101
+ response = await fetchImpl(url, {
12102
+ method,
12103
+ headers: {
12104
+ // `x-val-pat` is the header the rest of Val Build reads a PAT from.
12105
+ ...("pat" in credential ? {
12106
+ "x-val-pat": credential.pat
12107
+ } : {
12108
+ authorization: `Bearer ${credential.bearer}`
12109
+ }),
12110
+ accept: "application/json",
12111
+ ...(sendsBody ? {
12112
+ "content-type": "application/json"
12113
+ } : {})
12114
+ },
12115
+ body: sendsBody ? JSON.stringify(body) : undefined,
12116
+ // Never follow a redirect with the token attached.
12117
+ redirect: "manual",
12118
+ signal: AbortSignal.timeout(timeoutMs)
12119
+ });
12120
+ } catch {
12121
+ return {
12122
+ status: 500,
12123
+ json: {
12124
+ code: "unreachable",
12125
+ message: "Could not reach Val Build."
12126
+ }
12127
+ };
12128
+ }
12129
+ let json;
12130
+ try {
12131
+ json = await response.json();
12132
+ } catch {
12133
+ json = {
12134
+ code: "bad-gateway",
12135
+ message: `Val Build answered ${response.status} without JSON.`
12136
+ };
12137
+ }
12138
+ return {
12139
+ status: mapStatus(response.status),
12140
+ json
12141
+ };
12142
+ }
12143
+
12144
+ /**
12145
+ * `{contentUrl}/v1/studio{path}{rawQuery}`, or null when the result is not
12146
+ * under that prefix on that origin — the check that keeps the proxy from being
12147
+ * pointed anywhere else with the editor's token.
12148
+ */
12149
+ function studioApiUrl(contentUrl, path, rawQuery) {
12150
+ if (!path.startsWith("/") || rawQuery !== "" && !rawQuery.startsWith("?")) {
12151
+ return null;
12152
+ }
12153
+ let base;
12154
+ let url;
12155
+ try {
12156
+ base = new URL(contentUrl);
12157
+ url = new URL(`${STUDIO_API_PREFIX}${path.slice(1)}${rawQuery}`, base);
12158
+ } catch {
12159
+ return null;
12160
+ }
12161
+ if (url.origin !== base.origin || !url.pathname.startsWith(STUDIO_API_PREFIX)) {
12162
+ return null;
12163
+ }
12164
+ return url.toString();
12165
+ }
12166
+
12167
+ /**
12168
+ * Onto the statuses this server speaks. The JSON is passed through untouched,
12169
+ * so a `code` from Val Build still says what happened.
12170
+ */
12171
+ function mapStatus(status) {
12172
+ if (status >= 200 && status < 300) {
12173
+ return 200;
12174
+ }
12175
+ if (status === 400 || status === 401 || status === 403 || status === 404) {
12176
+ return status;
12177
+ }
12178
+ if (status >= 400 && status < 500) {
12179
+ return 400;
12180
+ }
12181
+ return 500;
12182
+ }
12183
+
11935
12184
  function getPersonalAccessTokenPath(root) {
11936
12185
  return path__default.join(path__default.resolve(root), ".val", "pat.json");
11937
12186
  }
@@ -12475,6 +12724,27 @@ function warnIfInsecureUrls(urls) {
12475
12724
  * genuinely confusing afternoon.
12476
12725
  */
12477
12726
 
12727
+ /**
12728
+ * The personal access token `val login` wrote for this project, or null when
12729
+ * there is none (or it does not parse). Only `fs` mode has a working directory
12730
+ * to read it from, so every other mode answers null.
12731
+ *
12732
+ * Unlike `resolveRemoteFileAuth` this never answers with the api key: the
12733
+ * caller wants to act as the DEVELOPER, and an api key is the project.
12734
+ */
12735
+ async function readValLoginToken(options) {
12736
+ if (options.mode !== "fs") {
12737
+ return null;
12738
+ }
12739
+ const fs = await import('fs');
12740
+ try {
12741
+ const file = await fs.promises.readFile(getPersonalAccessTokenPath(options.cwd), "utf-8");
12742
+ const parsed = parsePersonalAccessTokenFile(file);
12743
+ return parsed.success ? parsed.data.pat : null;
12744
+ } catch {
12745
+ return null;
12746
+ }
12747
+ }
12478
12748
  async function resolveRemoteFileAuth(options) {
12479
12749
  if (options.apiKey) {
12480
12750
  return {
@@ -12634,6 +12904,129 @@ const ValServer = (valModules, options, callbacks) => {
12634
12904
  return null;
12635
12905
  });
12636
12906
  };
12907
+
12908
+ /**
12909
+ * `/admin/proxy/*`, for Val Build's web components in the Studio: forwarded
12910
+ * to the Studio API on the content server. See `adminProxy.ts` for what is
12911
+ * forwarded and why.
12912
+ *
12913
+ * Three refusals before anything leaves this server, and the `code`s are
12914
+ * the ones the components act on:
12915
+ * - no `x-val-studio` header: 403, since a cross-site request cannot set it;
12916
+ * - no Val Build behind this Studio (no `valBuildUrl`, or fs mode without
12917
+ * a `val login`): 404 `not-connected`, which the component shows as such
12918
+ * rather than as an outage — though `/admin/status` keeps the Studio from
12919
+ * mounting it at all in that case;
12920
+ * - a deployed Studio without a valid session: 401 `unauthenticated`, which
12921
+ * the component answers by asking the Studio to sign in.
12922
+ */
12923
+ const adminProxy = async (method, req) => {
12924
+ if (req.headers[VAL_STUDIO_HEADER] !== "1") {
12925
+ return {
12926
+ status: 403,
12927
+ json: {
12928
+ code: "forbidden",
12929
+ message: `Requests to the Val Build proxy need the ${VAL_STUDIO_HEADER} header.`
12930
+ }
12931
+ };
12932
+ }
12933
+ const credential = await getValBuildCredential(req.cookies);
12934
+ if (credential.status !== "ok" || !options.valBuildUrl) {
12935
+ if (!serverOps.requiresAuth || !options.valBuildUrl) {
12936
+ return {
12937
+ status: 404,
12938
+ json: {
12939
+ code: "not-connected",
12940
+ message: "This Studio is not connected to Val Build."
12941
+ }
12942
+ };
12943
+ }
12944
+ return {
12945
+ status: 401,
12946
+ json: {
12947
+ code: "unauthenticated",
12948
+ message: credential.status === "invalid" ? credential.message : "Sign in to Val Build."
12949
+ }
12950
+ };
12951
+ }
12952
+ // To the content server, which serves the Studio API: Val Build's sign-in
12953
+ // (`valBuildUrl`) is what makes the credential, the content server is
12954
+ // what the components' data comes from.
12955
+ return forwardToStudioApi({
12956
+ method,
12957
+ path: req.path,
12958
+ rawQuery: req.rawQuery,
12959
+ body: req.body,
12960
+ credential: credential.credential,
12961
+ contentUrl: options.valContentUrl
12962
+ });
12963
+ };
12964
+
12965
+ /**
12966
+ * What this Studio can act on Val Build with, for the editor in front of it:
12967
+ *
12968
+ * - the session's token, in a deployed Studio (and in a local one where
12969
+ * someone signed in through the Studio anyway);
12970
+ * - otherwise, in fs mode, the developer's own `val login` token from
12971
+ * `.val/pat.json` — the same file `val validate --fix` reads.
12972
+ *
12973
+ * An api key is never one of them: it is the project, not a person, and
12974
+ * these requests are answered with a person's projects.
12975
+ */
12976
+ const getValBuildCredential = async cookies => {
12977
+ const session = getSessionToken(cookies);
12978
+ if (session.status === "ok") {
12979
+ return {
12980
+ status: "ok",
12981
+ credential: {
12982
+ bearer: session.token
12983
+ }
12984
+ };
12985
+ }
12986
+ if (options.mode === "fs") {
12987
+ const pat = await readValLoginToken(options);
12988
+ if (pat !== null) {
12989
+ return {
12990
+ status: "ok",
12991
+ credential: {
12992
+ pat
12993
+ }
12994
+ };
12995
+ }
12996
+ return {
12997
+ status: "none"
12998
+ };
12999
+ }
13000
+ return session;
13001
+ };
13002
+
13003
+ /** The editor's Val Build token, from inside their session cookie. */
13004
+ const getSessionToken = cookies => {
13005
+ const cookie = cookies[VAL_SESSION_COOKIE];
13006
+ if (typeof cookie !== "string" || !options.valSecret) {
13007
+ return {
13008
+ status: "none"
13009
+ };
13010
+ }
13011
+ const verified = verifyJwt(cookie, options.valSecret);
13012
+ if (!verified.success) {
13013
+ return {
13014
+ status: "invalid",
13015
+ message: sessionErrorMessage(verified.reason)
13016
+ };
13017
+ }
13018
+ const payload = IntegratedServerJwtPayload.safeParse(verified.data);
13019
+ if (!payload.success) {
13020
+ return {
13021
+ status: "invalid",
13022
+ message: "Session invalid. You will need to login again."
13023
+ };
13024
+ }
13025
+ return {
13026
+ status: "ok",
13027
+ token: payload.data.token
13028
+ };
13029
+ };
12637
13030
  const getAuth = cookies => {
12638
13031
  const cookie = cookies[VAL_SESSION_COOKIE];
12639
13032
  /*
@@ -14313,6 +14706,20 @@ const ValServer = (valModules, options, callbacks) => {
14313
14706
  }
14314
14707
  };
14315
14708
  }
14709
+ /*
14710
+ * What this build's draft applies: the pending patches and the ones
14711
+ * published AFTER this build, which it does not have -- see
14712
+ * `draftOverlay`. `getSources` already applied both, but
14713
+ * `analyzePatches` skips every patch with `appliedAt`, so a module
14714
+ * changed only by a post-build publish was reported with no
14715
+ * `patches`, and a reader that sends only changed modules (TanStack's
14716
+ * `fetchValDraft`) left it out: the page was rendered with this
14717
+ * build's older value. Chain order, which is what the Studio applies.
14718
+ */
14719
+ patchOps = {
14720
+ ...patchOps,
14721
+ patches: draftOverlay(patchOps.patches, patchOps.commits)
14722
+ };
14316
14723
  const patchAnalysis = serverOps.analyzePatches(patchOps.patches);
14317
14724
  const schemasRes = await serverOps.getSchemas();
14318
14725
  let sourcesRes = await serverOps.getSources();
@@ -14561,6 +14968,31 @@ const ValServer = (valModules, options, callbacks) => {
14561
14968
  });
14562
14969
  }
14563
14970
  },
14971
+ "/admin/status": {
14972
+ GET: async req => {
14973
+ const credential = await getValBuildCredential(req.cookies);
14974
+ return {
14975
+ status: 200,
14976
+ json: {
14977
+ connected: Boolean(options.valBuildUrl) && credential.status === "ok",
14978
+ /*
14979
+ * How an editor signs in to Val Build here, which only the server
14980
+ * knows. NOT the stat `mode`: that says whether patches are local,
14981
+ * and a deployed memory-mode host reports `fs` while having no
14982
+ * working directory to run `val login` in. Only a developer's own
14983
+ * checkout (fs mode) signs in with `val login`.
14984
+ */
14985
+ signIn: options.mode === "fs" ? "val-login" : "studio"
14986
+ }
14987
+ };
14988
+ }
14989
+ },
14990
+ "/admin/proxy": {
14991
+ GET: req => adminProxy("GET", req),
14992
+ POST: req => adminProxy("POST", req),
14993
+ PUT: req => adminProxy("PUT", req),
14994
+ DELETE: req => adminProxy("DELETE", req)
14995
+ },
14564
14996
  "/profiles": {
14565
14997
  GET: async req => {
14566
14998
  const cookies = req.cookies;
@@ -17041,6 +17473,13 @@ function createValApiRouter(route, valServerPromise, convert) {
17041
17473
  if (!cookiesRes.success) {
17042
17474
  return zodErrorResult(cookiesRes.error, "invalid cookies");
17043
17475
  }
17476
+ const headersRes = reqDefinition.headers ? getHeaders(req, reqDefinition.headers) : {
17477
+ success: true,
17478
+ data: {}
17479
+ };
17480
+ if (!headersRes.success) {
17481
+ return zodErrorResult(headersRes.error, "invalid headers");
17482
+ }
17044
17483
  const actualQueryParams = groupQueryParams(Array.from(url.searchParams.entries()));
17045
17484
  let query = {};
17046
17485
  if (reqDefinition.query) {
@@ -17089,6 +17528,8 @@ function createValApiRouter(route, valServerPromise, convert) {
17089
17528
  res = await endpointImpl({
17090
17529
  body: bodyRes.data,
17091
17530
  cookies: cookiesRes.data,
17531
+ headers: headersRes.data,
17532
+ rawQuery: reqDefinition.rawQuery ? url.search : undefined,
17092
17533
  query,
17093
17534
  path
17094
17535
  });
@@ -17185,10 +17626,22 @@ function zodErrorResult(error, message) {
17185
17626
  }
17186
17627
 
17187
17628
  // TODO: is this naive implementation is too naive?
17629
+ /** The declared headers, by name; `Headers.get` is case-insensitive. */
17630
+ function getHeaders(req, headersDef) {
17631
+ const input = {};
17632
+ for (const name of Object.keys(headersDef)) {
17633
+ var _req$headers;
17634
+ const value = (_req$headers = req.headers) === null || _req$headers === void 0 ? void 0 : _req$headers.get(name);
17635
+ if (value !== null && value !== undefined) {
17636
+ input[name] = value;
17637
+ }
17638
+ }
17639
+ return z.object(headersDef).safeParse(input);
17640
+ }
17188
17641
  function getCookies(req, cookiesDef) {
17189
- var _req$headers;
17642
+ var _req$headers2;
17190
17643
  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("; ");
17644
+ 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
17645
  for (const name of Object.keys(cookiesDef)) {
17193
17646
  const cookie = cookieParts === null || cookieParts === void 0 ? void 0 : cookieParts.find(cookie => cookie.startsWith(`${name}=`));
17194
17647
  const value = cookie ? decodeURIComponent(cookie === null || cookie === void 0 ? void 0 : cookie.split("=")[1]) : undefined;
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.139.2",
19
+ "version": "0.140.0",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0",
@@ -30,9 +30,9 @@
30
30
  "typescript": "^6.0.3",
31
31
  "zod": "^4.4.3",
32
32
  "zod-validation-error": "^5.0.0",
33
- "@valbuild/core": "0.139.1",
34
- "@valbuild/shared": "0.139.1",
35
- "@valbuild/ui": "0.139.2"
33
+ "@valbuild/ui": "0.140.0",
34
+ "@valbuild/shared": "0.140.0",
35
+ "@valbuild/core": "0.139.1"
36
36
  },
37
37
  "engines": {
38
38
  "node": "^20.19.0 || >=22"