@valbuild/server 0.121.0 → 0.122.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.
- package/CHANGELOG.md +132 -0
- package/dist/declarations/src/ValOps.d.ts +67 -1
- package/dist/declarations/src/ValOpsFS.d.ts +14 -0
- package/dist/declarations/src/ValOpsHttp.d.ts +30 -0
- package/dist/declarations/src/externalRecords.d.ts +366 -0
- package/dist/declarations/src/fixHandlers.d.ts +13 -0
- package/dist/declarations/src/history/HistoryError.d.ts +90 -0
- package/dist/declarations/src/history/types.d.ts +126 -0
- package/dist/declarations/src/index.d.ts +3 -1
- package/dist/declarations/src/tools/types.d.ts +22 -31
- package/dist/declarations/src/valServerConfig.d.ts +17 -14
- package/dist/valbuild-server.cjs.dev.js +1417 -286
- package/dist/valbuild-server.cjs.prod.js +1417 -286
- package/dist/valbuild-server.esm.js +1414 -289
- package/package.json +3 -3
|
@@ -8,14 +8,14 @@ import path__default from 'path';
|
|
|
8
8
|
import fs, { promises } from 'fs';
|
|
9
9
|
import vm from 'node:vm';
|
|
10
10
|
import { Module, createRequire } from 'node:module';
|
|
11
|
-
import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, PatchGroup, newestCommitSha, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
|
|
11
|
+
import { resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api, filterBlockingValidationErrors, describeContainerAtPath, safeParsePatch, buildDuplicatePatch, buildEmptyAtPathPatch, buildRemoveImageGalleryEntryPatch } from '@valbuild/shared/internal';
|
|
12
12
|
import { createUIRequestHandler } from '@valbuild/ui/server';
|
|
13
13
|
import crypto$1 from 'crypto';
|
|
14
14
|
import z$1, { z } from 'zod';
|
|
15
15
|
import sizeOf from 'image-size';
|
|
16
16
|
import { fromError } from 'zod-validation-error';
|
|
17
17
|
import os from 'os';
|
|
18
|
-
import { randomUUID
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
19
|
import { transform } from 'sucrase';
|
|
20
20
|
import http from 'http';
|
|
21
21
|
import https from 'https';
|
|
@@ -565,11 +565,13 @@ const patchValFile = async (id, rootDir, patch, sourceFileHandler) => {
|
|
|
565
565
|
// );
|
|
566
566
|
// sourceFileHandler.moveFile(tempFilePath, "." + filePath);
|
|
567
567
|
// TODO: ensure that directory exists
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
568
|
+
// NOTE: there used to be a branch here testing
|
|
569
|
+
// `content.startsWith("data:/image/svg+xml")` - with a stray slash after
|
|
570
|
+
// `data:`, so it could never match a real data URL and SVGs took the
|
|
571
|
+
// "binary" path anyway. Removed rather than fixed: the branch existed to
|
|
572
|
+
// write SVG as utf8, and writing the decoded bytes is correct for every
|
|
573
|
+
// type including SVG.
|
|
574
|
+
sourceFileHandler.writeFile("." + filePath, convertDataUrlToBase64(content).toString("binary"), "binary");
|
|
573
575
|
}
|
|
574
576
|
sourceFileHandler.writeSourceFile(newSourceFile.value);
|
|
575
577
|
// console.timeEnd("patchValFile" + timeId);
|
|
@@ -1918,6 +1920,183 @@ class Service {
|
|
|
1918
1920
|
}
|
|
1919
1921
|
}
|
|
1920
1922
|
|
|
1923
|
+
/**
|
|
1924
|
+
* The type surface of an external record's adapter.
|
|
1925
|
+
*
|
|
1926
|
+
* Nothing here runs: phase 0 is the contract, and the registry that executes it
|
|
1927
|
+
* arrives with the read endpoints. What the contract has to get right now is the
|
|
1928
|
+
* shape, because every adapter written against it is a compatibility promise.
|
|
1929
|
+
*
|
|
1930
|
+
* Four kinds of method, and each is required or not for one reason:
|
|
1931
|
+
*
|
|
1932
|
+
* - **Read** — `keys`, `get`. Cannot be built out of anything else, so always
|
|
1933
|
+
* required.
|
|
1934
|
+
* - **Write** — `put`, `delete`. Likewise, and they move together: required on a
|
|
1935
|
+
* writable record, forbidden on a `.readonly()` one.
|
|
1936
|
+
* - **Derived** — `count`, `search`. Computable from the read methods, so
|
|
1937
|
+
* omitting one costs performance, never capability. `false` declines the
|
|
1938
|
+
* fallback; that is the only thing `false` ever means here.
|
|
1939
|
+
* - **Media** — `putFile`, `getFile`. A pair, and required by the item SCHEMA
|
|
1940
|
+
* rather than by this type (see `hasMediaSchema`).
|
|
1941
|
+
*/
|
|
1942
|
+
|
|
1943
|
+
// #region result
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* Brands the result envelope so a bare value can be accepted alongside it.
|
|
1947
|
+
*
|
|
1948
|
+
* A symbol rather than a property name because `get` returns
|
|
1949
|
+
* `Record<key, Item>` — a record that can perfectly well contain a key called
|
|
1950
|
+
* `kind`. The envelope never crosses the wire (it travels in-process between
|
|
1951
|
+
* adapter and Val), so there is no serialization cost to it.
|
|
1952
|
+
*/
|
|
1953
|
+
const EXTERNAL_RESULT = Symbol.for("@valbuild/server/ExternalResult");
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* What every adapter method may return: the value on its own, or the envelope.
|
|
1957
|
+
*
|
|
1958
|
+
* A bare value means "ok, nothing to report", which keeps the common path one
|
|
1959
|
+
* line. Errors can be thrown instead of returned — the envelope is for a failure
|
|
1960
|
+
* worth classifying, or a success worth annotating.
|
|
1961
|
+
*/
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* Wrap a value as a successful result, optionally with warnings.
|
|
1965
|
+
*
|
|
1966
|
+
* `NoInfer` is load-bearing, not decoration. Without it `T` is inferred FROM the
|
|
1967
|
+
* argument, and an object literal that infers its own type is not contextually
|
|
1968
|
+
* typed by the adapter's contract — so `title` inside `ok({ a: { title } })` is a
|
|
1969
|
+
* fresh property that happens to be named `title`, with no declaration link back
|
|
1970
|
+
* to `title: s.string()` in the schema. "Find all references" on the schema field
|
|
1971
|
+
* then stops at the page that reads it and never reaches the adapter that
|
|
1972
|
+
* produces it. `NoInfer` blocks that inference, leaving the contextual return
|
|
1973
|
+
* type as the only source for `T`, which is what restores the link (and, as a
|
|
1974
|
+
* bonus, makes a typo report the offending property rather than the whole
|
|
1975
|
+
* return value).
|
|
1976
|
+
*
|
|
1977
|
+
* `externalNavigation.test.ts` guards this; nothing else would notice.
|
|
1978
|
+
*
|
|
1979
|
+
* The cost: `ok(...)` in a position with NO contextual type — a helper without a
|
|
1980
|
+
* return type annotation — infers `unknown` instead of the argument's type, and
|
|
1981
|
+
* fails where the helper is used rather than where it is written. Annotate the
|
|
1982
|
+
* helper's return type, or inline it.
|
|
1983
|
+
*/
|
|
1984
|
+
function ok$1(value, warnings) {
|
|
1985
|
+
return warnings && warnings.length > 0 ? {
|
|
1986
|
+
[EXTERNAL_RESULT]: true,
|
|
1987
|
+
kind: "ok",
|
|
1988
|
+
value,
|
|
1989
|
+
warnings
|
|
1990
|
+
} : {
|
|
1991
|
+
[EXTERNAL_RESULT]: true,
|
|
1992
|
+
kind: "ok",
|
|
1993
|
+
value
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
function err$1(issue) {
|
|
1997
|
+
return {
|
|
1998
|
+
[EXTERNAL_RESULT]: true,
|
|
1999
|
+
kind: "err",
|
|
2000
|
+
error: issue
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
function isExternalResult(value) {
|
|
2004
|
+
return typeof value === "object" && value !== null && value[EXTERNAL_RESULT] === true;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// #endregion
|
|
2008
|
+
|
|
2009
|
+
// #region context
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* What every adapter method is told about the call it is serving.
|
|
2013
|
+
*
|
|
2014
|
+
* `tx` is present only when the adapter declared an `around`; a store with no
|
|
2015
|
+
* transaction seam has nothing to put there and nothing to ignore.
|
|
2016
|
+
*/
|
|
2017
|
+
|
|
2018
|
+
// #endregion
|
|
2019
|
+
|
|
2020
|
+
// #region paging, sorting, searching
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* How a page should be ordered.
|
|
2024
|
+
*
|
|
2025
|
+
* Records are unordered today and sorting is coming; the parameter lands now
|
|
2026
|
+
* because adding one to `keys` later would break every adapter that exists by
|
|
2027
|
+
* then. Val passes `undefined` until sorting ships, and an adapter may ignore it.
|
|
2028
|
+
*
|
|
2029
|
+
* Two rules for whoever implements it:
|
|
2030
|
+
*
|
|
2031
|
+
* - **A cursor is only valid for the sort that issued it.** Key-ordered paging
|
|
2032
|
+
* is `where key > cursor`; sorted paging is `where (field, key) > (…, …)`.
|
|
2033
|
+
* Val pairs the cursor with a hash of the sort and restarts rather than
|
|
2034
|
+
* replaying a mismatched one.
|
|
2035
|
+
* - **The key is always the last sort term.** Ordering by a non-unique field
|
|
2036
|
+
* without a tiebreaker lets rows shift between pages, so page 2 can skip or
|
|
2037
|
+
* repeat an entry while both pages look fine on their own.
|
|
2038
|
+
*/
|
|
2039
|
+
|
|
2040
|
+
// #endregion
|
|
2041
|
+
|
|
2042
|
+
// #region media
|
|
2043
|
+
|
|
2044
|
+
// #endregion
|
|
2045
|
+
|
|
2046
|
+
// #region the adapter
|
|
2047
|
+
|
|
2048
|
+
/** Read methods. Always required. */
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Write methods. Required together, and forbidden together on `.readonly()`.
|
|
2052
|
+
*
|
|
2053
|
+
* `put` must be an UPSERT keyed by the entry key and `delete` must tolerate an
|
|
2054
|
+
* absent key, because a publish may be replayed: retry re-runs the whole scope
|
|
2055
|
+
* where there is a transaction, and the individual call where there is not.
|
|
2056
|
+
*/
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* Named so the compiler prints the reason. A bare `never` would report only
|
|
2060
|
+
* "not assignable to type 'undefined'", which tells nobody anything.
|
|
2061
|
+
*/
|
|
2062
|
+
|
|
2063
|
+
/** Derived and media methods, and the sort declaration. */
|
|
2064
|
+
|
|
2065
|
+
/** The item type an external module's entries hold, loosened as JSON allows. */
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* The adapter a given external module needs.
|
|
2069
|
+
*
|
|
2070
|
+
* Writes are required or forbidden by the module's own `.readonly()`, read off
|
|
2071
|
+
* the source marker's phantom.
|
|
2072
|
+
*/
|
|
2073
|
+
|
|
2074
|
+
/** What `entry()` returns: a module and its adapter, checked against each other. */
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* Declare the adapters for this project's external records.
|
|
2078
|
+
*
|
|
2079
|
+
* `Tx` is given explicitly: `around` offers the compiler no inference site,
|
|
2080
|
+
* since `run` is a callback you CALL rather than one whose signature you write.
|
|
2081
|
+
* One type argument, in one place, and every inline adapter then gets `tx`,
|
|
2082
|
+
* `cursor`, `limit` and the row shape correctly typed.
|
|
2083
|
+
*
|
|
2084
|
+
* @example
|
|
2085
|
+
* const { entry, modules } = defineExternal<typeof sql>({
|
|
2086
|
+
* around: (run) => sql.begin(run),
|
|
2087
|
+
* });
|
|
2088
|
+
*
|
|
2089
|
+
* export default modules({
|
|
2090
|
+
* posts: entry(postsVal, { keys, get, put, delete: del, search: false }),
|
|
2091
|
+
* });
|
|
2092
|
+
*
|
|
2093
|
+
* @example a store with no transaction
|
|
2094
|
+
* const { entry, modules } = defineExternal();
|
|
2095
|
+
*/
|
|
2096
|
+
function defineExternal(definition) {
|
|
2097
|
+
throw new Error("defineExternal is not implemented yet: phase 0 lands the contract, the registry that executes it arrives with the read endpoints.");
|
|
2098
|
+
}
|
|
2099
|
+
|
|
1921
2100
|
const JwtPayloadSchema = z.object({
|
|
1922
2101
|
sub: z.string(),
|
|
1923
2102
|
exp: z.number(),
|
|
@@ -3702,6 +3881,47 @@ class ValOps {
|
|
|
3702
3881
|
};
|
|
3703
3882
|
}
|
|
3704
3883
|
}));
|
|
3884
|
+
|
|
3885
|
+
/*
|
|
3886
|
+
* What each changed module IS after this commit, and the schema it is under.
|
|
3887
|
+
*
|
|
3888
|
+
* The data rather than the file's text, because that is the half git cannot
|
|
3889
|
+
* give back: a `.val.ts` in git is code, and turning code back into data
|
|
3890
|
+
* means parsing it, which is best-effort and rots across TypeScript,
|
|
3891
|
+
* runtime and Val versions. The schema comes along because a value on its
|
|
3892
|
+
* own cannot be RENDERED - showing a module as it was at a commit whose
|
|
3893
|
+
* schema has since changed needs the schema of that commit, and nothing in
|
|
3894
|
+
* the current checkout has it.
|
|
3895
|
+
*
|
|
3896
|
+
* Taken from `getSources(analysis)` rather than re-derived here so the data
|
|
3897
|
+
* stored is the same data the Studio shows, produced by the one
|
|
3898
|
+
* implementation of "apply these ops".
|
|
3899
|
+
*/
|
|
3900
|
+
const moduleVersions = {};
|
|
3901
|
+
const {
|
|
3902
|
+
sources: sourcesAfter
|
|
3903
|
+
} = await this.getSources(patchAnalysis);
|
|
3904
|
+
for (const path of Object.keys(patchesByModule)) {
|
|
3905
|
+
let serialized;
|
|
3906
|
+
try {
|
|
3907
|
+
var _schemas$path3;
|
|
3908
|
+
serialized = (_schemas$path3 = schemas[path]) === null || _schemas$path3 === void 0 ? void 0 : _schemas$path3["executeSerialize"]();
|
|
3909
|
+
} catch {
|
|
3910
|
+
// Same guard as above: one unserializable schema must not cost every
|
|
3911
|
+
// other module its history.
|
|
3912
|
+
serialized = undefined;
|
|
3913
|
+
}
|
|
3914
|
+
if (!serialized) {
|
|
3915
|
+
continue;
|
|
3916
|
+
}
|
|
3917
|
+
const source = sourcesAfter[path];
|
|
3918
|
+
moduleVersions[path] = {
|
|
3919
|
+
// `undefined` here means the module is gone, which is a state history
|
|
3920
|
+
// has to be able to show. `null` is how that travels over the wire.
|
|
3921
|
+
source: source === undefined ? null : source,
|
|
3922
|
+
schema: serialized
|
|
3923
|
+
};
|
|
3924
|
+
}
|
|
3705
3925
|
const res = {
|
|
3706
3926
|
hasErrors,
|
|
3707
3927
|
sourceFilePatchErrors,
|
|
@@ -3714,7 +3934,8 @@ class ValOps {
|
|
|
3714
3934
|
patchedBinaryFilesDescriptors,
|
|
3715
3935
|
appliedPatches,
|
|
3716
3936
|
skippedPatches,
|
|
3717
|
-
triedPatches
|
|
3937
|
+
triedPatches,
|
|
3938
|
+
moduleVersions
|
|
3718
3939
|
};
|
|
3719
3940
|
return res;
|
|
3720
3941
|
}
|
|
@@ -3771,6 +3992,66 @@ class ValOps {
|
|
|
3771
3992
|
}
|
|
3772
3993
|
|
|
3773
3994
|
// #region abstract ops
|
|
3995
|
+
|
|
3996
|
+
/**
|
|
3997
|
+
* Save a patch's binary file from a `data:...;base64,...` URL.
|
|
3998
|
+
*
|
|
3999
|
+
* The wire form: `FileReader.readAsDataURL` is what the browser produces, and
|
|
4000
|
+
* published `@valbuild/server` versions send it. Code that already HAS bytes
|
|
4001
|
+
* should call {@link saveBinaryFileFromPatch} instead of wrapping them in a
|
|
4002
|
+
* data URL just to have this unwrap them again.
|
|
4003
|
+
*
|
|
4004
|
+
* A `null` `data` records a DELETION, which is why this cannot simply be
|
|
4005
|
+
* replaced by the byte-taking sibling: there is nothing to hand it.
|
|
4006
|
+
*/
|
|
4007
|
+
|
|
4008
|
+
/**
|
|
4009
|
+
* The same, for a caller that already has the bytes.
|
|
4010
|
+
*
|
|
4011
|
+
* Default implementation wraps them back into a data URL so every backend
|
|
4012
|
+
* gets this for free; a backend that can take bytes straight through should
|
|
4013
|
+
* override it.
|
|
4014
|
+
*/
|
|
4015
|
+
async saveBinaryFileFromPatch(filePath, parentRef, patchId, bytes, mimeType, type, metadata) {
|
|
4016
|
+
return this.saveBase64EncodedBinaryFileFromPatch(filePath, parentRef, patchId, `data:${mimeType};base64,${bytes.toString("base64")}`, type, metadata);
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
// #region history
|
|
4020
|
+
//
|
|
4021
|
+
// Reading the past, as opposed to reading the present with pending patches
|
|
4022
|
+
// applied. Every one of these is Result-typed against `HistoryError`, because
|
|
4023
|
+
// the ways this can fail - an unreadable record, a source that no longer
|
|
4024
|
+
// parses, an op that will not replay, a schema that has moved on - are the
|
|
4025
|
+
// interesting part rather than an edge case, and a caller deciding whether to
|
|
4026
|
+
// offer a RESTORE has to know which one it hit.
|
|
4027
|
+
//
|
|
4028
|
+
// Only implemented where there is a service holding the history:
|
|
4029
|
+
// `ValOpsHttp`. `ValOpsFS` answers `not-supported-in-fs-mode`, because local
|
|
4030
|
+
// dev has git for this and no commit records of its own.
|
|
4031
|
+
|
|
4032
|
+
/** One page of a branch's commits, newest first. See history/listCommits. */
|
|
4033
|
+
|
|
4034
|
+
/** The patches that produced one commit, with their ops. */
|
|
4035
|
+
|
|
4036
|
+
/**
|
|
4037
|
+
* How each `.val.ts` the commit changed looked BEFORE it, keyed by module
|
|
4038
|
+
* file path. Empty for a commit made before this was recorded - which the
|
|
4039
|
+
* caller reports as `source-unavailable` rather than as an empty module.
|
|
4040
|
+
*/
|
|
4041
|
+
/**
|
|
4042
|
+
* Each module a commit changed: its data, and the schema it was under.
|
|
4043
|
+
*
|
|
4044
|
+
* `asOf` widens it from "what this commit changed" to "the whole project as
|
|
4045
|
+
* this commit left it", which is what reverting everything to a point in time
|
|
4046
|
+
* needs; `moduleFilePath` narrows it to one module, for navigating the
|
|
4047
|
+
* history pane off the changed set.
|
|
4048
|
+
*/
|
|
4049
|
+
|
|
4050
|
+
/** Which files the commit touched, and how. Names them; does not fetch them. */
|
|
4051
|
+
|
|
4052
|
+
/** One file's bytes as they were at one commit. */
|
|
4053
|
+
|
|
4054
|
+
// #endregion history
|
|
3774
4055
|
}
|
|
3775
4056
|
function isOnlyFileCheckValidationError(validationError) {
|
|
3776
4057
|
var _validationError$fixe9;
|
|
@@ -6376,6 +6657,46 @@ class ValOpsFS extends ValOps {
|
|
|
6376
6657
|
getPatchLockFile() {
|
|
6377
6658
|
return path__default.join(this.rootDir, ValOpsFS.VAL_DIR, PATCH_LOCK_FILE_NAME);
|
|
6378
6659
|
}
|
|
6660
|
+
|
|
6661
|
+
// #region history
|
|
6662
|
+
//
|
|
6663
|
+
// Not available locally, and deliberately not faked from git.
|
|
6664
|
+
//
|
|
6665
|
+
// History is a record of what VAL did: which patches produced a commit, who
|
|
6666
|
+
// wrote them, and what each module looked like immediately before. Git has
|
|
6667
|
+
// the files but not that - it cannot say which of a commit's changes were one
|
|
6668
|
+
// editor's patch set, so a "restore this change" built on it would be
|
|
6669
|
+
// guesswork wearing the same UI.
|
|
6670
|
+
//
|
|
6671
|
+
// A single, honest error rather than five different ones: the caller's
|
|
6672
|
+
// question is "is history available here", and the answer is no.
|
|
6673
|
+
|
|
6674
|
+
async listCommits() {
|
|
6675
|
+
return result.err({
|
|
6676
|
+
kind: "not-supported-in-fs-mode"
|
|
6677
|
+
});
|
|
6678
|
+
}
|
|
6679
|
+
async getCommitPatches() {
|
|
6680
|
+
return result.err({
|
|
6681
|
+
kind: "not-supported-in-fs-mode"
|
|
6682
|
+
});
|
|
6683
|
+
}
|
|
6684
|
+
async getCommitModules() {
|
|
6685
|
+
return result.err({
|
|
6686
|
+
kind: "not-supported-in-fs-mode"
|
|
6687
|
+
});
|
|
6688
|
+
}
|
|
6689
|
+
async getCommitAffectedFiles() {
|
|
6690
|
+
return result.err({
|
|
6691
|
+
kind: "not-supported-in-fs-mode"
|
|
6692
|
+
});
|
|
6693
|
+
}
|
|
6694
|
+
async getFileAtCommit() {
|
|
6695
|
+
return result.err({
|
|
6696
|
+
kind: "not-supported-in-fs-mode"
|
|
6697
|
+
});
|
|
6698
|
+
}
|
|
6699
|
+
// #endregion history
|
|
6379
6700
|
}
|
|
6380
6701
|
class FSOpsHost {
|
|
6381
6702
|
constructor() {}
|
|
@@ -6663,6 +6984,99 @@ const CommitResponse = z.object({
|
|
|
6663
6984
|
commit: CommitSha,
|
|
6664
6985
|
branch: z.string()
|
|
6665
6986
|
});
|
|
6987
|
+
// #region history wire schemas
|
|
6988
|
+
//
|
|
6989
|
+
// Validated on arrival rather than trusted: these come from a service that
|
|
6990
|
+
// versions separately, and a silently mis-shaped commit record reads as "this
|
|
6991
|
+
// commit changed nothing", which is indistinguishable from a real answer.
|
|
6992
|
+
const HistoricalCommitResponse = z.object({
|
|
6993
|
+
commitSha: z.string(),
|
|
6994
|
+
parentCommitSha: z.string(),
|
|
6995
|
+
clientCommitSha: z.string(),
|
|
6996
|
+
branch: z.string(),
|
|
6997
|
+
createdBranch: z.string().nullable(),
|
|
6998
|
+
creator: z.string().nullable(),
|
|
6999
|
+
message: z.string().nullable(),
|
|
7000
|
+
createdAt: z.string(),
|
|
7001
|
+
seqNum: z.string(),
|
|
7002
|
+
patchCount: z.number(),
|
|
7003
|
+
hasArchive: z.boolean()
|
|
7004
|
+
});
|
|
7005
|
+
const ListCommitsResponse = z.object({
|
|
7006
|
+
commits: z.array(HistoricalCommitResponse),
|
|
7007
|
+
nextCursor: z.string().nullable()
|
|
7008
|
+
});
|
|
7009
|
+
const CommitPatchesResponse = z.object({
|
|
7010
|
+
commitSha: z.string(),
|
|
7011
|
+
commit: z.object({
|
|
7012
|
+
commitSha: z.string(),
|
|
7013
|
+
parentCommitSha: z.string(),
|
|
7014
|
+
clientCommitSha: z.string(),
|
|
7015
|
+
branch: z.string(),
|
|
7016
|
+
createdBranch: z.string().nullable(),
|
|
7017
|
+
creator: z.string().nullable(),
|
|
7018
|
+
message: z.string().nullable(),
|
|
7019
|
+
createdAt: z.string(),
|
|
7020
|
+
seqNum: z.string(),
|
|
7021
|
+
hasArchive: z.boolean()
|
|
7022
|
+
}),
|
|
7023
|
+
patches: z.array(z.object({
|
|
7024
|
+
patchId: z.string(),
|
|
7025
|
+
path: z.string(),
|
|
7026
|
+
patch: z.unknown(),
|
|
7027
|
+
authorId: z.string().nullable(),
|
|
7028
|
+
createdAt: z.string(),
|
|
7029
|
+
baseSha: z.string(),
|
|
7030
|
+
coreVersion: z.string()
|
|
7031
|
+
}))
|
|
7032
|
+
});
|
|
7033
|
+
|
|
7034
|
+
/**
|
|
7035
|
+
* `home` — `Api["/commits/:commitSha/modules"]["GET"]["res"]`.
|
|
7036
|
+
*
|
|
7037
|
+
* `schema` stays `unknown` here on purpose. The content service stores it
|
|
7038
|
+
* opaquely and cannot vouch for it, so validating it at the transport boundary
|
|
7039
|
+
* would turn "a schema written by a different version of Val" into a failed
|
|
7040
|
+
* REQUEST rather than one module that cannot be shown. It is checked in
|
|
7041
|
+
* `getHistoricalPatchSet`, per module, where a failure degrades that module and
|
|
7042
|
+
* leaves the commit readable.
|
|
7043
|
+
*/
|
|
7044
|
+
const CommitModulesResponse = z.object({
|
|
7045
|
+
commitSha: z.string(),
|
|
7046
|
+
parentCommitSha: z.string(),
|
|
7047
|
+
/**
|
|
7048
|
+
* Whether an `asOf` read covered the whole project.
|
|
7049
|
+
*
|
|
7050
|
+
* Optional so an older content server still parses. False means modules last
|
|
7051
|
+
* edited before history started being recorded are missing from the answer -
|
|
7052
|
+
* which a whole-project revert has to say out loud rather than silently skip.
|
|
7053
|
+
*/
|
|
7054
|
+
complete: z.boolean().optional(),
|
|
7055
|
+
modules: z.array(z.object({
|
|
7056
|
+
moduleFilePath: z.string(),
|
|
7057
|
+
commitSha: z.string(),
|
|
7058
|
+
sourceSha: z.string().nullable(),
|
|
7059
|
+
schemaSha: z.string(),
|
|
7060
|
+
// Validated, because a Source IS just JSON and this side knows that much.
|
|
7061
|
+
source: JSONValue.nullable(),
|
|
7062
|
+
schema: z.unknown(),
|
|
7063
|
+
unavailable: z.boolean()
|
|
7064
|
+
}))
|
|
7065
|
+
});
|
|
7066
|
+
const CommitAffectedFilesResponse = z.object({
|
|
7067
|
+
commitSha: z.string(),
|
|
7068
|
+
files: z.array(z.union([z.object({
|
|
7069
|
+
kind: z.union([z.literal("module-source"), z.literal("json-entry"), z.literal("binary")]),
|
|
7070
|
+
gitPath: z.string(),
|
|
7071
|
+
change: z.union([z.literal("added"), z.literal("modified"), z.literal("deleted")])
|
|
7072
|
+
}), z.object({
|
|
7073
|
+
kind: z.literal("remote-binary"),
|
|
7074
|
+
ref: z.string(),
|
|
7075
|
+
change: z.union([z.literal("added"), z.literal("modified"), z.literal("deleted")])
|
|
7076
|
+
})]))
|
|
7077
|
+
});
|
|
7078
|
+
// #endregion history wire schemas
|
|
7079
|
+
|
|
6666
7080
|
/*
|
|
6667
7081
|
* The shared schema, not a copy of it.
|
|
6668
7082
|
*
|
|
@@ -7682,7 +8096,12 @@ class ValOpsHttp extends ValOps {
|
|
|
7682
8096
|
if (!file) {
|
|
7683
8097
|
return null;
|
|
7684
8098
|
}
|
|
7685
|
-
|
|
8099
|
+
// Plain base64, the same as the `repo` branch of getBinaryFile above.
|
|
8100
|
+
//
|
|
8101
|
+
// `value` used to be a data: URL here and plain base64 there - two
|
|
8102
|
+
// encodings in one field, told apart only by which branch produced them.
|
|
8103
|
+
// The content service answers base64 for both now.
|
|
8104
|
+
return Buffer.from(file.value, "base64");
|
|
7686
8105
|
}
|
|
7687
8106
|
async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, remote) {
|
|
7688
8107
|
const params = new URLSearchParams();
|
|
@@ -7851,6 +8270,19 @@ class ValOpsHttp extends ValOps {
|
|
|
7851
8270
|
patchedSourceFiles: prepared.patchedSourceFiles,
|
|
7852
8271
|
patchedBinaryFilesDescriptors: prepared.patchedBinaryFilesDescriptors,
|
|
7853
8272
|
appliedPatches: prepared.appliedPatches,
|
|
8273
|
+
/*
|
|
8274
|
+
* What each changed module IS after this commit, as DATA, with the
|
|
8275
|
+
* schema it is under.
|
|
8276
|
+
*
|
|
8277
|
+
* The half git cannot give back. Git keeps the `.val.ts`, but that is
|
|
8278
|
+
* code: turning it back into data means parsing it, which is
|
|
8279
|
+
* best-effort and rots across TypeScript, runtime and Val versions -
|
|
8280
|
+
* so a commit that reads today can quietly stop reading later. And
|
|
8281
|
+
* git has no copy at all of the SCHEMA a commit was written under,
|
|
8282
|
+
* which is what showing a module as it was needs once the schema has
|
|
8283
|
+
* moved on.
|
|
8284
|
+
*/
|
|
8285
|
+
modules: prepared.moduleVersions,
|
|
7854
8286
|
commit: this.commitSha,
|
|
7855
8287
|
root: this.root,
|
|
7856
8288
|
filesDirectory,
|
|
@@ -7911,80 +8343,681 @@ class ValOpsHttp extends ValOps {
|
|
|
7911
8343
|
};
|
|
7912
8344
|
}
|
|
7913
8345
|
}
|
|
7914
|
-
}
|
|
7915
8346
|
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
8347
|
+
// #region history
|
|
8348
|
+
|
|
8349
|
+
/**
|
|
8350
|
+
* One GET against the content service, parsed and Result-typed.
|
|
8351
|
+
*
|
|
8352
|
+
* Every history read has the same three failure modes - could not reach the
|
|
8353
|
+
* service, the commit is not there, the answer was not what was expected -
|
|
8354
|
+
* and each of them means something different to a caller deciding whether to
|
|
8355
|
+
* offer a restore. Doing it once here is what keeps that consistent across
|
|
8356
|
+
* the five endpoints.
|
|
8357
|
+
*/
|
|
8358
|
+
async getHistory(path, schema, commitShaForErrors) {
|
|
8359
|
+
let res;
|
|
8360
|
+
try {
|
|
8361
|
+
res = await fetch(`${this.contentUrl}/v1/${this.project}${path}`, {
|
|
8362
|
+
headers: {
|
|
8363
|
+
...this.authHeaders,
|
|
8364
|
+
"Content-Type": "application/json"
|
|
8365
|
+
}
|
|
8366
|
+
});
|
|
8367
|
+
} catch (err) {
|
|
8368
|
+
return result.err({
|
|
8369
|
+
kind: "transport",
|
|
8370
|
+
message: err instanceof Error ? err.message : String(err)
|
|
8371
|
+
});
|
|
8372
|
+
}
|
|
8373
|
+
if (res.status === 404) {
|
|
8374
|
+
return result.err({
|
|
8375
|
+
kind: "commit-not-found",
|
|
8376
|
+
commitSha: commitShaForErrors
|
|
8377
|
+
});
|
|
8378
|
+
}
|
|
8379
|
+
if (!res.ok) {
|
|
8380
|
+
var _res$headers$get4;
|
|
8381
|
+
let message = `${res.status} ${res.statusText}`;
|
|
8382
|
+
if ((_res$headers$get4 = res.headers.get("Content-Type")) !== null && _res$headers$get4 !== void 0 && _res$headers$get4.includes("application/json")) {
|
|
8383
|
+
message = getErrorMessageFromUnknownJson(await res.json(), message);
|
|
7932
8384
|
}
|
|
8385
|
+
// A 5xx from the service reading a record it says it has is a real
|
|
8386
|
+
// failure of that record, not a network problem - keep them apart.
|
|
8387
|
+
return result.err({
|
|
8388
|
+
kind: "archive-unreadable",
|
|
8389
|
+
commitSha: commitShaForErrors,
|
|
8390
|
+
message
|
|
8391
|
+
});
|
|
8392
|
+
}
|
|
8393
|
+
// A 200 is not a promise of JSON: a proxy or gateway in front of the
|
|
8394
|
+
// service answers HTML, and an unguarded `.json()` would reject straight
|
|
8395
|
+
// out of this Result-typed API and 500 the route.
|
|
8396
|
+
let body;
|
|
8397
|
+
try {
|
|
8398
|
+
body = await res.json();
|
|
8399
|
+
} catch (err) {
|
|
8400
|
+
return result.err({
|
|
8401
|
+
kind: "archive-unreadable",
|
|
8402
|
+
commitSha: commitShaForErrors,
|
|
8403
|
+
message: `response was not JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
8404
|
+
});
|
|
8405
|
+
}
|
|
8406
|
+
const parsed = schema.safeParse(body);
|
|
8407
|
+
if (!parsed.success) {
|
|
8408
|
+
return result.err({
|
|
8409
|
+
kind: "archive-unreadable",
|
|
8410
|
+
commitSha: commitShaForErrors,
|
|
8411
|
+
message: `unexpected response shape: ${fromError(parsed.error)}`
|
|
8412
|
+
});
|
|
8413
|
+
}
|
|
8414
|
+
return result.ok(parsed.data);
|
|
8415
|
+
}
|
|
8416
|
+
async listCommits(branch, options) {
|
|
8417
|
+
const params = new URLSearchParams({
|
|
8418
|
+
branch
|
|
7933
8419
|
});
|
|
7934
|
-
if (
|
|
7935
|
-
|
|
7936
|
-
success: false,
|
|
7937
|
-
message: `Project '${projectName}' not found: verify that the name of the project is correct and that you have access to it.`
|
|
7938
|
-
};
|
|
8420
|
+
if ((options === null || options === void 0 ? void 0 : options.limit) !== undefined) {
|
|
8421
|
+
params.set("limit", String(options.limit));
|
|
7939
8422
|
}
|
|
7940
|
-
if (
|
|
7941
|
-
|
|
7942
|
-
success: false,
|
|
7943
|
-
message: `Failed to get project id: ${response.statusText}`
|
|
7944
|
-
};
|
|
8423
|
+
if ((options === null || options === void 0 ? void 0 : options.cursor) !== undefined) {
|
|
8424
|
+
params.set("cursor", options.cursor);
|
|
7945
8425
|
}
|
|
7946
|
-
const
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
return {
|
|
7950
|
-
success: false,
|
|
7951
|
-
message: `Failed to parse settings data: ${parseRes.error.message}`
|
|
7952
|
-
};
|
|
8426
|
+
const res = await this.getHistory(`/commits?${params}`, ListCommitsResponse, branch);
|
|
8427
|
+
if (result.isErr(res)) {
|
|
8428
|
+
return res;
|
|
7953
8429
|
}
|
|
7954
|
-
return {
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
};
|
|
7958
|
-
} catch {
|
|
7959
|
-
return {
|
|
7960
|
-
success: false,
|
|
7961
|
-
message: `Failed to get project id. Check network connection and try again.`
|
|
7962
|
-
};
|
|
8430
|
+
return result.ok({
|
|
8431
|
+
commits: res.value.commits,
|
|
8432
|
+
nextCursor: res.value.nextCursor
|
|
8433
|
+
});
|
|
7963
8434
|
}
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
8435
|
+
async getCommitPatches(commitSha) {
|
|
8436
|
+
// One request. The endpoint returns the commit's own summary alongside its
|
|
8437
|
+
// patches, because it already has the row - this used to page the commit
|
|
8438
|
+
// LISTING until the sha turned up, which cost up to twenty requests to open
|
|
8439
|
+
// an old commit and could not find one on another branch at all.
|
|
8440
|
+
const patchesRes = await this.getHistory(`/commits/${commitSha}/patches`, CommitPatchesResponse, commitSha);
|
|
8441
|
+
if (result.isErr(patchesRes)) {
|
|
8442
|
+
return patchesRes;
|
|
8443
|
+
}
|
|
8444
|
+
return result.ok({
|
|
8445
|
+
commit: {
|
|
8446
|
+
...patchesRes.value.commit,
|
|
8447
|
+
patchCount: patchesRes.value.patches.length
|
|
8448
|
+
},
|
|
8449
|
+
patches: patchesRes.value.patches.map(patch => ({
|
|
8450
|
+
patchId: patch.patchId,
|
|
8451
|
+
moduleFilePath: patch.path,
|
|
8452
|
+
patch: patch.patch,
|
|
8453
|
+
authorId: patch.authorId,
|
|
8454
|
+
createdAt: patch.createdAt,
|
|
8455
|
+
baseSha: patch.baseSha,
|
|
8456
|
+
coreVersion: patch.coreVersion
|
|
8457
|
+
}))
|
|
8458
|
+
});
|
|
8459
|
+
}
|
|
8460
|
+
async getCommitModules(commitSha, options) {
|
|
8461
|
+
const query = new URLSearchParams();
|
|
8462
|
+
if (options !== null && options !== void 0 && options.asOf) {
|
|
8463
|
+
query.set("as_of", "1");
|
|
8464
|
+
}
|
|
8465
|
+
if (options !== null && options !== void 0 && options.moduleFilePath) {
|
|
8466
|
+
query.set("path", options.moduleFilePath);
|
|
8467
|
+
}
|
|
8468
|
+
const search = query.toString();
|
|
8469
|
+
const res = await this.getHistory(`/commits/${commitSha}/modules${search ? `?${search}` : ""}`, CommitModulesResponse, commitSha);
|
|
8470
|
+
if (result.isErr(res)) {
|
|
8471
|
+
return res;
|
|
8472
|
+
}
|
|
8473
|
+
return result.ok({
|
|
8474
|
+
modules: res.value.modules.map(module => ({
|
|
8475
|
+
...module,
|
|
8476
|
+
moduleFilePath: module.moduleFilePath
|
|
8477
|
+
})),
|
|
8478
|
+
// Absent from an older content server, which only ever answered "what
|
|
8479
|
+
// this commit changed" - and that answer is always whole.
|
|
8480
|
+
complete: res.value.complete ?? !(options !== null && options !== void 0 && options.asOf)
|
|
8481
|
+
});
|
|
8482
|
+
}
|
|
8483
|
+
async getCommitAffectedFiles(commitSha) {
|
|
8484
|
+
const res = await this.getHistory(`/commits/${commitSha}/affected-files`, CommitAffectedFilesResponse, commitSha);
|
|
8485
|
+
if (result.isErr(res)) {
|
|
8486
|
+
return res;
|
|
8487
|
+
}
|
|
8488
|
+
return result.ok(res.value.files);
|
|
8489
|
+
}
|
|
8490
|
+
async getFileAtCommit(commitSha, filePath, remote) {
|
|
8491
|
+
const params = new URLSearchParams({
|
|
8492
|
+
path: filePath
|
|
8493
|
+
});
|
|
8494
|
+
if (remote) {
|
|
8495
|
+
params.set("remote", "true");
|
|
8496
|
+
}
|
|
8497
|
+
let res;
|
|
8498
|
+
try {
|
|
8499
|
+
res = await fetch(`${this.contentUrl}/v1/${this.project}/commits/${commitSha}/file?${params}`, {
|
|
8500
|
+
headers: {
|
|
8501
|
+
...this.authHeaders
|
|
8502
|
+
}
|
|
8503
|
+
});
|
|
8504
|
+
} catch (err) {
|
|
8505
|
+
return result.err({
|
|
8506
|
+
kind: "transport",
|
|
8507
|
+
message: err instanceof Error ? err.message : String(err)
|
|
8508
|
+
});
|
|
8509
|
+
}
|
|
8510
|
+
if (!res.ok) {
|
|
8511
|
+
// A missing file is about the FILE, not the commit: the commit may be
|
|
8512
|
+
// perfectly readable and this one blob gone. Saying "commit not found"
|
|
8513
|
+
// here would send a caller looking in the wrong place.
|
|
8514
|
+
return result.err({
|
|
8515
|
+
kind: "file-unavailable",
|
|
8516
|
+
gitPath: filePath,
|
|
8517
|
+
message: `${res.status} ${res.statusText}`
|
|
8518
|
+
});
|
|
8519
|
+
}
|
|
8520
|
+
return result.ok(Buffer.from(await res.arrayBuffer()));
|
|
8521
|
+
}
|
|
8522
|
+
// #endregion history
|
|
8523
|
+
}
|
|
8524
|
+
|
|
8525
|
+
/**
|
|
8526
|
+
* Everything that can go wrong reading, replaying or restoring history.
|
|
8527
|
+
*
|
|
8528
|
+
* There is a lot of it, which is the point of making it one closed union rather
|
|
8529
|
+
* than strings: reading a commit means reaching a content host, finding the
|
|
8530
|
+
* commit, reading what was recorded for each of its modules, and asking whether
|
|
8531
|
+
* this version of Val can make sense of the schema it was stored under. Each of
|
|
8532
|
+
* those fails differently, and a caller deciding what to SHOW needs to know
|
|
8533
|
+
* which.
|
|
8534
|
+
*
|
|
8535
|
+
* Every member has a producer. The union once carried five more - a module
|
|
8536
|
+
* removed from the project, an op that would not replay, a value that no longer
|
|
8537
|
+
* fits today's schema, a field the schema no longer defines, and ops from a
|
|
8538
|
+
* core version known to replay wrongly. All five belonged to reconstructing a
|
|
8539
|
+
* commit by replaying patches against a parsed source; storing the data ended
|
|
8540
|
+
* that, and a member nothing can produce is a state the Studio writes handling
|
|
8541
|
+
* for and never sees.
|
|
8542
|
+
*
|
|
8543
|
+
* ## The rule
|
|
8544
|
+
*
|
|
8545
|
+
* Whole-commit failures are the `err` channel. Per-module and per-patch
|
|
8546
|
+
* failures ride along inside the `ok` payload.
|
|
8547
|
+
*
|
|
8548
|
+
* A commit that cannot be found or read at all has nothing to show. But one
|
|
8549
|
+
* unparseable module out of ten does not make the other nine unreadable, and
|
|
8550
|
+
* collapsing the whole view because of it would hide exactly the information
|
|
8551
|
+
* someone needs to see - that this module is the broken one.
|
|
8552
|
+
*/
|
|
8553
|
+
|
|
8554
|
+
function historyErrorMessage(error) {
|
|
8555
|
+
switch (error.kind) {
|
|
8556
|
+
case "commit-not-found":
|
|
8557
|
+
return `No commit ${error.commitSha} created by Val in this project`;
|
|
8558
|
+
case "archive-unreadable":
|
|
8559
|
+
return `Could not read the stored record of commit ${error.commitSha}: ${error.message}`;
|
|
8560
|
+
case "source-unavailable":
|
|
8561
|
+
return `No stored source for ${error.moduleFilePath} at this commit (it predates history being recorded)`;
|
|
8562
|
+
case "schema-unreadable":
|
|
8563
|
+
return `The schema stored for ${error.moduleFilePath} at this commit is not one this version of Val can read: ${error.message}`;
|
|
8564
|
+
case "file-unavailable":
|
|
8565
|
+
return `Could not read ${error.gitPath} at this commit: ${error.message}`;
|
|
8566
|
+
case "not-supported-in-fs-mode":
|
|
8567
|
+
return "History is only available for projects connected to Val's content service";
|
|
8568
|
+
case "transport":
|
|
8569
|
+
return `Could not reach the content service: ${error.message}`;
|
|
8570
|
+
}
|
|
8571
|
+
}
|
|
8572
|
+
|
|
8573
|
+
/**
|
|
8574
|
+
* Everything the content service knows about one commit, in one step.
|
|
8575
|
+
*
|
|
8576
|
+
* Three endpoints, requested together rather than in sequence: they are
|
|
8577
|
+
* independent, they all resolve from the same stored record, and the round
|
|
8578
|
+
* trips are what a user waits through when opening a commit.
|
|
8579
|
+
*
|
|
8580
|
+
* The commit itself comes from the patches call, which is the one that fails
|
|
8581
|
+
* usefully when the commit does not exist.
|
|
8582
|
+
*/
|
|
8583
|
+
async function fetchCommitRecord(ops, commitSha) {
|
|
8584
|
+
const [patchesRes, modulesRes, filesRes] = await Promise.all([ops.getCommitPatches(commitSha), ops.getCommitModules(commitSha), ops.getCommitAffectedFiles(commitSha)]);
|
|
8585
|
+
if (result.isErr(patchesRes)) {
|
|
8586
|
+
return patchesRes;
|
|
8587
|
+
}
|
|
8588
|
+
if (result.isErr(modulesRes)) {
|
|
8589
|
+
return modulesRes;
|
|
8590
|
+
}
|
|
8591
|
+
if (result.isErr(filesRes)) {
|
|
8592
|
+
return filesRes;
|
|
8593
|
+
}
|
|
8594
|
+
return result.ok({
|
|
8595
|
+
commit: patchesRes.value.commit,
|
|
8596
|
+
patches: patchesRes.value.patches,
|
|
8597
|
+
modules: modulesRes.value.modules,
|
|
8598
|
+
affectedFiles: filesRes.value
|
|
8599
|
+
});
|
|
8600
|
+
}
|
|
8601
|
+
|
|
8602
|
+
/**
|
|
8603
|
+
* The `*.val.json` entries a commit touched, read from git at that commit.
|
|
8604
|
+
*
|
|
8605
|
+
* Not stored in the commit record, unlike the `.val.ts` sources: an entry is a
|
|
8606
|
+
* plain JSON file that git has at every commit, so a second copy could only
|
|
8607
|
+
* drift from the first. A `.val.ts` is different - it is what a restore
|
|
8608
|
+
* replays patches against, and reading it from git would make every look at
|
|
8609
|
+
* history depend on the repository still being there.
|
|
8610
|
+
*
|
|
8611
|
+
* Reads at the commit itself rather than its parent: an entry's content AT a
|
|
8612
|
+
* commit is what that commit produced, which is the "after" side. The "before"
|
|
8613
|
+
* side is the same file at the parent commit, and a caller that wants it asks
|
|
8614
|
+
* for the parent.
|
|
8615
|
+
*/
|
|
8616
|
+
async function resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles) {
|
|
8617
|
+
const entryPaths = affectedFiles.filter(file => file.kind === "json-entry")
|
|
8618
|
+
// A deleted entry has no content at this commit; asking for it would be a
|
|
8619
|
+
// guaranteed 404 reported as a failure, which is noise rather than news.
|
|
8620
|
+
.filter(file => file.change !== "deleted").map(file => file.gitPath);
|
|
8621
|
+
if (entryPaths.length === 0) {
|
|
8622
|
+
return result.ok({
|
|
8623
|
+
entries: {},
|
|
8624
|
+
failures: []
|
|
8625
|
+
});
|
|
8626
|
+
}
|
|
8627
|
+
const entries = {};
|
|
8628
|
+
const failures = [];
|
|
8629
|
+
const fetched = await Promise.all(entryPaths.map(async gitPath => ({
|
|
8630
|
+
gitPath,
|
|
8631
|
+
res: await ops.getFileAtCommit(commitSha, gitPath, false)
|
|
8632
|
+
})));
|
|
8633
|
+
for (const {
|
|
8634
|
+
gitPath,
|
|
8635
|
+
res
|
|
8636
|
+
} of fetched) {
|
|
8637
|
+
if (result.isErr(res)) {
|
|
8638
|
+
failures.push({
|
|
8639
|
+
kind: "file-unavailable",
|
|
8640
|
+
gitPath,
|
|
8641
|
+
message: res.error.kind === "file-unavailable" ? res.error.message : `could not read entry: ${res.error.kind}`
|
|
8642
|
+
});
|
|
8643
|
+
continue;
|
|
8644
|
+
}
|
|
8645
|
+
try {
|
|
8646
|
+
entries[gitPath] = JSON.parse(res.value.toString("utf-8"));
|
|
8647
|
+
} catch (err) {
|
|
8648
|
+
failures.push({
|
|
8649
|
+
kind: "file-unavailable",
|
|
8650
|
+
gitPath,
|
|
8651
|
+
message: `not valid JSON at this commit: ${err instanceof Error ? err.message : String(err)}`
|
|
8652
|
+
});
|
|
8653
|
+
}
|
|
8654
|
+
}
|
|
8655
|
+
return result.ok({
|
|
8656
|
+
entries,
|
|
8657
|
+
failures
|
|
8658
|
+
});
|
|
8659
|
+
}
|
|
8660
|
+
|
|
8661
|
+
/**
|
|
8662
|
+
* Turn a commit's binary files into descriptors, WITHOUT fetching any of them.
|
|
8663
|
+
*
|
|
8664
|
+
* Pure, and that is the whole design. Showing that a commit changed six images
|
|
8665
|
+
* needs six rows, not six downloads; only an `<img>` that actually mounts pays
|
|
8666
|
+
* for its bytes. `url` points at the app's own history file route, which is
|
|
8667
|
+
* immutable for a given commit, so the browser's HTTP cache handles flipping
|
|
8668
|
+
* between commits with no JS cache at all.
|
|
8669
|
+
*/
|
|
8670
|
+
function describeBinaryFilesAtCommit(commitSha, affectedFiles, /** Where the app serves `/api/val` from, e.g. "/api/val". */
|
|
8671
|
+
apiBasePath) {
|
|
8672
|
+
const refs = [];
|
|
8673
|
+
for (const file of affectedFiles) {
|
|
8674
|
+
if (file.kind === "remote-binary") {
|
|
8675
|
+
refs.push({
|
|
8676
|
+
gitPath: file.ref,
|
|
8677
|
+
change: file.change,
|
|
8678
|
+
remote: true,
|
|
8679
|
+
url: historyFileUrl(apiBasePath, commitSha, file.ref, true)
|
|
8680
|
+
});
|
|
8681
|
+
continue;
|
|
8682
|
+
}
|
|
8683
|
+
if (file.kind !== "binary") {
|
|
8684
|
+
continue;
|
|
8685
|
+
}
|
|
8686
|
+
refs.push({
|
|
8687
|
+
gitPath: file.gitPath,
|
|
8688
|
+
change: file.change,
|
|
8689
|
+
remote: false,
|
|
8690
|
+
url: historyFileUrl(apiBasePath, commitSha, file.gitPath, false)
|
|
8691
|
+
});
|
|
8692
|
+
}
|
|
8693
|
+
return refs;
|
|
8694
|
+
}
|
|
8695
|
+
function historyFileUrl(apiBasePath, commitSha, filePath, remote) {
|
|
8696
|
+
const params = new URLSearchParams({
|
|
8697
|
+
commit_sha: commitSha,
|
|
8698
|
+
path: filePath
|
|
8699
|
+
});
|
|
8700
|
+
if (remote) {
|
|
8701
|
+
params.set("remote", "true");
|
|
8702
|
+
}
|
|
8703
|
+
return `${apiBasePath}/history/files?${params.toString()}`;
|
|
8704
|
+
}
|
|
8705
|
+
|
|
8706
|
+
/**
|
|
8707
|
+
* The paths a commit's ops touched, from the ops themselves.
|
|
8708
|
+
*
|
|
8709
|
+
* The alternative was reconstructing the module's previous state and diffing it
|
|
8710
|
+
* against the new one - which needed the pre-commit `.val.ts`, a static parse
|
|
8711
|
+
* of it, and a replay of every op, three fragile steps to rediscover something
|
|
8712
|
+
* the ops already say outright. An op carries the path it applies to. That is
|
|
8713
|
+
* the answer.
|
|
8714
|
+
*
|
|
8715
|
+
* Deduplicated and returned in first-touched order, because a module edited
|
|
8716
|
+
* eight times in one publish should list a field once, where it first changed.
|
|
8717
|
+
*/
|
|
8718
|
+
function changedPathsOf(moduleFilePath, patches) {
|
|
8719
|
+
const seen = new Set();
|
|
8720
|
+
const paths = [];
|
|
8721
|
+
for (const {
|
|
8722
|
+
patch
|
|
8723
|
+
} of patches) {
|
|
8724
|
+
for (const op of patch) {
|
|
8725
|
+
// A `file` op names a file, not a place in the source; the `replace` that
|
|
8726
|
+
// points the field at it is in the same patch and is the real change.
|
|
8727
|
+
if (op.op === "file") {
|
|
8728
|
+
continue;
|
|
8729
|
+
}
|
|
8730
|
+
/*
|
|
8731
|
+
* The canonical patch-path-to-source-path conversion, not a hand-rolled
|
|
8732
|
+
* join.
|
|
8733
|
+
*
|
|
8734
|
+
* `createValPathOfItem` JSON-quotes the ONE key it is handed, so joining
|
|
8735
|
+
* the segments with "." first produced `?p="teddy.name"` — a single
|
|
8736
|
+
* segment with a dot in its name — rather than `?p="teddy"."name"`. Only
|
|
8737
|
+
* top-level fields came out right, a record key containing a dot was
|
|
8738
|
+
* split in the other direction, and an op at the module root became
|
|
8739
|
+
* `?p=""`.
|
|
8740
|
+
*
|
|
8741
|
+
* `patchPathToModulePath` is what the rest of the Studio produces and
|
|
8742
|
+
* parses, integer segments left unquoted (`"items".2."label"`).
|
|
8743
|
+
*/
|
|
8744
|
+
const sourcePath = op.path.length === 0 ? moduleFilePath : Internal.joinModuleFilePathAndModulePath(moduleFilePath, Internal.patchPathToModulePath(op.path));
|
|
8745
|
+
if (!seen.has(sourcePath)) {
|
|
8746
|
+
seen.add(sourcePath);
|
|
8747
|
+
paths.push(sourcePath);
|
|
8748
|
+
}
|
|
8749
|
+
}
|
|
8750
|
+
}
|
|
8751
|
+
return paths;
|
|
8752
|
+
}
|
|
8753
|
+
|
|
8754
|
+
/**
|
|
8755
|
+
* Reconstruct one commit: how each module looked before it, and after it.
|
|
8756
|
+
*
|
|
8757
|
+
* Deliberately says NOTHING about the current source or the current schema. For
|
|
8758
|
+
* a given commit sha this can never change, which is what lets its result be
|
|
8759
|
+
* cached forever and what makes flipping between commits cheap. The comparison
|
|
8760
|
+
* against today is `compareWithCurrent`, and it is deliberately the cheap half.
|
|
8761
|
+
*
|
|
8762
|
+
* Nothing is parsed and nothing is replayed: the module's data was recorded at
|
|
8763
|
+
* the commit, so reading it back is a read. What IS done here is validating the
|
|
8764
|
+
* stored schema against this version of Val, because that is the one thing the
|
|
8765
|
+
* content service could not check for us.
|
|
8766
|
+
*
|
|
8767
|
+
* Failures are collected per module, not thrown - one module this Val cannot
|
|
8768
|
+
* read leaves the other nine readable, and knowing WHICH one is the thing
|
|
8769
|
+
* someone opening history actually needs. Only a commit that cannot be read at
|
|
8770
|
+
* all is an `err`.
|
|
8771
|
+
*/
|
|
8772
|
+
async function getHistoricalPatchSet(ops, commitSha, options) {
|
|
8773
|
+
const recordRes = await fetchCommitRecord(ops, commitSha);
|
|
8774
|
+
if (result.isErr(recordRes)) {
|
|
8775
|
+
return recordRes;
|
|
8776
|
+
}
|
|
8777
|
+
const {
|
|
8778
|
+
commit,
|
|
8779
|
+
patches,
|
|
8780
|
+
modules: stored,
|
|
8781
|
+
affectedFiles
|
|
8782
|
+
} = recordRes.value;
|
|
8783
|
+
const warnings = [];
|
|
8784
|
+
|
|
8785
|
+
// Group the commit's patches by module. Not to replay them - the stored
|
|
8786
|
+
// source already IS the result - but because the ops name the paths this
|
|
8787
|
+
// commit touched, which is what the view highlights.
|
|
8788
|
+
const patchesByModule = new Map();
|
|
8789
|
+
for (const patch of patches) {
|
|
8790
|
+
const existing = patchesByModule.get(patch.moduleFilePath) ?? [];
|
|
8791
|
+
existing.push({
|
|
8792
|
+
patchId: patch.patchId,
|
|
8793
|
+
coreVersion: patch.coreVersion,
|
|
8794
|
+
// The wire type is `unknown` because the content service does not know
|
|
8795
|
+
// Val's patch type; it has been validated on the way out of the archive.
|
|
8796
|
+
patch: patch.patch
|
|
8797
|
+
});
|
|
8798
|
+
patchesByModule.set(patch.moduleFilePath, existing);
|
|
8799
|
+
}
|
|
8800
|
+
const storedByPath = new Map(stored.map(module => [module.moduleFilePath, module]));
|
|
8801
|
+
|
|
8802
|
+
// Every module this commit touched: one it patched, and one we hold a stored
|
|
8803
|
+
// version of. Usually the same set - but a commit made by a Val too old to
|
|
8804
|
+
// send its modules has the first and not the second, and that has to read as
|
|
8805
|
+
// `source-unavailable` rather than as "no modules changed".
|
|
8806
|
+
const touched = new Set([...patchesByModule.keys(), ...storedByPath.keys()]);
|
|
8807
|
+
const modules = {};
|
|
8808
|
+
for (const moduleFilePath of touched) {
|
|
8809
|
+
const failures = [];
|
|
8810
|
+
const modulePatches = patchesByModule.get(moduleFilePath) ?? [];
|
|
8811
|
+
const patchIds = modulePatches.map(patch => patch.patchId);
|
|
8812
|
+
const changedPaths = changedPathsOf(moduleFilePath, modulePatches);
|
|
8813
|
+
const version = storedByPath.get(moduleFilePath);
|
|
8814
|
+
if (version === undefined || version.unavailable) {
|
|
8815
|
+
failures.push({
|
|
8816
|
+
kind: "source-unavailable",
|
|
8817
|
+
moduleFilePath
|
|
8818
|
+
});
|
|
8819
|
+
modules[moduleFilePath] = {
|
|
8820
|
+
source: null,
|
|
8821
|
+
schema: null,
|
|
8822
|
+
patchIds,
|
|
8823
|
+
changedPaths,
|
|
8824
|
+
failures
|
|
8825
|
+
};
|
|
8826
|
+
continue;
|
|
8827
|
+
}
|
|
8828
|
+
|
|
8829
|
+
/*
|
|
8830
|
+
* The schema is validated here, and nowhere earlier.
|
|
8831
|
+
*
|
|
8832
|
+
* It was stored as written and served back opaquely, so this is the first
|
|
8833
|
+
* place that knows what a schema is supposed to look like. A failure is
|
|
8834
|
+
* NOT damage and not anyone's mistake - Val's schema format is allowed to
|
|
8835
|
+
* move, and an older project opened in a newer Val (or the reverse) lands
|
|
8836
|
+
* exactly here. The module reads as one this version cannot show, and the
|
|
8837
|
+
* rest of the commit is unaffected.
|
|
8838
|
+
*/
|
|
8839
|
+
const schemaRes = SerializedSchema.safeParse(version.schema);
|
|
8840
|
+
if (!schemaRes.success) {
|
|
8841
|
+
failures.push({
|
|
8842
|
+
kind: "schema-unreadable",
|
|
8843
|
+
moduleFilePath,
|
|
8844
|
+
message: fromError(schemaRes.error).toString()
|
|
8845
|
+
});
|
|
8846
|
+
modules[moduleFilePath] = {
|
|
8847
|
+
source: version.source,
|
|
8848
|
+
schema: null,
|
|
8849
|
+
patchIds,
|
|
8850
|
+
changedPaths,
|
|
8851
|
+
failures
|
|
8852
|
+
};
|
|
8853
|
+
continue;
|
|
8854
|
+
}
|
|
8855
|
+
modules[moduleFilePath] = {
|
|
8856
|
+
source: version.source,
|
|
8857
|
+
schema: schemaRes.data,
|
|
8858
|
+
patchIds,
|
|
8859
|
+
changedPaths,
|
|
8860
|
+
failures
|
|
8861
|
+
};
|
|
8862
|
+
}
|
|
8863
|
+
const entriesRes = await resolveJsonEntriesAtCommit(ops, commitSha, affectedFiles);
|
|
8864
|
+
let jsonEntries = {};
|
|
8865
|
+
if (result.isErr(entriesRes)) {
|
|
8866
|
+
// Entry contents are supporting detail, not the commit. Losing them should
|
|
8867
|
+
// narrow what can be shown, not hide the commit entirely.
|
|
8868
|
+
warnings.push(entriesRes.error);
|
|
8869
|
+
} else {
|
|
8870
|
+
jsonEntries = entriesRes.value.entries;
|
|
8871
|
+
warnings.push(...entriesRes.value.failures);
|
|
8872
|
+
}
|
|
8873
|
+
return result.ok({
|
|
8874
|
+
commit,
|
|
8875
|
+
modules,
|
|
8876
|
+
patches,
|
|
8877
|
+
jsonEntries,
|
|
8878
|
+
binaryFiles: describeBinaryFilesAtCommit(commitSha, affectedFiles, (options === null || options === void 0 ? void 0 : options.apiBasePath) ?? "/api/val"),
|
|
8879
|
+
warnings
|
|
8880
|
+
});
|
|
8881
|
+
}
|
|
8882
|
+
|
|
8883
|
+
/**
|
|
8884
|
+
* One module, as of a commit — including a commit that never touched it.
|
|
8885
|
+
*
|
|
8886
|
+
* `getHistoricalPatchSet` answers "what did this commit change", which is what
|
|
8887
|
+
* a commit IS. This answers "how did this module look at that point", which is
|
|
8888
|
+
* what someone comparing two panes is asking as soon as they navigate off the
|
|
8889
|
+
* changed set — and without it the history pane can only show a handful of
|
|
8890
|
+
* modules per commit.
|
|
8891
|
+
*
|
|
8892
|
+
* `null` means history has no record at or before that commit: the module was
|
|
8893
|
+
* last edited before recording started, or never. That is deliberately NOT the
|
|
8894
|
+
* same as a module the commit deleted, which is a recorded fact with a source
|
|
8895
|
+
* of `null`, and not the same as an error.
|
|
8896
|
+
*/
|
|
8897
|
+
async function getModuleAtCommit(ops, commitSha, moduleFilePath) {
|
|
8898
|
+
const res = await ops.getCommitModules(commitSha, {
|
|
8899
|
+
asOf: true,
|
|
8900
|
+
moduleFilePath
|
|
8901
|
+
});
|
|
8902
|
+
if (result.isErr(res)) {
|
|
8903
|
+
return res;
|
|
8904
|
+
}
|
|
8905
|
+
const version = res.value.modules.find(module => module.moduleFilePath === moduleFilePath);
|
|
8906
|
+
if (version === undefined) {
|
|
8907
|
+
return result.ok(null);
|
|
8908
|
+
}
|
|
8909
|
+
if (version.unavailable) {
|
|
8910
|
+
return result.ok({
|
|
8911
|
+
source: null,
|
|
8912
|
+
schema: null,
|
|
8913
|
+
patchIds: [],
|
|
8914
|
+
changedPaths: [],
|
|
8915
|
+
failures: [{
|
|
8916
|
+
kind: "source-unavailable",
|
|
8917
|
+
moduleFilePath
|
|
8918
|
+
}]
|
|
8919
|
+
});
|
|
8920
|
+
}
|
|
8921
|
+
// Validated here, not at the transport boundary, for the same reason as in
|
|
8922
|
+
// `getHistoricalPatchSet`: a schema this Val cannot read is one module it
|
|
8923
|
+
// cannot show, not a failed request.
|
|
8924
|
+
const schemaRes = SerializedSchema.safeParse(version.schema);
|
|
8925
|
+
if (!schemaRes.success) {
|
|
8926
|
+
return result.ok({
|
|
8927
|
+
source: version.source,
|
|
8928
|
+
schema: null,
|
|
8929
|
+
patchIds: [],
|
|
8930
|
+
changedPaths: [],
|
|
8931
|
+
failures: [{
|
|
8932
|
+
kind: "schema-unreadable",
|
|
8933
|
+
moduleFilePath,
|
|
8934
|
+
message: fromError(schemaRes.error).toString()
|
|
8935
|
+
}]
|
|
8936
|
+
});
|
|
8937
|
+
}
|
|
8938
|
+
return result.ok({
|
|
8939
|
+
source: version.source,
|
|
8940
|
+
schema: schemaRes.data,
|
|
8941
|
+
// Empty, and correct: this module may well have been changed by an EARLIER
|
|
8942
|
+
// commit than the one asked about, and those are that commit's patches.
|
|
8943
|
+
patchIds: [],
|
|
8944
|
+
changedPaths: [],
|
|
8945
|
+
failures: []
|
|
8946
|
+
});
|
|
8947
|
+
}
|
|
8948
|
+
|
|
8949
|
+
const host = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
|
|
8950
|
+
const SettingsSchema = z.object({
|
|
8951
|
+
publicProjectId: z.string(),
|
|
8952
|
+
remoteFileBuckets: z.array(z.object({
|
|
8953
|
+
bucket: z.string()
|
|
8954
|
+
}))
|
|
8955
|
+
});
|
|
8956
|
+
async function getSettings(projectName, auth) {
|
|
8957
|
+
try {
|
|
8958
|
+
const response = await fetch(`${host}/v1/${projectName}/settings`, {
|
|
8959
|
+
headers: "pat" in auth ? {
|
|
8960
|
+
"x-val-pat": auth.pat,
|
|
8961
|
+
"Content-Type": "application/json"
|
|
8962
|
+
} : {
|
|
8963
|
+
Authorization: `Bearer ${auth.apiKey}`,
|
|
8964
|
+
"Content-Type": "application/json"
|
|
8965
|
+
}
|
|
8966
|
+
});
|
|
8967
|
+
if (response.status === 404) {
|
|
8968
|
+
return {
|
|
8969
|
+
success: false,
|
|
8970
|
+
message: `Project '${projectName}' not found: verify that the name of the project is correct and that you have access to it.`
|
|
8971
|
+
};
|
|
8972
|
+
}
|
|
8973
|
+
if (response.status !== 200) {
|
|
8974
|
+
return {
|
|
8975
|
+
success: false,
|
|
8976
|
+
message: `Failed to get project id: ${response.statusText}`
|
|
8977
|
+
};
|
|
8978
|
+
}
|
|
8979
|
+
const json = await response.json();
|
|
8980
|
+
const parseRes = SettingsSchema.safeParse(json);
|
|
8981
|
+
if (!parseRes.success) {
|
|
8982
|
+
return {
|
|
8983
|
+
success: false,
|
|
8984
|
+
message: `Failed to parse settings data: ${parseRes.error.message}`
|
|
8985
|
+
};
|
|
8986
|
+
}
|
|
8987
|
+
return {
|
|
8988
|
+
success: true,
|
|
8989
|
+
data: parseRes.data
|
|
8990
|
+
};
|
|
8991
|
+
} catch {
|
|
8992
|
+
return {
|
|
8993
|
+
success: false,
|
|
8994
|
+
message: `Failed to get project id. Check network connection and try again.`
|
|
8995
|
+
};
|
|
8996
|
+
}
|
|
8997
|
+
}
|
|
8998
|
+
|
|
8999
|
+
/**
|
|
9000
|
+
* Resolving how Val is configured, and building the data layer from it.
|
|
9001
|
+
*
|
|
9002
|
+
* Both live here rather than inside `createValApiRouter` because the MCP tool
|
|
9003
|
+
* registry needs exactly the same answers: which mode we are in, which
|
|
9004
|
+
* credential to use, and which `ValOps` implementation that implies. Two copies
|
|
9005
|
+
* of this would drift, and the failure would be quiet — a registry that decides
|
|
9006
|
+
* it is in fs mode while the Studio decides it is in proxy mode reads different
|
|
9007
|
+
* content from the same project.
|
|
9008
|
+
*
|
|
9009
|
+
* The type-only import from `./ValRouter` is deliberate: it keeps `ValApiOptions`
|
|
9010
|
+
* where its documentation lives without creating a runtime cycle.
|
|
9011
|
+
*
|
|
9012
|
+
* The credential-bearing URL check at the bottom of this file lives here for the
|
|
9013
|
+
* same reason: it is a check on the URLs `initHandlerOptions` resolves, and its
|
|
9014
|
+
* only caller is that function. Leaving it in `./ValRouter` would have meant
|
|
9015
|
+
* importing it back from there, which is the runtime cycle the paragraph above
|
|
9016
|
+
* exists to avoid.
|
|
9017
|
+
*/
|
|
9018
|
+
|
|
9019
|
+
const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
|
|
9020
|
+
|
|
7988
9021
|
/**
|
|
7989
9022
|
* Resolve options plus environment into a concrete {@link ValServerConfig}.
|
|
7990
9023
|
*
|
|
@@ -8068,31 +9101,39 @@ async function initHandlerOptions(route, opts, config) {
|
|
|
8068
9101
|
/**
|
|
8069
9102
|
* Build the data layer a {@link ValServerConfig} calls for.
|
|
8070
9103
|
*
|
|
8071
|
-
*
|
|
8072
|
-
*
|
|
8073
|
-
*
|
|
8074
|
-
*
|
|
9104
|
+
* The http backend always sees the app's own API key. That is what the Studio
|
|
9105
|
+
* wants — there the app has already verified a session cookie and is acting on
|
|
9106
|
+
* the user's behalf under its own authority — and it is now the only shape:
|
|
9107
|
+
* every caller that reaches here has been authenticated by the app itself, so
|
|
9108
|
+
* there is no request left on which the app is a pipe rather than an authority.
|
|
8075
9109
|
*
|
|
8076
|
-
*
|
|
8077
|
-
* user's personal access token
|
|
8078
|
-
* what the caller
|
|
8079
|
-
*
|
|
8080
|
-
*
|
|
8081
|
-
*
|
|
8082
|
-
*
|
|
9110
|
+
* This took a parameter for the other case: a caller acting for a user it had
|
|
9111
|
+
* *not* authenticated passed that user's personal access token, and the backend
|
|
9112
|
+
* decided what the caller could do. `ValOpsHttp` still accepts such a token —
|
|
9113
|
+
* the CLI's `debug` command uses the developer's own from `val login` — but no
|
|
9114
|
+
* server request builds one any more, because a request the app cannot
|
|
9115
|
+
* authenticate is now refused instead of relayed.
|
|
9116
|
+
*
|
|
9117
|
+
* What has not changed is why the API key must never stand in for a credential
|
|
9118
|
+
* that was merely *absent*: it works, and it works for every project the key
|
|
9119
|
+
* can reach, including the ones the caller cannot. Callers are refused for a
|
|
9120
|
+
* missing credential well before this point.
|
|
8083
9121
|
*/
|
|
8084
|
-
function createValOps(valModules, options
|
|
9122
|
+
function createValOps(valModules, options) {
|
|
8085
9123
|
if (options.mode === "fs") {
|
|
8086
9124
|
// No credential in fs mode: this reads and writes the developer's own
|
|
8087
|
-
// working tree, and there is no backend to authenticate to. A
|
|
8088
|
-
//
|
|
9125
|
+
// working tree, and there is no backend to authenticate to. A credential
|
|
9126
|
+
// that arrives for such a project is not ignored quietly — the caller is
|
|
9127
|
+
// told, by `createValTools` when the project is configured for oauth and by
|
|
9128
|
+
// `initValMcp` when it is not, since with no issuer there is no verified
|
|
9129
|
+
// credential left for the registry to see.
|
|
8089
9130
|
return new ValOpsFS(options.valContentUrl, options.cwd, valModules, {
|
|
8090
9131
|
formatter: options.formatter,
|
|
8091
9132
|
config: options.config
|
|
8092
9133
|
});
|
|
8093
9134
|
}
|
|
8094
9135
|
if (options.mode === "http") {
|
|
8095
|
-
return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch,
|
|
9136
|
+
return new ValOpsHttp(options.valContentUrl, options.project, options.commit, options.branch, {
|
|
8096
9137
|
apiKey: options.apiKey
|
|
8097
9138
|
}, valModules, {
|
|
8098
9139
|
formatter: options.formatter,
|
|
@@ -8714,90 +9755,6 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
8714
9755
|
};
|
|
8715
9756
|
}
|
|
8716
9757
|
},
|
|
8717
|
-
"/session": {
|
|
8718
|
-
GET: async req => {
|
|
8719
|
-
const cookies = req.cookies;
|
|
8720
|
-
if (serverOps instanceof ValOpsFS) {
|
|
8721
|
-
return {
|
|
8722
|
-
status: 200,
|
|
8723
|
-
json: {
|
|
8724
|
-
mode: "local",
|
|
8725
|
-
enabled: await callbacks.isEnabled()
|
|
8726
|
-
}
|
|
8727
|
-
};
|
|
8728
|
-
}
|
|
8729
|
-
if (!options.project) {
|
|
8730
|
-
return {
|
|
8731
|
-
status: 500,
|
|
8732
|
-
json: {
|
|
8733
|
-
message: "Project is not set"
|
|
8734
|
-
}
|
|
8735
|
-
};
|
|
8736
|
-
}
|
|
8737
|
-
if (!options.valSecret) {
|
|
8738
|
-
return {
|
|
8739
|
-
status: 500,
|
|
8740
|
-
json: {
|
|
8741
|
-
message: "Secret is not set"
|
|
8742
|
-
}
|
|
8743
|
-
};
|
|
8744
|
-
}
|
|
8745
|
-
return withAuth(options.valSecret, cookies, "session", async data => {
|
|
8746
|
-
if (!options.valBuildUrl) {
|
|
8747
|
-
return {
|
|
8748
|
-
status: 500,
|
|
8749
|
-
json: {
|
|
8750
|
-
message: "Val is not correctly setup. Build url is missing"
|
|
8751
|
-
}
|
|
8752
|
-
};
|
|
8753
|
-
}
|
|
8754
|
-
const url = new URL(`/api/val/${options.project}/auth/session`, options.valBuildUrl);
|
|
8755
|
-
const fetchRes = await fetch(url, {
|
|
8756
|
-
headers: getAuthHeaders(data.token, "application/json")
|
|
8757
|
-
});
|
|
8758
|
-
if (fetchRes.status === 200) {
|
|
8759
|
-
const json = z.object({
|
|
8760
|
-
member_role: z.union([z.literal("owner"), z.literal("developer"), z.literal("editor")]).optional(),
|
|
8761
|
-
id: z.string(),
|
|
8762
|
-
full_name: z.string().optional(),
|
|
8763
|
-
username: z.string().optional(),
|
|
8764
|
-
avatar_url: z.string().optional()
|
|
8765
|
-
}).safeParse(await fetchRes.json());
|
|
8766
|
-
if (json.success) {
|
|
8767
|
-
return {
|
|
8768
|
-
status: fetchRes.status,
|
|
8769
|
-
json: {
|
|
8770
|
-
mode: "proxy",
|
|
8771
|
-
enabled: await callbacks.isEnabled(),
|
|
8772
|
-
...json.data
|
|
8773
|
-
}
|
|
8774
|
-
};
|
|
8775
|
-
} else {
|
|
8776
|
-
const message = getErrorMessageFromUnknownJson(json, "Could not parse session response. Unexpected error (no error message). Status: " + fetchRes.status);
|
|
8777
|
-
return {
|
|
8778
|
-
status: 500,
|
|
8779
|
-
json: {
|
|
8780
|
-
message: message,
|
|
8781
|
-
...json
|
|
8782
|
-
}
|
|
8783
|
-
};
|
|
8784
|
-
}
|
|
8785
|
-
} else {
|
|
8786
|
-
const json = z.object({
|
|
8787
|
-
message: z.string()
|
|
8788
|
-
}).safeParse(await fetchRes.json());
|
|
8789
|
-
const message = getErrorMessageFromUnknownJson(json, "Unknown error");
|
|
8790
|
-
return {
|
|
8791
|
-
status: fetchRes.status,
|
|
8792
|
-
json: {
|
|
8793
|
-
message: message,
|
|
8794
|
-
...json
|
|
8795
|
-
}
|
|
8796
|
-
};
|
|
8797
|
-
}
|
|
8798
|
-
});
|
|
8799
|
-
}
|
|
8800
|
-
},
|
|
8801
9758
|
"/logout": {
|
|
8802
9759
|
GET: async req => {
|
|
8803
9760
|
const query = req.query;
|
|
@@ -11011,11 +11968,13 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
11011
11968
|
}
|
|
11012
11969
|
};
|
|
11013
11970
|
}
|
|
11014
|
-
|
|
11015
|
-
|
|
11016
|
-
|
|
11971
|
+
// Bytes straight through. This used to base64 the buffer,
|
|
11972
|
+
// wrap it in a data: URL, and hand that to a method whose first
|
|
11973
|
+
// act was to unwrap it again - ceremony to satisfy a convention
|
|
11974
|
+
// that no longer exists.
|
|
11975
|
+
const bytes = Buffer.from(await binaryRes.arrayBuffer());
|
|
11017
11976
|
const type = file.metadata.mimeType.startsWith("image/") ? "image" : "file";
|
|
11018
|
-
const saveRes = await serverOps.
|
|
11977
|
+
const saveRes = await serverOps.saveBinaryFileFromPatch(file.filePath, req.body.parentRef, req.body.patchId, bytes, file.metadata.mimeType, type, file.metadata);
|
|
11019
11978
|
if (saveRes.error) {
|
|
11020
11979
|
return {
|
|
11021
11980
|
status: 500,
|
|
@@ -11143,6 +12102,137 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
11143
12102
|
}
|
|
11144
12103
|
},
|
|
11145
12104
|
//#region files
|
|
12105
|
+
// #region history
|
|
12106
|
+
"/history/commits": {
|
|
12107
|
+
GET: async req => {
|
|
12108
|
+
const auth = getAuth(req.cookies);
|
|
12109
|
+
if (auth.error) {
|
|
12110
|
+
return {
|
|
12111
|
+
status: 401,
|
|
12112
|
+
json: {
|
|
12113
|
+
message: auth.error
|
|
12114
|
+
}
|
|
12115
|
+
};
|
|
12116
|
+
}
|
|
12117
|
+
const res = await serverOps.listCommits(req.query.branch, {
|
|
12118
|
+
limit: req.query.limit,
|
|
12119
|
+
cursor: req.query.cursor
|
|
12120
|
+
});
|
|
12121
|
+
if (result.isErr(res)) {
|
|
12122
|
+
return historyErrorResponse(res.error);
|
|
12123
|
+
}
|
|
12124
|
+
return {
|
|
12125
|
+
status: 200,
|
|
12126
|
+
json: res.value,
|
|
12127
|
+
// The head moves, so a listing is never reusable.
|
|
12128
|
+
headers: {
|
|
12129
|
+
"Cache-Control": "no-store"
|
|
12130
|
+
}
|
|
12131
|
+
};
|
|
12132
|
+
}
|
|
12133
|
+
},
|
|
12134
|
+
"/history/commit": {
|
|
12135
|
+
GET: async req => {
|
|
12136
|
+
const auth = getAuth(req.cookies);
|
|
12137
|
+
if (auth.error) {
|
|
12138
|
+
return {
|
|
12139
|
+
status: 401,
|
|
12140
|
+
json: {
|
|
12141
|
+
message: auth.error
|
|
12142
|
+
}
|
|
12143
|
+
};
|
|
12144
|
+
}
|
|
12145
|
+
const res = await getHistoricalPatchSet(serverOps, req.query.commit_sha);
|
|
12146
|
+
if (result.isErr(res)) {
|
|
12147
|
+
return historyErrorResponse(res.error);
|
|
12148
|
+
}
|
|
12149
|
+
/*
|
|
12150
|
+
* Immutable ONLY when nothing in the answer was transient.
|
|
12151
|
+
*
|
|
12152
|
+
* What a commit recorded cannot change, so a clean answer is reusable
|
|
12153
|
+
* forever - that is what makes comparing many commits cheap. But a
|
|
12154
|
+
* module marked `unavailable` means a blob fetch failed, and a warning
|
|
12155
|
+
* means an entry read against GitHub did; both can succeed on the next
|
|
12156
|
+
* try. Cached for a year, one flaky read would become "nothing was
|
|
12157
|
+
* recorded for this module" for the rest of the session and beyond.
|
|
12158
|
+
*
|
|
12159
|
+
* `private`, not `public`: this route is behind the session cookie, and
|
|
12160
|
+
* a shared cache holding it would hand one project's module sources to
|
|
12161
|
+
* whoever asked next. The browser's own cache is where the reuse was
|
|
12162
|
+
* wanted anyway.
|
|
12163
|
+
*/
|
|
12164
|
+
const settled = res.value.warnings.length === 0 && Object.values(res.value.modules).every(module => module.failures.length === 0);
|
|
12165
|
+
return {
|
|
12166
|
+
status: 200,
|
|
12167
|
+
json: res.value,
|
|
12168
|
+
headers: {
|
|
12169
|
+
"Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
|
|
12170
|
+
}
|
|
12171
|
+
};
|
|
12172
|
+
}
|
|
12173
|
+
},
|
|
12174
|
+
"/history/module": {
|
|
12175
|
+
GET: async req => {
|
|
12176
|
+
const auth = getAuth(req.cookies);
|
|
12177
|
+
if (auth.error) {
|
|
12178
|
+
return {
|
|
12179
|
+
status: 401,
|
|
12180
|
+
json: {
|
|
12181
|
+
message: auth.error
|
|
12182
|
+
}
|
|
12183
|
+
};
|
|
12184
|
+
}
|
|
12185
|
+
const res = await getModuleAtCommit(serverOps, req.query.commit_sha, req.query.module_file_path);
|
|
12186
|
+
if (result.isErr(res)) {
|
|
12187
|
+
return historyErrorResponse(res.error);
|
|
12188
|
+
}
|
|
12189
|
+
// Immutable only when the answer settled — a module reported
|
|
12190
|
+
// unavailable means a blob fetch failed and may succeed next time, and
|
|
12191
|
+
// caching that for a year turns one flaky read into a permanent gap.
|
|
12192
|
+
const settled = res.value === null || res.value.failures.length === 0;
|
|
12193
|
+
return {
|
|
12194
|
+
status: 200,
|
|
12195
|
+
json: {
|
|
12196
|
+
moduleFilePath: req.query.module_file_path,
|
|
12197
|
+
module: res.value
|
|
12198
|
+
},
|
|
12199
|
+
headers: {
|
|
12200
|
+
"Cache-Control": settled ? "private, max-age=31536000, immutable" : "no-store"
|
|
12201
|
+
}
|
|
12202
|
+
};
|
|
12203
|
+
}
|
|
12204
|
+
},
|
|
12205
|
+
"/history/files": {
|
|
12206
|
+
GET: async req => {
|
|
12207
|
+
// No auth, for the same reason /files has none: this is served to an
|
|
12208
|
+
// <img> that the app's own backend may fetch during image
|
|
12209
|
+
// optimisation, with no cookies. What it exposes is a file at a commit
|
|
12210
|
+
// that is already in the repository.
|
|
12211
|
+
const res = await serverOps.getFileAtCommit(req.query.commit_sha, req.query.path, req.query.remote === "true");
|
|
12212
|
+
if (result.isErr(res)) {
|
|
12213
|
+
const response = historyErrorResponse(res.error);
|
|
12214
|
+
// The stream branch of this route's response union has no json, so
|
|
12215
|
+
// narrow to the shapes it does allow.
|
|
12216
|
+
if (response.status === 401 || response.status === 404) {
|
|
12217
|
+
return response;
|
|
12218
|
+
}
|
|
12219
|
+
return {
|
|
12220
|
+
status: 400,
|
|
12221
|
+
json: response.json
|
|
12222
|
+
};
|
|
12223
|
+
}
|
|
12224
|
+
return {
|
|
12225
|
+
status: 200,
|
|
12226
|
+
headers: {
|
|
12227
|
+
"Content-Type": guessMimeTypeFromPath(req.query.path) ?? "application/octet-stream",
|
|
12228
|
+
// A file at a fixed commit cannot change.
|
|
12229
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
12230
|
+
},
|
|
12231
|
+
body: bufferToReadableStream(res.value)
|
|
12232
|
+
};
|
|
12233
|
+
}
|
|
12234
|
+
},
|
|
12235
|
+
// #endregion history
|
|
11146
12236
|
"/files": {
|
|
11147
12237
|
GET: async req => {
|
|
11148
12238
|
const query = req.query;
|
|
@@ -11641,6 +12731,45 @@ const ENABLE_COOKIE_VALUE = {
|
|
|
11641
12731
|
}
|
|
11642
12732
|
};
|
|
11643
12733
|
const chunkSize = 1024 * 1024;
|
|
12734
|
+
|
|
12735
|
+
/**
|
|
12736
|
+
* Turn a HistoryError into the HTTP answer it deserves.
|
|
12737
|
+
*
|
|
12738
|
+
* The `kind` travels in the body alongside the message, because the Studio
|
|
12739
|
+
* decides what to OFFER from it - "cannot restore this field" and "cannot
|
|
12740
|
+
* restore this module at all" are different affordances, and a rendered string
|
|
12741
|
+
* cannot be told apart.
|
|
12742
|
+
*/
|
|
12743
|
+
function historyErrorResponse(error) {
|
|
12744
|
+
const message = historyErrorMessage(error);
|
|
12745
|
+
switch (error.kind) {
|
|
12746
|
+
case "commit-not-found":
|
|
12747
|
+
return {
|
|
12748
|
+
status: 404,
|
|
12749
|
+
json: {
|
|
12750
|
+
message
|
|
12751
|
+
}
|
|
12752
|
+
};
|
|
12753
|
+
case "not-supported-in-fs-mode":
|
|
12754
|
+
// Not an error in the request - this deployment simply has no history
|
|
12755
|
+
// service. 400 rather than 500 so it does not read as a bug.
|
|
12756
|
+
return {
|
|
12757
|
+
status: 400,
|
|
12758
|
+
json: {
|
|
12759
|
+
message,
|
|
12760
|
+
kind: error.kind
|
|
12761
|
+
}
|
|
12762
|
+
};
|
|
12763
|
+
default:
|
|
12764
|
+
return {
|
|
12765
|
+
status: 500,
|
|
12766
|
+
json: {
|
|
12767
|
+
message,
|
|
12768
|
+
kind: error.kind
|
|
12769
|
+
}
|
|
12770
|
+
};
|
|
12771
|
+
}
|
|
12772
|
+
}
|
|
11644
12773
|
function bufferToReadableStream(buffer) {
|
|
11645
12774
|
const stream = new ReadableStream({
|
|
11646
12775
|
start(controller) {
|
|
@@ -12721,21 +13850,22 @@ async function savePatch(deps, moduleFilePath, patch, onInvalid = "reject") {
|
|
|
12721
13850
|
}
|
|
12722
13851
|
|
|
12723
13852
|
/**
|
|
12724
|
-
*
|
|
13853
|
+
* The verified profile, or null when there was nothing to verify.
|
|
12725
13854
|
*
|
|
12726
|
-
*
|
|
12727
|
-
*
|
|
12728
|
-
* checked
|
|
12729
|
-
*
|
|
12730
|
-
*
|
|
13855
|
+
* An author is written only when somebody checked it. On the token path the
|
|
13856
|
+
* host verified a signature over a key it does not hold, so the profile is
|
|
13857
|
+
* checked rather than claimed, and the backend has no token of its own to
|
|
13858
|
+
* attribute from — the call reaches it under the app's API key. If this
|
|
13859
|
+
* stayed null there, every edit made through a signed-in editor's own session
|
|
13860
|
+
* would land with no author at all, which is worse than useless on a CMS
|
|
13861
|
+
* whose review screen is organised by who changed what.
|
|
12731
13862
|
*
|
|
12732
|
-
*
|
|
12733
|
-
*
|
|
12734
|
-
*
|
|
12735
|
-
*
|
|
12736
|
-
*
|
|
12737
|
-
*
|
|
12738
|
-
* review screen is organised by who changed what.
|
|
13863
|
+
* Null is what local filesystem mode gets, where there is no credential to
|
|
13864
|
+
* resolve, exactly as the Studio does locally. It is also what the removed
|
|
13865
|
+
* personal-access-token path got, and for a reason worth keeping in view: an
|
|
13866
|
+
* id derived from a credential the app cannot resolve is an unverified claim
|
|
13867
|
+
* dressed up as a checked one. Should another unverified credential ever
|
|
13868
|
+
* reach here, null remains its only honest author.
|
|
12739
13869
|
*/
|
|
12740
13870
|
const authorId = ((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) === "verified-profile" ? ctx.auth.profileId : null;
|
|
12741
13871
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -12993,14 +14123,17 @@ function rejectFileOps(patch) {
|
|
|
12993
14123
|
*/
|
|
12994
14124
|
|
|
12995
14125
|
/**
|
|
12996
|
-
* How the caller was established, and
|
|
12997
|
-
*
|
|
14126
|
+
* How the caller was established, and there is one acceptable answer: the host
|
|
14127
|
+
* **checked a signature**.
|
|
12998
14128
|
*
|
|
12999
|
-
*
|
|
13000
|
-
* backend unchecked,
|
|
13001
|
-
*
|
|
13002
|
-
*
|
|
13003
|
-
*
|
|
14129
|
+
* A union of one, deliberately. It carried a second variant — a personal access
|
|
14130
|
+
* token relayed to the backend unchecked, on the reasoning that the app cannot
|
|
14131
|
+
* resolve one and the backend can. The reasoning held; the shape did not. A
|
|
14132
|
+
* credential the host cannot check is one it also cannot refuse, so accepting
|
|
14133
|
+
* one made "a deployed endpoint that authenticates nobody" a supported
|
|
14134
|
+
* configuration, and it let a host serve these tools without ever being told
|
|
14135
|
+
* where callers should authorize. The discriminant stays so that adding a
|
|
14136
|
+
* second *verified* kind stays a one-line change at every call site.
|
|
13004
14137
|
*/
|
|
13005
14138
|
|
|
13006
14139
|
/**
|
|
@@ -13035,17 +14168,6 @@ const VAL_SCOPE_READ = "val:read";
|
|
|
13035
14168
|
/** Write access. Needed *in addition* by any tool not marked `readOnlyHint`. */
|
|
13036
14169
|
const VAL_SCOPE_WRITE = "val:write";
|
|
13037
14170
|
|
|
13038
|
-
/**
|
|
13039
|
-
* How many callers' data layers to keep around in proxy mode.
|
|
13040
|
-
*
|
|
13041
|
-
* Each entry holds one `ValOpsHttp`, and each of those caches the project's
|
|
13042
|
-
* evaluated modules once `initSources` has run — so this bounds memory, not just
|
|
13043
|
-
* entry count. Small on purpose: the cost of a miss is re-evaluating the
|
|
13044
|
-
* modules on the next call, which is what happened on *every* call before this
|
|
13045
|
-
* cache existed.
|
|
13046
|
-
*/
|
|
13047
|
-
const MAX_CACHED_OPS = 8;
|
|
13048
|
-
|
|
13049
14171
|
/**
|
|
13050
14172
|
* Val's server-side tool registry.
|
|
13051
14173
|
*
|
|
@@ -13145,17 +14267,25 @@ function createValTools(valModules, options) {
|
|
|
13145
14267
|
* Pick the data layer for a call, which in proxy mode means picking whose
|
|
13146
14268
|
* credential the backend will see.
|
|
13147
14269
|
*
|
|
13148
|
-
* This is the one place authorization is decided, and
|
|
13149
|
-
*
|
|
13150
|
-
*
|
|
13151
|
-
*
|
|
13152
|
-
*
|
|
14270
|
+
* This is the one place authorization is decided, and in proxy mode there is
|
|
14271
|
+
* exactly one credential it will act on: an access token whose signature,
|
|
14272
|
+
* issuer, audience and expiry the host verified against the authorization
|
|
14273
|
+
* server's published key. Anything less is refused here rather than forwarded.
|
|
14274
|
+
*
|
|
14275
|
+
* There used to be a second route — the caller's personal access token, passed
|
|
14276
|
+
* through unread on the reasoning that the backend, not the app, is the
|
|
14277
|
+
* authority on what it may do. That was true, and it was still the wrong shape:
|
|
14278
|
+
* it made an unauthenticated bearer token on a deployed endpoint a supported
|
|
14279
|
+
* configuration, and it meant `initValMcp` had a path where an app served MCP
|
|
14280
|
+
* without ever being told where to authorize. A host that has not verified
|
|
14281
|
+
* anything now gets a refusal that names the missing `oauth` config.
|
|
13153
14282
|
*
|
|
13154
|
-
*
|
|
13155
|
-
*
|
|
13156
|
-
*
|
|
13157
|
-
*
|
|
13158
|
-
*
|
|
14283
|
+
* What has *not* changed is why a verified token does not become the app's own
|
|
14284
|
+
* API key by some other name. The app authenticates to the backend with its own
|
|
14285
|
+
* key here, and who did what travels as the patch's `authorId` — so the
|
|
14286
|
+
* profile has to be one the host checked cryptographically, never one it was
|
|
14287
|
+
* handed. An `authenticate()` that decided a credential's rights inside the app
|
|
14288
|
+
* would make every bug in it full access to every project that key can reach.
|
|
13159
14289
|
*/
|
|
13160
14290
|
function createOpsResolver(valModules, options) {
|
|
13161
14291
|
if (options.mode === "fs") {
|
|
@@ -13169,18 +14299,17 @@ function createOpsResolver(valModules, options) {
|
|
|
13169
14299
|
// and the difference matters, because fs mode writes straight to disk
|
|
13170
14300
|
// with no backend permission check at all.
|
|
13171
14301
|
//
|
|
13172
|
-
//
|
|
13173
|
-
//
|
|
13174
|
-
//
|
|
13175
|
-
//
|
|
13176
|
-
//
|
|
13177
|
-
// difference between a two-minute fix and an afternoon.
|
|
14302
|
+
// A verified access token is not something the caller chose to send: it
|
|
14303
|
+
// only exists because this app advertised an authorization server, so
|
|
14304
|
+
// the developer seeing this did not do anything wrong — a config file
|
|
14305
|
+
// did, and naming it is the difference between a two-minute fix and an
|
|
14306
|
+
// afternoon.
|
|
13178
14307
|
return {
|
|
13179
14308
|
status: "error",
|
|
13180
14309
|
result: {
|
|
13181
14310
|
status: "error",
|
|
13182
14311
|
code: "unsupported",
|
|
13183
|
-
message:
|
|
14312
|
+
message: "This Val project is running in local filesystem mode, so there is nothing to authenticate against, but it is configured with an `oauth` issuer and is therefore asking clients for an access token it cannot use. Remove the `oauth` config (or `VAL_OAUTH_ISSUER` from your local `.env`) for local development."
|
|
13184
14313
|
}
|
|
13185
14314
|
};
|
|
13186
14315
|
}
|
|
@@ -13191,78 +14320,50 @@ function createOpsResolver(valModules, options) {
|
|
|
13191
14320
|
};
|
|
13192
14321
|
}
|
|
13193
14322
|
|
|
13194
|
-
// Keyed by a hash of the PAT, so the same caller reuses their own instance and
|
|
13195
|
-
// two callers can never share one. Hashing is not a security boundary — the
|
|
13196
|
-
// instance holds the token regardless — but it keeps credentials out of the
|
|
13197
|
-
// key set, which is the thing that ends up in a heap dump or an error dump.
|
|
13198
|
-
const byPatHash = new Map();
|
|
13199
14323
|
/**
|
|
13200
|
-
* One instance for every verified caller, and
|
|
13201
|
-
*
|
|
13202
|
-
*
|
|
13203
|
-
*
|
|
14324
|
+
* One instance for every verified caller, and that is correct rather than a
|
|
14325
|
+
* shortcut: this instance authenticates with the app's own API key, so there
|
|
14326
|
+
* is nothing per-caller in it to keep apart. Who did what travels as the
|
|
14327
|
+
* patch's `authorId` instead — see `writePath`.
|
|
14328
|
+
*
|
|
14329
|
+
* One instance is also all proxy mode keeps now. It could not share while a
|
|
14330
|
+
* personal access token reached this function: each token needed its own
|
|
14331
|
+
* `ValOpsHttp` to hold it, each of those cached the project's evaluated
|
|
14332
|
+
* modules, and the bounded cache that kept that memory in check turned an
|
|
14333
|
+
* eviction into a re-evaluation of every module on the next call.
|
|
13204
14334
|
*/
|
|
13205
14335
|
let sharedOps = null;
|
|
13206
14336
|
return ctx => {
|
|
13207
|
-
|
|
14337
|
+
var _ctx$auth;
|
|
14338
|
+
if (((_ctx$auth = ctx.auth) === null || _ctx$auth === void 0 ? void 0 : _ctx$auth.type) !== "verified-profile") {
|
|
13208
14339
|
return {
|
|
13209
14340
|
status: "error",
|
|
13210
14341
|
result: {
|
|
13211
14342
|
status: "error",
|
|
13212
14343
|
code: "forbidden",
|
|
13213
|
-
message: "This Val project talks to the Val content backend, so every call needs
|
|
14344
|
+
message: "This Val project talks to the Val content backend, so every call needs an access token from the Val authorization server. If this endpoint is not asking clients to authorize, it has no `oauth` config — give `initValMcp` one, or run the project in local filesystem mode for development."
|
|
13214
14345
|
}
|
|
13215
14346
|
};
|
|
13216
14347
|
}
|
|
13217
|
-
if (
|
|
13218
|
-
|
|
13219
|
-
|
|
13220
|
-
|
|
13221
|
-
// alternative to refusing is building ops with no credential at all.
|
|
13222
|
-
return {
|
|
13223
|
-
status: "error",
|
|
13224
|
-
result: {
|
|
13225
|
-
status: "error",
|
|
13226
|
-
code: "forbidden",
|
|
13227
|
-
message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
|
|
13228
|
-
}
|
|
13229
|
-
};
|
|
13230
|
-
}
|
|
13231
|
-
if (!sharedOps) {
|
|
13232
|
-
sharedOps = createValOps(valModules, options);
|
|
13233
|
-
}
|
|
13234
|
-
return {
|
|
13235
|
-
status: "ok",
|
|
13236
|
-
ops: sharedOps
|
|
13237
|
-
};
|
|
13238
|
-
}
|
|
13239
|
-
const key = createHash("sha256").update(ctx.auth.pat).digest("hex");
|
|
13240
|
-
const cached = byPatHash.get(key);
|
|
13241
|
-
if (cached) {
|
|
13242
|
-
// Re-inserted so eviction drops the least recently used rather than the
|
|
13243
|
-
// oldest — a long-running caller should not be evicted by a burst of
|
|
13244
|
-
// one-off ones.
|
|
13245
|
-
byPatHash.delete(key);
|
|
13246
|
-
byPatHash.set(key, cached);
|
|
14348
|
+
if (!options.apiKey) {
|
|
14349
|
+
// Proxy mode is inferred from the api key being present, so this is
|
|
14350
|
+
// unreachable through `initHandlerOptions`. It stays because the
|
|
14351
|
+
// alternative to refusing is building ops with no credential at all.
|
|
13247
14352
|
return {
|
|
13248
|
-
status: "
|
|
13249
|
-
|
|
14353
|
+
status: "error",
|
|
14354
|
+
result: {
|
|
14355
|
+
status: "error",
|
|
14356
|
+
code: "forbidden",
|
|
14357
|
+
message: "This Val project has no API key configured, so a verified access token cannot be exchanged for backend access."
|
|
14358
|
+
}
|
|
13250
14359
|
};
|
|
13251
14360
|
}
|
|
13252
|
-
|
|
13253
|
-
|
|
13254
|
-
});
|
|
13255
|
-
byPatHash.set(key, ops);
|
|
13256
|
-
while (byPatHash.size > MAX_CACHED_OPS) {
|
|
13257
|
-
const oldest = byPatHash.keys().next();
|
|
13258
|
-
if (oldest.done) {
|
|
13259
|
-
break;
|
|
13260
|
-
}
|
|
13261
|
-
byPatHash.delete(oldest.value);
|
|
14361
|
+
if (!sharedOps) {
|
|
14362
|
+
sharedOps = createValOps(valModules, options);
|
|
13262
14363
|
}
|
|
13263
14364
|
return {
|
|
13264
14365
|
status: "ok",
|
|
13265
|
-
ops
|
|
14366
|
+
ops: sharedOps
|
|
13266
14367
|
};
|
|
13267
14368
|
};
|
|
13268
14369
|
}
|
|
@@ -13360,14 +14461,18 @@ function describeZodError(error) {
|
|
|
13360
14461
|
* the safe direction: a tool that forgets the hint is treated as a write and
|
|
13361
14462
|
* demands the wider scope, rather than a write slipping through as a read.
|
|
13362
14463
|
*
|
|
13363
|
-
*
|
|
13364
|
-
*
|
|
13365
|
-
*
|
|
13366
|
-
*
|
|
14464
|
+
* The early return is a call carrying no verified credential, and there is no
|
|
14465
|
+
* scope to check because nothing granted one. In Val's own host that means
|
|
14466
|
+
* local filesystem mode, where a project writing a developer's own working tree
|
|
14467
|
+
* has no wider authority to withhold. A host assembling its own context can
|
|
14468
|
+
* also reach it with an unauthenticated proxy-mode call — refused a few lines
|
|
14469
|
+
* later, by `resolveOps`, for the credential rather than the scope. Every other
|
|
14470
|
+
* caller arrives as a verified profile, carrying the scopes its token was
|
|
14471
|
+
* issued with.
|
|
13367
14472
|
*/
|
|
13368
14473
|
function refuseInsufficientScope(tool, ctx) {
|
|
13369
|
-
var _ctx$
|
|
13370
|
-
if (((_ctx$
|
|
14474
|
+
var _ctx$auth2, _tool$annotations;
|
|
14475
|
+
if (((_ctx$auth2 = ctx.auth) === null || _ctx$auth2 === void 0 ? void 0 : _ctx$auth2.type) !== "verified-profile") {
|
|
13371
14476
|
return null;
|
|
13372
14477
|
}
|
|
13373
14478
|
// Read is needed by every call, including the writes: a tool that changes
|
|
@@ -14896,6 +16001,25 @@ async function handleJsonValuesExtractEntry(ctx) {
|
|
|
14896
16001
|
};
|
|
14897
16002
|
}
|
|
14898
16003
|
|
|
16004
|
+
/**
|
|
16005
|
+
* `external:upload` under `val validate --fix`: refuse, and say what to run.
|
|
16006
|
+
*
|
|
16007
|
+
* Every other fix in this registry rewrites something inside the repository —
|
|
16008
|
+
* reversible, visible in a diff, wrong by at most one commit. This one would
|
|
16009
|
+
* write entries into a live external store: not in a diff, not undone by
|
|
16010
|
+
* `git revert`, and against production indistinguishable from an editor's
|
|
16011
|
+
* publish. So a blanket `--fix` must never apply it.
|
|
16012
|
+
*
|
|
16013
|
+
* `fixableErrorMessage` rather than a plain error, because the error IS fixable
|
|
16014
|
+
* — just not by this command.
|
|
16015
|
+
*/
|
|
16016
|
+
async function handleExternalUpload() {
|
|
16017
|
+
return {
|
|
16018
|
+
success: true,
|
|
16019
|
+
fixableErrorMessage: "This entry is written inline but its record is .external(). " + "Run 'val external upload' to move it into the store — " + "'val validate --fix' will not write to a live store."
|
|
16020
|
+
};
|
|
16021
|
+
}
|
|
16022
|
+
|
|
14899
16023
|
// Fix handler registry. `keyof:check-keys` and `router:check-route` are
|
|
14900
16024
|
// resolved upfront by the shared resolveSchemaSourceFixes — they never reach
|
|
14901
16025
|
// this registry, so they're excluded from the key set.
|
|
@@ -14918,7 +16042,8 @@ const currentFixHandlers = {
|
|
|
14918
16042
|
"files:check-unique-folder": handleUniqueFolderCheck,
|
|
14919
16043
|
"images:check-all-files": handleCheckAllFiles,
|
|
14920
16044
|
"files:check-all-files": handleCheckAllFiles,
|
|
14921
|
-
"jsonValues:extract-entry": handleJsonValuesExtractEntry
|
|
16045
|
+
"jsonValues:extract-entry": handleJsonValuesExtractEntry,
|
|
16046
|
+
"external:upload": handleExternalUpload
|
|
14922
16047
|
};
|
|
14923
16048
|
const deprecatedFixHandlers = {
|
|
14924
16049
|
"image:replace-metadata": handleFileMetadata
|
|
@@ -15567,4 +16692,4 @@ function readCapturedReport(snapshotDir) {
|
|
|
15567
16692
|
return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
|
|
15568
16693
|
}
|
|
15569
16694
|
|
|
15570
|
-
export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, Service, VAL_SCOPE_READ, VAL_SCOPE_WRITE, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, authorIdFromVerifiedSubject, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, createValTools, currentFixHandlers, decodeJwtWithoutVerifying, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
|
|
16695
|
+
export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, Service, VAL_SCOPE_READ, VAL_SCOPE_WRITE, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, authorIdFromVerifiedSubject, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, createValTools, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err$1 as err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, isExternalResult, loadValModules, ok$1 as ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
|